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
@@ -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`}>