fix: persist server mute/deafen state across reloads and prevent client bypass

Server-side: add DB persistence for voice restrictions (schema, migration,
ready payload, cleanup on leave). Client-side: fix four bugs that wiped or
bypassed server restriction state — leaveVoice() no longer clears global
restriction Sets, voice_state_update leave no longer drops amber icons,
toggleMic/toggleDeafen now guard against server restrictions, and force-mute/
deafen uses direct setState instead of fragile toggle calls.
This commit is contained in:
Jannis Braun
2026-03-09 18:36:17 +01:00
parent f6cdfe993f
commit c2ddfe0cb7
7 changed files with 236 additions and 28 deletions
+83 -25
View File
@@ -167,12 +167,25 @@ function handleEvent(origin: string, event: ServerEvent): void {
setVoiceUserStatus(uid, status.isMuted, status.isDeafened, status.isCameraOn, status.isScreenSharing);
}
}
// Populate server mute/deafen states
if (event.serverVoiceStates) {
const { setServerMutedUser, setServerDeafenedUser } = useVoiceStore.getState();
for (const [uid, state] of Object.entries(event.serverVoiceStates as Record<string, { serverMuted: boolean; serverDeafened: boolean }>)) {
if (state.serverMuted) setServerMutedUser(uid, true);
if (state.serverDeafened) setServerDeafenedUser(uid, true);
// Clear stale server voice states before applying fresh from ready payload
{
const { clearServerVoiceStates, setServerMutedUser, setServerDeafenedUser } = useVoiceStore.getState();
clearServerVoiceStates();
if (event.serverVoiceStates) {
for (const [uid, state] of Object.entries(event.serverVoiceStates as Record<string, { serverMuted: boolean; serverDeafened: boolean }>)) {
if (state.serverMuted) setServerMutedUser(uid, true);
if (state.serverDeafened) setServerDeafenedUser(uid, true);
}
}
// 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();
if (vs.serverDeafenedUserIds.has(myReadyId) && !vs.isDeafened) {
useVoiceStore.setState({ isMuted: true, isDeafened: true });
} else if (vs.serverMutedUserIds.has(myReadyId) && !vs.isMuted) {
useVoiceStore.setState({ isMuted: true });
}
}
}
@@ -266,10 +279,6 @@ 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;
@@ -280,14 +289,26 @@ function handleEvent(origin: string, event: ServerEvent): void {
case 'voice_server_muted': {
const { setServerMutedUser } = useVoiceStore.getState();
setServerMutedUser(event.userId, event.muted);
// If the local user was server-muted, force-mute the mic
const myUserId = useAuthStore.getState().user?.id;
if (event.userId === myUserId && event.muted) {
const vs = useVoiceStore.getState();
if (!vs.isMuted) {
vs.toggleMic();
const voiceOrigin = vs.currentVoiceChannelId ? getChannelOrigin(vs.currentVoiceChannelId) : '';
wsSend({ type: 'voice_status', isMuted: true, isDeafened: vs.isDeafened, isCameraOn: vs.isCameraOn, isScreenSharing: vs.isScreenSharing }, voiceOrigin);
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(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);
}
}
}
break;
@@ -296,15 +317,52 @@ 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 (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();
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);
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(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(() => {});
}
});
}
}
}
break;
+34 -3
View File
@@ -3,6 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware';
import type { ParticipantInfo } from '../hooks/useLiveKit';
import { AudioManager } from '../audio/AudioManager';
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from './spaceStore';
import { useAuthStore } from './authStore';
export interface ScreenShareConfig {
height: 1080 | 720 | 540;
@@ -239,6 +240,11 @@ export const useVoiceStore = create<VoiceState>()(
setOutputDevice: (deviceId) => set({ outputDeviceId: deviceId }),
toggleMic: () => set((state) => {
// Server-muted/deafened users cannot unmute themselves
const myId = useAuthStore.getState().user?.id;
if (myId && state.isMuted && (state.serverMutedUserIds.has(myId) || state.serverDeafenedUserIds.has(myId))) {
return {};
}
if (state.isMuted && state.isDeafened) {
// Unmuting while deafened → clear both (Discord behavior)
return { isMuted: false, isDeafened: false };
@@ -246,6 +252,11 @@ export const useVoiceStore = create<VoiceState>()(
return { isMuted: !state.isMuted };
}),
toggleDeafen: () => set((state) => {
// Server-deafened users cannot undeafen themselves
const myId = useAuthStore.getState().user?.id;
if (myId && state.isDeafened && state.serverDeafenedUserIds.has(myId)) {
return {};
}
if (state.isDeafened) {
// Undeafening → clear both
return { isMuted: false, isDeafened: false };
@@ -358,8 +369,6 @@ export const useVoiceStore = create<VoiceState>()(
streamVolumes: new Map(),
streamMutes: new Map(),
watchingStreams: new Set(),
serverMutedUserIds: new Set(),
serverDeafenedUserIds: new Set(),
voiceUsers,
};
});
@@ -397,7 +406,7 @@ export const useVoiceStore = create<VoiceState>()(
}),
{
name: 'backspace-voice-settings',
version: 6,
version: 7,
migrate: (persistedState: any, version: number) => {
if (version === 0) {
persistedState.streamAttenuationEnabled = false;
@@ -427,6 +436,9 @@ export const useVoiceStore = create<VoiceState>()(
persistedState.screenShareConfig.customBitrateKbps = null;
}
}
if (version < 7) {
// No data migration needed — Sets will be populated from server on next connect
}
return persistedState;
},
storage: createJSONStorage(() => localStorage),
@@ -447,7 +459,26 @@ export const useVoiceStore = create<VoiceState>()(
rnnoiseEnabled: state.rnnoiseEnabled,
streamAttenuationEnabled: state.streamAttenuationEnabled,
streamAttenuationStrength: state.streamAttenuationStrength,
_serverMutedArr: [...state.serverMutedUserIds],
_serverDeafenedArr: [...state.serverDeafenedUserIds],
}),
merge: (persistedState: any, currentState: VoiceState) => {
const merged = { ...currentState, ...persistedState };
// Reconstruct Sets from persisted arrays (Sets aren't JSON-serializable)
merged.serverMutedUserIds = new Set(persistedState?._serverMutedArr ?? []);
merged.serverDeafenedUserIds = new Set(persistedState?._serverDeafenedArr ?? []);
// Reconstruct non-persisted Sets/Maps to their defaults
merged.voiceUsers = currentState.voiceUsers;
merged.participants = currentState.participants;
merged.speakingParticipantIds = currentState.speakingParticipantIds;
merged.deafenedUserIds = currentState.deafenedUserIds;
merged.voiceUserStates = currentState.voiceUserStates;
merged.participantVolumes = currentState.participantVolumes;
merged.streamVolumes = currentState.streamVolumes;
merged.streamMutes = currentState.streamMutes;
merged.watchingStreams = currentState.watchingStreams;
return merged;
},
}
)
);