diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 68a80de2..6b5449db 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -484,6 +484,7 @@ function handleVoiceLeave(userId: string): void { broadcastRoomLeave(left.roomId, left.room, userId); } connectionManager.clearVoiceUserStatus(userId); + connectionManager.clearServerVoiceState(userId); } function handleVoiceStatus(event: Record, userId: string): void { @@ -497,15 +498,19 @@ function handleVoiceStatus(event: Record, userId: string): void const userRoom = connectionManager.getUserRoom(userId); if (!userRoom) return; - connectionManager.setVoiceUserStatus(userId, isMuted, isDeafened, isCameraOn, isScreenSharing); + // Server-side enforcement: prevent clients from bypassing server mute/deafen + const effectiveMuted = connectionManager.isServerMuted(userId) ? true : isMuted; + const effectiveDeafened = connectionManager.isServerDeafened(userId) ? true : isDeafened; + + connectionManager.setVoiceUserStatus(userId, effectiveMuted, effectiveDeafened, isCameraOn, isScreenSharing); // sendToRoom routes to sendToSpace for space rooms, sendToDmMembers for DM rooms connectionManager.sendToRoom(userRoom.roomId, { type: 'voice_status_update', userId, channelId: userRoom.roomId, - isMuted, - isDeafened, + isMuted: effectiveMuted, + isDeafened: effectiveDeafened, isCameraOn, isScreenSharing, }); diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index bc1a35d7..c92227c4 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -40,34 +40,41 @@ export function ChannelSidebar() { const location = useLocation(); const handleMicToggle = async () => { - if (isServerMuted || isServerDeafened) return; // Blocked by server mute/deafen + if (isServerMuted || isServerDeafened) return; + const wasDeafened = useVoiceStore.getState().isDeafened; toggleMic(); - // Broadcast mute status via WebSocket so non-joined users can see it - const willBeMuted = !isMuted; - const { isCameraOn, isScreenSharing } = useVoiceStore.getState(); + // 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: willBeMuted, isDeafened, isCameraOn, isScreenSharing }, voiceOrigin); + 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(() => {}); + } + } }; const handleDeafenToggle = async () => { - if (isServerDeafened) return; // Blocked by server deafen + if (isServerDeafened) return; const room = getActiveRoom(); - const willDeafen = !isDeafened; - // Update store FIRST so updateParticipants reads correct state when LiveKit events fire toggleDeafen(); - if (willDeafen && !isMuted) toggleMic(); - if (!willDeafen && isMuted) toggleMic(); - // Broadcast status via WebSocket so non-joined users can see it - const willBeMuted = willDeafen ? true : false; - const { isCameraOn, isScreenSharing } = useVoiceStore.getState(); + // 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: willBeMuted, isDeafened: willDeafen, isCameraOn, isScreenSharing }, voiceOrigin2); + wsSend({ type: 'voice_status', isMuted: effectiveMuted, isDeafened: d, isCameraOn: c, isScreenSharing: ss }, voiceOrigin2); if (room) { try { - // Broadcast deafen state to other participants via LiveKit data channel const encoder = new TextEncoder(); room.localParticipant.publishData( - encoder.encode(JSON.stringify({ type: 'deafen', deafened: willDeafen })), + encoder.encode(JSON.stringify({ type: 'deafen', deafened: d })), { reliable: true } ).catch(() => {}); } catch (err) { @@ -788,15 +795,15 @@ function UserAreaPanel({ {/* Input chevron */} diff --git a/packages/web/src/components/voice/VoiceChannel.tsx b/packages/web/src/components/voice/VoiceChannel.tsx index 070df7bc..0d8e721a 100644 --- a/packages/web/src/components/voice/VoiceChannel.tsx +++ b/packages/web/src/components/voice/VoiceChannel.tsx @@ -111,8 +111,8 @@ export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceC {displayName} {/* Status badges */}
- {isServerMuted && ( - + {(isServerMuted || isServerDeafened) && ( + @@ -128,7 +128,7 @@ export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceC )} - {!isServerMuted && isMuted && ( + {!isServerMuted && !isServerDeafened && isMuted && ( diff --git a/packages/web/src/components/voice/VoiceControlBar.tsx b/packages/web/src/components/voice/VoiceControlBar.tsx index 61030a52..54776e43 100644 --- a/packages/web/src/components/voice/VoiceControlBar.tsx +++ b/packages/web/src/components/voice/VoiceControlBar.tsx @@ -35,34 +35,45 @@ export function VoiceControlBar() { const handleMute = React.useCallback(async () => { if (isServerMuted || isServerDeafened) return; + const wasDeafened = useVoiceStore.getState().isDeafened; toggleMic(); - // Broadcast via WebSocket so sidebar shows status without joining - wsSend({ type: 'voice_status', isMuted: !isMuted, isDeafened, isCameraOn, isScreenSharing }, voiceOrigin); - }, [isMuted, isDeafened, isCameraOn, isScreenSharing, toggleMic, voiceOrigin, isServerMuted, isServerDeafened]); + // 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(() => {}); + } + } + }, [toggleMic, voiceOrigin, isServerMuted, isServerDeafened]); const handleDeafen = React.useCallback(async () => { if (isServerDeafened) return; const room = getActiveRoom(); - const willDeafen = !isDeafened; - // Update store FIRST so updateParticipants reads correct state toggleDeafen(); - if (willDeafen && !isMuted) toggleMic(); - if (!willDeafen && isMuted) toggleMic(); - // Broadcast via WebSocket - wsSend({ type: 'voice_status', isMuted: willDeafen, isDeafened: willDeafen, isCameraOn, isScreenSharing }, voiceOrigin); + // 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 { - // Broadcast deafen state via LiveKit data channel for in-room users const encoder = new TextEncoder(); room.localParticipant.publishData( - encoder.encode(JSON.stringify({ type: 'deafen', deafened: willDeafen })), + encoder.encode(JSON.stringify({ type: 'deafen', deafened: d })), { reliable: true } ).catch(() => {}); } catch (err) { console.error('[VoiceControlBar] Failed to toggle deafen:', err); } } - }, [isDeafened, isMuted, isCameraOn, isScreenSharing, toggleDeafen, toggleMic]); + }, [toggleDeafen, isServerMuted, isServerDeafened, voiceOrigin]); const handleCamera = async () => { const room = getActiveRoom(); @@ -155,13 +166,13 @@ export function VoiceControlBar() { {/* Mute */}
- {participant.isMuted && ( -
- - - - -
- )} - {(isLocal ? isDeafened : participant.isDeafened) && ( -
- - - - -
- )} + {participant.isMuted && (() => { + const isServerMutedUser = serverMutedUserIds.has(participant.userId); + const isServerDeafenedUser = serverDeafenedUserIds.has(participant.userId); + const badgeBg = (isServerMutedUser || isServerDeafenedUser) ? 'bg-accent-amber/90' : 'bg-accent-rose/90'; + return ( +
+ + + + +
+ ); + })()} + {(isLocal ? isDeafened : participant.isDeafened) && (() => { + const isServerDeafenedUser = serverDeafenedUserIds.has(participant.userId); + const badgeBg = isServerDeafenedUser ? 'bg-accent-amber/90' : 'bg-accent-rose/90'; + return ( +
+ + + + +
+ ); + })()}
diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 1c693f04..56828021 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -266,6 +266,10 @@ function handleEvent(origin: string, event: ServerEvent): void { addVoiceUser(event.channelId, event.userId); } else { removeVoiceUser(event.channelId, event.userId); + // Clear server mute/deafen state for departed user + const { setServerMutedUser, setServerDeafenedUser } = useVoiceStore.getState(); + setServerMutedUser(event.userId, false); + setServerDeafenedUser(event.userId, false); } break; @@ -292,15 +296,15 @@ function handleEvent(origin: string, event: ServerEvent): void { case 'voice_server_deafened': { const { setServerDeafenedUser } = useVoiceStore.getState(); setServerDeafenedUser(event.userId, event.deafened); - // If the local user was server-deafened, force-deafen and force-mute + // If the local user was server-deafened, force-deafen (smart toggle sets both muted+deafened) const myUid = useAuthStore.getState().user?.id; if (event.userId === myUid && event.deafened) { const vs = useVoiceStore.getState(); if (!vs.isDeafened) { vs.toggleDeafen(); - if (!vs.isMuted) vs.toggleMic(); - const voiceOrigin = vs.currentVoiceChannelId ? getChannelOrigin(vs.currentVoiceChannelId) : ''; - wsSend({ type: 'voice_status', isMuted: true, isDeafened: true, isCameraOn: vs.isCameraOn, isScreenSharing: vs.isScreenSharing }, voiceOrigin); + 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); } } break; diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index ddd2cf25..fc0b33e1 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -238,8 +238,21 @@ export const useVoiceStore = create()( setOutputDevice: (deviceId) => set({ outputDeviceId: deviceId }), - toggleMic: () => set((state) => ({ isMuted: !state.isMuted })), - toggleDeafen: () => set((state) => ({ isDeafened: !state.isDeafened })), + toggleMic: () => set((state) => { + if (state.isMuted && state.isDeafened) { + // Unmuting while deafened → clear both (Discord behavior) + return { isMuted: false, isDeafened: false }; + } + return { isMuted: !state.isMuted }; + }), + toggleDeafen: () => set((state) => { + if (state.isDeafened) { + // Undeafening → clear both + return { isMuted: false, isDeafened: false }; + } + // Deafening → set both + return { isMuted: true, isDeafened: true }; + }), toggleCamera: () => set((state) => ({ isCameraOn: !state.isCameraOn })), toggleScreenShare: () => set((state) => ({ isScreenSharing: !state.isScreenSharing })), @@ -345,6 +358,8 @@ export const useVoiceStore = create()( streamVolumes: new Map(), streamMutes: new Map(), watchingStreams: new Set(), + serverMutedUserIds: new Set(), + serverDeafenedUserIds: new Set(), voiceUsers, }; });