Skip to content

Commit

Permalink
parent 5df24e2
Browse files Browse the repository at this point in the history
author Tong Chen <[email protected]> 1729290698 -0700
committer Tong Chen <[email protected]> 1735866104 -0800

feat(@fluid-example/ai-collab): Integrate User Avatar into Sample App
  • Loading branch information
chentong7 committed Jan 3, 2025
1 parent 5df24e2 commit 3635f44
Show file tree
Hide file tree
Showing 15 changed files with 335 additions and 8 deletions.
12 changes: 10 additions & 2 deletions examples/apps/ai-collab/.eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,23 @@ module.exports = {
"import/no-internal-modules": [
"error",
{
allow: importInternalModulesAllowed.concat([
allow: [
"@fluidframework/*/beta",
"@fluidframework/*/alpha",

// NextJS requires reaching to its internal modules
"next/**",

// Path aliases
"@/actions/**",
"@/types/**",
"@/infra/**",
"@/components/**",
]),
"@/app/**",

// Experimental package APIs and exports are unknown, so allow any imports from them.
"@fluidframework/ai-collab/alpha",
],
},
],
// This is an example/test app; all its dependencies are dev dependencies so as not to pollute the lockfile
Expand Down
2 changes: 1 addition & 1 deletion examples/apps/ai-collab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ You can run this example using the following steps:
- For an even faster build, you can add the package name to the build command, like this:
`pnpm run build:fast --nolint @fluid-example/ai-collab`
1. Start a Tinylicious server by running `pnpm start:server` from this directory.
1. In a separate terminal also from this directory, run `pnpm next:dev` and open http://localhost:3000/ in a
1. In a separate terminal also from this directory, run `pnpm start` and open http://localhost:3000/ in a
web browser to see the app running.

### Using SharePoint embedded instead of tinylicious
Expand Down
13 changes: 13 additions & 0 deletions examples/apps/ai-collab/next.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/

// We deliberately configure NextJS to not use React Strict Mode, so we don't get double-rendering of React components
// during development. Otherwise containers get loaded twice, and the presence functionality works incorrectly, detecting
// every browser tab that *loaded* a container (but not the one that originally created it) as 2 presence participants.
const nextConfig = {
reactStrictMode: false,
};

export default nextConfig;
1 change: 1 addition & 0 deletions examples/apps/ai-collab/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@fluidframework/devtools": "workspace:~",
"@fluidframework/eslint-config-fluid": "^5.6.0",
"@fluidframework/odsp-client": "workspace:~",
"@fluidframework/presence": "workspace:~",
"@fluidframework/tinylicious-client": "workspace:~",
"@fluidframework/tree": "workspace:~",
"@iconify/react": "^5.0.2",
Expand Down
11 changes: 11 additions & 0 deletions examples/apps/ai-collab/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

"use client";

import { acquirePresenceViaDataObject } from "@fluidframework/presence/alpha";
import {
Box,
Button,
Expand All @@ -18,7 +19,10 @@ import {
import type { IFluidContainer, TreeView } from "fluid-framework";
import React, { useEffect, useState } from "react";

import { PresenceManager } from "./presence";

import { TaskGroup } from "@/components/TaskGroup";
import { UserPresenceGroup } from "@/components/UserPresenceGroup";
import {
CONTAINER_SCHEMA,
INITIAL_APP_STATE,
Expand Down Expand Up @@ -47,6 +51,7 @@ export async function createAndInitializeContainer(): Promise<
export default function TasksListPage(): JSX.Element {
const [selectedTaskGroup, setSelectedTaskGroup] = useState<SharedTreeTaskGroup>();
const [treeView, setTreeView] = useState<TreeView<typeof SharedTreeAppState>>();
const [presenceManagerContext, setPresenceManagerContext] = useState<PresenceManager>();

const { container, isFluidInitialized, data } = useFluidContainerNextJs(
containerIdFromUrl(),
Expand All @@ -57,6 +62,9 @@ export default function TasksListPage(): JSX.Element {
(fluidContainer) => {
const _treeView = fluidContainer.initialObjects.appState.viewWith(TREE_CONFIGURATION);
setTreeView(_treeView);

const presence = acquirePresenceViaDataObject(fluidContainer.initialObjects.presence);
setPresenceManagerContext(new PresenceManager(presence));
return { sharedTree: _treeView };
},
);
Expand All @@ -79,6 +87,9 @@ export default function TasksListPage(): JSX.Element {
sx={{ display: "flex", flexDirection: "column", alignItems: "center" }}
maxWidth={false}
>
{presenceManagerContext && (
<UserPresenceGroup presenceManager={presenceManagerContext} />
)}
<Typography variant="h2" sx={{ my: 3 }}>
My Work Items
</Typography>
Expand Down
126 changes: 126 additions & 0 deletions examples/apps/ai-collab/src/app/presence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/

import {
IPresence,
Latest,
type ISessionClient,
type PresenceStates,
type PresenceStatesEntries,
type PresenceStatesSchema,
} from "@fluidframework/presence/alpha";

import { getProfilePhoto } from "@/infra/authHelper";

export interface User {
photo: string;
}

const statesSchema = {
onlineUsers: Latest({ photo: "" } satisfies User),
} satisfies PresenceStatesSchema;

export type UserPresence = PresenceStates<typeof statesSchema>;

// Takes a presence object and returns the user presence object that contains the shared object states
export function buildUserPresence(presence: IPresence): UserPresence {
const states = presence.getStates(`name:user-avatar-states`, statesSchema);
return states;
}

export class PresenceManager {
// A PresenceState object to manage the presence of users within the app
private readonly usersState: UserPresence;
// A map of SessionClient to UserInfo, where users can share their info with other users
private readonly userInfoMap: Map<ISessionClient, User> = new Map();
// A callback methid to get updates when remote UserInfo changes
private userInfoCallback: (userInfoMap: Map<ISessionClient, User>) => void = () => {};

constructor(private readonly presence: IPresence) {
this.presence = presence;

// Address for the presence state, this is used to organize the presence states and avoid conflicts
const appSelectionWorkspaceAddress = "aiCollab:workspace";

// Initialize presence state for the app selection workspace
this.usersState = presence.getStates(
appSelectionWorkspaceAddress, // Workspace address
statesSchema, // Workspace schema
);

// Listen for updates to the userInfo property in the presence state
this.usersState.props.onlineUsers.events.on("updated", (update) => {
// The remote client that updated the userInfo property
const remoteSessionClient = update.client;
// The new value of the userInfo property
const remoteUserInfo = update.value;

// Update the userInfoMap with the new value
this.userInfoMap.set(remoteSessionClient, remoteUserInfo);
// Notify the app about the updated userInfoMap
this.userInfoCallback(this.userInfoMap);
});

// Set the local user's info
this.setMyUserInfo().catch((error) => {
console.error(`Error: ${error} when setting local user info`);
});
}

// Set the local user's info and set it on the Presence State to share with other clients
private async setMyUserInfo(): Promise<void> {
const clientId = process.env.NEXT_PUBLIC_SPE_CLIENT_ID;
const tenantId = process.env.NEXT_PUBLIC_SPE_ENTRA_TENANT_ID;

// spe client
if (tenantId !== undefined && clientId !== undefined) {
const photoUrl = await getProfilePhoto();
this.usersState.props.onlineUsers.local = { photo: photoUrl };
}

this.userInfoMap.set(this.presence.getMyself(), this.usersState.props.onlineUsers.local);
this.userInfoCallback(this.userInfoMap);
}

// Returns the presence state of the app selection workspace
getStates(): PresenceStatesEntries<typeof statesSchema> {
return this.usersState.props;
}

// Returns the presence object
getPresence(): IPresence {
return this.presence;
}

// Allows the app to listen for updates to the userInfoMap
setUserInfoUpdateListener(callback: (userInfoMap: Map<ISessionClient, User>) => void): void {
this.userInfoCallback = callback;
}

// Returns the UserInfo of given session clients
getUserInfo(sessionList: ISessionClient[]): User[] {
const userInfoList: User[] = [];

for (const sessionClient of sessionList) {
// If local user or remote user is connected, then only add it to the list
try {
const userInfo = this.usersState.props.onlineUsers.clientValue(sessionClient).value;
// If the user is local user, then add it to the beginning of the list
if (sessionClient.sessionId === this.presence.getMyself().sessionId) {
userInfoList.push(userInfo);
} else {
// If the user is remote user, then add it to the end of the list
userInfoList.unshift(userInfo);
}
} catch (error) {
console.error(
`Error: ${error} when getting user info for session client: ${sessionClient.sessionId}`,
);
}
}

return userInfoList;
}
}
2 changes: 1 addition & 1 deletion examples/apps/ai-collab/src/app/spe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import type { ContainerSchema, IFluidContainer } from "fluid-framework";

import { start } from "@/infra/authHelper"; // eslint-disable-line import/no-internal-modules
import { start } from "@/infra/authHelper";

const { client, getShareLink, containerId: _containerId } = await start();

Expand Down
1 change: 0 additions & 1 deletion examples/apps/ai-collab/src/components/TaskCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import { Tree, type TreeView } from "fluid-framework";
import { useSnackbar } from "notistack";
import React, { useState, type ReactNode, type SetStateAction } from "react";

// eslint-disable-next-line import/no-internal-modules
import { getOpenAiClient } from "@/infra/openAiClient";
import {
SharedTreeTask,
Expand Down
1 change: 0 additions & 1 deletion examples/apps/ai-collab/src/components/TaskGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ import React, { useEffect, useState } from "react";

import { TaskCard } from "./TaskCard";

// eslint-disable-next-line import/no-internal-modules
import { getOpenAiClient } from "@/infra/openAiClient";
import {
aiCollabLlmTreeNodeValidator,
Expand Down
112 changes: 112 additions & 0 deletions examples/apps/ai-collab/src/components/UserPresenceGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/

"use client";

import { Avatar, Badge, styled } from "@mui/material";
import React, { useEffect, useState } from "react";

import type { PresenceManager } from "@/app/presence";

interface UserPresenceProps {
presenceManager: PresenceManager;
}

const UserPresenceGroup: React.FC<UserPresenceProps> = ({ presenceManager }): JSX.Element => {
const [invalidations, setInvalidations] = useState(0);

useEffect(() => {
// Listen to the attendeeJoined event and update the presence group when a new attendee joins
const unsubJoin = presenceManager.getPresence().events.on("attendeeJoined", () => {
setInvalidations(invalidations + Math.random());
});
// Listen to the attendeeDisconnected event and update the presence group when an attendee leaves
const unsubDisconnect = presenceManager
.getPresence()
.events.on("attendeeDisconnected", () => {
setInvalidations(invalidations + Math.random());
});
// Listen to the userInfoUpdate event and update the presence group when the user info is updated
presenceManager.setUserInfoUpdateListener(() => {
setInvalidations(invalidations + Math.random());
});

return () => {
unsubJoin();
unsubDisconnect();
presenceManager.setUserInfoUpdateListener(() => {});
};
});

// Get the list of connected attendees
const connectedAttendees = [...presenceManager.getPresence().getAttendees()].filter(
(attendee) => attendee.getConnectionStatus() === "Connected",
);

// Get the user info for the connected attendees
const userInfoList = presenceManager.getUserInfo(connectedAttendees);

const StyledBadge = styled(Badge)(({ theme }) => ({
"& .MuiBadge-badge": {
backgroundColor: "#44b700",
color: "#44b700",
boxShadow: `0 0 0 2px ${theme.palette.background.paper}`,
"&::after": {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
borderRadius: "50%",
animation: "ripple 1.2s infinite ease-in-out",
border: "1px solid currentColor",
content: '""',
},
},
"@keyframes ripple": {
"0%": {
transform: "scale(.8)",
opacity: 1,
},
"100%": {
transform: "scale(2.4)",
opacity: 0,
},
},
}));

return (
<div>
{userInfoList.length === 0 ? (
<Avatar alt="User Photo" sx={{ width: 56, height: 56 }} />
) : (
<>
{userInfoList.slice(0, 4).map((userInfo, index) => (
<StyledBadge
key={index}
overlap="circular"
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
variant="dot"
>
<Avatar alt="User Photo" src={userInfo.photo} sx={{ width: 56, height: 56 }} />
</StyledBadge>
))}
{userInfoList.length > 4 && (
<Badge
overlap="circular"
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
badgeContent={`+${userInfoList.length - 4}`}
color="primary"
>
<Avatar alt="More Users" sx={{ width: 56, height: 56 }} />
</Badge>
)}
</>
)}
</div>
);
};

export { UserPresenceGroup };
Loading

0 comments on commit 3635f44

Please sign in to comment.