From 8910de3053f6f4436473c12dc4e39560b5a62f64 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:01:34 +0100 Subject: [PATCH] feat(voice): add cachedUser carry-forward to ParticipantInfo Extends ParticipantInfo with cachedUser: User | null. In updateParticipants, snapshots the previous participant list and carries forward the cached User object when spaceStore member lookup fails (user navigated to a different space). Local user falls back to authStore. homeUserId is now derived from cachedUser when the direct member lookup misses. Updates EMPTY_PARTICIPANT in PictureInPicture.tsx. --- .../src/components/voice/PictureInPicture.tsx | 1 + packages/web/src/hooks/useLiveKit.ts | 29 ++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/web/src/components/voice/PictureInPicture.tsx b/packages/web/src/components/voice/PictureInPicture.tsx index f71db55f..1b9c72fc 100644 --- a/packages/web/src/components/voice/PictureInPicture.tsx +++ b/packages/web/src/components/voice/PictureInPicture.tsx @@ -14,6 +14,7 @@ const EMPTY_PARTICIPANT: ParticipantInfo = { isMuted: false, isDeafened: false, isCameraOn: false, isScreenSharing: false, isLocal: false, audioTrack: null, videoTrack: null, screenTrack: null, screenAudioTrack: null, lkVideoTrack: null, lkScreenTrack: null, + cachedUser: null, }; const PIP_WIDTH = 320; diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts index adf17bea..53520477 100644 --- a/packages/web/src/hooks/useLiveKit.ts +++ b/packages/web/src/hooks/useLiveKit.ts @@ -16,6 +16,8 @@ import { import { getApiForOrigin, getChannelOrigin, getMyUserIdForOrigin, useSpaceStore } from '../stores/spaceStore'; import { wsSend } from './useWebSocket'; import { useVoiceStore } from '../stores/voiceStore'; +import { useAuthStore } from '../stores/authStore'; +import type { User } from '@backspace/shared'; import { broadcastVoiceStatus } from '../utils/voice'; import { AudioManager } from '../audio/AudioManager'; import { SpeakingDetector } from '../audio/SpeakingDetector'; @@ -55,6 +57,7 @@ export interface ParticipantInfo { screenAudioTrack: MediaStreamTrack | null; lkVideoTrack: Track | null; // LiveKit Track for attach/detach (adaptive stream) lkScreenTrack: Track | null; // LiveKit Track for attach/detach (adaptive stream) + cachedUser: User | null; // Hydrated User from member lookup, carried forward across space switches } export interface UserTile { @@ -170,12 +173,35 @@ export function useLiveKit() { const updateParticipants = useCallback(() => { const r = roomRef.current; if (!r) return; + + // Carry-forward: snapshot previous participants for cachedUser preservation + const prevParticipants = useVoiceStore.getState().participants; + const prevCacheMap = new Map(); + for (const prev of prevParticipants) { + prevCacheMap.set(prev.identity, prev.cachedUser); + } + const allParticipants: ParticipantInfo[] = []; const processParticipant = (p: Participant, isLocal: boolean) => { if (!p.identity) return; const { userId, username } = parseIdentity(p.identity); const memberMatch = useSpaceStore.getState().members.find(m => m.userId === userId); - const homeUserId = memberMatch?.user.homeUserId ?? null; + let cachedUser: User | null; + let homeUserId: string | null; + + if (memberMatch) { + // Fresh data available — use and update cache + cachedUser = memberMatch.user as User; + homeUserId = memberMatch.user.homeUserId ?? null; + } else if (isLocal) { + // Local user safety net — authStore is always available + cachedUser = useAuthStore.getState().user; + homeUserId = cachedUser?.homeUserId ?? cachedUser?.id ?? null; + } else { + // Space switched — carry forward from previous cycle + cachedUser = prevCacheMap.get(p.identity) ?? null; + homeUserId = cachedUser?.homeUserId ?? null; + } let audioTrack: MediaStreamTrack | null = null; let videoTrack: MediaStreamTrack | null = null; let screenTrack: MediaStreamTrack | null = null; @@ -229,6 +255,7 @@ export function useLiveKit() { userId, username, homeUserId, + cachedUser, isMuted: isPartMuted, isDeafened: isPartDeafened, isCameraOn: hasCameraPublication && p.isCameraEnabled, // True even when unsubscribed