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
+47 -12
View File
@@ -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;
+18 -113
View File
@@ -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;
}