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

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
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
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
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
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 {
chentong7 marked this conversation as resolved.
Show resolved Hide resolved
const states = presence.getStates("name:app-client-states", statesSchema);
return states;
}
153 changes: 153 additions & 0 deletions examples/apps/ai-collab/src/components/UserPresenceGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/*!
* 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, useRef, 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 isFirstRender = useRef(true);
const [photoUrls, setPhotoUrls] = useState<string[]>([]);

const fetchAllPhotos = (): string[] => {
const allPhotos: string[] = [];
// eslint-disable-next-line unicorn/no-array-for-each
userPresenceGroup.onlineUsers.local.forEach((user) => {
allPhotos.push(user.value.photo);
});
return allPhotos;
};

/**
* fetch the user's photo if it's spe client, for the tinylicious client, it will use the default photo.
* */
const fetchPhoto = async (): Promise<void> => {
const clientId = process.env.NEXT_PUBLIC_SPE_CLIENT_ID;
const tenantId = process.env.NEXT_PUBLIC_SPE_ENTRA_TENANT_ID;
let photoUrl: string = "";

// spe client
if (tenantId !== undefined && clientId !== undefined) {
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
photoUrl = URL.createObjectURL(photoBlob);
} catch (error) {
console.error(error);
}
}
console.log("USER PRESENCES SHOULD ONLY BE SET ONCE");
userPresenceGroup.onlineUsers.local.set(`id-${uuid()}`, {
value: { photo: photoUrl },
});

isFirstRender.current = false;
};

useEffect((): void => {
if (isFirstRender.current) {
fetchPhoto().catch((error) => console.error(error));
}

const handleUpdate = (): void => {
// console.log("Now it has item: ", userPresenceGroup.onlineUsers.local);
setPhotoUrls(fetchAllPhotos());
};

userPresenceGroup.onlineUsers.events.on("updated", handleUpdate);
}, [userPresenceGroup]);

// this effect will run when the userPresenceGroup changes and will update the photoUrls state
useEffect(() => {
setPhotoUrls(fetchAllPhotos());
}, [userPresenceGroup.onlineUsers.events, fetchAllPhotos, fetchPhoto]);

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

Loading