Skip to content

Commit

Permalink
feat(@fluid-example/ai-collab): Integrate User Avatar into Sample App
Browse files Browse the repository at this point in the history
  • Loading branch information
chentong7 committed Oct 25, 2024
1 parent c4560a7 commit 9942e2a
Show file tree
Hide file tree
Showing 8 changed files with 192 additions and 2 deletions.
1 change: 1 addition & 0 deletions examples/apps/ai-collab/.eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ module.exports = {
"@/actions/**",
"@/types/**",
"@/components/**",
"@/app/**",

// Experimental package APIs and exports are unknown, so allow any imports from them.
"@fluid-experimental/**",
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
2 changes: 2 additions & 0 deletions examples/apps/ai-collab/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@fluid-experimental/ai-collab": "workspace:~",
"@fluid-experimental/presence": "workspace:~",
"@fluid-internal/mocha-test-setup": "workspace:~",
"@fluid-tools/build-cli": "^0.49.0",
"@fluidframework/build-common": "^2.0.3",
Expand All @@ -62,6 +63,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",
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 "@fluid-experimental/presence";
import {
Box,
Button,
Expand All @@ -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 { 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 [userPresenceGroup, setUserPresenceGroup] = useState<UserPresence>();

const { container, isFluidInitialized, data } = useFluidContainerNextJs(
containerIdFromUrl(),
Expand All @@ -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 _userPresenceGroup = buildUserPresence(presence);
setUserPresenceGroup(_userPresenceGroup);

return { sharedTree: _treeView };
},
);
Expand All @@ -79,6 +89,7 @@ export default function TasksListPage(): JSX.Element {
sx={{ display: "flex", flexDirection: "column", alignItems: "center" }}
maxWidth={false}
>
{userPresenceGroup && <UserPresenceGroup userPresenceGroup={userPresenceGroup} />}
<Typography variant="h2" sx={{ my: 3 }}>
My Work Items
</Typography>
Expand Down
27 changes: 27 additions & 0 deletions examples/apps/ai-collab/src/app/presence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*!
* 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<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:app-client-states", statesSchema);
return states;
}
135 changes: 135 additions & 0 deletions examples/apps/ai-collab/src/components/UserPresenceGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/

"use client";

import { InteractiveBrowserCredential } from "@azure/identity";
import { Client } from "@microsoft/microsoft-graph-client";
// eslint-disable-next-line import/no-internal-modules
import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials";
import { Avatar, Badge, styled } from "@mui/material";
import React, { useEffect, useState } from "react";
import { v4 as uuid } from "uuid";

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

interface UserPresenceProps {
userPresenceGroup: UserPresence;
}

const UserPresenceGroup: React.FC<UserPresenceProps> = ({ userPresenceGroup }): JSX.Element => {
const [photoUrls, setPhotoUrls] = useState<string[]>([]);
const [isPhotoFetched, setIsPhotoFetched] = useState<boolean>(false);

// this effect will run when the userPresenceGroup changes and will update the photoUrls state
useEffect(() => {
const allPhotos: string[] = [];
for (const element of [...userPresenceGroup.onlineUsers.clientValues()]) {
for (const user of [...element.items.values()]) {
allPhotos.push(user.value.value.photo);
}
}
setPhotoUrls(allPhotos);
}, [userPresenceGroup]);

/**
* this effect will run once when the component mounts and will fetch the user's photo if it's spe client,
* for the tinylicious client, it will use the default photo.
* */
useEffect(() => {
const fetchPhoto = async (): Promise<void> => {
const clientId = process.env.NEXT_PUBLIC_SPE_CLIENT_ID;
const tenantId = process.env.NEXT_PUBLIC_SPE_ENTRA_TENANT_ID;
if (tenantId === undefined || clientId === undefined) {
// add a default photo for tinylicious client
userPresenceGroup.onlineUsers.local.set(`id-${uuid()}`, {
value: { photo: "" },
});
return;
}

const credential = new InteractiveBrowserCredential({
clientId,
tenantId,
});

const authProvider = new TokenCredentialAuthenticationProvider(credential, {
scopes: ["User.Read"],
});

const client = Client.initWithMiddleware({ authProvider });
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const photoBlob = await client.api("/me/photo/$value").get();
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const photoUrl = URL.createObjectURL(photoBlob);
setPhotoUrls((prevPhotos) => {
return [...prevPhotos, photoUrl];
});
userPresenceGroup.onlineUsers.local.set(`id-${uuid()}`, {
value: { photo: photoUrl },
});
setIsPhotoFetched(true);
} catch (error) {
console.error(error);
}
};

if (!isPhotoFetched) {
fetchPhoto().catch((error) => console.error(error));
}
}, [isPhotoFetched, setIsPhotoFetched]);

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>
{photoUrls.length === 0 ? (
<Avatar alt="User Photo" sx={{ width: 56, height: 56 }} />
) : (
photoUrls.map((photo, index) => (
<StyledBadge
key={index}
max={4}
overlap="circular"
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
variant="dot"
>
<Avatar alt="User Photo" src={photo} sx={{ width: 56, height: 56 }} />
</StyledBadge>
))
)}
</div>
);
};

export { UserPresenceGroup };
10 changes: 9 additions & 1 deletion examples/apps/ai-collab/src/types/sharedTreeAppSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Licensed under the MIT License.
*/

import { ExperimentalPresenceManager } from "@fluid-experimental/presence";
import { SchemaFactory, TreeViewConfiguration } from "@fluidframework/tree";
import { SharedTree } from "fluid-framework";

Expand Down Expand Up @@ -162,7 +163,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({
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 9942e2a

Please sign in to comment.