feat: real-time SPEAK permission enforcement in voice channels

Permission changes now take effect immediately without requiring
disconnect/reconnect. Modeled as "permission mute" parallel to
server mute — server recomputes SPEAK for all voice participants
on role/override changes and broadcasts state via WebSocket.
Includes amber UI indicators and mic toggle blocking.
This commit is contained in:
Jannis Braun
2026-03-10 14:50:01 +01:00
parent 4d9454382c
commit ce63c5ed36
13 changed files with 173 additions and 28 deletions
+16 -2
View File
@@ -184,6 +184,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
const nextServerMuted = new Set(vsState.serverMutedUserIds);
const nextServerDeafened = new Set(vsState.serverDeafenedUserIds);
const nextPermissionMuted = new Set(vsState.permissionMutedUserIds);
// Clear existing restrictions that belong to spaces on THIS origin
// (If a space was deleted while offline, its orphaned restrictions remain, which is harmless)
@@ -195,15 +196,20 @@ function handleEvent(origin: string, event: ServerEvent): void {
const spaceId = key.split(':')[0];
if (spaceId && originSpaceIds.has(spaceId)) nextServerDeafened.delete(key);
}
for (const key of nextPermissionMuted) {
const spaceId = key.split(':')[0];
if (spaceId && originSpaceIds.has(spaceId)) nextPermissionMuted.delete(key);
}
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; permissionMuted?: boolean }>)) {
if (state.serverMuted) nextServerMuted.add(uid);
if (state.serverDeafened) nextServerDeafened.add(uid);
if (state.permissionMuted) nextPermissionMuted.add(uid);
}
}
// Single atomic update
useVoiceStore.setState({ serverMutedUserIds: nextServerMuted, serverDeafenedUserIds: nextServerDeafened });
useVoiceStore.setState({ serverMutedUserIds: nextServerMuted, serverDeafenedUserIds: nextServerDeafened, permissionMutedUserIds: nextPermissionMuted });
// With decoupled state, user intent is never force-set by the server.
// Effective state (intent || serverEnforcement) is computed reactively
@@ -316,6 +322,14 @@ function handleEvent(origin: string, event: ServerEvent): void {
break;
}
case 'voice_permission_muted': {
const { setPermissionMutedUser } = useVoiceStore.getState();
setPermissionMutedUser(event.spaceId, event.userId, event.muted);
const myPermMuteId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin);
if (event.userId === myPermMuteId) broadcastVoiceStatus();
break;
}
case 'voice_server_deafened': {
const { setServerDeafenedUser } = useVoiceStore.getState();
setServerDeafenedUser(event.spaceId, event.userId, event.deafened);