fix: rearchitect server mute/deafen pipeline to scope restrictions by spaceId

- Replaces global `userId` tracking with `spaceId:userId` composite keys across both backend and frontend, fixing the issue where server-muting a user in one space bled into others.
- Modifies client-side `ready` event hydration to merge voice states per-origin instead of completely overwriting the store, preventing federated connections from wiping out home instance mutes.
- Excludes server voice restrictions from `zustand/persist` so stale client caches don't override the server's authority on reload.
- Fixes React component reactivity by using reactive store selections for `spaceId` instead of imperative `getState()` calls, ensuring UI lockdown indicators accurately reflect the initial websocket handshake.
This commit is contained in:
Jannis Braun
2026-03-09 19:44:37 +01:00
parent d09d956a9d
commit 6a12fe2024
10 changed files with 132 additions and 73 deletions
@@ -34,8 +34,11 @@ export function ChannelSidebar() {
const isDeafened = useVoiceStore((s) => s.isDeafened);
const toggleMic = useVoiceStore((s) => s.toggleMic);
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
const isServerMuted = useVoiceStore((s) => user ? s.serverMutedUserIds.has(user.id) : false);
const isServerDeafened = useVoiceStore((s) => user ? s.serverDeafenedUserIds.has(user.id) : false);
const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null);
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
const isServerMuted = !!(user && spaceId && serverMutedUserIds.has(`${spaceId}:${user.id}`));
const isServerDeafened = !!(user && spaceId && serverDeafenedUserIds.has(`${spaceId}:${user.id}`));
const navigate = useNavigate();
const location = useLocation();
@@ -1,6 +1,6 @@
import React, { useState, useCallback } from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
import { useAuthStore } from '../../stores/authStore';
import { Avatar } from '../ui/Avatar';
import { VoiceModContextMenu } from './VoiceModContextMenu';
@@ -28,6 +28,7 @@ export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceC
return local?.userId ?? null;
});
const members = useSpaceStore((s) => s.members);
const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap);
const myUser = useAuthStore((s) => s.user);
const isActive = currentVoiceChannel === channelId;
@@ -92,8 +93,9 @@ export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceC
: (participant?.isMuted ?? wsStatus?.isMuted ?? false);
const hasCamera = participant?.isCameraOn ?? wsStatus?.isCameraOn ?? false;
const isScreenSharing = participant?.isScreenSharing ?? wsStatus?.isScreenSharing ?? false;
const isServerMuted = serverMutedUserIds.has(userId);
const isServerDeafened = serverDeafenedUserIds.has(userId);
const spaceId = channelToSpaceMap.get(channelId);
const isServerMuted = serverMutedUserIds.has(`${spaceId}:${userId}`);
const isServerDeafened = serverDeafenedUserIds.has(`${spaceId}:${userId}`);
return (
<div
@@ -4,7 +4,7 @@ import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket';
import { getChannelOrigin } from '../../stores/spaceStore';
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
import { CAMERA_PRESET, startScreenShare, stopScreenShare } from '../../utils/screenShare';
@@ -27,8 +27,11 @@ export function VoiceControlBar() {
const toggleVoiceFullscreen = useUIStore((s) => s.toggleVoiceFullscreen);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const myUser = useAuthStore((s) => s.user);
const isServerMuted = useVoiceStore((s) => myUser ? s.serverMutedUserIds.has(myUser.id) : false);
const isServerDeafened = useVoiceStore((s) => myUser ? s.serverDeafenedUserIds.has(myUser.id) : false);
const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null);
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
const isServerMuted = !!(myUser && spaceId && serverMutedUserIds.has(`${spaceId}:${myUser.id}`));
const isServerDeafened = !!(myUser && spaceId && serverDeafenedUserIds.has(`${spaceId}:${myUser.id}`));
const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
const [qualityOpen, setQualityOpen] = useState(false);
const qualityBtnRef = useRef<HTMLButtonElement>(null);
@@ -34,9 +34,10 @@ export function VoiceModMenuItems({ targetUserId, channelId, onAction }: VoiceMo
);
const voiceOrigin = getChannelOrigin(channelId);
const spaceId = useSpaceStore((s) => s.channelToSpaceMap.get(channelId));
const isServerMuted = serverMutedUserIds.has(targetUserId);
const isServerDeafened = serverDeafenedUserIds.has(targetUserId);
const isServerMuted = serverMutedUserIds.has(`${spaceId}:${targetUserId}`);
const isServerDeafened = serverDeafenedUserIds.has(`${spaceId}:${targetUserId}`);
if (!canMuteMembers && !canDeafenMembers && !canMoveMembers) return null;
@@ -6,6 +6,7 @@ import { VoiceModMenuItems } from './VoiceModContextMenu';
import { useSpaceStore } from '../../stores/spaceStore';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import type { UserTile } from '../../hooks/useLiveKit';
import { getChannelOrigin } from '../../stores/spaceStore';
interface VoiceUserProps {
tile: UserTile;
@@ -22,6 +23,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
const isSpeaking = useVoiceStore((s) => s.speakingParticipantIds.has(participant.identity));
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null);
const [, forceUpdate] = useState(0);
@@ -149,8 +151,8 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{participant.isMuted && (() => {
const isServerMutedUser = serverMutedUserIds.has(participant.userId);
const isServerDeafenedUser = serverDeafenedUserIds.has(participant.userId);
const isServerMutedUser = spaceId ? serverMutedUserIds.has(`${spaceId}:${participant.userId}`) : false;
const isServerDeafenedUser = spaceId ? serverDeafenedUserIds.has(`${spaceId}:${participant.userId}`) : false;
const badgeBg = (isServerMutedUser || isServerDeafenedUser) ? 'bg-accent-amber/90' : 'bg-accent-rose/90';
return (
<div className={`w-5 h-5 ${badgeBg} rounded-full flex items-center justify-center`}>
@@ -169,7 +171,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
);
})()}
{(isLocal ? isDeafened : participant.isDeafened) && (() => {
const isServerDeafenedUser = serverDeafenedUserIds.has(participant.userId);
const isServerDeafenedUser = spaceId ? serverDeafenedUserIds.has(`${spaceId}:${participant.userId}`) : false;
const badgeBg = isServerDeafenedUser ? 'bg-accent-amber/90' : 'bg-accent-rose/90';
return (
<div className={`w-5 h-5 ${badgeBg} rounded-full flex items-center justify-center`}>
+43 -16
View File
@@ -169,25 +169,52 @@ function handleEvent(origin: string, event: ServerEvent): void {
}
// Build new restriction Sets atomically from ready payload, then apply in one setState
{
const newServerMuted = new Set<string>();
const newServerDeafened = new Set<string>();
if (event.serverVoiceStates) {
for (const [uid, state] of Object.entries(event.serverVoiceStates as Record<string, { serverMuted: boolean; serverDeafened: boolean }>)) {
if (state.serverMuted) newServerMuted.add(uid);
if (state.serverDeafened) newServerDeafened.add(uid);
const vsState = useVoiceStore.getState();
const spaceStoreState = useSpaceStore.getState();
// Find all space IDs that belong to the current origin
const originSpaceIds = new Set<string>();
for (const s of spaceStoreState.spaces) {
if (s._instanceOrigin === origin) {
originSpaceIds.add(s.id);
}
}
// Single atomic update — no intermediate empty-Set state
useVoiceStore.setState({ serverMutedUserIds: newServerMuted, serverDeafenedUserIds: newServerDeafened });
const nextServerMuted = new Set(vsState.serverMutedUserIds);
const nextServerDeafened = new Set(vsState.serverDeafenedUserIds);
// Clear existing restrictions that belong to spaces on THIS origin
// (If a space was deleted while offline, its orphaned restrictions remain, which is harmless)
for (const key of nextServerMuted) {
const spaceId = key.split(':')[0];
if (spaceId && originSpaceIds.has(spaceId)) nextServerMuted.delete(key);
}
for (const key of nextServerDeafened) {
const spaceId = key.split(':')[0];
if (spaceId && originSpaceIds.has(spaceId)) nextServerDeafened.delete(key);
}
if (event.serverVoiceStates) {
for (const [uid, state] of Object.entries(event.serverVoiceStates as Record<string, { serverMuted: boolean; serverDeafened: boolean }>)) {
if (state.serverMuted) nextServerMuted.add(uid);
if (state.serverDeafened) nextServerDeafened.add(uid);
}
}
// 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();
if (newServerDeafened.has(myReadyId) && !vs.isDeafened) {
useVoiceStore.setState({ isMuted: true, isDeafened: true });
} else if (newServerMuted.has(myReadyId) && !vs.isMuted) {
useVoiceStore.setState({ isMuted: true });
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 });
}
}
}
}
@@ -291,7 +318,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
case 'voice_server_muted': {
const { setServerMutedUser } = useVoiceStore.getState();
setServerMutedUser(event.userId, event.muted);
setServerMutedUser(event.spaceId, event.userId, event.muted);
const myUserId = useAuthStore.getState().user?.id;
if (event.userId === myUserId) {
if (event.muted) {
@@ -306,7 +333,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
} else {
// Server unmuted — auto-restore mic unless still server-deafened
const vs = useVoiceStore.getState();
if (!vs.serverDeafenedUserIds.has(myUserId) && vs.isMuted) {
if (!vs.serverDeafenedUserIds.has(`${event.spaceId}:${myUserId}`) && vs.isMuted) {
useVoiceStore.setState({ isMuted: false });
const fresh = useVoiceStore.getState();
const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : '';
@@ -319,7 +346,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
case 'voice_server_deafened': {
const { setServerDeafenedUser } = useVoiceStore.getState();
setServerDeafenedUser(event.userId, event.deafened);
setServerDeafenedUser(event.spaceId, event.userId, event.deafened);
const myUid = useAuthStore.getState().user?.id;
if (event.userId === myUid) {
if (event.deafened) {
@@ -346,7 +373,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
// Server un-deafened — auto-restore
const vs = useVoiceStore.getState();
if (vs.isDeafened) {
const stillServerMuted = vs.serverMutedUserIds.has(myUid);
const stillServerMuted = vs.serverMutedUserIds.has(`${event.spaceId}:${myUid}`);
useVoiceStore.setState({
isDeafened: false,
...(stillServerMuted ? {} : { isMuted: false }),
+16 -15
View File
@@ -88,10 +88,10 @@ interface VoiceState {
setVoiceUserStatus: (userId: string, isMuted: boolean, isDeafened: boolean, isCameraOn: boolean, isScreenSharing: boolean) => void;
clearVoiceUserStatus: (userId: string) => void;
// Server mute/deafen state (moderator action)
serverMutedUserIds: Set<string>;
serverDeafenedUserIds: Set<string>;
setServerMutedUser: (userId: string, muted: boolean) => void;
setServerDeafenedUser: (userId: string, deafened: boolean) => void;
serverMutedUserIds: Set<string>; // Stores "spaceId:userId"
serverDeafenedUserIds: Set<string>; // Stores "spaceId:userId"
setServerMutedUser: (spaceId: string, userId: string, muted: boolean) => void;
setServerDeafenedUser: (spaceId: string, userId: string, deafened: boolean) => void;
clearServerVoiceStates: () => void;
getVoiceUsers: (channelId: string) => string[];
clearAllVoiceUsers: () => void;
@@ -242,7 +242,8 @@ export const useVoiceStore = create<VoiceState>()(
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))) {
const spaceId = state.currentVoiceChannelId ? useSpaceStore.getState().channelToSpaceMap.get(state.currentVoiceChannelId) : null;
if (myId && spaceId && state.isMuted && (state.serverMutedUserIds.has(`${spaceId}:${myId}`) || state.serverDeafenedUserIds.has(`${spaceId}:${myId}`))) {
return {};
}
if (state.isMuted && state.isDeafened) {
@@ -254,7 +255,8 @@ export const useVoiceStore = create<VoiceState>()(
toggleDeafen: () => set((state) => {
// Server-deafened users cannot undeafen themselves
const myId = useAuthStore.getState().user?.id;
if (myId && state.isDeafened && state.serverDeafenedUserIds.has(myId)) {
const spaceId = state.currentVoiceChannelId ? useSpaceStore.getState().channelToSpaceMap.get(state.currentVoiceChannelId) : null;
if (myId && spaceId && state.isDeafened && state.serverDeafenedUserIds.has(`${spaceId}:${myId}`)) {
return {};
}
if (state.isDeafened) {
@@ -305,17 +307,19 @@ export const useVoiceStore = create<VoiceState>()(
serverMutedUserIds: new Set(),
serverDeafenedUserIds: new Set(),
setServerMutedUser: (userId, muted) => {
setServerMutedUser: (spaceId, userId, muted) => {
set((state) => {
const newSet = new Set(state.serverMutedUserIds);
if (muted) newSet.add(userId); else newSet.delete(userId);
const key = `${spaceId}:${userId}`;
if (muted) newSet.add(key); else newSet.delete(key);
return { serverMutedUserIds: newSet };
});
},
setServerDeafenedUser: (userId, deafened) => {
setServerDeafenedUser: (spaceId, userId, deafened) => {
set((state) => {
const newSet = new Set(state.serverDeafenedUserIds);
if (deafened) newSet.add(userId); else newSet.delete(userId);
const key = `${spaceId}:${userId}`;
if (deafened) newSet.add(key); else newSet.delete(key);
return { serverDeafenedUserIds: newSet };
});
},
@@ -459,15 +463,12 @@ 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.serverMutedUserIds = currentState.serverMutedUserIds;
merged.serverDeafenedUserIds = currentState.serverDeafenedUserIds;
merged.voiceUsers = currentState.voiceUsers;
merged.participants = currentState.participants;
merged.speakingParticipantIds = currentState.speakingParticipantIds;