Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(@fluid-example/ai-collab): Integrate User Avatar into Sample App #22850

Merged
merged 6 commits into from
Jan 6, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
chentong7 marked this conversation as resolved.
Show resolved Hide resolved
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 {
chentong7 marked this conversation as resolved.
Show resolved Hide resolved
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
chentong7 marked this conversation as resolved.
Show resolved Hide resolved
private userInfoCallback: (userInfoMap: Map<ISessionClient, User>) => void = () => {};

constructor(private readonly presence: IPresence) {
this.presence = presence;
chentong7 marked this conversation as resolved.
Show resolved Hide resolved

// 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;
}
chentong7 marked this conversation as resolved.
Show resolved Hide resolved

// 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
Loading