diff --git a/examples/apps/ai-collab/.eslintrc.cjs b/examples/apps/ai-collab/.eslintrc.cjs index f199ba4fbc14..7f44e85cba45 100644 --- a/examples/apps/ai-collab/.eslintrc.cjs +++ b/examples/apps/ai-collab/.eslintrc.cjs @@ -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 diff --git a/examples/apps/ai-collab/README.md b/examples/apps/ai-collab/README.md index b983c6f709e0..22a65cd440c8 100644 --- a/examples/apps/ai-collab/README.md +++ b/examples/apps/ai-collab/README.md @@ -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 diff --git a/examples/apps/ai-collab/next.config.mjs b/examples/apps/ai-collab/next.config.mjs new file mode 100644 index 000000000000..040de0843359 --- /dev/null +++ b/examples/apps/ai-collab/next.config.mjs @@ -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; diff --git a/examples/apps/ai-collab/package.json b/examples/apps/ai-collab/package.json index 22f2f1acc60a..9e5635897c9c 100644 --- a/examples/apps/ai-collab/package.json +++ b/examples/apps/ai-collab/package.json @@ -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", diff --git a/examples/apps/ai-collab/src/app/page.tsx b/examples/apps/ai-collab/src/app/page.tsx index 87de00d1606d..60cd11d80e60 100644 --- a/examples/apps/ai-collab/src/app/page.tsx +++ b/examples/apps/ai-collab/src/app/page.tsx @@ -5,6 +5,7 @@ "use client"; +import { acquirePresenceViaDataObject } from "@fluidframework/presence/alpha"; import { Box, Button, @@ -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, @@ -47,6 +51,7 @@ export async function createAndInitializeContainer(): Promise< export default function TasksListPage(): JSX.Element { const [selectedTaskGroup, setSelectedTaskGroup] = useState(); const [treeView, setTreeView] = useState>(); + const [presenceManagerContext, setPresenceManagerContext] = useState(); const { container, isFluidInitialized, data } = useFluidContainerNextJs( containerIdFromUrl(), @@ -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 }; }, ); @@ -79,6 +87,9 @@ export default function TasksListPage(): JSX.Element { sx={{ display: "flex", flexDirection: "column", alignItems: "center" }} maxWidth={false} > + {presenceManagerContext && ( + + )} My Work Items diff --git a/examples/apps/ai-collab/src/app/presence.ts b/examples/apps/ai-collab/src/app/presence.ts new file mode 100644 index 000000000000..ddc5c3889911 --- /dev/null +++ b/examples/apps/ai-collab/src/app/presence.ts @@ -0,0 +1,118 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { + IPresence, + Latest, + type ISessionClient, + type PresenceStates, + 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; + +// 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 = new Map(); + // A callback method to get updates when remote UserInfo changes + private userInfoCallback: (userInfoMap: Map) => void = () => {}; + + constructor(private readonly presence: IPresence) { + // 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 { + 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 object + getPresence(): IPresence { + return this.presence; + } + + // Allows the app to listen for updates to the userInfoMap + setUserInfoUpdateListener(callback: (userInfoMap: Map) => 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; + } +} diff --git a/examples/apps/ai-collab/src/app/spe.ts b/examples/apps/ai-collab/src/app/spe.ts index 4c8bb159fc6e..46fc993c8aff 100644 --- a/examples/apps/ai-collab/src/app/spe.ts +++ b/examples/apps/ai-collab/src/app/spe.ts @@ -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(); diff --git a/examples/apps/ai-collab/src/components/TaskCard.tsx b/examples/apps/ai-collab/src/components/TaskCard.tsx index 3b3dc08cc262..3d5864de2a5d 100644 --- a/examples/apps/ai-collab/src/components/TaskCard.tsx +++ b/examples/apps/ai-collab/src/components/TaskCard.tsx @@ -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, diff --git a/examples/apps/ai-collab/src/components/TaskGroup.tsx b/examples/apps/ai-collab/src/components/TaskGroup.tsx index c55662e78c78..a343791704e6 100644 --- a/examples/apps/ai-collab/src/components/TaskGroup.tsx +++ b/examples/apps/ai-collab/src/components/TaskGroup.tsx @@ -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, diff --git a/examples/apps/ai-collab/src/components/UserPresenceGroup.tsx b/examples/apps/ai-collab/src/components/UserPresenceGroup.tsx new file mode 100644 index 000000000000..839d3a6387e4 --- /dev/null +++ b/examples/apps/ai-collab/src/components/UserPresenceGroup.tsx @@ -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 = ({ 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 ( +
+ {userInfoList.length === 0 ? ( + + ) : ( + <> + {userInfoList.slice(0, 4).map((userInfo, index) => ( + + + + ))} + {userInfoList.length > 4 && ( + + + + )} + + )} +
+ ); +}; + +export { UserPresenceGroup }; diff --git a/examples/apps/ai-collab/src/infra/authHelper.ts b/examples/apps/ai-collab/src/infra/authHelper.ts index 66041ba0a5b1..955a57f6c940 100644 --- a/examples/apps/ai-collab/src/infra/authHelper.ts +++ b/examples/apps/ai-collab/src/infra/authHelper.ts @@ -87,6 +87,44 @@ export async function start(): Promise<{ } } +export async function getProfilePhoto(): Promise { + const msalInstance = await authHelper(); + + // Handle the login redirect flows + const tokenResponse: AuthenticationResult | null = + await msalInstance.handleRedirectPromise(); + + // If the tokenResponse is not null, then the user is signed in + // and the tokenResponse is the result of the redirect. + if (tokenResponse === null) { + const currentAccounts = msalInstance.getAllAccounts(); + if (currentAccounts.length === 0) { + // no accounts signed-in, attempt to sign a user in + await msalInstance.loginRedirect({ + scopes: ["FileStorageContainer.Selected", "Files.ReadWrite"], + }); + + throw new Error( + "This should never happen! The previous line should have caused a browser redirect.", + ); + } else { + // The user is signed in. + // Treat more than one account signed in and a single account the same as this is just a sample. + // A real app would need to handle the multiple accounts case. + // For now, just use the first account. + const account = msalInstance.getAllAccounts()[0]; + if (account === undefined) { + throw new Error("No account found after logging in"); + } + graphHelper = new GraphHelper(msalInstance, account); + } + } else { + graphHelper = new GraphHelper(msalInstance, tokenResponse.account); + } + const response = await graphHelper.getProfilePhoto(); + return response; +} + async function signedInStart( msalInstance: PublicClientApplication, account: AccountInfo, diff --git a/examples/apps/ai-collab/src/infra/graphHelper.ts b/examples/apps/ai-collab/src/infra/graphHelper.ts index 09e5a0b52819..390350a37b83 100644 --- a/examples/apps/ai-collab/src/infra/graphHelper.ts +++ b/examples/apps/ai-collab/src/infra/graphHelper.ts @@ -135,4 +135,12 @@ export class GraphHelper { driveId: response.parentReference.driveId, }; } + + // Function to get the user's profile photo + public async getProfilePhoto(): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const photoBlob = await this.graphClient.api("/me/photo/$value").get(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + return URL.createObjectURL(photoBlob); + } } diff --git a/examples/apps/ai-collab/src/types/sharedTreeAppSchema.ts b/examples/apps/ai-collab/src/types/sharedTreeAppSchema.ts index 2959d66f3a96..71b4fc3581ee 100644 --- a/examples/apps/ai-collab/src/types/sharedTreeAppSchema.ts +++ b/examples/apps/ai-collab/src/types/sharedTreeAppSchema.ts @@ -3,7 +3,8 @@ * Licensed under the MIT License. */ -import { Tree, TreeViewConfiguration, type TreeNode } from "@fluidframework/tree"; +import { ExperimentalPresenceManager } from "@fluidframework/presence/alpha"; +import { Tree, type TreeNode, TreeViewConfiguration } from "@fluidframework/tree"; import { SchemaFactoryAlpha } from "@fluidframework/tree/alpha"; import { SharedTree } from "fluid-framework"; @@ -199,7 +200,14 @@ export const INITIAL_APP_STATE = { } as const; export const CONTAINER_SCHEMA = { - initialObjects: { appState: SharedTree }, + initialObjects: { + appState: SharedTree, + /** + * A Presence Manager object temporarily needs to be placed within container schema + * https://github.com/microsoft/FluidFramework/blob/main/packages/framework/presence/README.md#onboarding + * */ + presence: ExperimentalPresenceManager, + }, }; export const TREE_CONFIGURATION = new TreeViewConfiguration({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f97d07498a47..c9de698706e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,6 +260,9 @@ importers: '@fluidframework/odsp-client': specifier: workspace:~ version: link:../../../packages/service-clients/odsp-client + '@fluidframework/presence': + specifier: workspace:~ + version: link:../../../packages/framework/presence '@fluidframework/tinylicious-client': specifier: workspace:~ version: link:../../../packages/service-clients/tinylicious-client