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.
This commit is contained in:
Jannis Braun
2026-03-09 22:25:56 +01:00
parent db1909d785
commit 907f8285cd
9 changed files with 164 additions and 233 deletions
@@ -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);
@@ -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));
@@ -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<HTMLButtonElement>(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)'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
@@ -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);