diff --git a/examples/apps/ai-collab/README.md b/examples/apps/ai-collab/README.md index 934878c9d997..dbecdb420cc7 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/package.json b/examples/apps/ai-collab/package.json index 125ee6188ec8..0558bceaf6bf 100644 --- a/examples/apps/ai-collab/package.json +++ b/examples/apps/ai-collab/package.json @@ -29,7 +29,9 @@ "start": "next dev", "start:server": "tinylicious" }, - "dependencies": {}, + "dependencies": { + "@fluid-experimental/presence": "workspace:~" + }, "devDependencies": { "@azure/identity": "^4.4.1", "@azure/msal-browser": "^3.25.0", @@ -62,6 +64,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "rimraf": "^4.4.0", + "source-map-loader": "^5.0.0", "tinylicious": "^5.0.0", "typechat": "^0.1.1", "typescript": "~5.4.5", diff --git a/examples/apps/ai-collab/src/app/page.tsx b/examples/apps/ai-collab/src/app/page.tsx index 8074d0f13817..d8fab9f3c1b8 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 "@fluid-experimental/presence"; import { Box, Button, @@ -18,7 +19,10 @@ import { import type { IFluidContainer, TreeView } from "fluid-framework"; import React, { useEffect, useState } from "react"; +import { buildUserPresence, type UserPresence } from "./presence"; + import { TaskGroup } from "@/components/TaskGroup"; +import UserProfilePhoto from "@/components/UserProfilePhoto"; 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 [onlineUsers, setOnlineUsers] = useState(); const { container, isFluidInitialized, data } = useFluidContainerNextJs( containerIdFromUrl(), @@ -57,6 +62,11 @@ export default function TasksListPage(): JSX.Element { (fluidContainer) => { const _treeView = fluidContainer.initialObjects.appState.viewWith(TREE_CONFIGURATION); setTreeView(_treeView); + + const presence = acquirePresenceViaDataObject(fluidContainer.initialObjects.presence); + const _onlineUsers = buildUserPresence(presence); + setOnlineUsers(_onlineUsers); + return { sharedTree: _treeView }; }, ); @@ -79,6 +89,7 @@ export default function TasksListPage(): JSX.Element { sx={{ display: "flex", flexDirection: "column", alignItems: "center" }} maxWidth={"lg"} > + {onlineUsers && } 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..188481b3dc0c --- /dev/null +++ b/examples/apps/ai-collab/src/app/presence.ts @@ -0,0 +1,26 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { + IPresence, + LatestMap, + type PresenceStates, + type PresenceStatesSchema, +} from "@fluid-experimental/presence"; + +export interface User { + photo: string; +} + +const statesSchema = { + onlineUsers: LatestMap<{ value: User }, `id-${string}`>(), +} satisfies PresenceStatesSchema; + +export type UserPresence = PresenceStates; + +export function buildUserPresence(presence: IPresence): UserPresence { + const states = presence.getStates("name:app-client-states", statesSchema); + return states; +} diff --git a/examples/apps/ai-collab/src/components/UserProfilePhoto.tsx b/examples/apps/ai-collab/src/components/UserProfilePhoto.tsx new file mode 100644 index 000000000000..9b318cc94a32 --- /dev/null +++ b/examples/apps/ai-collab/src/components/UserProfilePhoto.tsx @@ -0,0 +1,92 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { InteractiveBrowserCredential } from "@azure/identity"; +import { Client } from "@microsoft/microsoft-graph-client"; +import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials"; +import { Avatar, Badge } from "@mui/material"; +import React, { useEffect, useState } from "react"; +import { v4 as uuid } from "uuid"; + +import type { UserPresence } from "@/app/presence"; + +interface UserProfilePhotoProps { + onlineUsers: UserPresence; +} + +const UserProfilePhoto: React.FC = ({ onlineUsers }) => { + const [photos, setPhotos] = useState([]); + const [isPhotoFetched, setIsPhotoFetched] = useState(false); + + useEffect(() => { + const allPhotos: string[] = []; + for (const element of Array.from(onlineUsers.onlineUsers.clientValues())) { + for (const user of Array.from(element.items.values())) { + allPhotos.push(user.value.value.photo); + } + } + setPhotos(allPhotos); + }, [onlineUsers]); + + useEffect(() => { + const fetchPhoto = async () => { + const clientId = process.env.NEXT_PUBLIC_SPE_CLIENT_ID; + const tenantId = process.env.NEXT_PUBLIC_SPE_ENTRA_TENANT_ID; + if (tenantId === undefined || clientId === undefined) { + return; + } + + const credential = new InteractiveBrowserCredential({ + clientId, + tenantId, + }); + + const authProvider = new TokenCredentialAuthenticationProvider(credential, { + scopes: ["User.Read"], + }); + + const client = Client.initWithMiddleware({ authProvider }); + try { + const photoBlob = await client.api("/me/photo/$value").get(); + const photoUrl = URL.createObjectURL(photoBlob); + setPhotos((prevPhotos) => { + if (!prevPhotos.includes(photoUrl)) { + return [...prevPhotos, photoUrl]; + } + return prevPhotos; + }); + onlineUsers.onlineUsers.local.set(`id-${uuid()}`, { value: { photo: photoUrl } }); + setIsPhotoFetched(true); + } catch (error) { + console.error(error); + } + }; + + if (!isPhotoFetched) { + fetchPhoto(); + } + }, [isPhotoFetched, onlineUsers]); + + return ( +
+ {photos.length === 0 ? ( + + ) : ( + photos.map((photo, index) => ( + + + + )) + )} +
+ ); +}; + +export default UserProfilePhoto; diff --git a/examples/apps/ai-collab/src/types/sharedTreeAppSchema.ts b/examples/apps/ai-collab/src/types/sharedTreeAppSchema.ts index db8b74a71498..2738601552e6 100644 --- a/examples/apps/ai-collab/src/types/sharedTreeAppSchema.ts +++ b/examples/apps/ai-collab/src/types/sharedTreeAppSchema.ts @@ -4,7 +4,7 @@ */ import { SharedTree, SchemaFactory, TreeViewConfiguration } from "fluid-framework"; - +import { ExperimentalPresenceManager } from "@fluid-experimental/presence"; import type { Engineer, Task, TaskGroup } from "./task"; // The string passed to the SchemaFactory should be unique @@ -155,7 +155,12 @@ 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 604c08ed28b4..056832674f86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -356,6 +356,10 @@ importers: version: 5.4.5 examples/apps/ai-collab: + dependencies: + '@fluid-experimental/presence': + specifier: workspace:~ + version: link:../../../packages/framework/presence devDependencies: '@azure/identity': specifier: ^4.4.1 @@ -450,6 +454,9 @@ importers: rimraf: specifier: ^4.4.0 version: 4.4.1 + source-map-loader: + specifier: ^5.0.0 + version: 5.0.0(webpack@5.95.0) tinylicious: specifier: ^5.0.0 version: 5.0.0