diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index e3422f75..1887f73f 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -172,6 +172,9 @@ function handleEvent(origin: string, event: ServerEvent): void { console.log('[WebSocket] Re-syncing voice status on reconnect:', { currentVoiceChannelId, origin, curMuted, curDeafened, curCamera, curScreen }); wsSend({ type: 'voice_join', channelId: currentVoiceChannelId }, origin); wsSend({ type: 'voice_status', isMuted: curMuted, isDeafened: curDeafened, isCameraOn: curCamera, isScreenSharing: curScreen }, origin); + // Optimistic: immediately show self in voice channel sidebar + const myId = isHome ? event.user.id : useAuthStore.getState().user?.id; + if (myId) addVoiceUser(currentVoiceChannelId, myId); } } } diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index c196c408..c40ef2d7 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -3,6 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware'; import type { ParticipantInfo } from '../hooks/useLiveKit'; import { AudioManager } from '../audio/AudioManager'; import { useServerStore } from './serverStore'; +import { useAuthStore } from './authStore'; export interface ScreenShareConfig { height: 1080 | 720 | 540; @@ -291,23 +292,40 @@ export const useVoiceStore = create()( }, // Leave voice without wiping the voiceUsers map (so sidebar still shows others) - leaveVoice: () => set({ - currentVoiceChannelId: null, - isCameraOn: false, - isScreenSharing: false, - participants: [], - speakingParticipantIds: new Set(), - connectionError: null, - isLiveKitConnected: false, - connectionQuality: 'unknown', - focusedParticipantId: null, - activeDmCall: null, - outgoingCall: null, - deafenedUserIds: new Set(), - streamVolumes: new Map(), - streamMutes: new Map(), - watchingStreams: new Set(), - }), + leaveVoice: () => { + const channelId = get().currentVoiceChannelId; + const myId = useAuthStore.getState().user?.id; + + set((state) => { + // Optimistic: immediately remove self from the channel's voice users + const voiceUsers = (channelId && myId) + ? (() => { + const m = new Map(state.voiceUsers); + m.set(channelId, (m.get(channelId) ?? []).filter(id => id !== myId)); + return m; + })() + : state.voiceUsers; + + return { + currentVoiceChannelId: null, + isCameraOn: false, + isScreenSharing: false, + participants: [], + speakingParticipantIds: new Set(), + connectionError: null, + isLiveKitConnected: false, + connectionQuality: 'unknown', + focusedParticipantId: null, + activeDmCall: null, + outgoingCall: null, + deafenedUserIds: new Set(), + streamVolumes: new Map(), + streamMutes: new Map(), + watchingStreams: new Set(), + voiceUsers, + }; + }); + }, reset: () => set({ voiceUsers: new Map(), diff --git a/packages/web/src/utils/voice.ts b/packages/web/src/utils/voice.ts index 209afb90..5fbd8e2d 100644 --- a/packages/web/src/utils/voice.ts +++ b/packages/web/src/utils/voice.ts @@ -1,4 +1,5 @@ import { useVoiceStore } from '../stores/voiceStore'; +import { useAuthStore } from '../stores/authStore'; import { getChannelOrigin } from '../stores/serverStore'; import { wsSend } from '../hooks/useWebSocket'; @@ -9,9 +10,11 @@ import { wsSend } from '../hooks/useWebSocket'; * broadcasts a leave event and the client cleans up stale voice state. */ export function joinVoiceChannel(channelId: string): void { - const { currentVoiceChannelId, setCurrentVoiceChannel } = useVoiceStore.getState(); + const { currentVoiceChannelId, setCurrentVoiceChannel, addVoiceUser, removeVoiceUser } = useVoiceStore.getState(); if (currentVoiceChannelId === channelId) return; + const myId = useAuthStore.getState().user?.id; + // Leave old instance if switching cross-origin if (currentVoiceChannelId) { const oldOrigin = getChannelOrigin(currentVoiceChannelId); @@ -19,8 +22,12 @@ export function joinVoiceChannel(channelId: string): void { if (oldOrigin !== newOrigin) { wsSend({ type: 'voice_leave' }, oldOrigin); } + // Optimistic: immediately remove self from old channel + if (myId) removeVoiceUser(currentVoiceChannelId, myId); } setCurrentVoiceChannel(channelId); wsSend({ type: 'voice_join', channelId }, getChannelOrigin(channelId)); + // Optimistic: immediately show self in new channel + if (myId) addVoiceUser(channelId, myId); }