-
Notifications
You must be signed in to change notification settings - Fork 535
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
author Tong Chen <[email protected]> 1729290698 -0700 committer Tong Chen <[email protected]> 1735866104 -0800 feat(@fluid-example/ai-collab): Integrate User Avatar into Sample App
- Loading branch information
Showing
15 changed files
with
335 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 { | ||
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 | ||
private userInfoCallback: (userInfoMap: Map<ISessionClient, User>) => void = () => {}; | ||
|
||
constructor(private readonly presence: IPresence) { | ||
this.presence = presence; | ||
|
||
// 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; | ||
} | ||
|
||
// 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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
112 changes: 112 additions & 0 deletions
112
examples/apps/ai-collab/src/components/UserPresenceGroup.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
Oops, something went wrong.