From 907f8285cd798b919ee7a8f0448cc06ee66c72cf Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:25:56 +0100 Subject: [PATCH] fix: decouple voice intent from server enforcement to prevent involuntary unmute When a moderator lifted a server mute/deafen, the client was involuntarily turning on the user's microphone because isMuted/isDeafened conflated user intent with server enforcement. Now intent (isMuted/isDeafened) is never mutated by server events. Effective state (intent || serverEnforcement) is computed at broadcast and hardware time via centralized helpers. --- .../src/components/layout/ChannelSidebar.tsx | 43 +----- .../components/voice/GlobalAudioRenderer.tsx | 11 +- .../src/components/voice/VoiceControlBar.tsx | 57 ++------ .../src/components/voice/VoiceControls.tsx | 13 +- packages/web/src/hooks/useLiveKit.ts | 59 ++++++-- packages/web/src/hooks/useWebSocket.ts | 131 +++--------------- packages/web/src/stores/voiceStore.ts | 18 +-- packages/web/src/utils/screenShare.ts | 5 +- packages/web/src/utils/voice.ts | 60 +++++++- 9 files changed, 164 insertions(+), 233 deletions(-) diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index 2400788b..41de6e13 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -11,11 +11,10 @@ import { useVoiceStore } from '../../stores/voiceStore'; import { Avatar } from '../ui/Avatar'; import { Username } from '../ui/Username'; import { wsSend } from '../../hooks/useWebSocket'; -import { getActiveRoom } from '../../hooks/useLiveKit'; import { AudioManager } from '../../audio/AudioManager'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import { parseFederatedUsername, isSelf } from '../../utils/identity'; -import { joinVoiceChannel } from '../../utils/voice'; +import { joinVoiceChannel, broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../../utils/voice'; export function ChannelSidebar() { const spaces = useSpaceStore((s) => s.spaces); @@ -44,47 +43,19 @@ export function ChannelSidebar() { const location = useLocation(); const handleMicToggle = async () => { - if (isServerMuted || isServerDeafened) return; const wasDeafened = useVoiceStore.getState().isDeafened; toggleMic(); - // Read fresh state after the smart toggle (may have cleared deafen too) - const { isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: ss } = useVoiceStore.getState(); - const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : ''; - wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: ss }, voiceOrigin); - // If unmuting while deafened cleared deafen, broadcast deafen=false via LiveKit data channel - if (wasDeafened && !d) { - const room = getActiveRoom(); - if (room) { - const encoder = new TextEncoder(); - room.localParticipant.publishData( - encoder.encode(JSON.stringify({ type: 'deafen', deafened: false })), - { reliable: true } - ).catch(() => {}); - } + broadcastVoiceStatus(); + // If unmuting while deafened cleared deafen, broadcast via LiveKit data channel + if (wasDeafened && !useVoiceStore.getState().isDeafened) { + broadcastDeafenViaLiveKit(); } }; const handleDeafenToggle = async () => { - if (isServerDeafened) return; - const room = getActiveRoom(); toggleDeafen(); - // Read fresh state — smart toggle handles mute coupling - const { isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: ss } = useVoiceStore.getState(); - // If server-muted, enforce muted even after undeafen - const effectiveMuted = isServerMuted ? true : m; - const voiceOrigin2 = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : ''; - wsSend({ type: 'voice_status', isMuted: effectiveMuted, isDeafened: d, isCameraOn: c, isScreenSharing: ss }, voiceOrigin2); - if (room) { - try { - const encoder = new TextEncoder(); - room.localParticipant.publishData( - encoder.encode(JSON.stringify({ type: 'deafen', deafened: d })), - { reliable: true } - ).catch(() => {}); - } catch (err) { - console.error('[ChannelSidebar] Failed to toggle deafen:', err); - } - } + broadcastVoiceStatus(); + broadcastDeafenViaLiveKit(); }; const spacePermissions = useSpaceStore((s) => s.spacePermissions); diff --git a/packages/web/src/components/voice/GlobalAudioRenderer.tsx b/packages/web/src/components/voice/GlobalAudioRenderer.tsx index c48b100a..64cd6a0b 100644 --- a/packages/web/src/components/voice/GlobalAudioRenderer.tsx +++ b/packages/web/src/components/voice/GlobalAudioRenderer.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useVoiceStore } from '../../stores/voiceStore'; +import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore'; import { useAudioTrackPlayer } from '../../hooks/useAudioTrackPlayer'; import type { ParticipantInfo } from '../../hooks/useLiveKit'; @@ -66,7 +67,9 @@ function AudioTrackElement({ */ export function GlobalAudioRenderer() { const participants = useVoiceStore((s) => s.participants); - const isDeafened = useVoiceStore((s) => s.isDeafened); + const isDeafenedIntent = useVoiceStore((s) => s.isDeafened); + const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds); + const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const outputVolume = useVoiceStore((s) => s.outputVolume); const participantVolumes = useVoiceStore((s) => s.participantVolumes); const streamVolumes = useVoiceStore((s) => s.streamVolumes); @@ -76,6 +79,12 @@ export function GlobalAudioRenderer() { const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength); const speakingParticipantIds = useVoiceStore((s) => s.speakingParticipantIds); + // Compute effective deafened: user intent || server enforcement + const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null); + const myOriginId = currentVoiceChannelId ? getMyUserIdForOrigin(getChannelOrigin(currentVoiceChannelId)) : undefined; + const serverKey = (spaceId && myOriginId) ? `${spaceId}:${myOriginId}` : ''; + const isDeafened = isDeafenedIntent || serverDeafenedUserIds.has(serverKey); + // Determine if someone is currently speaking (for stream attenuation) const someoneIsSpeaking = participants.some((p) => !p.isLocal && speakingParticipantIds.has(p.identity)); diff --git a/packages/web/src/components/voice/VoiceControlBar.tsx b/packages/web/src/components/voice/VoiceControlBar.tsx index aee50b8b..aa952be9 100644 --- a/packages/web/src/components/voice/VoiceControlBar.tsx +++ b/packages/web/src/components/voice/VoiceControlBar.tsx @@ -7,6 +7,7 @@ import { wsSend } from '../../hooks/useWebSocket'; import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore'; import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover'; import { CAMERA_PRESET, startScreenShare, stopScreenShare } from '../../utils/screenShare'; +import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../../utils/voice'; const btnBase = 'w-10 h-10 flex items-center justify-center rounded-full transition-colors'; const btnDefault = `${btnBase} bg-surface-channel text-txt-secondary hover:bg-surface-elevated hover:text-txt-primary`; @@ -38,46 +39,20 @@ export function VoiceControlBar() { const qualityBtnRef = useRef(null); const handleMute = React.useCallback(async () => { - if (isServerMuted || isServerDeafened) return; const wasDeafened = useVoiceStore.getState().isDeafened; toggleMic(); - // Read fresh state after the smart toggle (may have cleared deafen too) - const { isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: ss } = useVoiceStore.getState(); - wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: ss }, voiceOrigin); - // If unmuting while deafened cleared deafen, broadcast deafen=false via LiveKit data channel - if (wasDeafened && !d) { - const room = getActiveRoom(); - if (room) { - const encoder = new TextEncoder(); - room.localParticipant.publishData( - encoder.encode(JSON.stringify({ type: 'deafen', deafened: false })), - { reliable: true } - ).catch(() => {}); - } + broadcastVoiceStatus(); + // If unmuting while deafened cleared deafen, broadcast via LiveKit data channel + if (wasDeafened && !useVoiceStore.getState().isDeafened) { + broadcastDeafenViaLiveKit(); } - }, [toggleMic, voiceOrigin, isServerMuted, isServerDeafened]); + }, [toggleMic]); const handleDeafen = React.useCallback(async () => { - if (isServerDeafened) return; - const room = getActiveRoom(); toggleDeafen(); - // Read fresh state — smart toggle handles mute coupling - const { isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: ss } = useVoiceStore.getState(); - // If server-muted, enforce muted even after undeafen - const effectiveMuted = isServerMuted ? true : m; - wsSend({ type: 'voice_status', isMuted: effectiveMuted, isDeafened: d, isCameraOn: c, isScreenSharing: ss }, voiceOrigin); - if (room) { - try { - const encoder = new TextEncoder(); - room.localParticipant.publishData( - encoder.encode(JSON.stringify({ type: 'deafen', deafened: d })), - { reliable: true } - ).catch(() => {}); - } catch (err) { - console.error('[VoiceControlBar] Failed to toggle deafen:', err); - } - } - }, [toggleDeafen, isServerMuted, isServerDeafened, voiceOrigin]); + broadcastVoiceStatus(); + broadcastDeafenViaLiveKit(); + }, [toggleDeafen]); const handleCamera = async () => { const room = getActiveRoom(); @@ -97,9 +72,7 @@ export function VoiceControlBar() { await room.localParticipant.setCameraEnabled(false); } toggleCamera(); - // Broadcast camera state via WebSocket - const { isMuted: m, isDeafened: d, isScreenSharing: ss } = useVoiceStore.getState(); - wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: willEnable, isScreenSharing: ss }, voiceOrigin); + broadcastVoiceStatus(); } catch (err) { console.error('[VoiceControlBar] Failed to toggle camera:', err); } @@ -111,14 +84,10 @@ export function VoiceControlBar() { try { if (!isScreenSharing) { const started = await startScreenShare(room); - if (started) { - const { isMuted: m, isDeafened: d, isCameraOn: c } = useVoiceStore.getState(); - wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: true }, voiceOrigin); - } + if (started) broadcastVoiceStatus(); } else { await stopScreenShare(room); - const { isMuted: m, isDeafened: d, isCameraOn: c } = useVoiceStore.getState(); - wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: false }, voiceOrigin); + broadcastVoiceStatus(); } } catch (err) { console.error('[VoiceControlBar] Failed to toggle screen share:', err); @@ -176,7 +145,7 @@ export function VoiceControlBar() { ? `${btnBase} bg-accent-rose/20 text-txt-danger hover:bg-accent-rose/30` : btnDefault } - title={(isServerMuted || isServerDeafened) ? 'Server Muted' : isMuted ? 'Unmute (M)' : 'Mute (M)'} + title={(isServerMuted || isServerDeafened) ? (isMuted ? 'Server Muted (self-muted)' : 'Server Muted') : isMuted ? 'Unmute (M)' : 'Mute (M)'} > diff --git a/packages/web/src/components/voice/VoiceControls.tsx b/packages/web/src/components/voice/VoiceControls.tsx index ddffdfae..9df07d7d 100644 --- a/packages/web/src/components/voice/VoiceControls.tsx +++ b/packages/web/src/components/voice/VoiceControls.tsx @@ -7,6 +7,7 @@ import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover'; import { ConnectionInfoPopover } from './ConnectionInfoPopover'; import { startScreenShare, stopScreenShare } from '../../utils/screenShare'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; +import { broadcastVoiceStatus } from '../../utils/voice'; /** * VoiceControls renders the voice status + button rows. @@ -50,9 +51,7 @@ export function VoiceControls() { const willEnable = !isCameraOn; await room.localParticipant.setCameraEnabled(willEnable); toggleCamera(); - // Broadcast camera state via WebSocket - const { isMuted: m, isDeafened: d, isScreenSharing: ss } = useVoiceStore.getState(); - wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: willEnable, isScreenSharing: ss }, voiceOrigin); + broadcastVoiceStatus(); } catch (err) { console.error('[VoiceControls] Failed to toggle camera:', err); } @@ -64,14 +63,10 @@ export function VoiceControls() { try { if (!isScreenSharing) { const started = await startScreenShare(room); - if (started) { - const { isMuted: m, isDeafened: d, isCameraOn: c } = useVoiceStore.getState(); - wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: true }, voiceOrigin); - } + if (started) broadcastVoiceStatus(); } else { await stopScreenShare(room); - const { isMuted: m, isDeafened: d, isCameraOn: c } = useVoiceStore.getState(); - wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: false }, voiceOrigin); + broadcastVoiceStatus(); } } catch (err) { console.error('[VoiceControls] Failed to toggle screen share:', err); diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts index 588c18d8..edd0de9f 100644 --- a/packages/web/src/hooks/useLiveKit.ts +++ b/packages/web/src/hooks/useLiveKit.ts @@ -11,10 +11,12 @@ import { ConnectionQuality, LocalAudioTrack, LocalTrackPublication, + DisconnectReason, } from 'livekit-client'; -import { getApiForOrigin, getChannelOrigin, useSpaceStore } from '../stores/spaceStore'; +import { getApiForOrigin, getChannelOrigin, getMyUserIdForOrigin, useSpaceStore } from '../stores/spaceStore'; import { wsSend } from './useWebSocket'; import { useVoiceStore } from '../stores/voiceStore'; +import { broadcastVoiceStatus } from '../utils/voice'; import { AudioManager } from '../audio/AudioManager'; import { SpeakingDetector } from '../audio/SpeakingDetector'; import { @@ -138,6 +140,8 @@ export function useLiveKit() { const isScreenSharing = useVoiceStore((s) => s.isScreenSharing); const screenShareConfig = useVoiceStore((s) => s.screenShareConfig); const voiceUserStates = useVoiceStore((s) => s.voiceUserStates); + const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds); + const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds); const inputVolume = useVoiceStore((s) => s.inputVolume); const inputDeviceId = useVoiceStore((s) => s.inputDeviceId); const echoCancellation = useVoiceStore((s) => s.echoCancellation); @@ -187,8 +191,15 @@ export function useLiveKit() { let isPartMuted = !p.isMicrophoneEnabled; if (isLocal) { - isPartDeafened = useVoiceStore.getState().isDeafened; - isPartMuted = useVoiceStore.getState().isMuted; + // Compute effective state: user intent || server enforcement + const vs = useVoiceStore.getState(); + const cvId = vs.currentVoiceChannelId; + const localOrigin = cvId ? getChannelOrigin(cvId) : ''; + const localMyId = cvId ? getMyUserIdForOrigin(localOrigin) : undefined; + const localSpaceId = cvId ? useSpaceStore.getState().channelToSpaceMap.get(cvId) : null; + const localKey = (localSpaceId && localMyId) ? `${localSpaceId}:${localMyId}` : ''; + isPartMuted = vs.isMuted || vs.serverMutedUserIds.has(localKey); + isPartDeafened = vs.isDeafened || vs.serverDeafenedUserIds.has(localKey); } else { isPartDeafened = userState?.isDeafened ?? useVoiceStore.getState().deafenedUserIds.has(userId); if (userState) isPartMuted = userState.isMuted; @@ -238,6 +249,16 @@ export function useLiveKit() { const r = roomRef.current; if (!r || !isConnected) return; + // Compute effective mute/deafen: user intent || server enforcement + const vs = useVoiceStore.getState(); + const cvId = vs.currentVoiceChannelId; + const effOrigin = cvId ? getChannelOrigin(cvId) : ''; + const effMyId = cvId ? getMyUserIdForOrigin(effOrigin) : undefined; + const effSpaceId = cvId ? useSpaceStore.getState().channelToSpaceMap.get(cvId) : null; + const effKey = (effSpaceId && effMyId) ? `${effSpaceId}:${effMyId}` : ''; + const effectiveMuted = isMuted || serverMutedUserIds.has(effKey); + const effectiveDeafened = isDeafened || serverDeafenedUserIds.has(effKey); + const syncMic = async () => { try { const audioManager = AudioManager.getInstance(); @@ -249,8 +270,8 @@ export function useLiveKit() { const micPub = r.localParticipant.getTrackPublications() .find(p => p.source === Track.Source.Microphone); - // If muted or deafened, mute the track in-place (keep it published) - if (isMuted || isDeafened) { + // If effectively muted or deafened, mute the track in-place (keep it published) + if (effectiveMuted || effectiveDeafened) { if (micPub?.track && !micPub.isMuted) { await r.localParticipant.setMicrophoneEnabled(false); } @@ -302,7 +323,7 @@ export function useLiveKit() { return () => { unsubscribe(); }; - }, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled]); + }, [isMuted, isDeafened, serverMutedUserIds, serverDeafenedUserIds, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled]); const connect = useCallback(async (channelId: string, isDm?: boolean) => { const storedId = isDm ? `dm-${channelId}` : channelId; @@ -313,8 +334,7 @@ export function useLiveKit() { if (isDm) return; const origin = getChannelOrigin(channelId); wsSend({ type: 'voice_join', channelId }, origin); - const { isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: s } = useVoiceStore.getState(); - wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: s }, origin); + broadcastVoiceStatus(origin); }; const gen = ++_connectGeneration; @@ -361,7 +381,15 @@ export function useLiveKit() { const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); }; newRoom.on(RoomEvent.ParticipantConnected, (participant) => { guardedUpdate(); - if (useVoiceStore.getState().isDeafened) { + // Notify new participant of our effective deafen state + const vsConn = useVoiceStore.getState(); + const cvIdConn = vsConn.currentVoiceChannelId; + const connOrigin = cvIdConn ? getChannelOrigin(cvIdConn) : ''; + const connMyId = cvIdConn ? getMyUserIdForOrigin(connOrigin) : undefined; + const connSpaceId = cvIdConn ? useSpaceStore.getState().channelToSpaceMap.get(cvIdConn) : null; + const connKey = (connSpaceId && connMyId) ? `${connSpaceId}:${connMyId}` : ''; + const effDeaf = vsConn.isDeafened || vsConn.serverDeafenedUserIds.has(connKey); + if (effDeaf) { const encoder = new TextEncoder(); newRoom.localParticipant.publishData( encoder.encode(JSON.stringify({ type: 'deafen', deafened: true })), @@ -449,7 +477,7 @@ export function useLiveKit() { } } }); - newRoom.on(RoomEvent.Disconnected, () => { + newRoom.on(RoomEvent.Disconnected, (reason?: DisconnectReason) => { if (roomRef.current !== newRoom) return; SpeakingDetector.getInstance().clear(); setConnectionState(ConnectionState.Disconnected); @@ -458,6 +486,13 @@ export function useLiveKit() { useVoiceStore.getState().setParticipants([]); useVoiceStore.getState().setSpeakingParticipants(new Set()); useVoiceStore.getState().setIsLiveKitConnected(false); + + // Non-client disconnect (identity collision, server shutdown, kicked, etc.) + // → clear voice intent so AppLayout doesn't auto-retry into an infinite loop. + // Client-initiated disconnects already clear this via leaveVoice() / VoiceControlBar. + if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { + useVoiceStore.getState().leaveVoice(); + } }); await newRoom.connect(url, token, { autoSubscribe: false }); @@ -495,7 +530,7 @@ export function useLiveKit() { } updateParticipants(); - } catch (err) { if (gen === _connectGeneration) { setConnectionError('Failed to connect'); useVoiceStore.getState().setConnectionError('Failed to connect'); } } + } catch (err) { if (gen === _connectGeneration) { setConnectionError('Failed to connect'); useVoiceStore.getState().setConnectionError('Failed to connect'); useVoiceStore.getState().leaveVoice(); } } finally { if (gen === _connectGeneration) setIsConnecting(false); } }, [updateParticipants, handleDataReceived]); @@ -545,7 +580,7 @@ export function useLiveKit() { useEffect(() => { updateParticipants(); - }, [voiceUserStates, isMuted, isDeafened, updateParticipants]); + }, [voiceUserStates, isMuted, isDeafened, serverMutedUserIds, serverDeafenedUserIds, updateParticipants]); useEffect(() => { if (!room) return; diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 887aa47b..24598a5a 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -1,12 +1,13 @@ import React, { useEffect, useRef } from 'react'; import { useAuthStore } from '../stores/authStore'; -import { useSpaceStore, getChannelOrigin } from '../stores/spaceStore'; +import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../stores/spaceStore'; import { useChatStore } from '../stores/chatStore'; import { useVoiceStore } from '../stores/voiceStore'; import { useSocialStore } from '../stores/socialStore'; import { useSettingsStore } from '../stores/settingsStore'; import type { ServerEvent, ClientEvent, ActiveCallInfo } from '@backspace/shared'; import { resolveAssetUrl, normalizeUserAssets, normalizeMessageAssets } from '../utils/assetUrls'; +import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../utils/voice'; // ─── Connection state ───────────────────────────────────────────────────────── @@ -83,7 +84,7 @@ function handleEvent(origin: string, event: ServerEvent): void { const { setUser } = useAuthStore.getState(); const { populateFromReady, loadSpaceDetail, currentSpaceId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useSpaceStore.getState(); const { addMessage, addRealtimeMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState(); - const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, clearVoiceUsersForOrigin, setVoiceUsers, setVoiceUserStatus, clearVoiceUserStatus } = useVoiceStore.getState(); + const { addVoiceUser, removeVoiceUser, clearVoiceUsersForOrigin, setVoiceUsers, setVoiceUserStatus, clearVoiceUserStatus } = useVoiceStore.getState(); switch (event.type) { case 'ready': @@ -150,12 +151,8 @@ function handleEvent(origin: string, event: ServerEvent): void { useChatStore.getState().setReadStates(event.readStates, channelLastMessageIds, originChannelIds); } - // Clear voice state for the reconnecting origin before repopulating - if (isHome) { - clearAllVoiceUsers(); - } else { - clearVoiceUsersForOrigin(origin); - } + // Clear voice state only for the reconnecting origin before repopulating + clearVoiceUsersForOrigin(origin); if (event.voiceStates) { for (const [channelId, userIds] of Object.entries(event.voiceStates)) { setVoiceUsers(channelId, userIds); @@ -203,20 +200,9 @@ function handleEvent(origin: string, event: ServerEvent): void { // Single atomic update useVoiceStore.setState({ serverMutedUserIds: nextServerMuted, serverDeafenedUserIds: nextServerDeafened }); - // Enforce local mute/deafen to match server restrictions (one-directional: only force-mute, never auto-unmute) - const myReadyId = useAuthStore.getState().user?.id; - if (myReadyId) { - const vs = useVoiceStore.getState(); - const activeSpaceId = vs.currentVoiceChannelId ? useSpaceStore.getState().channelToSpaceMap.get(vs.currentVoiceChannelId) : null; - if (activeSpaceId) { - const key = `${activeSpaceId}:${myReadyId}`; - if (nextServerDeafened.has(key) && !vs.isDeafened) { - useVoiceStore.setState({ isMuted: true, isDeafened: true }); - } else if (nextServerMuted.has(key) && !vs.isMuted) { - useVoiceStore.setState({ isMuted: true }); - } - } - } + // With decoupled state, user intent is never force-set by the server. + // Effective state (intent || serverEnforcement) is computed reactively + // at broadcast and hardware time. } // Re-register in voice channel after WS reconnect — the server lost @@ -225,14 +211,14 @@ function handleEvent(origin: string, event: ServerEvent): void { // tabs can reconnect WS but not LiveKit — sending voice_join without // an active media plane would create ghost users in the sidebar. { - const { currentVoiceChannelId, isLiveKitConnected, isMuted, isDeafened, isCameraOn, isScreenSharing } = useVoiceStore.getState(); + const { currentVoiceChannelId, isLiveKitConnected } = useVoiceStore.getState(); if (currentVoiceChannelId && isLiveKitConnected) { const voiceOrigin = getChannelOrigin(currentVoiceChannelId); if (voiceOrigin === origin) { const myId = event.user.id; if (myId) addVoiceUser(currentVoiceChannelId, myId); wsSend({ type: 'voice_join', channelId: currentVoiceChannelId }, origin); - wsSend({ type: 'voice_status', isMuted, isDeafened, isCameraOn, isScreenSharing }, origin); + broadcastVoiceStatus(origin); } } } @@ -319,101 +305,20 @@ function handleEvent(origin: string, event: ServerEvent): void { case 'voice_server_muted': { const { setServerMutedUser } = useVoiceStore.getState(); setServerMutedUser(event.spaceId, event.userId, event.muted); - - const checkMute = (myUserId: string | undefined) => { - if (event.userId === myUserId) { - if (event.muted) { - // Force-mute the mic - const vs = useVoiceStore.getState(); - if (!vs.isMuted) { - useVoiceStore.setState({ isMuted: true }); - const fresh = useVoiceStore.getState(); - const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : ''; - wsSend({ type: 'voice_status', isMuted: true, isDeafened: fresh.isDeafened, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin); - } - } else { - // Server unmuted — auto-restore mic unless still server-deafened - const vs = useVoiceStore.getState(); - if (!vs.serverDeafenedUserIds.has(`${event.spaceId}:${myUserId}`) && vs.isMuted) { - useVoiceStore.setState({ isMuted: false }); - const fresh = useVoiceStore.getState(); - const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : ''; - wsSend({ type: 'voice_status', isMuted: false, isDeafened: fresh.isDeafened, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin); - } - } - } - }; - - if (isHome) { - checkMute(useAuthStore.getState().user?.id); - } else { - import('../stores/spaceStore').then(({ getMyUserIdForOrigin }) => { - checkMute(getMyUserIdForOrigin(origin)); - }); - } + // Broadcast effective state if this targets the current user + const myMuteId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin); + if (event.userId === myMuteId) broadcastVoiceStatus(); break; } case 'voice_server_deafened': { const { setServerDeafenedUser } = useVoiceStore.getState(); setServerDeafenedUser(event.spaceId, event.userId, event.deafened); - - const checkDeafen = (myUid: string | undefined) => { - if (event.userId === myUid) { - if (event.deafened) { - // Force-deafen (smart toggle sets both muted+deafened) - const vs = useVoiceStore.getState(); - if (!vs.isDeafened) { - useVoiceStore.setState({ isMuted: true, isDeafened: true }); - const fresh = useVoiceStore.getState(); - const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : ''; - wsSend({ type: 'voice_status', isMuted: true, isDeafened: true, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin); - // Broadcast deafen to in-room participants via LiveKit data channel - import('./useLiveKit').then(({ getActiveRoom }) => { - const room = getActiveRoom(); - if (room) { - const encoder = new TextEncoder(); - room.localParticipant.publishData( - encoder.encode(JSON.stringify({ type: 'deafen', deafened: true })), - { reliable: true } - ).catch(() => {}); - } - }); - } - } else { - // Server un-deafened — auto-restore - const vs = useVoiceStore.getState(); - if (vs.isDeafened) { - const stillServerMuted = vs.serverMutedUserIds.has(`${event.spaceId}:${myUid}`); - useVoiceStore.setState({ - isDeafened: false, - ...(stillServerMuted ? {} : { isMuted: false }), - }); - const fresh = useVoiceStore.getState(); - const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : ''; - wsSend({ type: 'voice_status', isMuted: fresh.isMuted, isDeafened: false, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin); - // Broadcast undeafen via LiveKit data channel - import('./useLiveKit').then(({ getActiveRoom }) => { - const room = getActiveRoom(); - if (room) { - const encoder = new TextEncoder(); - room.localParticipant.publishData( - encoder.encode(JSON.stringify({ type: 'deafen', deafened: false })), - { reliable: true } - ).catch(() => {}); - } - }); - } - } - } - }; - - if (isHome) { - checkDeafen(useAuthStore.getState().user?.id); - } else { - import('../stores/spaceStore').then(({ getMyUserIdForOrigin }) => { - checkDeafen(getMyUserIdForOrigin(origin)); - }); + // Broadcast effective state if this targets the current user + const myDeafenId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin); + if (event.userId === myDeafenId) { + broadcastVoiceStatus(); + broadcastDeafenViaLiveKit(); } break; } diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index a9980a1a..468d257f 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -240,13 +240,9 @@ export const useVoiceStore = create()( setOutputDevice: (deviceId) => set({ outputDeviceId: deviceId }), toggleMic: () => set((state) => { - // Server-muted/deafened users cannot unmute themselves - const origin = state.currentVoiceChannelId ? getChannelOrigin(state.currentVoiceChannelId) : ''; - const myId = getMyUserIdForOrigin(origin); - const spaceId = state.currentVoiceChannelId ? useSpaceStore.getState().channelToSpaceMap.get(state.currentVoiceChannelId) : null; - if (myId && spaceId && state.isMuted && (state.serverMutedUserIds.has(`${spaceId}:${myId}`) || state.serverDeafenedUserIds.has(`${spaceId}:${myId}`))) { - return {}; - } + // User intent toggle — effective state (intent || serverEnforcement) is + // computed at broadcast/hardware time, so the mic stays off while server-muted + // even if the user toggles isMuted to false. if (state.isMuted && state.isDeafened) { // Unmuting while deafened → clear both (Discord behavior) return { isMuted: false, isDeafened: false }; @@ -254,13 +250,7 @@ export const useVoiceStore = create()( return { isMuted: !state.isMuted }; }), toggleDeafen: () => set((state) => { - // Server-deafened users cannot undeafen themselves - const origin = state.currentVoiceChannelId ? getChannelOrigin(state.currentVoiceChannelId) : ''; - const myId = getMyUserIdForOrigin(origin); - const spaceId = state.currentVoiceChannelId ? useSpaceStore.getState().channelToSpaceMap.get(state.currentVoiceChannelId) : null; - if (myId && spaceId && state.isDeafened && state.serverDeafenedUserIds.has(`${spaceId}:${myId}`)) { - return {}; - } + // User intent toggle — same decoupled model as toggleMic. if (state.isDeafened) { // Undeafening → clear both return { isMuted: false, isDeafened: false }; diff --git a/packages/web/src/utils/screenShare.ts b/packages/web/src/utils/screenShare.ts index 7ab14551..07d3c79c 100644 --- a/packages/web/src/utils/screenShare.ts +++ b/packages/web/src/utils/screenShare.ts @@ -2,8 +2,8 @@ import { Room, Track } from 'livekit-client'; import { useVoiceStore } from '../stores/voiceStore'; import type { ScreenShareConfig } from '../stores/voiceStore'; import { getStreamingLimits } from '../stores/settingsStore'; -import { wsSend } from '../hooks/useWebSocket'; import { getPublisherPC, getMediaStreamTrack } from './livekitInternals'; +import { broadcastVoiceStatus } from './voice'; // --------------------------------------------------------------------------- // Types @@ -224,6 +224,5 @@ export async function changeScreenShare(room: Room): Promise { export function handleScreenShareUnpublished(): void { useVoiceStore.setState({ isScreenSharing: false }); - const { isMuted, isDeafened, isCameraOn } = useVoiceStore.getState(); - wsSend({ type: 'voice_status', isMuted, isDeafened, isCameraOn, isScreenSharing: false }); + broadcastVoiceStatus(); } diff --git a/packages/web/src/utils/voice.ts b/packages/web/src/utils/voice.ts index f61d0fde..d9953efa 100644 --- a/packages/web/src/utils/voice.ts +++ b/packages/web/src/utils/voice.ts @@ -1,7 +1,65 @@ import { useVoiceStore } from '../stores/voiceStore'; -import { getChannelOrigin, getMyUserIdForOrigin } from '../stores/spaceStore'; +import { getChannelOrigin, getMyUserIdForOrigin, useSpaceStore } from '../stores/spaceStore'; import { wsSend } from '../hooks/useWebSocket'; +// --------------------------------------------------------------------------- +// Effective-state helpers — single source of truth for broadcasts +// --------------------------------------------------------------------------- + +/** + * Compute effective mute/deafen by merging user intent with server enforcement, + * then broadcast the effective voice_status over the WebSocket. + * + * @param overrideOrigin Pass explicitly when called from a WS handler that + * knows the origin. Omit to derive from currentVoiceChannelId. + */ +export function broadcastVoiceStatus(overrideOrigin?: string): void { + const vs = useVoiceStore.getState(); + const { isMuted, isDeafened, isCameraOn, isScreenSharing, currentVoiceChannelId, serverMutedUserIds, serverDeafenedUserIds } = vs; + if (!currentVoiceChannelId) return; + + const origin = overrideOrigin ?? getChannelOrigin(currentVoiceChannelId); + const myId = getMyUserIdForOrigin(origin); + const spaceId = useSpaceStore.getState().channelToSpaceMap.get(currentVoiceChannelId); + const serverKey = (spaceId && myId) ? `${spaceId}:${myId}` : ''; + + const effectiveMuted = isMuted || serverMutedUserIds.has(serverKey); + const effectiveDeafened = isDeafened || serverDeafenedUserIds.has(serverKey); + + wsSend({ type: 'voice_status', isMuted: effectiveMuted, isDeafened: effectiveDeafened, isCameraOn, isScreenSharing }, origin); +} + +/** + * Broadcast the effective deafen state to in-room participants via the + * LiveKit data channel. Dynamic-imports getActiveRoom to avoid circular deps. + */ +export function broadcastDeafenViaLiveKit(): void { + const vs = useVoiceStore.getState(); + const { isDeafened, currentVoiceChannelId, serverDeafenedUserIds } = vs; + if (!currentVoiceChannelId) return; + + const origin = getChannelOrigin(currentVoiceChannelId); + const myId = getMyUserIdForOrigin(origin); + const spaceId = useSpaceStore.getState().channelToSpaceMap.get(currentVoiceChannelId); + const serverKey = (spaceId && myId) ? `${spaceId}:${myId}` : ''; + const effectiveDeafened = isDeafened || serverDeafenedUserIds.has(serverKey); + + import('../hooks/useLiveKit').then(({ getActiveRoom }) => { + const room = getActiveRoom(); + if (room) { + const encoder = new TextEncoder(); + room.localParticipant.publishData( + encoder.encode(JSON.stringify({ type: 'deafen', deafened: effectiveDeafened })), + { reliable: true } + ).catch(() => {}); + } + }); +} + +// --------------------------------------------------------------------------- +// Voice channel join +// --------------------------------------------------------------------------- + /** * Centralized voice channel join that handles cross-instance cleanup. * When switching from a channel on Instance A to one on Instance B,