From a4c228e94b1262db941bfc171d67db9b22b4e7fb Mon Sep 17 00:00:00 2001 From: arnavsoni1 Date: Wed, 11 Mar 2026 17:01:18 +0000 Subject: [PATCH 1/2] feat: added image uplading and rendering on web --- .../src/app/api/uploads/avatar/sign/route.ts | 94 +++++++++++++++++++ apps/web/src/app/components/BrowserLayout.tsx | 3 + .../app/components/DevPlaygroundLayout.tsx | 3 + apps/web/src/app/components/GridLayout.tsx | 3 + .../src/app/components/MeetsMainContent.tsx | 15 +++ .../src/app/components/ParticipantVideo.tsx | 35 +++++-- .../src/app/components/PresentationLayout.tsx | 3 + .../src/app/components/WhiteboardLayout.tsx | 3 + apps/web/src/app/hooks/useMeetDisplayName.ts | 6 +- apps/web/src/app/hooks/useMeetRefs.ts | 1 + apps/web/src/app/hooks/useMeetSocket.ts | 67 +++++++++++++ apps/web/src/app/meets-client.tsx | 16 ++++ packages/sfu/server/notifications.ts | 8 +- .../sfu/server/socket/handlers/joinRoom.ts | 22 +++++ 14 files changed, 270 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/app/api/uploads/avatar/sign/route.ts diff --git a/apps/web/src/app/api/uploads/avatar/sign/route.ts b/apps/web/src/app/api/uploads/avatar/sign/route.ts new file mode 100644 index 0000000..779ac84 --- /dev/null +++ b/apps/web/src/app/api/uploads/avatar/sign/route.ts @@ -0,0 +1,94 @@ +import { createHash } from "node:crypto"; +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; + +type SignRequestBody = { + filename?: string; + contentType?: string; +}; + +const ALLOWED_CONTENT_TYPES = new Set([ + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", +]); + +const MAX_FILENAME_LENGTH = 120; + +const sanitizeFileBaseName = (filename: string): string => { + const baseName = filename.replace(/\.[^/.]+$/, "").trim().toLowerCase(); + const cleaned = baseName + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + const trimmed = cleaned.slice(0, MAX_FILENAME_LENGTH); + return trimmed || "avatar"; +}; + +const buildCloudinarySignature = ( + params: Record, + apiSecret: string, +): string => { + const canonical = Object.entries(params) + .filter(([, value]) => value) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}=${value}`) + .join("&"); + + return createHash("sha1") + .update(`${canonical}${apiSecret}`) + .digest("hex"); +}; + +export async function POST(request: Request) { + const cloudName = process.env.CLOUDINARY_CLOUD_NAME?.trim(); + const apiKey = process.env.CLOUDINARY_API_KEY?.trim(); + const apiSecret = process.env.CLOUDINARY_API_SECRET?.trim(); + const folder = + process.env.CLOUDINARY_AVATAR_FOLDER?.trim() || "conclave/avatars"; + + if (!cloudName || !apiKey || !apiSecret) { + return NextResponse.json( + { error: "Avatar upload is not configured." }, + { status: 500 }, + ); + } + + let body: SignRequestBody; + try { + body = (await request.json()) as SignRequestBody; + } catch { + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + } + + const contentType = body.contentType?.trim().toLowerCase() || ""; + if (!ALLOWED_CONTENT_TYPES.has(contentType)) { + return NextResponse.json({ error: "Unsupported image format" }, { status: 400 }); + } + + const filename = body.filename?.trim() || "avatar"; + const fileBaseName = sanitizeFileBaseName(filename); + const timestamp = Math.floor(Date.now() / 1000).toString(); + const publicId = `${fileBaseName}-${Date.now()}`; + + const signableParams = { + folder, + public_id: publicId, + timestamp, + }; + + const signature = buildCloudinarySignature(signableParams, apiSecret); + + return NextResponse.json({ + uploadUrl: `https://api.cloudinary.com/v1_1/${cloudName}/image/upload`, + params: { + api_key: apiKey, + timestamp, + signature, + folder, + public_id: publicId, + }, + }); +} diff --git a/apps/web/src/app/components/BrowserLayout.tsx b/apps/web/src/app/components/BrowserLayout.tsx index 69b6613..dd9b4e0 100644 --- a/apps/web/src/app/components/BrowserLayout.tsx +++ b/apps/web/src/app/components/BrowserLayout.tsx @@ -28,6 +28,7 @@ interface BrowserLayoutProps { currentUserId: string; audioOutputDeviceId?: string; getDisplayName: (userId: string) => string; + getAvatarUrl: (userId: string) => string | undefined; isAdmin?: boolean; isBrowserLaunching?: boolean; onNavigateBrowser?: (url: string) => Promise; @@ -50,6 +51,7 @@ function BrowserLayout({ currentUserId, audioOutputDeviceId, getDisplayName, + getAvatarUrl, isAdmin, isBrowserLaunching = false, onNavigateBrowser, @@ -292,6 +294,7 @@ function BrowserLayout({ key={participant.userId} participant={participant} displayName={getDisplayName(participant.userId)} + avatarUrl={getAvatarUrl(participant.userId)} isActiveSpeaker={activeSpeakerId === participant.userId} compact audioOutputDeviceId={audioOutputDeviceId} diff --git a/apps/web/src/app/components/DevPlaygroundLayout.tsx b/apps/web/src/app/components/DevPlaygroundLayout.tsx index dc29df3..36ec8cf 100644 --- a/apps/web/src/app/components/DevPlaygroundLayout.tsx +++ b/apps/web/src/app/components/DevPlaygroundLayout.tsx @@ -21,6 +21,7 @@ interface DevPlaygroundLayoutProps { currentUserId: string; audioOutputDeviceId?: string; getDisplayName: (userId: string) => string; + getAvatarUrl: (userId: string) => string | undefined; } function DevPlaygroundLayout({ @@ -36,6 +37,7 @@ function DevPlaygroundLayout({ currentUserId, audioOutputDeviceId, getDisplayName, + getAvatarUrl, }: DevPlaygroundLayoutProps) { const localVideoRef = useRef(null); const isLocalActiveSpeaker = activeSpeakerId === currentUserId; @@ -122,6 +124,7 @@ function DevPlaygroundLayout({ key={participant.userId} participant={participant} displayName={getDisplayName(participant.userId)} + avatarUrl={getAvatarUrl(participant.userId)} isActiveSpeaker={activeSpeakerId === participant.userId} compact audioOutputDeviceId={audioOutputDeviceId} diff --git a/apps/web/src/app/components/GridLayout.tsx b/apps/web/src/app/components/GridLayout.tsx index 464dda0..8796aa8 100644 --- a/apps/web/src/app/components/GridLayout.tsx +++ b/apps/web/src/app/components/GridLayout.tsx @@ -23,6 +23,7 @@ interface GridLayoutProps { onParticipantClick?: (userId: string) => void; onOpenParticipantsPanel?: () => void; getDisplayName: (userId: string) => string; + getAvatarUrl: (userId: string) => string | undefined; } const MAX_GRID_TILES = 16; @@ -44,6 +45,7 @@ function GridLayout({ onParticipantClick, onOpenParticipantsPanel, getDisplayName, + getAvatarUrl, }: GridLayoutProps) { const localVideoRef = useRef(null); const stableOrderRef = useRef([]); @@ -378,6 +380,7 @@ function GridLayout({ key={participant.userId} participant={participant} displayName={getDisplayName(participant.userId)} + avatarUrl={getAvatarUrl(participant.userId)} isActiveSpeaker={activeSpeakerId === participant.userId} audioOutputDeviceId={audioOutputDeviceId} isAdmin={isAdmin} diff --git a/apps/web/src/app/components/MeetsMainContent.tsx b/apps/web/src/app/components/MeetsMainContent.tsx index 35c66fd..49b35d8 100644 --- a/apps/web/src/app/components/MeetsMainContent.tsx +++ b/apps/web/src/app/components/MeetsMainContent.tsx @@ -65,6 +65,8 @@ interface MeetsMainContentProps { refreshRooms: () => void; displayNameInput: string; setDisplayNameInput: Dispatch>; + joinAvatarUrl?: string; + setJoinAvatarUrl: Dispatch>; ghostEnabled: boolean; setIsGhostMode: Dispatch>; presentationStream: MediaStream | null; @@ -102,6 +104,7 @@ interface MeetsMainContentProps { socket: Socket | null; setPendingUsers: Dispatch>>; resolveDisplayName: (userId: string) => string; + resolveAvatarUrl: (userId: string) => string | undefined; reactions: ReactionEvent[]; onUserChange: ( user: { id: string; email: string; name: string } | null, @@ -219,6 +222,8 @@ export default function MeetsMainContent({ refreshRooms, displayNameInput, setDisplayNameInput, + joinAvatarUrl, + setJoinAvatarUrl, ghostEnabled, setIsGhostMode, presentationStream, @@ -256,6 +261,7 @@ export default function MeetsMainContent({ socket, setPendingUsers, resolveDisplayName, + resolveAvatarUrl, reactions, onUserChange, onIsAdminChange, @@ -697,6 +703,8 @@ export default function MeetsMainContent({ onJoinRoom={joinRoomById} displayNameInput={displayNameInput} onDisplayNameInputChange={setDisplayNameInput} + avatarUrl={joinAvatarUrl} + onAvatarUrlChange={setJoinAvatarUrl} isGhostMode={ghostEnabled} onGhostModeChange={setIsGhostMode} onUserChange={onUserChange} @@ -717,6 +725,7 @@ export default function MeetsMainContent({ )}:${webinarStage.isScreenShare ? "screen" : "camera"}`} participant={webinarStage.main.participant} displayName={webinarStage.main.displayName} + avatarUrl={resolveAvatarUrl(webinarStage.main.participant.userId)} isActiveSpeaker={ activeSpeakerId === webinarStage.main.participant.userId } @@ -747,6 +756,7 @@ export default function MeetsMainContent({ )}:pip`} participant={webinarStage.pip.participant} displayName={webinarStage.pip.displayName} + avatarUrl={resolveAvatarUrl(webinarStage.pip.participant.userId)} /> ) : null} @@ -773,6 +783,7 @@ export default function MeetsMainContent({ currentUserId={currentUserId} audioOutputDeviceId={audioOutputDeviceId} getDisplayName={resolveDisplayName} + getAvatarUrl={resolveAvatarUrl} /> ) : isDevPlaygroundEnabled && isDevPlaygroundActive ? ( ) : browserState?.active && browserState.noVncUrl ? ( ) : ( )} diff --git a/apps/web/src/app/components/ParticipantVideo.tsx b/apps/web/src/app/components/ParticipantVideo.tsx index 4e4e752..c29f94b 100644 --- a/apps/web/src/app/components/ParticipantVideo.tsx +++ b/apps/web/src/app/components/ParticipantVideo.tsx @@ -8,6 +8,7 @@ import { truncateDisplayName } from "../lib/utils"; interface ParticipantVideoProps { participant: Participant; displayName: string; + avatarUrl?: string; compact?: boolean; isActiveSpeaker?: boolean; audioOutputDeviceId?: string; @@ -20,6 +21,7 @@ interface ParticipantVideoProps { function ParticipantVideo({ participant, displayName, + avatarUrl, compact = false, isActiveSpeaker = false, audioOutputDeviceId, @@ -31,14 +33,20 @@ function ParticipantVideo({ const videoRef = useRef(null); const audioRef = useRef(null); const [isNew, setIsNew] = useState(true); + const [avatarLoadFailed, setAvatarLoadFailed] = useState(false); const labelWidthClass = compact ? "max-w-[65%]" : "max-w-[75%]"; const displayLabel = truncateDisplayName(displayName, compact ? 12 : 18); + const normalizedAvatarUrl = avatarUrl?.trim() || ""; useEffect(() => { const timer = setTimeout(() => setIsNew(false), 800); return () => clearTimeout(timer); }, []); + useEffect(() => { + setAvatarLoadFailed(false); + }, [normalizedAvatarUrl]); + useEffect(() => { const video = videoRef.current; if (!video) return; @@ -196,6 +204,8 @@ function ParticipantVideo({ ]); const showPlaceholder = !participant.videoStream || participant.isCameraOff; + const showAvatarImage = + showPlaceholder && Boolean(normalizedAvatarUrl) && !avatarLoadFailed; const handleClick = () => { if (isAdmin && onAdminClick) { @@ -236,13 +246,24 @@ function ParticipantVideo({ /> {showPlaceholder && (
-
- {displayName[0]?.toUpperCase() || "?"} -
+ {showAvatarImage ? ( + {`${displayName} setAvatarLoadFailed(true)} + /> + ) : ( +
+ {displayName[0]?.toUpperCase() || "?"} +
+ )}
)} {participant.isGhost && ( diff --git a/apps/web/src/app/components/PresentationLayout.tsx b/apps/web/src/app/components/PresentationLayout.tsx index 031fb8a..aab9587 100644 --- a/apps/web/src/app/components/PresentationLayout.tsx +++ b/apps/web/src/app/components/PresentationLayout.tsx @@ -22,6 +22,7 @@ interface PresentationLayoutProps { currentUserId: string; audioOutputDeviceId?: string; getDisplayName: (userId: string) => string; + getAvatarUrl: (userId: string) => string | undefined; } function PresentationLayout({ @@ -39,6 +40,7 @@ function PresentationLayout({ currentUserId, audioOutputDeviceId, getDisplayName, + getAvatarUrl, }: PresentationLayoutProps) { const localVideoRef = useRef(null); const presentationVideoRef = useRef(null); @@ -227,6 +229,7 @@ function PresentationLayout({ key={participant.userId} participant={participant} displayName={getDisplayName(participant.userId)} + avatarUrl={getAvatarUrl(participant.userId)} isActiveSpeaker={activeSpeakerId === participant.userId} compact audioOutputDeviceId={audioOutputDeviceId} diff --git a/apps/web/src/app/components/WhiteboardLayout.tsx b/apps/web/src/app/components/WhiteboardLayout.tsx index 2d65afa..6f76080 100644 --- a/apps/web/src/app/components/WhiteboardLayout.tsx +++ b/apps/web/src/app/components/WhiteboardLayout.tsx @@ -21,6 +21,7 @@ interface WhiteboardLayoutProps { currentUserId: string; audioOutputDeviceId?: string; getDisplayName: (userId: string) => string; + getAvatarUrl: (userId: string) => string | undefined; } function WhiteboardLayout({ @@ -36,6 +37,7 @@ function WhiteboardLayout({ currentUserId, audioOutputDeviceId, getDisplayName, + getAvatarUrl, }: WhiteboardLayoutProps) { const localVideoRef = useRef(null); const isLocalActiveSpeaker = activeSpeakerId === currentUserId; @@ -122,6 +124,7 @@ function WhiteboardLayout({ key={participant.userId} participant={participant} displayName={getDisplayName(participant.userId)} + avatarUrl={getAvatarUrl(participant.userId)} isActiveSpeaker={activeSpeakerId === participant.userId} compact audioOutputDeviceId={audioOutputDeviceId} diff --git a/apps/web/src/app/hooks/useMeetDisplayName.ts b/apps/web/src/app/hooks/useMeetDisplayName.ts index 116d107..9ee72f5 100644 --- a/apps/web/src/app/hooks/useMeetDisplayName.ts +++ b/apps/web/src/app/hooks/useMeetDisplayName.ts @@ -18,9 +18,11 @@ interface UseMeetDisplayNameOptions { userId: string; isAdmin: boolean; ghostEnabled: boolean; + avatarUrl?: string; socketRef: React.MutableRefObject; joinOptionsRef: React.MutableRefObject<{ displayName?: string; + avatarUrl?: string; isGhost: boolean; joinMode: JoinMode; webinarInviteCode?: string; @@ -33,6 +35,7 @@ export function useMeetDisplayName({ userId, isAdmin, ghostEnabled, + avatarUrl, socketRef, joinOptionsRef, }: UseMeetDisplayNameOptions) { @@ -101,9 +104,10 @@ export function useMeetDisplayName({ joinOptionsRef.current = { ...joinOptionsRef.current, displayName: isAdmin ? normalized || undefined : undefined, + avatarUrl: avatarUrl?.trim() || undefined, isGhost: ghostEnabled, }; - }, [displayNameInput, ghostEnabled, isAdmin, joinOptionsRef]); + }, [avatarUrl, displayNameInput, ghostEnabled, isAdmin, joinOptionsRef]); useEffect(() => { if (!displayNameStatus) return; diff --git a/apps/web/src/app/hooks/useMeetRefs.ts b/apps/web/src/app/hooks/useMeetRefs.ts index 010aa60..49e374a 100644 --- a/apps/web/src/app/hooks/useMeetRefs.ts +++ b/apps/web/src/app/hooks/useMeetRefs.ts @@ -51,6 +51,7 @@ export function useMeetRefs() { const shouldAutoJoinRef = useRef(false); const joinOptionsRef = useRef<{ displayName?: string; + avatarUrl?: string; isGhost: boolean; joinMode: JoinMode; webinarInviteCode?: string; diff --git a/apps/web/src/app/hooks/useMeetSocket.ts b/apps/web/src/app/hooks/useMeetSocket.ts index 0649f2b..7f3b716 100644 --- a/apps/web/src/app/hooks/useMeetSocket.ts +++ b/apps/web/src/app/hooks/useMeetSocket.ts @@ -83,6 +83,7 @@ interface UseMeetSocketOptions { setLocalStream: React.Dispatch>; dispatchParticipants: (action: ParticipantAction) => void; setDisplayNames: React.Dispatch>>; + setAvatarUrls: React.Dispatch>>; setPendingUsers: React.Dispatch>>; setConnectionState: (state: ConnectionState) => void; setMeetError: (error: MeetError | null) => void; @@ -162,6 +163,7 @@ export function useMeetSocket({ setLocalStream, dispatchParticipants, setDisplayNames, + setAvatarUrls, setPendingUsers, setConnectionState, setMeetError, @@ -299,6 +301,7 @@ export function useMeetSocket({ clearReactions(); setPendingUsers(new Map()); setDisplayNames(new Map()); + setAvatarUrls(new Map()); setHostUserId(null); setHostUserIds([]); setWebinarRole(null); @@ -1237,6 +1240,7 @@ export function useMeetSocket({ stream: MediaStream | null, joinOptions: { displayName?: string; + avatarUrl?: string; isGhost: boolean; joinMode: JoinMode; webinarInviteCode?: string; @@ -1256,6 +1260,7 @@ export function useMeetSocket({ roomId: targetRoomId, sessionId: sessionIdRef.current, displayName: joinOptions.displayName, + avatarUrl: joinOptions.avatarUrl, ghost: joinOptions.isGhost, webinarInviteCode: joinOptions.webinarInviteCode, meetingInviteCode: joinOptions.meetingInviteCode, @@ -1662,10 +1667,12 @@ export function useMeetSocket({ ({ userId: joinedUserId, displayName, + avatarUrl, isGhost, }: { userId: string; displayName?: string; + avatarUrl?: string; isGhost?: boolean; }) => { console.log("[Meets] User joined:", joinedUserId); @@ -1682,6 +1689,15 @@ export function useMeetSocket({ return next; }); } + setAvatarUrls((prev) => { + const next = new Map(prev); + if (avatarUrl?.trim()) { + next.set(joinedUserId, avatarUrl.trim()); + } else { + next.delete(joinedUserId); + } + return next; + }); const leaveTimeout = leaveTimeoutsRef.current.get(joinedUserId); if (leaveTimeout) { window.clearTimeout(leaveTimeout); @@ -1711,6 +1727,12 @@ export function useMeetSocket({ next.delete(leftUserId); return next; }); + setAvatarUrls((prev) => { + if (!prev.has(leftUserId)) return prev; + const next = new Map(prev); + next.delete(leftUserId); + return next; + }); const producersToClose = Array.from( producerMapRef.current.entries(), @@ -1776,6 +1798,27 @@ export function useMeetSocket({ }, ); + socket.on( + "avatarSnapshot", + ({ + users, + roomId: eventRoomId, + }: { + users: { userId: string; avatarUrl?: string }[]; + roomId?: string; + }) => { + if (!isRoomEvent(eventRoomId)) return; + const snapshot = new Map(); + (users || []).forEach(({ userId: snapshotUserId, avatarUrl }) => { + const normalizedAvatarUrl = avatarUrl?.trim(); + if (normalizedAvatarUrl) { + snapshot.set(snapshotUserId, normalizedAvatarUrl); + } + }); + setAvatarUrls(snapshot); + }, + ); + socket.on( "handRaisedSnapshot", ({ users, roomId: eventRoomId }: HandRaisedSnapshot) => { @@ -1794,6 +1837,30 @@ export function useMeetSocket({ }, ); + socket.on( + "avatarUpdated", + ({ + userId: updatedUserId, + avatarUrl, + roomId: eventRoomId, + }: { + userId: string; + avatarUrl?: string; + roomId?: string; + }) => { + if (!isRoomEvent(eventRoomId)) return; + setAvatarUrls((prev) => { + const next = new Map(prev); + if (avatarUrl?.trim()) { + next.set(updatedUserId, avatarUrl.trim()); + } else { + next.delete(updatedUserId); + } + return next; + }); + }, + ); + socket.on( "displayNameUpdated", ({ diff --git a/apps/web/src/app/meets-client.tsx b/apps/web/src/app/meets-client.tsx index f22b26b..82062f3 100644 --- a/apps/web/src/app/meets-client.tsx +++ b/apps/web/src/app/meets-client.tsx @@ -358,6 +358,7 @@ export default function MeetsClient({ }; }, [setIsNetworkOffline]); + const [joinAvatarUrl, setJoinAvatarUrl] = useState(undefined); const { setDisplayNames, displayNameInput, @@ -372,9 +373,19 @@ export default function MeetsClient({ userId, isAdmin: isAdminFlag, ghostEnabled, + avatarUrl: joinAvatarUrl, socketRef: refs.socketRef, joinOptionsRef: refs.joinOptionsRef, }); + const [avatarUrls, setAvatarUrls] = useState>(new Map()); + const resolveAvatarUrl = useCallback( + (targetUserId: string) => { + const avatarUrl = avatarUrls.get(targetUserId); + const normalizedAvatarUrl = avatarUrl?.trim(); + return normalizedAvatarUrl || undefined; + }, + [avatarUrls], + ); const appsUser = useMemo( () => ({ id: userId, @@ -686,6 +697,7 @@ export default function MeetsClient({ setLocalStream, dispatchParticipants, setDisplayNames, + setAvatarUrls, setPendingUsers, setConnectionState, setMeetError, @@ -1138,6 +1150,7 @@ export default function MeetsClient({ socket={refs.socketRef.current} setPendingUsers={setPendingUsers} resolveDisplayName={resolveDisplayName} + resolveAvatarUrl={resolveAvatarUrl} reactions={reactionEvents} onUserChange={(user) => setCurrentUser(user ?? undefined)} onIsAdminChange={setCurrentIsAdmin} @@ -1255,6 +1268,8 @@ export default function MeetsClient({ refreshRooms={refreshRooms} displayNameInput={displayNameInput} setDisplayNameInput={setDisplayNameInput} + joinAvatarUrl={joinAvatarUrl} + setJoinAvatarUrl={setJoinAvatarUrl} ghostEnabled={ghostEnabled} setIsGhostMode={setIsGhostMode} presentationStream={presentationStream} @@ -1292,6 +1307,7 @@ export default function MeetsClient({ socket={refs.socketRef.current} setPendingUsers={setPendingUsers} resolveDisplayName={resolveDisplayName} + resolveAvatarUrl={resolveAvatarUrl} reactions={reactionEvents} onUserChange={(user) => setCurrentUser(user ?? undefined)} onIsAdminChange={setCurrentIsAdmin} diff --git a/packages/sfu/server/notifications.ts b/packages/sfu/server/notifications.ts index 53ea742..f21481b 100644 --- a/packages/sfu/server/notifications.ts +++ b/packages/sfu/server/notifications.ts @@ -4,7 +4,12 @@ export const emitUserJoined = ( room: Room, userId: string, displayName: string, - options?: { ghostOnly?: boolean; excludeUserId?: string; isGhost?: boolean }, + options?: { + ghostOnly?: boolean; + excludeUserId?: string; + isGhost?: boolean; + avatarUrl?: string; + }, ): void => { for (const client of room.clients.values()) { if (options?.excludeUserId && client.id === options.excludeUserId) { @@ -16,6 +21,7 @@ export const emitUserJoined = ( client.socket.emit("userJoined", { userId, displayName, + avatarUrl: options?.avatarUrl, isGhost: options?.isGhost, }); } diff --git a/packages/sfu/server/socket/handlers/joinRoom.ts b/packages/sfu/server/socket/handlers/joinRoom.ts index 21f0980..7135cab 100644 --- a/packages/sfu/server/socket/handlers/joinRoom.ts +++ b/packages/sfu/server/socket/handlers/joinRoom.ts @@ -2,6 +2,7 @@ import { Admin } from "../../../config/classes/Admin.js"; import { Client } from "../../../config/classes/Client.js"; import { config } from "../../../config/config.js"; import type { + AvatarSnapshot, AppsAwarenessData, HandRaisedSnapshot, JoinRoomData, @@ -84,6 +85,10 @@ export const registerJoinRoomHandler = (context: ConnectionContext): void => { const clientPolicy = config.clientPolicies[clientId] ?? config.clientPolicies.default; const displayNameCandidate = normalizeDisplayName(data?.displayName); + const hasAvatarOverride = typeof data?.avatarUrl === "string"; + const avatarUrlCandidate = + typeof data?.avatarUrl === "string" ? data.avatarUrl.trim() : ""; + const requestedAvatarUrl = avatarUrlCandidate || undefined; if ( displayNameCandidate && displayNameCandidate.length > MAX_DISPLAY_NAME_LENGTH @@ -451,6 +456,9 @@ export const registerJoinRoomHandler = (context: ConnectionContext): void => { context.currentRoom.setUserIdentity(userId, userKey, displayName, { forceDisplayName: hasDisplayNameOverride, }); + context.currentRoom.setUserAvatar(userId, userKey, requestedAvatarUrl, { + forceAvatar: hasAvatarOverride, + }); context.currentRoom.addClient(context.currentClient); socket.join(roomChannelId); @@ -479,20 +487,25 @@ export const registerJoinRoomHandler = (context: ConnectionContext): void => { const resolvedDisplayName = context.currentRoom.getDisplayNameForUser(userId) || displayName; + const resolvedAvatarUrl = context.currentRoom.getAvatarForUser(userId); if (!wasReconnecting) { if (context.currentClient.isGhost) { emitUserJoined(context.currentRoom, userId, resolvedDisplayName, { ghostOnly: true, excludeUserId: userId, isGhost: true, + avatarUrl: resolvedAvatarUrl, }); for (const [clientId, client] of context.currentRoom.clients) { if (clientId === userId || !client.isGhost) continue; const ghostDisplayName = context.currentRoom.getDisplayNameForUser(clientId) || clientId; + const ghostAvatarUrl = + context.currentRoom.getAvatarForUser(clientId); socket.emit("userJoined", { userId: clientId, displayName: ghostDisplayName, + avatarUrl: ghostAvatarUrl, isGhost: true, }); } @@ -504,6 +517,7 @@ export const registerJoinRoomHandler = (context: ConnectionContext): void => { client.socket.emit("userJoined", { userId, displayName: resolvedDisplayName, + avatarUrl: resolvedAvatarUrl, }); } } @@ -520,6 +534,14 @@ export const registerJoinRoomHandler = (context: ConnectionContext): void => { roomId: context.currentRoom.id, }); + socket.emit("avatarSnapshot", { + users: context.currentRoom.getAvatarSnapshot({ + includeGhosts: context.currentClient.isGhost, + includeWebinarAttendees: false, + }), + roomId: context.currentRoom.id, + } satisfies AvatarSnapshot & { roomId: string }); + socket.emit("handRaisedSnapshot", { users: context.currentRoom.getHandRaisedSnapshot(), roomId: context.currentRoom.id, From f65e415318742abf6794af3f0310f281671bb3bc Mon Sep 17 00:00:00 2001 From: arnavsoni1 Date: Wed, 11 Mar 2026 18:19:36 +0000 Subject: [PATCH 2/2] fix: got it working --- apps/mobile/package.json | 1 + .../features/meets/components/call-screen.tsx | 26 +- .../features/meets/components/join-screen.tsx | 305 +++++++++++++++++- .../features/meets/components/meet-screen.tsx | 17 + .../meets/components/participant-tile.tsx | 53 ++- .../meets/hooks/use-meet-display-name.ts | 6 +- .../src/features/meets/hooks/use-meet-refs.ts | 1 + .../features/meets/hooks/use-meet-socket.ts | 68 ++++ apps/web/README.md | 7 + .../src/app/api/uploads/avatar/sign/route.ts | 107 ++++-- apps/web/src/app/components/BrowserLayout.tsx | 21 +- .../app/components/DevPlaygroundLayout.tsx | 23 +- apps/web/src/app/components/GridLayout.tsx | 21 +- apps/web/src/app/components/JoinScreen.tsx | 263 ++++++++++++++- .../src/app/components/PresentationLayout.tsx | 23 +- .../src/app/components/WhiteboardLayout.tsx | 23 +- apps/web/src/app/hooks/useMeetSocket.ts | 14 + apps/web/src/app/meets-client.tsx | 9 +- .../sfu/server/socket/handlers/joinRoom.ts | 60 +++- 19 files changed, 968 insertions(+), 80 deletions(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 464517b..5d137c5 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -30,6 +30,7 @@ "expo-font": "~14.0.11", "expo-glass-effect": "~0.1.8", "expo-haptics": "~15.0.8", + "expo-image-picker": "~16.0.7", "expo-image": "^3.0.10", "expo-keep-awake": "~15.0.7", "expo-linear-gradient": "~15.0.8", diff --git a/apps/mobile/src/features/meets/components/call-screen.tsx b/apps/mobile/src/features/meets/components/call-screen.tsx index fc97a5b..5722606 100644 --- a/apps/mobile/src/features/meets/components/call-screen.tsx +++ b/apps/mobile/src/features/meets/components/call-screen.tsx @@ -22,7 +22,7 @@ import { isSystemUserId } from "../utils"; import { useDeviceLayout, type DeviceLayout } from "../hooks/use-device-layout"; import { ControlsBar } from "./controls-bar"; import { ParticipantTile } from "./participant-tile"; -import { FlatList, Text, Pressable } from "@/tw"; +import { FlatList, Text, Pressable, Image } from "@/tw"; import { Lock, Settings, Users, MicOff, VenetianMask } from "lucide-react-native"; import { GlassPill } from "./glass-pill"; import { useApps } from "@conclave/apps-sdk"; @@ -75,6 +75,7 @@ interface CallScreenProps { isMirrorCamera: boolean; activeSpeakerId: string | null; resolveDisplayName: (userId: string) => string; + resolveAvatarUrl: (userId: string) => string | undefined; onToggleMute: () => void; onToggleCamera: () => void; onToggleScreenShare: () => void; @@ -180,6 +181,7 @@ export function CallScreen({ isMirrorCamera, activeSpeakerId, resolveDisplayName, + resolveAvatarUrl, onToggleMute, onToggleCamera, onToggleScreenShare, @@ -1003,6 +1005,7 @@ export function CallScreen({ : resolveDisplayName(item.userId); const initials = label?.trim()?.[0]?.toUpperCase() || "?"; + const avatarUrl = resolveAvatarUrl(item.userId); return ( {item.videoStream && !item.isCameraOff ? ( @@ -1018,7 +1021,17 @@ export function CallScreen({ /> ) : ( - {initials} + {avatarUrl ? ( + + + + ) : ( + {initials} + )} )} @@ -1092,6 +1105,7 @@ export function CallScreen({ type Phase = "welcome" | "auth" | "join"; const isIos = Platform.OS === "ios"; +const AVATAR_URL_STORAGE_KEY = "conclave:avatar-url"; +const MAX_AVATAR_FILE_BYTES = 5 * 1024 * 1024; const GoogleIcon = ({ size = 18 }: { size?: number }) => ( @@ -118,6 +124,9 @@ interface JoinScreenProps { isLoading: boolean; displayNameInput: string; onDisplayNameInputChange: (value: string) => void; + avatarUrl?: string; + onAvatarUrlChange: (value: string | undefined) => void; + uploadApiBaseUrl?: string; isMuted: boolean; isCameraOff: boolean; localStream: MediaStream | null; @@ -144,6 +153,9 @@ export function JoinScreen({ isLoading, displayNameInput, onDisplayNameInputChange, + avatarUrl, + onAvatarUrlChange, + uploadApiBaseUrl, isMuted, isCameraOff, localStream, @@ -174,6 +186,10 @@ export function JoinScreen({ null ); const [isAppleAvailable, setIsAppleAvailable] = useState(false); + const [avatarPreviewUri, setAvatarPreviewUri] = useState(null); + const [avatarUploadError, setAvatarUploadError] = useState(null); + const [isAvatarUploading, setIsAvatarUploading] = useState(false); + const [avatarLoadFailed, setAvatarLoadFailed] = useState(false); const [isEditingName, setIsEditingName] = useState(false); const nameInputRef = useRef>(null); const isAuthLoading = authProvider !== null; @@ -361,6 +377,135 @@ export function JoinScreen({ }, [canJoin, isLoading, haptic, onIsAdminChange, onJoinRoom, roomId]); const userInitial = displayNameInput?.[0]?.toUpperCase() || "?"; + const effectiveAvatarUri = avatarPreviewUri || avatarUrl || ""; + + useEffect(() => { + if (avatarUrl?.trim()) { + AsyncStorage.setItem(AVATAR_URL_STORAGE_KEY, avatarUrl.trim()).catch(() => {}); + return; + } + AsyncStorage.removeItem(AVATAR_URL_STORAGE_KEY).catch(() => {}); + }, [avatarUrl]); + + useEffect(() => { + if (avatarUrl?.trim()) return; + AsyncStorage.getItem(AVATAR_URL_STORAGE_KEY) + .then((stored) => { + const normalized = stored?.trim(); + if (normalized) { + onAvatarUrlChange(normalized); + } + }) + .catch(() => {}); + }, [avatarUrl, onAvatarUrlChange]); + + useEffect(() => { + setAvatarLoadFailed(false); + }, [effectiveAvatarUri]); + + const clearAvatar = useCallback(() => { + setAvatarPreviewUri(null); + setAvatarUploadError(null); + onAvatarUrlChange(undefined); + AsyncStorage.removeItem(AVATAR_URL_STORAGE_KEY).catch(() => {}); + }, [onAvatarUrlChange]); + + const uploadAvatarToSignedUrl = useCallback( + async (asset: ImagePicker.ImagePickerAsset) => { + const normalizedBase = uploadApiBaseUrl?.trim().replace(/\/$/, "") || authBaseUrl.replace(/\/$/, ""); + if (!normalizedBase) { + throw new Error("Missing EXPO_PUBLIC_APP_URL or EXPO_PUBLIC_API_URL"); + } + + const signResponse = await fetch(`${normalizedBase}/api/uploads/avatar/sign`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + filename: asset.fileName || `avatar-${Date.now()}.jpg`, + contentType: asset.mimeType || "image/jpeg", + }), + }); + + const signResult = (await signResponse.json().catch(() => null)) as { + uploadUrl?: string; + params?: Record; + error?: string; + } | null; + + if (!signResponse.ok || !signResult?.uploadUrl || !signResult.params) { + throw new Error(signResult?.error || "Failed to prepare avatar upload."); + } + + const formData = new FormData(); + for (const [key, value] of Object.entries(signResult.params)) { + formData.append(key, value); + } + + formData.append("file", { + uri: asset.uri, + type: asset.mimeType || "image/jpeg", + name: asset.fileName || `avatar-${Date.now()}.jpg`, + } as unknown as Blob); + + const uploadResponse = await fetch(signResult.uploadUrl, { + method: "POST", + body: formData, + }); + + const uploadResult = (await uploadResponse.json().catch(() => null)) as { + secure_url?: string; + error?: { message?: string }; + } | null; + + const uploadedUrl = uploadResult?.secure_url?.trim(); + if (!uploadResponse.ok || !uploadedUrl) { + throw new Error(uploadResult?.error?.message || "Avatar upload failed."); + } + + return uploadedUrl; + }, + [uploadApiBaseUrl] + ); + + const handlePickAvatar = useCallback(async () => { + haptic(); + setAvatarUploadError(null); + setIsAvatarUploading(true); + try { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images"], + allowsEditing: true, + aspect: [1, 1], + quality: 0.8, + exif: false, + }); + + if (result.canceled || !result.assets?.length) { + return; + } + + const asset = result.assets[0]; + const fileSize = asset.fileSize ?? 0; + if (fileSize > MAX_AVATAR_FILE_BYTES) { + throw new Error("Image must be 5MB or smaller."); + } + + setAvatarPreviewUri(asset.uri); + const uploadedUrl = await uploadAvatarToSignedUrl(asset); + onAvatarUrlChange(uploadedUrl); + setAvatarPreviewUri(null); + setAvatarUploadError(null); + await AsyncStorage.setItem(AVATAR_URL_STORAGE_KEY, uploadedUrl); + } catch (error) { + setAvatarUploadError( + error instanceof Error ? error.message : "Failed to upload avatar.", + ); + } finally { + setIsAvatarUploading(false); + } + }, [haptic, onAvatarUrlChange, uploadAvatarToSignedUrl]); useEffect(() => { if (Platform.OS !== "ios") return; @@ -646,12 +791,24 @@ export function JoinScreen({ colors={["rgba(249, 95, 74, 0.2)", "rgba(255, 0, 122, 0.1)"]} style={styles.previewGradient} /> - - - - {userInitial} - - + {effectiveAvatarUri && !avatarLoadFailed ? ( + + setAvatarLoadFailed(true)} + /> + + + ) : ( + + + + {userInitial} + + + )} {shouldShowPermissionPrompt ? ( @@ -739,11 +896,43 @@ export function JoinScreen({ + + + {isAvatarUploading ? ( + + ) : ( + + )} + + {isAvatarUploading ? "Uploading" : "Upload photo"} + + + {effectiveAvatarUri ? ( + + + Remove + + ) : null} + + + {avatarUploadError ? ( + + {avatarUploadError} + + ) : null} {!forceJoinOnly ? ( - - - - {userInitial} - - + {effectiveAvatarUri && !avatarLoadFailed ? ( + + setAvatarLoadFailed(true)} + /> + + + ) : ( + + + + {userInitial} + + + )} {shouldShowPermissionPrompt ? ( @@ -999,6 +1200,33 @@ export function JoinScreen({ + + + + {isAvatarUploading ? ( + + ) : ( + + )} + + {isAvatarUploading ? "Uploading" : "Upload photo"} + + + {effectiveAvatarUri ? ( + + + Remove + + ) : null} + @@ -1622,6 +1850,11 @@ const styles = StyleSheet.create({ justifyContent: "center", backgroundColor: "rgba(249, 95, 74, 0.15)", position: "relative", + overflow: "hidden", + }, + userAvatarImage: { + width: "100%", + height: "100%", }, userAvatarBorder: { ...StyleSheet.absoluteFillObject, @@ -1666,6 +1899,54 @@ const styles = StyleSheet.create({ right: 0, alignItems: "center", }, + avatarActionsOverlay: { + position: "absolute", + right: 12, + top: 12, + gap: 8, + alignItems: "flex-end", + }, + avatarActionButton: { + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingHorizontal: 10, + paddingVertical: 7, + borderRadius: 999, + borderWidth: 1, + borderColor: "rgba(254, 252, 217, 0.2)", + backgroundColor: "rgba(0,0,0,0.55)", + }, + avatarActionButtonDisabled: { + opacity: 0.7, + }, + avatarActionDanger: { + borderColor: "rgba(249, 95, 74, 0.4)", + backgroundColor: "rgba(249, 95, 74, 0.18)", + }, + avatarActionText: { + fontSize: 10, + lineHeight: textLineHeight(10, 1.2), + color: COLORS.cream, + fontFamily: "PolySans-Mono", + letterSpacing: 1, + textTransform: "uppercase", + }, + avatarErrorPill: { + borderRadius: 999, + borderWidth: 1, + borderColor: "rgba(249,95,74,0.35)", + backgroundColor: "rgba(249,95,74,0.12)", + paddingHorizontal: 12, + paddingVertical: 8, + }, + avatarErrorText: { + color: COLORS.primaryOrange, + fontSize: 11, + lineHeight: textLineHeight(11, 1.25), + fontFamily: "PolySans-Mono", + textAlign: "center", + }, mediaControlsPill: { flexDirection: "row", gap: 8, diff --git a/apps/mobile/src/features/meets/components/meet-screen.tsx b/apps/mobile/src/features/meets/components/meet-screen.tsx index 2dd95e2..a6415bf 100644 --- a/apps/mobile/src/features/meets/components/meet-screen.tsx +++ b/apps/mobile/src/features/meets/components/meet-screen.tsx @@ -270,6 +270,7 @@ export function MeetScreen({ const userKey = user?.email || user?.id || `guest-${guestSessionId}`; const userId = `${userKey}#${refs.sessionIdRef.current}`; + const [joinAvatarUrl, setJoinAvatarUrl] = useState(undefined); const { setDisplayNames, @@ -285,9 +286,20 @@ export function MeetScreen({ userId, isAdmin, ghostEnabled: isGhostMode, + avatarUrl: joinAvatarUrl, socketRef: refs.socketRef, joinOptionsRef: refs.joinOptionsRef, }); + const [avatarUrls, setAvatarUrls] = useState>(new Map()); + const resolveAvatarUrl = useCallback( + (targetUserId: string) => { + if (targetUserId === userId) { + return joinAvatarUrl?.trim() || avatarUrls.get(targetUserId)?.trim() || undefined; + } + return avatarUrls.get(targetUserId)?.trim() || undefined; + }, + [avatarUrls, joinAvatarUrl, userId] + ); const appsUser = useMemo( () => ({ id: userId, @@ -716,6 +728,7 @@ export function MeetScreen({ setLocalStream, dispatchParticipants, setDisplayNames, + setAvatarUrls, setPendingUsers, setConnectionState, setMeetError, @@ -1410,6 +1423,9 @@ export function MeetScreen({ isLoading={isLoading} displayNameInput={displayNameInput} onDisplayNameInputChange={setDisplayNameInput} + avatarUrl={joinAvatarUrl} + onAvatarUrlChange={setJoinAvatarUrl} + uploadApiBaseUrl={apiBaseUrl || undefined} isMuted={isMuted} isCameraOff={isCameraOff} localStream={localStream} @@ -1444,6 +1460,7 @@ export function MeetScreen({ isMirrorCamera={isMirrorCamera} activeSpeakerId={effectiveActiveSpeakerId} resolveDisplayName={resolveDisplayName} + resolveAvatarUrl={resolveAvatarUrl} onToggleMute={toggleMute} onToggleCamera={toggleCamera} onToggleScreenShare={handleToggleScreenShare} diff --git a/apps/mobile/src/features/meets/components/participant-tile.tsx b/apps/mobile/src/features/meets/components/participant-tile.tsx index e4d4e87..7af183d 100644 --- a/apps/mobile/src/features/meets/components/participant-tile.tsx +++ b/apps/mobile/src/features/meets/components/participant-tile.tsx @@ -1,10 +1,10 @@ -import React, { useMemo } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { StyleSheet, View as RNView } from "react-native"; import { RTCView } from "react-native-webrtc"; import { LinearGradient } from "expo-linear-gradient"; import type { Participant } from "../types"; import { useDeviceLayout } from "../hooks/use-device-layout"; -import { Text } from "@/tw"; +import { Image, Text } from "@/tw"; import { Hand, MicOff, VenetianMask } from "lucide-react-native"; const COLORS = { @@ -22,6 +22,7 @@ const COLORS = { interface ParticipantTileProps { participant: Participant; displayName: string; + avatarUrl?: string; isLocal?: boolean; isActiveSpeaker?: boolean; mirror?: boolean; @@ -36,6 +37,7 @@ const hiddenAudioStyle = { function ParticipantTileComponent({ participant, displayName, + avatarUrl, isLocal = false, isActiveSpeaker = false, mirror = false, @@ -45,6 +47,13 @@ function ParticipantTileComponent({ const audioStream = participant.audioStream; const hasVideo = !!videoStream && !participant.isCameraOff; const shouldRenderAudioView = !!audioStream && !hasVideo; + const [avatarLoadFailed, setAvatarLoadFailed] = useState(false); + const normalizedAvatarUrl = avatarUrl?.trim() || ""; + const showAvatarImage = !hasVideo && Boolean(normalizedAvatarUrl) && !avatarLoadFailed; + + useEffect(() => { + setAvatarLoadFailed(false); + }, [normalizedAvatarUrl]); const initials = useMemo(() => { const parts = displayName.trim().split(/\s+/).filter(Boolean); @@ -78,17 +87,34 @@ function ParticipantTileComponent({ colors={["rgba(249, 95, 74, 0.2)", "rgba(255, 0, 122, 0.1)"]} style={styles.avatarGradient} /> - - - - {initials} - - + {showAvatarImage ? ( + + setAvatarLoadFailed(true)} + /> + + + ) : ( + + + + {initials} + + + )} )} @@ -161,6 +187,7 @@ export const ParticipantTile = React.memo( ParticipantTileComponent, (prevProps, nextProps) => prevProps.displayName === nextProps.displayName && + prevProps.avatarUrl === nextProps.avatarUrl && prevProps.isLocal === nextProps.isLocal && prevProps.isActiveSpeaker === nextProps.isActiveSpeaker && prevProps.mirror === nextProps.mirror && diff --git a/apps/mobile/src/features/meets/hooks/use-meet-display-name.ts b/apps/mobile/src/features/meets/hooks/use-meet-display-name.ts index 00c6ffb..1ccd0e1 100644 --- a/apps/mobile/src/features/meets/hooks/use-meet-display-name.ts +++ b/apps/mobile/src/features/meets/hooks/use-meet-display-name.ts @@ -17,9 +17,11 @@ interface UseMeetDisplayNameOptions { userId: string; isAdmin: boolean; ghostEnabled: boolean; + avatarUrl?: string; socketRef: React.MutableRefObject; joinOptionsRef: React.MutableRefObject<{ displayName?: string; + avatarUrl?: string; isGhost: boolean; joinMode: JoinMode; webinarInviteCode?: string; @@ -32,6 +34,7 @@ export function useMeetDisplayName({ userId, isAdmin: _isAdmin, ghostEnabled, + avatarUrl, socketRef, joinOptionsRef, }: UseMeetDisplayNameOptions) { @@ -89,9 +92,10 @@ export function useMeetDisplayName({ joinOptionsRef.current = { ...joinOptionsRef.current, displayName: normalized || undefined, + avatarUrl: avatarUrl?.trim() || undefined, isGhost: ghostEnabled, }; - }, [displayNameInput, ghostEnabled, joinOptionsRef]); + }, [avatarUrl, displayNameInput, ghostEnabled, joinOptionsRef]); useEffect(() => { if (!displayNameStatus) return; diff --git a/apps/mobile/src/features/meets/hooks/use-meet-refs.ts b/apps/mobile/src/features/meets/hooks/use-meet-refs.ts index 958c5af..09d11b5 100644 --- a/apps/mobile/src/features/meets/hooks/use-meet-refs.ts +++ b/apps/mobile/src/features/meets/hooks/use-meet-refs.ts @@ -55,6 +55,7 @@ export function useMeetRefs() { const shouldAutoJoinRef = useRef(false); const joinOptionsRef = useRef<{ displayName?: string; + avatarUrl?: string; isGhost: boolean; joinMode: JoinMode; webinarInviteCode?: string; diff --git a/apps/mobile/src/features/meets/hooks/use-meet-socket.ts b/apps/mobile/src/features/meets/hooks/use-meet-socket.ts index 26856da..8a4d673 100644 --- a/apps/mobile/src/features/meets/hooks/use-meet-socket.ts +++ b/apps/mobile/src/features/meets/hooks/use-meet-socket.ts @@ -83,6 +83,7 @@ interface UseMeetSocketOptions { setLocalStream: React.Dispatch>; dispatchParticipants: (action: ParticipantAction) => void; setDisplayNames: React.Dispatch>>; + setAvatarUrls: React.Dispatch>>; setPendingUsers: React.Dispatch>>; setConnectionState: (state: ConnectionState) => void; setMeetError: (error: MeetError | null) => void; @@ -160,6 +161,7 @@ export function useMeetSocket({ setLocalStream, dispatchParticipants, setDisplayNames, + setAvatarUrls, setPendingUsers, setConnectionState, setMeetError, @@ -309,6 +311,7 @@ export function useMeetSocket({ clearReactions(); setPendingUsers(new Map()); setDisplayNames(new Map()); + setAvatarUrls(new Map()); setHostUserId(null); setHostUserIds([]); setWebinarRole(null); @@ -380,6 +383,7 @@ export function useMeetSocket({ screenShareStreamRef, setActiveScreenShareId, setDisplayNames, + setAvatarUrls, setIsHandRaised, setIsNoGuests, setIsScreenSharing, @@ -1233,6 +1237,7 @@ export function useMeetSocket({ stream: MediaStream | null, joinOptions: { displayName?: string; + avatarUrl?: string; isGhost: boolean; joinMode: JoinMode; webinarInviteCode?: string; @@ -1252,6 +1257,7 @@ export function useMeetSocket({ roomId: targetRoomId, sessionId: sessionIdRef.current, displayName: joinOptions.displayName, + avatarUrl: joinOptions.avatarUrl, ghost: joinOptions.isGhost, webinarInviteCode: joinOptions.webinarInviteCode, meetingInviteCode: joinOptions.meetingInviteCode, @@ -1688,10 +1694,12 @@ export function useMeetSocket({ ({ userId: joinedUserId, displayName, + avatarUrl, isGhost, }: { userId: string; displayName?: string; + avatarUrl?: string; isGhost?: boolean; }) => { console.log("[Meets] User joined:", joinedUserId); @@ -1708,6 +1716,15 @@ export function useMeetSocket({ return next; }); } + setAvatarUrls((prev) => { + const next = new Map(prev); + if (avatarUrl?.trim()) { + next.set(joinedUserId, avatarUrl.trim()); + } else { + next.delete(joinedUserId); + } + return next; + }); const leaveTimeout = leaveTimeoutsRef.current.get(joinedUserId); if (leaveTimeout) { clearTimeout(leaveTimeout); @@ -1737,6 +1754,12 @@ export function useMeetSocket({ next.delete(leftUserId); return next; }); + setAvatarUrls((prev) => { + if (!prev.has(leftUserId)) return prev; + const next = new Map(prev); + next.delete(leftUserId); + return next; + }); const producersToClose = Array.from( producerMapRef.current.entries() @@ -1801,6 +1824,27 @@ export function useMeetSocket({ } ); + socket.on( + "avatarSnapshot", + ({ + users, + roomId: eventRoomId, + }: { + users: { userId: string; avatarUrl?: string }[]; + roomId?: string; + }) => { + if (!isRoomEvent(eventRoomId)) return; + const snapshot = new Map(); + (users || []).forEach(({ userId: snapshotUserId, avatarUrl }) => { + const normalizedAvatarUrl = avatarUrl?.trim(); + if (normalizedAvatarUrl) { + snapshot.set(snapshotUserId, normalizedAvatarUrl); + } + }); + setAvatarUrls(snapshot); + } + ); + socket.on( "handRaisedSnapshot", ({ users, roomId: eventRoomId }: HandRaisedSnapshot) => { @@ -1819,6 +1863,30 @@ export function useMeetSocket({ } ); + socket.on( + "avatarUpdated", + ({ + userId: updatedUserId, + avatarUrl, + roomId: eventRoomId, + }: { + userId: string; + avatarUrl?: string; + roomId?: string; + }) => { + if (!isRoomEvent(eventRoomId)) return; + setAvatarUrls((prev) => { + const next = new Map(prev); + if (avatarUrl?.trim()) { + next.set(updatedUserId, avatarUrl.trim()); + } else { + next.delete(updatedUserId); + } + return next; + }); + } + ); + socket.on( "displayNameUpdated", ({ diff --git a/apps/web/README.md b/apps/web/README.md index 683cb37..b01f840 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -9,3 +9,10 @@ Integration notes - Optionally provide `getRooms` and `getRoomsForRedirect` to populate host room lists. - Reaction assets are served from `public/reactions` and passed via `reactionAssets`. - Set `NEXT_PUBLIC_SFU_CLIENT_ID` to tag requests with `x-sfu-client` so the SFU can apply per-client policies. + +Avatar upload (pre-join) +- Configure Cloudinary for signed uploads in your web env: + - `CLOUDINARY_CLOUD_NAME` + - `CLOUDINARY_API_KEY` + - `CLOUDINARY_API_SECRET` + - optional: `CLOUDINARY_AVATAR_FOLDER` (default: `conclave/avatars`) diff --git a/apps/web/src/app/api/uploads/avatar/sign/route.ts b/apps/web/src/app/api/uploads/avatar/sign/route.ts index 779ac84..5f1774f 100644 --- a/apps/web/src/app/api/uploads/avatar/sign/route.ts +++ b/apps/web/src/app/api/uploads/avatar/sign/route.ts @@ -1,13 +1,23 @@ -import { createHash } from "node:crypto"; -import { NextResponse } from "next/server"; - export const runtime = "nodejs"; +declare const process: + | { + env?: Record; + } + | undefined; + type SignRequestBody = { filename?: string; contentType?: string; }; +type CloudinaryConfig = { + cloudName?: string; + apiKey?: string; + apiSecret?: string; + folder: string; +}; + const ALLOWED_CONTENT_TYPES = new Set([ "image/jpeg", "image/jpg", @@ -27,30 +37,89 @@ const sanitizeFileBaseName = (filename: string): string => { return trimmed || "avatar"; }; -const buildCloudinarySignature = ( +const buildCloudinaryCanonicalString = ( params: Record, - apiSecret: string, ): string => { - const canonical = Object.entries(params) + return Object.entries(params) .filter(([, value]) => value) .sort(([a], [b]) => a.localeCompare(b)) .map(([key, value]) => `${key}=${value}`) .join("&"); +}; + +const buildCloudinarySignature = async ( + params: Record, + apiSecret: string, +): Promise => { + const canonical = buildCloudinaryCanonicalString(params); + const payload = `${canonical}${apiSecret}`; + const bytes = new TextEncoder().encode(payload); + const digest = await crypto.subtle.digest("SHA-1", bytes); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +}; - return createHash("sha1") - .update(`${canonical}${apiSecret}`) - .digest("hex"); +const normalizeEnvValue = (value?: string): string | undefined => { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; }; -export async function POST(request: Request) { - const cloudName = process.env.CLOUDINARY_CLOUD_NAME?.trim(); - const apiKey = process.env.CLOUDINARY_API_KEY?.trim(); - const apiSecret = process.env.CLOUDINARY_API_SECRET?.trim(); +const parseCloudinaryUrl = ( + cloudinaryUrl?: string, +): { cloudName?: string; apiKey?: string; apiSecret?: string } => { + const normalized = normalizeEnvValue(cloudinaryUrl); + if (!normalized) { + return {}; + } + + try { + const parsed = new URL(normalized); + const cloudName = normalizeEnvValue(parsed.hostname); + const apiKey = normalizeEnvValue(parsed.username); + const apiSecret = normalizeEnvValue(parsed.password); + return { cloudName, apiKey, apiSecret }; + } catch { + return {}; + } +}; + +const resolveCloudinaryConfig = (): CloudinaryConfig => { + const env = process?.env ?? {}; + const fromUrl = parseCloudinaryUrl(env.CLOUDINARY_URL); + + const cloudName = + normalizeEnvValue(env.CLOUDINARY_CLOUD_NAME) || + normalizeEnvValue(env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME) || + normalizeEnvValue(env.CLOUDINARY_NAME) || + fromUrl.cloudName; + + const apiKey = + normalizeEnvValue(env.CLOUDINARY_API_KEY) || + normalizeEnvValue(env.NEXT_PUBLIC_CLOUDINARY_API_KEY) || + fromUrl.apiKey; + + const apiSecret = + normalizeEnvValue(env.CLOUDINARY_API_SECRET) || fromUrl.apiSecret; + const folder = - process.env.CLOUDINARY_AVATAR_FOLDER?.trim() || "conclave/avatars"; + normalizeEnvValue(env.CLOUDINARY_AVATAR_FOLDER) || + normalizeEnvValue(env.NEXT_PUBLIC_CLOUDINARY_AVATAR_FOLDER) || + "conclave/avatars"; + + return { + cloudName, + apiKey, + apiSecret, + folder, + }; +}; + +export async function POST(request: Request) { + const { cloudName, apiKey, apiSecret, folder } = resolveCloudinaryConfig(); if (!cloudName || !apiKey || !apiSecret) { - return NextResponse.json( + return Response.json( { error: "Avatar upload is not configured." }, { status: 500 }, ); @@ -60,12 +129,12 @@ export async function POST(request: Request) { try { body = (await request.json()) as SignRequestBody; } catch { - return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + return Response.json({ error: "Invalid request body" }, { status: 400 }); } const contentType = body.contentType?.trim().toLowerCase() || ""; if (!ALLOWED_CONTENT_TYPES.has(contentType)) { - return NextResponse.json({ error: "Unsupported image format" }, { status: 400 }); + return Response.json({ error: "Unsupported image format" }, { status: 400 }); } const filename = body.filename?.trim() || "avatar"; @@ -79,9 +148,9 @@ export async function POST(request: Request) { timestamp, }; - const signature = buildCloudinarySignature(signableParams, apiSecret); + const signature = await buildCloudinarySignature(signableParams, apiSecret); - return NextResponse.json({ + return Response.json({ uploadUrl: `https://api.cloudinary.com/v1_1/${cloudName}/image/upload`, params: { api_key: apiKey, diff --git a/apps/web/src/app/components/BrowserLayout.tsx b/apps/web/src/app/components/BrowserLayout.tsx index dd9b4e0..d131f0b 100644 --- a/apps/web/src/app/components/BrowserLayout.tsx +++ b/apps/web/src/app/components/BrowserLayout.tsx @@ -63,6 +63,12 @@ function BrowserLayout({ const [isReady, setIsReady] = useState(false); const [navInput, setNavInput] = useState(browserUrl); const [navError, setNavError] = useState(null); + const localAvatarUrl = getAvatarUrl(currentUserId)?.trim() || ""; + const [localAvatarLoadFailed, setLocalAvatarLoadFailed] = useState(false); + + useEffect(() => { + setLocalAvatarLoadFailed(false); + }, [localAvatarUrl]); // Wait for browser container to be ready before showing iframe useEffect(() => { @@ -253,9 +259,18 @@ function BrowserLayout({ /> {isCameraOff && (
-
- {userEmail[0]?.toUpperCase() || "?"} -
+ {localAvatarUrl && !localAvatarLoadFailed ? ( + Your avatar setLocalAvatarLoadFailed(true)} + /> + ) : ( +
+ {userEmail[0]?.toUpperCase() || "?"} +
+ )}
)} {isGhost && ( diff --git a/apps/web/src/app/components/DevPlaygroundLayout.tsx b/apps/web/src/app/components/DevPlaygroundLayout.tsx index 36ec8cf..6796924 100644 --- a/apps/web/src/app/components/DevPlaygroundLayout.tsx +++ b/apps/web/src/app/components/DevPlaygroundLayout.tsx @@ -1,7 +1,7 @@ "use client"; import { Ghost, Hand } from "lucide-react"; -import { memo, useEffect, useRef } from "react"; +import { memo, useEffect, useRef, useState } from "react"; import { DevPlaygroundWebApp } from "@conclave/apps-sdk/dev-playground/web"; import { useSmartParticipantOrder } from "../hooks/useSmartParticipantOrder"; import type { Participant } from "../lib/types"; @@ -41,6 +41,12 @@ function DevPlaygroundLayout({ }: DevPlaygroundLayoutProps) { const localVideoRef = useRef(null); const isLocalActiveSpeaker = activeSpeakerId === currentUserId; + const localAvatarUrl = getAvatarUrl(currentUserId)?.trim() || ""; + const [localAvatarLoadFailed, setLocalAvatarLoadFailed] = useState(false); + + useEffect(() => { + setLocalAvatarLoadFailed(false); + }, [localAvatarUrl]); useEffect(() => { const video = localVideoRef.current; @@ -85,9 +91,18 @@ function DevPlaygroundLayout({ /> {isCameraOff && (
-
- {userEmail[0]?.toUpperCase() || "?"} -
+ {localAvatarUrl && !localAvatarLoadFailed ? ( + Your avatar setLocalAvatarLoadFailed(true)} + /> + ) : ( +
+ {userEmail[0]?.toUpperCase() || "?"} +
+ )}
)} {isGhost && ( diff --git a/apps/web/src/app/components/GridLayout.tsx b/apps/web/src/app/components/GridLayout.tsx index 8796aa8..a6a22b2 100644 --- a/apps/web/src/app/components/GridLayout.tsx +++ b/apps/web/src/app/components/GridLayout.tsx @@ -220,6 +220,12 @@ function GridLayout({ const localSpeakerHighlight = isLocalActiveSpeaker ? "speaking" : ""; + const localAvatarUrl = getAvatarUrl(currentUserId)?.trim() || ""; + const [localAvatarLoadFailed, setLocalAvatarLoadFailed] = useState(false); + + useEffect(() => { + setLocalAvatarLoadFailed(false); + }, [localAvatarUrl]); const copyToClipboard = async (value: string) => { if (navigator.clipboard?.writeText) { @@ -297,9 +303,18 @@ function GridLayout({ /> {isCameraOff && (
-
- {userEmail[0]?.toUpperCase() || "?"} -
+ {localAvatarUrl && !localAvatarLoadFailed ? ( + {`${localDisplayName} setLocalAvatarLoadFailed(true)} + /> + ) : ( +
+ {userEmail[0]?.toUpperCase() || "?"} +
+ )}
)} {isGhost && ( diff --git a/apps/web/src/app/components/JoinScreen.tsx b/apps/web/src/app/components/JoinScreen.tsx index 5b9529b..e1ff8a9 100644 --- a/apps/web/src/app/components/JoinScreen.tsx +++ b/apps/web/src/app/components/JoinScreen.tsx @@ -12,8 +12,10 @@ import { Plus, ArrowRight, RefreshCw, + Upload, + X, } from "lucide-react"; -import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { signIn, signOut, useSession } from "@/lib/auth-client"; import type { RoomInfo } from "@/lib/sfu-types"; import type { ConnectionState, MeetError } from "../lib/types"; @@ -35,6 +37,10 @@ import MeetsErrorBanner from "./MeetsErrorBanner"; const normalizeGuestName = (value: string): string => value.trim().replace(/\s+/g, " "); const GUEST_USER_STORAGE_KEY = "conclave:guest-user"; +const AVATAR_URL_STORAGE_KEY = "conclave:avatar-url"; +const MAX_AVATAR_FILE_BYTES = 5 * 1024 * 1024; +const MAX_LOCAL_AVATAR_DIMENSION = 256; +const LOCAL_AVATAR_QUALITY = 0.82; const createGuestId = (): string => { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { @@ -62,6 +68,45 @@ const buildGuestUser = ( }; }; +const buildLocalAvatarDataUrl = async (file: File): Promise => { + const imageUrl = URL.createObjectURL(file); + try { + const image = await new Promise((resolve, reject) => { + const element = new Image(); + element.onload = () => resolve(element); + element.onerror = () => reject(new Error("Unable to read avatar image.")); + element.src = imageUrl; + }); + + const width = image.naturalWidth || image.width; + const height = image.naturalHeight || image.height; + const scale = Math.min( + 1, + MAX_LOCAL_AVATAR_DIMENSION / Math.max(width || 1, height || 1), + ); + const targetWidth = Math.max(1, Math.round(width * scale)); + const targetHeight = Math.max(1, Math.round(height * scale)); + + const canvas = document.createElement("canvas"); + canvas.width = targetWidth; + canvas.height = targetHeight; + + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Unable to process avatar image."); + } + + context.drawImage(image, 0, 0, targetWidth, targetHeight); + const dataUrl = canvas.toDataURL("image/jpeg", LOCAL_AVATAR_QUALITY); + if (!dataUrl) { + throw new Error("Unable to prepare avatar image."); + } + return dataUrl; + } finally { + URL.revokeObjectURL(imageUrl); + } +}; + interface JoinScreenProps { roomId: string; onRoomIdChange: (id: string) => void; @@ -84,6 +129,8 @@ interface JoinScreenProps { onRefreshRooms: () => void; displayNameInput: string; onDisplayNameInputChange: (value: string) => void; + avatarUrl?: string; + onAvatarUrlChange: (value: string | undefined) => void; isGhostMode: boolean; onGhostModeChange: (value: boolean) => void; onUserChange: (user: { id: string; email: string; name: string } | null) => void; @@ -112,6 +159,8 @@ function JoinScreen({ onRefreshRooms, displayNameInput, onDisplayNameInputChange, + avatarUrl, + onAvatarUrlChange, isGhostMode, onGhostModeChange, onUserChange, @@ -148,6 +197,11 @@ function JoinScreen({ ); const isSigningIn = signInProvider !== null; const [isSigningOut, setIsSigningOut] = useState(false); + const [avatarPreviewUrl, setAvatarPreviewUrl] = useState(null); + const [avatarUploadError, setAvatarUploadError] = useState(null); + const [isAvatarUploading, setIsAvatarUploading] = useState(false); + const avatarFileInputRef = useRef(null); + const avatarObjectUrlRef = useRef(null); const normalizedSegments = useMemo( () => normalizedRoomId.split("-"), [normalizedRoomId] @@ -245,6 +299,155 @@ function JoinScreen({ if (videoRef.current && localStream) videoRef.current.srcObject = localStream; }, [localStream]); + useEffect(() => { + if (typeof window === "undefined") return; + if (avatarUrl?.trim()) { + window.localStorage.setItem(AVATAR_URL_STORAGE_KEY, avatarUrl.trim()); + return; + } + window.localStorage.removeItem(AVATAR_URL_STORAGE_KEY); + }, [avatarUrl]); + + useEffect(() => { + if (typeof window === "undefined") return; + if (avatarUrl?.trim()) return; + const stored = window.localStorage.getItem(AVATAR_URL_STORAGE_KEY)?.trim(); + if (stored) { + onAvatarUrlChange(stored); + } + }, [avatarUrl, onAvatarUrlChange]); + + useEffect(() => { + return () => { + if (avatarObjectUrlRef.current) { + URL.revokeObjectURL(avatarObjectUrlRef.current); + avatarObjectUrlRef.current = null; + } + }; + }, []); + + const clearAvatar = () => { + if (avatarObjectUrlRef.current) { + URL.revokeObjectURL(avatarObjectUrlRef.current); + avatarObjectUrlRef.current = null; + } + setAvatarPreviewUrl(null); + setAvatarUploadError(null); + onAvatarUrlChange(undefined); + if (typeof window !== "undefined") { + window.localStorage.removeItem(AVATAR_URL_STORAGE_KEY); + } + if (avatarFileInputRef.current) { + avatarFileInputRef.current.value = ""; + } + }; + + const handleAvatarFileSelect = async ( + event: ChangeEvent, + ) => { + const file = event.target.files?.[0]; + if (!file) return; + + const normalizedType = file.type?.toLowerCase() || ""; + const isAllowedType = + normalizedType === "image/jpeg" || + normalizedType === "image/jpg" || + normalizedType === "image/png" || + normalizedType === "image/webp"; + + if (!isAllowedType) { + setAvatarUploadError("Upload a JPG, PNG, or WebP image."); + return; + } + if (file.size > MAX_AVATAR_FILE_BYTES) { + setAvatarUploadError("Image must be 5MB or smaller."); + return; + } + + if (avatarObjectUrlRef.current) { + URL.revokeObjectURL(avatarObjectUrlRef.current); + avatarObjectUrlRef.current = null; + } + const objectUrl = URL.createObjectURL(file); + avatarObjectUrlRef.current = objectUrl; + setAvatarPreviewUrl(objectUrl); + setAvatarUploadError(null); + setIsAvatarUploading(true); + + try { + const signResponse = await fetch("/api/uploads/avatar/sign", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + filename: file.name, + contentType: file.type, + }), + }); + + const signResult = (await signResponse.json().catch(() => null)) as { + uploadUrl?: string; + params?: Record; + error?: string; + } | null; + + if (!signResponse.ok || !signResult?.uploadUrl || !signResult.params) { + const localAvatarUrl = await buildLocalAvatarDataUrl(file); + onAvatarUrlChange(localAvatarUrl); + if (typeof window !== "undefined") { + window.localStorage.setItem(AVATAR_URL_STORAGE_KEY, localAvatarUrl); + } + setAvatarPreviewUrl(null); + if (avatarObjectUrlRef.current) { + URL.revokeObjectURL(avatarObjectUrlRef.current); + avatarObjectUrlRef.current = null; + } + setAvatarUploadError(null); + return; + } + + const uploadBody = new FormData(); + for (const [key, value] of Object.entries(signResult.params)) { + uploadBody.append(key, value); + } + uploadBody.append("file", file); + + const uploadResponse = await fetch(signResult.uploadUrl, { + method: "POST", + body: uploadBody, + }); + + const uploadResult = (await uploadResponse.json().catch(() => null)) as { + secure_url?: string; + error?: { message?: string }; + } | null; + + const uploadedUrl = uploadResult?.secure_url?.trim(); + if (!uploadResponse.ok || !uploadedUrl) { + throw new Error(uploadResult?.error?.message || "Avatar upload failed."); + } + + onAvatarUrlChange(uploadedUrl); + if (typeof window !== "undefined") { + window.localStorage.setItem(AVATAR_URL_STORAGE_KEY, uploadedUrl); + } + setAvatarPreviewUrl(null); + if (avatarObjectUrlRef.current) { + URL.revokeObjectURL(avatarObjectUrlRef.current); + avatarObjectUrlRef.current = null; + } + setAvatarUploadError(null); + } catch (error) { + console.error("[JoinScreen] Avatar upload error:", error); + setAvatarUploadError( + error instanceof Error ? error.message : "Failed to upload avatar.", + ); + } finally { + setIsAvatarUploading(false); + } + }; + + const effectiveAvatarUrl = avatarPreviewUrl || avatarUrl || ""; + const toggleCamera = async () => { if (isCameraOn && localStream) { // Turn off camera - stop the video track @@ -624,9 +827,17 @@ function JoinScreen({