From d09d956a9d86bc91d287526298b691717488f50b Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:56:00 +0100 Subject: [PATCH] fix: make server mute/deafen state survive page reload - Client ready handler now builds restriction Sets atomically in a single setState call, eliminating intermediate empty-Set state that caused amber icons to flash rose on reload - buildReadyPayload queries all voice restrictions from DB across the user's spaces instead of relying on in-memory state (which is lost on disconnect timeout) - voice_join early-return path now re-broadcasts restrictions to the reconnecting user, covering fast reload where the user is still in the room --- packages/server/src/ws/events.ts | 29 +++++++++++++++++++++ packages/server/src/ws/handler.ts | 35 ++++++++------------------ packages/web/src/hooks/useWebSocket.ts | 17 +++++++------ 3 files changed, 50 insertions(+), 31 deletions(-) diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 0e57a4c7..c19f1413 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -425,6 +425,35 @@ function handleVoiceJoin(event: Record, userId: string): void { isScreenSharing: status.isScreenSharing, }); } + + // Re-broadcast persistent restrictions (covers page reload while in voice) + const db = getDb(); + const restrictions = db.select() + .from(schema.voiceRestrictions) + .where(and( + eq(schema.voiceRestrictions.spaceId, spaceId), + eq(schema.voiceRestrictions.userId, userId), + )) + .all(); + for (const r of restrictions) { + if (r.restrictionType === 'mute') { + connectionManager.setServerMuted(userId, true); + connectionManager.sendToUser(userId, { + type: 'voice_server_muted', + userId, + channelId, + muted: true, + }); + } else if (r.restrictionType === 'deafen') { + connectionManager.setServerDeafened(userId, true); + connectionManager.sendToUser(userId, { + type: 'voice_server_deafened', + userId, + channelId, + deafened: true, + }); + } + } return; } diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 2fdcd388..4e1cd7a2 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -875,34 +875,21 @@ function buildReadyPayload(userId: string): { } } - // Build server mute/deafen states for users currently in voice + // Build server mute/deafen states from DB (authoritative source for all spaces the user belongs to) const serverVoiceStates: Record = {}; - for (const chId of Object.keys(voiceStates)) { - const usersInChannel = voiceStates[chId]; - if (usersInChannel) { - for (const uid of usersInChannel) { - const sm = connectionManager.isServerMuted(uid); - const sd = connectionManager.isServerDeafened(uid); - if (sm || sd) { - serverVoiceStates[uid] = { serverMuted: sm, serverDeafened: sd }; - } - } + if (spaceIds.length > 0) { + const allRestrictions = db.select() + .from(schema.voiceRestrictions) + .where(inArray(schema.voiceRestrictions.spaceId, spaceIds)) + .all(); + for (const r of allRestrictions) { + const existing = serverVoiceStates[r.userId] ?? { serverMuted: false, serverDeafened: false }; + if (r.restrictionType === 'mute') existing.serverMuted = true; + if (r.restrictionType === 'deafen') existing.serverDeafened = true; + serverVoiceStates[r.userId] = existing; } } - // Also include the connecting user's own DB-persisted restrictions - // (covers reconnect after disconnect timeout cleared in-memory state) - const myRestrictions = db.select() - .from(schema.voiceRestrictions) - .where(eq(schema.voiceRestrictions.userId, userId)) - .all(); - for (const r of myRestrictions) { - const existing = serverVoiceStates[userId] ?? { serverMuted: false, serverDeafened: false }; - if (r.restrictionType === 'mute') existing.serverMuted = true; - if (r.restrictionType === 'deafen') existing.serverDeafened = true; - serverVoiceStates[userId] = existing; - } - // Fetch read states for unread tracking const readStateRows = db.select() .from(schema.readStates) diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index c1791c4a..4fb1f695 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -167,23 +167,26 @@ function handleEvent(origin: string, event: ServerEvent): void { setVoiceUserStatus(uid, status.isMuted, status.isDeafened, status.isCameraOn, status.isScreenSharing); } } - // Clear stale server voice states before applying fresh from ready payload + // Build new restriction Sets atomically from ready payload, then apply in one setState { - const { clearServerVoiceStates, setServerMutedUser, setServerDeafenedUser } = useVoiceStore.getState(); - clearServerVoiceStates(); + 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) setServerMutedUser(uid, true); - if (state.serverDeafened) setServerDeafenedUser(uid, true); + if (state.serverMuted) newServerMuted.add(uid); + if (state.serverDeafened) newServerDeafened.add(uid); } } + // Single atomic update — no intermediate empty-Set state + useVoiceStore.setState({ serverMutedUserIds: newServerMuted, serverDeafenedUserIds: newServerDeafened }); + // 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) { + if (newServerDeafened.has(myReadyId) && !vs.isDeafened) { useVoiceStore.setState({ isMuted: true, isDeafened: true }); - } else if (vs.serverMutedUserIds.has(myReadyId) && !vs.isMuted) { + } else if (newServerMuted.has(myReadyId) && !vs.isMuted) { useVoiceStore.setState({ isMuted: true }); } }