From 6a12fe2024a6dbe436aec711baf18cd9d6c932af Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 9 Mar 2026 19:44:37 +0100 Subject: [PATCH] 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. --- packages/server/src/ws/events.ts | 31 +++++++--- packages/server/src/ws/handler.ts | 43 ++++++++------ packages/shared/src/types.ts | 4 +- .../src/components/layout/ChannelSidebar.tsx | 7 ++- .../web/src/components/voice/VoiceChannel.tsx | 8 ++- .../src/components/voice/VoiceControlBar.tsx | 9 ++- .../components/voice/VoiceModContextMenu.tsx | 5 +- .../web/src/components/voice/VoiceUser.tsx | 8 ++- packages/web/src/hooks/useWebSocket.ts | 59 ++++++++++++++----- packages/web/src/stores/voiceStore.ts | 31 +++++----- 10 files changed, 132 insertions(+), 73 deletions(-) diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index c19f1413..127b8017 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -437,19 +437,21 @@ function handleVoiceJoin(event: Record, userId: string): void { .all(); for (const r of restrictions) { if (r.restrictionType === 'mute') { - connectionManager.setServerMuted(userId, true); + connectionManager.setServerMuted(spaceId, userId, true); connectionManager.sendToUser(userId, { type: 'voice_server_muted', userId, channelId, + spaceId, muted: true, }); } else if (r.restrictionType === 'deafen') { - connectionManager.setServerDeafened(userId, true); + connectionManager.setServerDeafened(spaceId, userId, true); connectionManager.sendToUser(userId, { type: 'voice_server_deafened', userId, channelId, + spaceId, deafened: true, }); } @@ -518,19 +520,21 @@ function handleVoiceJoin(event: Record, userId: string): void { for (const r of restrictions) { if (r.restrictionType === 'mute') { - connectionManager.setServerMuted(userId, true); + connectionManager.setServerMuted(spaceId, userId, true); connectionManager.sendToSpace(spaceId, { type: 'voice_server_muted', userId, channelId, + spaceId, muted: true, }); } else if (r.restrictionType === 'deafen') { - connectionManager.setServerDeafened(userId, true); + connectionManager.setServerDeafened(spaceId, userId, true); connectionManager.sendToSpace(spaceId, { type: 'voice_server_deafened', userId, channelId, + spaceId, deafened: true, }); } @@ -543,7 +547,6 @@ function handleVoiceLeave(userId: string): void { broadcastRoomLeave(left.roomId, left.room, userId); } connectionManager.clearVoiceUserStatus(userId); - connectionManager.clearServerVoiceState(userId); } function handleVoiceStatus(event: Record, userId: string): void { @@ -557,9 +560,17 @@ function handleVoiceStatus(event: Record, userId: string): void const userRoom = connectionManager.getUserRoom(userId); if (!userRoom) return; + let isSpaceMuted = false; + let isSpaceDeafened = false; + if (userRoom.room.roomType === 'space') { + const meta = userRoom.room.metadata as SpaceRoomMeta; + isSpaceMuted = connectionManager.isServerMuted(meta.spaceId, userId); + isSpaceDeafened = connectionManager.isServerDeafened(meta.spaceId, userId); + } + // Server-side enforcement: prevent clients from bypassing server mute/deafen - const effectiveMuted = connectionManager.isServerMuted(userId) ? true : isMuted; - const effectiveDeafened = connectionManager.isServerDeafened(userId) ? true : isDeafened; + const effectiveMuted = isSpaceMuted ? true : isMuted; + const effectiveDeafened = isSpaceDeafened ? true : isDeafened; connectionManager.setVoiceUserStatus(userId, effectiveMuted, effectiveDeafened, isCameraOn, isScreenSharing); @@ -1149,7 +1160,7 @@ function handleVoiceServerMute(event: Record, userId: string): return; } - connectionManager.setServerMuted(targetUserId, muted); + connectionManager.setServerMuted(meta.spaceId, targetUserId, muted); // Persist to DB const db = getDb(); @@ -1176,6 +1187,7 @@ function handleVoiceServerMute(event: Record, userId: string): type: 'voice_server_muted', userId: targetUserId, channelId: targetRoom.roomId, + spaceId: meta.spaceId, muted, }); } @@ -1206,7 +1218,7 @@ function handleVoiceServerDeafen(event: Record, userId: string) return; } - connectionManager.setServerDeafened(targetUserId, deafened); + connectionManager.setServerDeafened(meta.spaceId, targetUserId, deafened); // Persist to DB const db = getDb(); @@ -1232,6 +1244,7 @@ function handleVoiceServerDeafen(event: Record, userId: string) type: 'voice_server_deafened', userId: targetUserId, channelId: targetRoom.roomId, + spaceId: meta.spaceId, deafened, }); } diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 4e1cd7a2..ab53019b 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -78,8 +78,8 @@ class ConnectionManager { // roomId → Timeout for ringing DM rooms (60s auto-cleanup) private ringingTimeouts: Map = new Map(); // Server-muted/deafened users (moderator action) - private serverMutedUsers: Set = new Set(); - private serverDeafenedUsers: Set = new Set(); + private serverMutedUsers: Set = new Set(); // Stores spaceId:userId + private serverDeafenedUsers: Set = new Set(); // Stores spaceId:userId addConnection(userId: string, ws: WebSocket): void { if (!this.connections.has(userId)) { @@ -304,7 +304,11 @@ class ConnectionManager { room.participants.delete(userId); this.userToRoom.delete(userId); - this.clearServerVoiceState(userId); + + if (room.roomType === 'space') { + const meta = room.metadata as SpaceRoomMeta; + this.clearServerVoiceState(meta.spaceId, userId); + } // Auto-cleanup empty space rooms (they're lazy-created) if (room.participants.size === 0 && room.roomType === 'space') { @@ -386,27 +390,29 @@ class ConnectionManager { this.voiceUserStates.delete(userId); } - setServerMuted(userId: string, muted: boolean): void { - if (muted) this.serverMutedUsers.add(userId); - else this.serverMutedUsers.delete(userId); + setServerMuted(spaceId: string, userId: string, muted: boolean): void { + const key = `${spaceId}:${userId}`; + if (muted) this.serverMutedUsers.add(key); + else this.serverMutedUsers.delete(key); } - isServerMuted(userId: string): boolean { - return this.serverMutedUsers.has(userId); + isServerMuted(spaceId: string, userId: string): boolean { + return this.serverMutedUsers.has(`${spaceId}:${userId}`); } - setServerDeafened(userId: string, deafened: boolean): void { - if (deafened) this.serverDeafenedUsers.add(userId); - else this.serverDeafenedUsers.delete(userId); + setServerDeafened(spaceId: string, userId: string, deafened: boolean): void { + const key = `${spaceId}:${userId}`; + if (deafened) this.serverDeafenedUsers.add(key); + else this.serverDeafenedUsers.delete(key); } - isServerDeafened(userId: string): boolean { - return this.serverDeafenedUsers.has(userId); + isServerDeafened(spaceId: string, userId: string): boolean { + return this.serverDeafenedUsers.has(`${spaceId}:${userId}`); } - clearServerVoiceState(userId: string): void { - this.serverMutedUsers.delete(userId); - this.serverDeafenedUsers.delete(userId); + clearServerVoiceState(spaceId: string, userId: string): void { + this.serverMutedUsers.delete(`${spaceId}:${userId}`); + this.serverDeafenedUsers.delete(`${spaceId}:${userId}`); } getAllVoiceUserStates(): Map { @@ -883,10 +889,11 @@ function buildReadyPayload(userId: string): { .where(inArray(schema.voiceRestrictions.spaceId, spaceIds)) .all(); for (const r of allRestrictions) { - const existing = serverVoiceStates[r.userId] ?? { serverMuted: false, serverDeafened: false }; + const key = `${r.spaceId}:${r.userId}`; + const existing = serverVoiceStates[key] ?? { serverMuted: false, serverDeafened: false }; if (r.restrictionType === 'mute') existing.serverMuted = true; if (r.restrictionType === 'deafen') existing.serverDeafened = true; - serverVoiceStates[r.userId] = existing; + serverVoiceStates[key] = existing; } } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 7bd2501c..8ed3a2fb 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -279,8 +279,8 @@ export type ServerEvent = | { type: 'join_request_received'; request: JoinRequest } | { type: 'join_request_accepted'; request: JoinRequest; space: SpaceWithChannelsAndMembers } | { type: 'join_request_declined'; request: JoinRequest } - | { type: 'voice_server_muted'; userId: string; channelId: string; muted: boolean } - | { type: 'voice_server_deafened'; userId: string; channelId: string; deafened: boolean } + | { type: 'voice_server_muted'; userId: string; channelId: string; spaceId: string; muted: boolean } + | { type: 'voice_server_deafened'; userId: string; channelId: string; spaceId: string; deafened: boolean } | { type: 'voice_moved'; userId: string; oldChannelId: string; newChannelId: string } | { type: 'member_banned'; spaceId: string; reason: string | null } | { type: 'pong' } diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index c92227c4..c251199b 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -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(); diff --git a/packages/web/src/components/voice/VoiceChannel.tsx b/packages/web/src/components/voice/VoiceChannel.tsx index 0d8e721a..f31dc394 100644 --- a/packages/web/src/components/voice/VoiceChannel.tsx +++ b/packages/web/src/components/voice/VoiceChannel.tsx @@ -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 (
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(null); diff --git a/packages/web/src/components/voice/VoiceModContextMenu.tsx b/packages/web/src/components/voice/VoiceModContextMenu.tsx index e5de02bf..a370d5b5 100644 --- a/packages/web/src/components/voice/VoiceModContextMenu.tsx +++ b/packages/web/src/components/voice/VoiceModContextMenu.tsx @@ -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; diff --git a/packages/web/src/components/voice/VoiceUser.tsx b/packages/web/src/components/voice/VoiceUser.tsx index 1c55a516..266fde6e 100644 --- a/packages/web/src/components/voice/VoiceUser.tsx +++ b/packages/web/src/components/voice/VoiceUser.tsx @@ -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) {
{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 (
@@ -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 (
diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 4fb1f695..58a39fc0 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -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(); - const newServerDeafened = new Set(); - if (event.serverVoiceStates) { - for (const [uid, state] of Object.entries(event.serverVoiceStates as Record)) { - 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(); + 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)) { + 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 }), diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index 3183d661..297aa3a0 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -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; - serverDeafenedUserIds: Set; - setServerMutedUser: (userId: string, muted: boolean) => void; - setServerDeafenedUser: (userId: string, deafened: boolean) => void; + serverMutedUserIds: Set; // Stores "spaceId:userId" + serverDeafenedUserIds: Set; // 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()( 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()( 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()( 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()( 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;