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
This commit is contained in:
Jannis Braun
2026-03-09 18:56:00 +01:00
parent c2ddfe0cb7
commit d09d956a9d
3 changed files with 50 additions and 31 deletions
+29
View File
@@ -425,6 +425,35 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
isScreenSharing: status.isScreenSharing, 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; return;
} }
+8 -21
View File
@@ -875,32 +875,19 @@ 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<string, { serverMuted: boolean; serverDeafened: boolean }> = {}; const serverVoiceStates: Record<string, { serverMuted: boolean; serverDeafened: boolean }> = {};
for (const chId of Object.keys(voiceStates)) { if (spaceIds.length > 0) {
const usersInChannel = voiceStates[chId]; const allRestrictions = db.select()
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 };
}
}
}
}
// 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) .from(schema.voiceRestrictions)
.where(eq(schema.voiceRestrictions.userId, userId)) .where(inArray(schema.voiceRestrictions.spaceId, spaceIds))
.all(); .all();
for (const r of myRestrictions) { for (const r of allRestrictions) {
const existing = serverVoiceStates[userId] ?? { serverMuted: false, serverDeafened: false }; const existing = serverVoiceStates[r.userId] ?? { serverMuted: false, serverDeafened: false };
if (r.restrictionType === 'mute') existing.serverMuted = true; if (r.restrictionType === 'mute') existing.serverMuted = true;
if (r.restrictionType === 'deafen') existing.serverDeafened = true; if (r.restrictionType === 'deafen') existing.serverDeafened = true;
serverVoiceStates[userId] = existing; serverVoiceStates[r.userId] = existing;
}
} }
// Fetch read states for unread tracking // Fetch read states for unread tracking
+10 -7
View File
@@ -167,23 +167,26 @@ function handleEvent(origin: string, event: ServerEvent): void {
setVoiceUserStatus(uid, status.isMuted, status.isDeafened, status.isCameraOn, status.isScreenSharing); 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(); const newServerMuted = new Set<string>();
clearServerVoiceStates(); const newServerDeafened = new Set<string>();
if (event.serverVoiceStates) { if (event.serverVoiceStates) {
for (const [uid, state] of Object.entries(event.serverVoiceStates as Record<string, { serverMuted: boolean; serverDeafened: boolean }>)) { for (const [uid, state] of Object.entries(event.serverVoiceStates as Record<string, { serverMuted: boolean; serverDeafened: boolean }>)) {
if (state.serverMuted) setServerMutedUser(uid, true); if (state.serverMuted) newServerMuted.add(uid);
if (state.serverDeafened) setServerDeafenedUser(uid, true); 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) // Enforce local mute/deafen to match server restrictions (one-directional: only force-mute, never auto-unmute)
const myReadyId = useAuthStore.getState().user?.id; const myReadyId = useAuthStore.getState().user?.id;
if (myReadyId) { if (myReadyId) {
const vs = useVoiceStore.getState(); const vs = useVoiceStore.getState();
if (vs.serverDeafenedUserIds.has(myReadyId) && !vs.isDeafened) { if (newServerDeafened.has(myReadyId) && !vs.isDeafened) {
useVoiceStore.setState({ isMuted: true, isDeafened: true }); 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 }); useVoiceStore.setState({ isMuted: true });
} }
} }