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
+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;
},
}
)
);