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:
@@ -437,19 +437,21 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
|
|||||||
.all();
|
.all();
|
||||||
for (const r of restrictions) {
|
for (const r of restrictions) {
|
||||||
if (r.restrictionType === 'mute') {
|
if (r.restrictionType === 'mute') {
|
||||||
connectionManager.setServerMuted(userId, true);
|
connectionManager.setServerMuted(spaceId, userId, true);
|
||||||
connectionManager.sendToUser(userId, {
|
connectionManager.sendToUser(userId, {
|
||||||
type: 'voice_server_muted',
|
type: 'voice_server_muted',
|
||||||
userId,
|
userId,
|
||||||
channelId,
|
channelId,
|
||||||
|
spaceId,
|
||||||
muted: true,
|
muted: true,
|
||||||
});
|
});
|
||||||
} else if (r.restrictionType === 'deafen') {
|
} else if (r.restrictionType === 'deafen') {
|
||||||
connectionManager.setServerDeafened(userId, true);
|
connectionManager.setServerDeafened(spaceId, userId, true);
|
||||||
connectionManager.sendToUser(userId, {
|
connectionManager.sendToUser(userId, {
|
||||||
type: 'voice_server_deafened',
|
type: 'voice_server_deafened',
|
||||||
userId,
|
userId,
|
||||||
channelId,
|
channelId,
|
||||||
|
spaceId,
|
||||||
deafened: true,
|
deafened: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -518,19 +520,21 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
|
|||||||
|
|
||||||
for (const r of restrictions) {
|
for (const r of restrictions) {
|
||||||
if (r.restrictionType === 'mute') {
|
if (r.restrictionType === 'mute') {
|
||||||
connectionManager.setServerMuted(userId, true);
|
connectionManager.setServerMuted(spaceId, userId, true);
|
||||||
connectionManager.sendToSpace(spaceId, {
|
connectionManager.sendToSpace(spaceId, {
|
||||||
type: 'voice_server_muted',
|
type: 'voice_server_muted',
|
||||||
userId,
|
userId,
|
||||||
channelId,
|
channelId,
|
||||||
|
spaceId,
|
||||||
muted: true,
|
muted: true,
|
||||||
});
|
});
|
||||||
} else if (r.restrictionType === 'deafen') {
|
} else if (r.restrictionType === 'deafen') {
|
||||||
connectionManager.setServerDeafened(userId, true);
|
connectionManager.setServerDeafened(spaceId, userId, true);
|
||||||
connectionManager.sendToSpace(spaceId, {
|
connectionManager.sendToSpace(spaceId, {
|
||||||
type: 'voice_server_deafened',
|
type: 'voice_server_deafened',
|
||||||
userId,
|
userId,
|
||||||
channelId,
|
channelId,
|
||||||
|
spaceId,
|
||||||
deafened: true,
|
deafened: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -543,7 +547,6 @@ function handleVoiceLeave(userId: string): void {
|
|||||||
broadcastRoomLeave(left.roomId, left.room, userId);
|
broadcastRoomLeave(left.roomId, left.room, userId);
|
||||||
}
|
}
|
||||||
connectionManager.clearVoiceUserStatus(userId);
|
connectionManager.clearVoiceUserStatus(userId);
|
||||||
connectionManager.clearServerVoiceState(userId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleVoiceStatus(event: Record<string, unknown>, userId: string): void {
|
function handleVoiceStatus(event: Record<string, unknown>, userId: string): void {
|
||||||
@@ -557,9 +560,17 @@ function handleVoiceStatus(event: Record<string, unknown>, userId: string): void
|
|||||||
const userRoom = connectionManager.getUserRoom(userId);
|
const userRoom = connectionManager.getUserRoom(userId);
|
||||||
if (!userRoom) return;
|
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
|
// Server-side enforcement: prevent clients from bypassing server mute/deafen
|
||||||
const effectiveMuted = connectionManager.isServerMuted(userId) ? true : isMuted;
|
const effectiveMuted = isSpaceMuted ? true : isMuted;
|
||||||
const effectiveDeafened = connectionManager.isServerDeafened(userId) ? true : isDeafened;
|
const effectiveDeafened = isSpaceDeafened ? true : isDeafened;
|
||||||
|
|
||||||
connectionManager.setVoiceUserStatus(userId, effectiveMuted, effectiveDeafened, isCameraOn, isScreenSharing);
|
connectionManager.setVoiceUserStatus(userId, effectiveMuted, effectiveDeafened, isCameraOn, isScreenSharing);
|
||||||
|
|
||||||
@@ -1149,7 +1160,7 @@ function handleVoiceServerMute(event: Record<string, unknown>, userId: string):
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
connectionManager.setServerMuted(targetUserId, muted);
|
connectionManager.setServerMuted(meta.spaceId, targetUserId, muted);
|
||||||
|
|
||||||
// Persist to DB
|
// Persist to DB
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -1176,6 +1187,7 @@ function handleVoiceServerMute(event: Record<string, unknown>, userId: string):
|
|||||||
type: 'voice_server_muted',
|
type: 'voice_server_muted',
|
||||||
userId: targetUserId,
|
userId: targetUserId,
|
||||||
channelId: targetRoom.roomId,
|
channelId: targetRoom.roomId,
|
||||||
|
spaceId: meta.spaceId,
|
||||||
muted,
|
muted,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1206,7 +1218,7 @@ function handleVoiceServerDeafen(event: Record<string, unknown>, userId: string)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
connectionManager.setServerDeafened(targetUserId, deafened);
|
connectionManager.setServerDeafened(meta.spaceId, targetUserId, deafened);
|
||||||
|
|
||||||
// Persist to DB
|
// Persist to DB
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -1232,6 +1244,7 @@ function handleVoiceServerDeafen(event: Record<string, unknown>, userId: string)
|
|||||||
type: 'voice_server_deafened',
|
type: 'voice_server_deafened',
|
||||||
userId: targetUserId,
|
userId: targetUserId,
|
||||||
channelId: targetRoom.roomId,
|
channelId: targetRoom.roomId,
|
||||||
|
spaceId: meta.spaceId,
|
||||||
deafened,
|
deafened,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ class ConnectionManager {
|
|||||||
// roomId → Timeout for ringing DM rooms (60s auto-cleanup)
|
// roomId → Timeout for ringing DM rooms (60s auto-cleanup)
|
||||||
private ringingTimeouts: Map<string, NodeJS.Timeout> = new Map();
|
private ringingTimeouts: Map<string, NodeJS.Timeout> = new Map();
|
||||||
// Server-muted/deafened users (moderator action)
|
// Server-muted/deafened users (moderator action)
|
||||||
private serverMutedUsers: Set<string> = new Set();
|
private serverMutedUsers: Set<string> = new Set(); // Stores spaceId:userId
|
||||||
private serverDeafenedUsers: Set<string> = new Set();
|
private serverDeafenedUsers: Set<string> = new Set(); // Stores spaceId:userId
|
||||||
|
|
||||||
addConnection(userId: string, ws: WebSocket): void {
|
addConnection(userId: string, ws: WebSocket): void {
|
||||||
if (!this.connections.has(userId)) {
|
if (!this.connections.has(userId)) {
|
||||||
@@ -304,7 +304,11 @@ class ConnectionManager {
|
|||||||
|
|
||||||
room.participants.delete(userId);
|
room.participants.delete(userId);
|
||||||
this.userToRoom.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)
|
// Auto-cleanup empty space rooms (they're lazy-created)
|
||||||
if (room.participants.size === 0 && room.roomType === 'space') {
|
if (room.participants.size === 0 && room.roomType === 'space') {
|
||||||
@@ -386,27 +390,29 @@ class ConnectionManager {
|
|||||||
this.voiceUserStates.delete(userId);
|
this.voiceUserStates.delete(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
setServerMuted(userId: string, muted: boolean): void {
|
setServerMuted(spaceId: string, userId: string, muted: boolean): void {
|
||||||
if (muted) this.serverMutedUsers.add(userId);
|
const key = `${spaceId}:${userId}`;
|
||||||
else this.serverMutedUsers.delete(userId);
|
if (muted) this.serverMutedUsers.add(key);
|
||||||
|
else this.serverMutedUsers.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
isServerMuted(userId: string): boolean {
|
isServerMuted(spaceId: string, userId: string): boolean {
|
||||||
return this.serverMutedUsers.has(userId);
|
return this.serverMutedUsers.has(`${spaceId}:${userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setServerDeafened(userId: string, deafened: boolean): void {
|
setServerDeafened(spaceId: string, userId: string, deafened: boolean): void {
|
||||||
if (deafened) this.serverDeafenedUsers.add(userId);
|
const key = `${spaceId}:${userId}`;
|
||||||
else this.serverDeafenedUsers.delete(userId);
|
if (deafened) this.serverDeafenedUsers.add(key);
|
||||||
|
else this.serverDeafenedUsers.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
isServerDeafened(userId: string): boolean {
|
isServerDeafened(spaceId: string, userId: string): boolean {
|
||||||
return this.serverDeafenedUsers.has(userId);
|
return this.serverDeafenedUsers.has(`${spaceId}:${userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearServerVoiceState(userId: string): void {
|
clearServerVoiceState(spaceId: string, userId: string): void {
|
||||||
this.serverMutedUsers.delete(userId);
|
this.serverMutedUsers.delete(`${spaceId}:${userId}`);
|
||||||
this.serverDeafenedUsers.delete(userId);
|
this.serverDeafenedUsers.delete(`${spaceId}:${userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllVoiceUserStates(): Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> {
|
getAllVoiceUserStates(): Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> {
|
||||||
@@ -883,10 +889,11 @@ function buildReadyPayload(userId: string): {
|
|||||||
.where(inArray(schema.voiceRestrictions.spaceId, spaceIds))
|
.where(inArray(schema.voiceRestrictions.spaceId, spaceIds))
|
||||||
.all();
|
.all();
|
||||||
for (const r of allRestrictions) {
|
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 === 'mute') existing.serverMuted = true;
|
||||||
if (r.restrictionType === 'deafen') existing.serverDeafened = true;
|
if (r.restrictionType === 'deafen') existing.serverDeafened = true;
|
||||||
serverVoiceStates[r.userId] = existing;
|
serverVoiceStates[key] = existing;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -279,8 +279,8 @@ export type ServerEvent =
|
|||||||
| { type: 'join_request_received'; request: JoinRequest }
|
| { type: 'join_request_received'; request: JoinRequest }
|
||||||
| { type: 'join_request_accepted'; request: JoinRequest; space: SpaceWithChannelsAndMembers }
|
| { type: 'join_request_accepted'; request: JoinRequest; space: SpaceWithChannelsAndMembers }
|
||||||
| { type: 'join_request_declined'; request: JoinRequest }
|
| { type: 'join_request_declined'; request: JoinRequest }
|
||||||
| { type: 'voice_server_muted'; userId: string; channelId: string; muted: boolean }
|
| { type: 'voice_server_muted'; userId: string; channelId: string; spaceId: string; muted: boolean }
|
||||||
| { type: 'voice_server_deafened'; userId: string; channelId: string; deafened: boolean }
|
| { type: 'voice_server_deafened'; userId: string; channelId: string; spaceId: string; deafened: boolean }
|
||||||
| { type: 'voice_moved'; userId: string; oldChannelId: string; newChannelId: string }
|
| { type: 'voice_moved'; userId: string; oldChannelId: string; newChannelId: string }
|
||||||
| { type: 'member_banned'; spaceId: string; reason: string | null }
|
| { type: 'member_banned'; spaceId: string; reason: string | null }
|
||||||
| { type: 'pong' }
|
| { type: 'pong' }
|
||||||
|
|||||||
@@ -34,8 +34,11 @@ export function ChannelSidebar() {
|
|||||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||||
const toggleMic = useVoiceStore((s) => s.toggleMic);
|
const toggleMic = useVoiceStore((s) => s.toggleMic);
|
||||||
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
|
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
|
||||||
const isServerMuted = useVoiceStore((s) => user ? s.serverMutedUserIds.has(user.id) : false);
|
const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null);
|
||||||
const isServerDeafened = useVoiceStore((s) => user ? s.serverDeafenedUserIds.has(user.id) : false);
|
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 navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { VoiceModContextMenu } from './VoiceModContextMenu';
|
import { VoiceModContextMenu } from './VoiceModContextMenu';
|
||||||
@@ -28,6 +28,7 @@ export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceC
|
|||||||
return local?.userId ?? null;
|
return local?.userId ?? null;
|
||||||
});
|
});
|
||||||
const members = useSpaceStore((s) => s.members);
|
const members = useSpaceStore((s) => s.members);
|
||||||
|
const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap);
|
||||||
const myUser = useAuthStore((s) => s.user);
|
const myUser = useAuthStore((s) => s.user);
|
||||||
const isActive = currentVoiceChannel === channelId;
|
const isActive = currentVoiceChannel === channelId;
|
||||||
|
|
||||||
@@ -92,8 +93,9 @@ export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceC
|
|||||||
: (participant?.isMuted ?? wsStatus?.isMuted ?? false);
|
: (participant?.isMuted ?? wsStatus?.isMuted ?? false);
|
||||||
const hasCamera = participant?.isCameraOn ?? wsStatus?.isCameraOn ?? false;
|
const hasCamera = participant?.isCameraOn ?? wsStatus?.isCameraOn ?? false;
|
||||||
const isScreenSharing = participant?.isScreenSharing ?? wsStatus?.isScreenSharing ?? false;
|
const isScreenSharing = participant?.isScreenSharing ?? wsStatus?.isScreenSharing ?? false;
|
||||||
const isServerMuted = serverMutedUserIds.has(userId);
|
const spaceId = channelToSpaceMap.get(channelId);
|
||||||
const isServerDeafened = serverDeafenedUserIds.has(userId);
|
const isServerMuted = serverMutedUserIds.has(`${spaceId}:${userId}`);
|
||||||
|
const isServerDeafened = serverDeafenedUserIds.has(`${spaceId}:${userId}`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useUIStore } from '../../stores/uiStore';
|
|||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
import { getChannelOrigin } from '../../stores/spaceStore';
|
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
|
||||||
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
||||||
import { CAMERA_PRESET, startScreenShare, stopScreenShare } from '../../utils/screenShare';
|
import { CAMERA_PRESET, startScreenShare, stopScreenShare } from '../../utils/screenShare';
|
||||||
|
|
||||||
@@ -27,8 +27,11 @@ export function VoiceControlBar() {
|
|||||||
const toggleVoiceFullscreen = useUIStore((s) => s.toggleVoiceFullscreen);
|
const toggleVoiceFullscreen = useUIStore((s) => s.toggleVoiceFullscreen);
|
||||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||||
const myUser = useAuthStore((s) => s.user);
|
const myUser = useAuthStore((s) => s.user);
|
||||||
const isServerMuted = useVoiceStore((s) => myUser ? s.serverMutedUserIds.has(myUser.id) : false);
|
const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null);
|
||||||
const isServerDeafened = useVoiceStore((s) => myUser ? s.serverDeafenedUserIds.has(myUser.id) : false);
|
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 voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
|
||||||
const [qualityOpen, setQualityOpen] = useState(false);
|
const [qualityOpen, setQualityOpen] = useState(false);
|
||||||
const qualityBtnRef = useRef<HTMLButtonElement>(null);
|
const qualityBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ export function VoiceModMenuItems({ targetUserId, channelId, onAction }: VoiceMo
|
|||||||
);
|
);
|
||||||
|
|
||||||
const voiceOrigin = getChannelOrigin(channelId);
|
const voiceOrigin = getChannelOrigin(channelId);
|
||||||
|
const spaceId = useSpaceStore((s) => s.channelToSpaceMap.get(channelId));
|
||||||
|
|
||||||
const isServerMuted = serverMutedUserIds.has(targetUserId);
|
const isServerMuted = serverMutedUserIds.has(`${spaceId}:${targetUserId}`);
|
||||||
const isServerDeafened = serverDeafenedUserIds.has(targetUserId);
|
const isServerDeafened = serverDeafenedUserIds.has(`${spaceId}:${targetUserId}`);
|
||||||
|
|
||||||
if (!canMuteMembers && !canDeafenMembers && !canMoveMembers) return null;
|
if (!canMuteMembers && !canDeafenMembers && !canMoveMembers) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { VoiceModMenuItems } from './VoiceModContextMenu';
|
|||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||||
import type { UserTile } from '../../hooks/useLiveKit';
|
import type { UserTile } from '../../hooks/useLiveKit';
|
||||||
|
import { getChannelOrigin } from '../../stores/spaceStore';
|
||||||
|
|
||||||
interface VoiceUserProps {
|
interface VoiceUserProps {
|
||||||
tile: UserTile;
|
tile: UserTile;
|
||||||
@@ -22,6 +23,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
|||||||
const isSpeaking = useVoiceStore((s) => s.speakingParticipantIds.has(participant.identity));
|
const isSpeaking = useVoiceStore((s) => s.speakingParticipantIds.has(participant.identity));
|
||||||
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
|
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
|
||||||
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
|
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
|
||||||
|
const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null);
|
||||||
|
|
||||||
const [, forceUpdate] = useState(0);
|
const [, forceUpdate] = useState(0);
|
||||||
|
|
||||||
@@ -149,8 +151,8 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
{participant.isMuted && (() => {
|
{participant.isMuted && (() => {
|
||||||
const isServerMutedUser = serverMutedUserIds.has(participant.userId);
|
const isServerMutedUser = spaceId ? serverMutedUserIds.has(`${spaceId}:${participant.userId}`) : false;
|
||||||
const isServerDeafenedUser = serverDeafenedUserIds.has(participant.userId);
|
const isServerDeafenedUser = spaceId ? serverDeafenedUserIds.has(`${spaceId}:${participant.userId}`) : false;
|
||||||
const badgeBg = (isServerMutedUser || isServerDeafenedUser) ? 'bg-accent-amber/90' : 'bg-accent-rose/90';
|
const badgeBg = (isServerMutedUser || isServerDeafenedUser) ? 'bg-accent-amber/90' : 'bg-accent-rose/90';
|
||||||
return (
|
return (
|
||||||
<div className={`w-5 h-5 ${badgeBg} rounded-full flex items-center justify-center`}>
|
<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) && (() => {
|
{(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';
|
const badgeBg = isServerDeafenedUser ? 'bg-accent-amber/90' : 'bg-accent-rose/90';
|
||||||
return (
|
return (
|
||||||
<div className={`w-5 h-5 ${badgeBg} rounded-full flex items-center justify-center`}>
|
<div className={`w-5 h-5 ${badgeBg} rounded-full flex items-center justify-center`}>
|
||||||
|
|||||||
@@ -169,28 +169,55 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
}
|
}
|
||||||
// Build new restriction Sets atomically from ready payload, then apply in one setState
|
// Build new restriction Sets atomically from ready payload, then apply in one setState
|
||||||
{
|
{
|
||||||
const newServerMuted = new Set<string>();
|
const vsState = useVoiceStore.getState();
|
||||||
const newServerDeafened = new Set<string>();
|
const spaceStoreState = useSpaceStore.getState();
|
||||||
|
|
||||||
|
// Find all space IDs that belong to the current origin
|
||||||
|
const originSpaceIds = new Set<string>();
|
||||||
|
for (const s of spaceStoreState.spaces) {
|
||||||
|
if (s._instanceOrigin === origin) {
|
||||||
|
originSpaceIds.add(s.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
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) newServerMuted.add(uid);
|
if (state.serverMuted) nextServerMuted.add(uid);
|
||||||
if (state.serverDeafened) newServerDeafened.add(uid);
|
if (state.serverDeafened) nextServerDeafened.add(uid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Single atomic update — no intermediate empty-Set state
|
// Single atomic update
|
||||||
useVoiceStore.setState({ serverMutedUserIds: newServerMuted, serverDeafenedUserIds: newServerDeafened });
|
useVoiceStore.setState({ serverMutedUserIds: nextServerMuted, serverDeafenedUserIds: nextServerDeafened });
|
||||||
|
|
||||||
// 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 (newServerDeafened.has(myReadyId) && !vs.isDeafened) {
|
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 });
|
useVoiceStore.setState({ isMuted: true, isDeafened: true });
|
||||||
} else if (newServerMuted.has(myReadyId) && !vs.isMuted) {
|
} else if (nextServerMuted.has(key) && !vs.isMuted) {
|
||||||
useVoiceStore.setState({ isMuted: true });
|
useVoiceStore.setState({ isMuted: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Re-register in voice channel after WS reconnect — the server lost
|
// Re-register in voice channel after WS reconnect — the server lost
|
||||||
// voice state on restart, so we must tell it we're still connected.
|
// voice state on restart, so we must tell it we're still connected.
|
||||||
@@ -291,7 +318,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
|
|
||||||
case 'voice_server_muted': {
|
case 'voice_server_muted': {
|
||||||
const { setServerMutedUser } = useVoiceStore.getState();
|
const { setServerMutedUser } = useVoiceStore.getState();
|
||||||
setServerMutedUser(event.userId, event.muted);
|
setServerMutedUser(event.spaceId, event.userId, event.muted);
|
||||||
const myUserId = useAuthStore.getState().user?.id;
|
const myUserId = useAuthStore.getState().user?.id;
|
||||||
if (event.userId === myUserId) {
|
if (event.userId === myUserId) {
|
||||||
if (event.muted) {
|
if (event.muted) {
|
||||||
@@ -306,7 +333,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
} else {
|
} else {
|
||||||
// Server unmuted — auto-restore mic unless still server-deafened
|
// Server unmuted — auto-restore mic unless still server-deafened
|
||||||
const vs = useVoiceStore.getState();
|
const vs = useVoiceStore.getState();
|
||||||
if (!vs.serverDeafenedUserIds.has(myUserId) && vs.isMuted) {
|
if (!vs.serverDeafenedUserIds.has(`${event.spaceId}:${myUserId}`) && vs.isMuted) {
|
||||||
useVoiceStore.setState({ isMuted: false });
|
useVoiceStore.setState({ isMuted: false });
|
||||||
const fresh = useVoiceStore.getState();
|
const fresh = useVoiceStore.getState();
|
||||||
const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : '';
|
const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : '';
|
||||||
@@ -319,7 +346,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
|
|
||||||
case 'voice_server_deafened': {
|
case 'voice_server_deafened': {
|
||||||
const { setServerDeafenedUser } = useVoiceStore.getState();
|
const { setServerDeafenedUser } = useVoiceStore.getState();
|
||||||
setServerDeafenedUser(event.userId, event.deafened);
|
setServerDeafenedUser(event.spaceId, event.userId, event.deafened);
|
||||||
const myUid = useAuthStore.getState().user?.id;
|
const myUid = useAuthStore.getState().user?.id;
|
||||||
if (event.userId === myUid) {
|
if (event.userId === myUid) {
|
||||||
if (event.deafened) {
|
if (event.deafened) {
|
||||||
@@ -346,7 +373,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
// Server un-deafened — auto-restore
|
// Server un-deafened — auto-restore
|
||||||
const vs = useVoiceStore.getState();
|
const vs = useVoiceStore.getState();
|
||||||
if (vs.isDeafened) {
|
if (vs.isDeafened) {
|
||||||
const stillServerMuted = vs.serverMutedUserIds.has(myUid);
|
const stillServerMuted = vs.serverMutedUserIds.has(`${event.spaceId}:${myUid}`);
|
||||||
useVoiceStore.setState({
|
useVoiceStore.setState({
|
||||||
isDeafened: false,
|
isDeafened: false,
|
||||||
...(stillServerMuted ? {} : { isMuted: false }),
|
...(stillServerMuted ? {} : { isMuted: false }),
|
||||||
|
|||||||
@@ -88,10 +88,10 @@ interface VoiceState {
|
|||||||
setVoiceUserStatus: (userId: string, isMuted: boolean, isDeafened: boolean, isCameraOn: boolean, isScreenSharing: boolean) => void;
|
setVoiceUserStatus: (userId: string, isMuted: boolean, isDeafened: boolean, isCameraOn: boolean, isScreenSharing: boolean) => void;
|
||||||
clearVoiceUserStatus: (userId: string) => void;
|
clearVoiceUserStatus: (userId: string) => void;
|
||||||
// Server mute/deafen state (moderator action)
|
// Server mute/deafen state (moderator action)
|
||||||
serverMutedUserIds: Set<string>;
|
serverMutedUserIds: Set<string>; // Stores "spaceId:userId"
|
||||||
serverDeafenedUserIds: Set<string>;
|
serverDeafenedUserIds: Set<string>; // Stores "spaceId:userId"
|
||||||
setServerMutedUser: (userId: string, muted: boolean) => void;
|
setServerMutedUser: (spaceId: string, userId: string, muted: boolean) => void;
|
||||||
setServerDeafenedUser: (userId: string, deafened: boolean) => void;
|
setServerDeafenedUser: (spaceId: string, userId: string, deafened: boolean) => void;
|
||||||
clearServerVoiceStates: () => void;
|
clearServerVoiceStates: () => void;
|
||||||
getVoiceUsers: (channelId: string) => string[];
|
getVoiceUsers: (channelId: string) => string[];
|
||||||
clearAllVoiceUsers: () => void;
|
clearAllVoiceUsers: () => void;
|
||||||
@@ -242,7 +242,8 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
toggleMic: () => set((state) => {
|
toggleMic: () => set((state) => {
|
||||||
// Server-muted/deafened users cannot unmute themselves
|
// Server-muted/deafened users cannot unmute themselves
|
||||||
const myId = useAuthStore.getState().user?.id;
|
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 {};
|
return {};
|
||||||
}
|
}
|
||||||
if (state.isMuted && state.isDeafened) {
|
if (state.isMuted && state.isDeafened) {
|
||||||
@@ -254,7 +255,8 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
toggleDeafen: () => set((state) => {
|
toggleDeafen: () => set((state) => {
|
||||||
// Server-deafened users cannot undeafen themselves
|
// Server-deafened users cannot undeafen themselves
|
||||||
const myId = useAuthStore.getState().user?.id;
|
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 {};
|
return {};
|
||||||
}
|
}
|
||||||
if (state.isDeafened) {
|
if (state.isDeafened) {
|
||||||
@@ -305,17 +307,19 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
|
|
||||||
serverMutedUserIds: new Set(),
|
serverMutedUserIds: new Set(),
|
||||||
serverDeafenedUserIds: new Set(),
|
serverDeafenedUserIds: new Set(),
|
||||||
setServerMutedUser: (userId, muted) => {
|
setServerMutedUser: (spaceId, userId, muted) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newSet = new Set(state.serverMutedUserIds);
|
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 };
|
return { serverMutedUserIds: newSet };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
setServerDeafenedUser: (userId, deafened) => {
|
setServerDeafenedUser: (spaceId, userId, deafened) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newSet = new Set(state.serverDeafenedUserIds);
|
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 };
|
return { serverDeafenedUserIds: newSet };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -459,15 +463,12 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
rnnoiseEnabled: state.rnnoiseEnabled,
|
rnnoiseEnabled: state.rnnoiseEnabled,
|
||||||
streamAttenuationEnabled: state.streamAttenuationEnabled,
|
streamAttenuationEnabled: state.streamAttenuationEnabled,
|
||||||
streamAttenuationStrength: state.streamAttenuationStrength,
|
streamAttenuationStrength: state.streamAttenuationStrength,
|
||||||
_serverMutedArr: [...state.serverMutedUserIds],
|
|
||||||
_serverDeafenedArr: [...state.serverDeafenedUserIds],
|
|
||||||
}),
|
}),
|
||||||
merge: (persistedState: any, currentState: VoiceState) => {
|
merge: (persistedState: any, currentState: VoiceState) => {
|
||||||
const merged = { ...currentState, ...persistedState };
|
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
|
// Reconstruct non-persisted Sets/Maps to their defaults
|
||||||
|
merged.serverMutedUserIds = currentState.serverMutedUserIds;
|
||||||
|
merged.serverDeafenedUserIds = currentState.serverDeafenedUserIds;
|
||||||
merged.voiceUsers = currentState.voiceUsers;
|
merged.voiceUsers = currentState.voiceUsers;
|
||||||
merged.participants = currentState.participants;
|
merged.participants = currentState.participants;
|
||||||
merged.speakingParticipantIds = currentState.speakingParticipantIds;
|
merged.speakingParticipantIds = currentState.speakingParticipantIds;
|
||||||
|
|||||||
Reference in New Issue
Block a user