refactor: rename "Server Mute/Deafen" to "Space Mute/Deafen" across entire stack

Aligns voice moderation terminology with Backspace's "Spaces" branding.
Renames WS protocol strings, backend handlers, frontend store/hooks/utils,
user-facing labels, and documentation — 15 files, zero functional changes.
This commit is contained in:
Jannis Braun
2026-03-12 00:12:33 +01:00
parent 68a4c453de
commit fc72e424d6
15 changed files with 186 additions and 186 deletions
+6 -6
View File
@@ -680,8 +680,8 @@ All WebSocket messages are JSON over `/ws`. Client authenticates by sending `{ t
{ type: 'voice_disconnect', userId } { type: 'voice_disconnect', userId }
# Voice Moderation # Voice Moderation
{ type: 'voice_server_mute', userId, muted } { type: 'voice_space_mute', userId, muted }
{ type: 'voice_server_deafen', userId, deafened } { type: 'voice_space_deafen', userId, deafened }
{ type: 'voice_move', userId, targetChannelId } { type: 'voice_move', userId, targetChannelId }
# DM Calls # DM Calls
@@ -731,8 +731,8 @@ All WebSocket messages are JSON over `/ws`. Client authenticates by sending `{ t
{ type: 'voice_state_update', channelId, userId, action: 'join' | 'leave' } { type: 'voice_state_update', channelId, userId, action: 'join' | 'leave' }
{ type: 'voice_status_update', userId, isMuted, isDeafened, isCameraOn, isScreenSharing } { type: 'voice_status_update', userId, isMuted, isDeafened, isCameraOn, isScreenSharing }
{ type: 'voice_disconnected', userId, channelId } { type: 'voice_disconnected', userId, channelId }
{ type: 'voice_server_muted', userId, spaceId, muted } { type: 'voice_space_muted', userId, spaceId, muted }
{ type: 'voice_server_deafened', userId, spaceId, deafened } { type: 'voice_space_deafened', userId, spaceId, deafened }
{ type: 'voice_permission_muted', userId, spaceId, muted } { type: 'voice_permission_muted', userId, spaceId, muted }
{ type: 'voice_moved', userId, oldChannelId, newChannelId } { type: 'voice_moved', userId, oldChannelId, newChannelId }
@@ -774,8 +774,8 @@ Bitwise permission engine defined in `packages/shared/src/permissions.ts`. Store
| 14 | ADD_REACTIONS | Add emoji reactions | | 14 | ADD_REACTIONS | Add emoji reactions |
| 20 | CONNECT | Join voice channels | | 20 | CONNECT | Join voice channels |
| 21 | SPEAK | Transmit audio in voice | | 21 | SPEAK | Transmit audio in voice |
| 22 | MUTE_MEMBERS | Server-mute other members | | 22 | MUTE_MEMBERS | Space-mute other members |
| 23 | DEAFEN_MEMBERS | Server-deafen other members | | 23 | DEAFEN_MEMBERS | Space-deafen other members |
| 24 | MOVE_MEMBERS | Move members between voice channels | | 24 | MOVE_MEMBERS | Move members between voice channels |
| 25 | STREAM | Share screen in voice channels | | 25 | STREAM | Share screen in voice channels |
| 26 | DISCONNECT_MEMBERS | Disconnect members from voice channels | | 26 | DISCONNECT_MEMBERS | Disconnect members from voice channels |
+23 -23
View File
@@ -180,11 +180,11 @@ export function handleClientEvent(
case 'voice_status': case 'voice_status':
handleVoiceStatus(event, userId); handleVoiceStatus(event, userId);
break; break;
case 'voice_server_mute': case 'voice_space_mute':
handleVoiceServerMute(event, userId); handleVoiceSpaceMute(event, userId);
break; break;
case 'voice_server_deafen': case 'voice_space_deafen':
handleVoiceServerDeafen(event, userId); handleVoiceSpaceDeafen(event, userId);
break; break;
case 'voice_move': case 'voice_move':
handleVoiceMove(event, userId); handleVoiceMove(event, userId);
@@ -470,18 +470,18 @@ 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(spaceId, userId, true); connectionManager.setSpaceMuted(spaceId, userId, true);
connectionManager.sendToUser(userId, { connectionManager.sendToUser(userId, {
type: 'voice_server_muted', type: 'voice_space_muted',
userId, userId,
channelId, channelId,
spaceId, spaceId,
muted: true, muted: true,
}); });
} else if (r.restrictionType === 'deafen') { } else if (r.restrictionType === 'deafen') {
connectionManager.setServerDeafened(spaceId, userId, true); connectionManager.setSpaceDeafened(spaceId, userId, true);
connectionManager.sendToUser(userId, { connectionManager.sendToUser(userId, {
type: 'voice_server_deafened', type: 'voice_space_deafened',
userId, userId,
channelId, channelId,
spaceId, spaceId,
@@ -569,18 +569,18 @@ 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(spaceId, userId, true); connectionManager.setSpaceMuted(spaceId, userId, true);
connectionManager.sendToSpace(spaceId, { connectionManager.sendToSpace(spaceId, {
type: 'voice_server_muted', type: 'voice_space_muted',
userId, userId,
channelId, channelId,
spaceId, spaceId,
muted: true, muted: true,
}); });
} else if (r.restrictionType === 'deafen') { } else if (r.restrictionType === 'deafen') {
connectionManager.setServerDeafened(spaceId, userId, true); connectionManager.setSpaceDeafened(spaceId, userId, true);
connectionManager.sendToSpace(spaceId, { connectionManager.sendToSpace(spaceId, {
type: 'voice_server_deafened', type: 'voice_space_deafened',
userId, userId,
channelId, channelId,
spaceId, spaceId,
@@ -629,8 +629,8 @@ function handleVoiceStatus(event: Record<string, unknown>, userId: string): void
let isPermMuted = false; let isPermMuted = false;
if (userRoom.room.roomType === 'space') { if (userRoom.room.roomType === 'space') {
const meta = userRoom.room.metadata as SpaceRoomMeta; const meta = userRoom.room.metadata as SpaceRoomMeta;
isSpaceMuted = connectionManager.isServerMuted(meta.spaceId, userId); isSpaceMuted = connectionManager.isSpaceMuted(meta.spaceId, userId);
isSpaceDeafened = connectionManager.isServerDeafened(meta.spaceId, userId); isSpaceDeafened = connectionManager.isSpaceDeafened(meta.spaceId, userId);
isPermMuted = connectionManager.isPermissionMuted(meta.spaceId, userId); isPermMuted = connectionManager.isPermissionMuted(meta.spaceId, userId);
} }
@@ -1198,7 +1198,7 @@ function handleDmCallEnd(event: Record<string, unknown>, userId: string): void {
// ─── Voice Moderation Handlers ────────────────────────────────────────────── // ─── Voice Moderation Handlers ──────────────────────────────────────────────
function handleVoiceServerMute(event: Record<string, unknown>, userId: string): void { function handleVoiceSpaceMute(event: Record<string, unknown>, userId: string): void {
const targetUserId = event.userId as string; const targetUserId = event.userId as string;
const muted = event.muted === true; const muted = event.muted === true;
@@ -1220,13 +1220,13 @@ function handleVoiceServerMute(event: Record<string, unknown>, userId: string):
return; return;
} }
// Cannot server-mute yourself // Cannot space-mute yourself
if (targetUserId === userId) { if (targetUserId === userId) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Cannot server-mute yourself' }); connectionManager.sendToUser(userId, { type: 'error', message: 'Cannot space-mute yourself' });
return; return;
} }
connectionManager.setServerMuted(meta.spaceId, targetUserId, muted); connectionManager.setSpaceMuted(meta.spaceId, targetUserId, muted);
// Persist to DB // Persist to DB
const db = getDb(); const db = getDb();
@@ -1250,7 +1250,7 @@ function handleVoiceServerMute(event: Record<string, unknown>, userId: string):
// Broadcast to all space members // Broadcast to all space members
connectionManager.sendToSpace(meta.spaceId, { connectionManager.sendToSpace(meta.spaceId, {
type: 'voice_server_muted', type: 'voice_space_muted',
userId: targetUserId, userId: targetUserId,
channelId: targetRoom.roomId, channelId: targetRoom.roomId,
spaceId: meta.spaceId, spaceId: meta.spaceId,
@@ -1258,7 +1258,7 @@ function handleVoiceServerMute(event: Record<string, unknown>, userId: string):
}); });
} }
function handleVoiceServerDeafen(event: Record<string, unknown>, userId: string): void { function handleVoiceSpaceDeafen(event: Record<string, unknown>, userId: string): void {
const targetUserId = event.userId as string; const targetUserId = event.userId as string;
const deafened = event.deafened === true; const deafened = event.deafened === true;
@@ -1280,11 +1280,11 @@ function handleVoiceServerDeafen(event: Record<string, unknown>, userId: string)
} }
if (targetUserId === userId) { if (targetUserId === userId) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Cannot server-deafen yourself' }); connectionManager.sendToUser(userId, { type: 'error', message: 'Cannot space-deafen yourself' });
return; return;
} }
connectionManager.setServerDeafened(meta.spaceId, targetUserId, deafened); connectionManager.setSpaceDeafened(meta.spaceId, targetUserId, deafened);
// Persist to DB // Persist to DB
const db = getDb(); const db = getDb();
@@ -1307,7 +1307,7 @@ function handleVoiceServerDeafen(event: Record<string, unknown>, userId: string)
} }
connectionManager.sendToSpace(meta.spaceId, { connectionManager.sendToSpace(meta.spaceId, {
type: 'voice_server_deafened', type: 'voice_space_deafened',
userId: targetUserId, userId: targetUserId,
channelId: targetRoom.roomId, channelId: targetRoom.roomId,
spaceId: meta.spaceId, spaceId: meta.spaceId,
+27 -27
View File
@@ -82,9 +82,9 @@ class ConnectionManager {
private pendingOfflineTimeouts: Map<string, NodeJS.Timeout> = new Map(); private pendingOfflineTimeouts: Map<string, NodeJS.Timeout> = new Map();
// 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) // Space-muted/deafened users (moderator action)
private serverMutedUsers: Set<string> = new Set(); // Stores spaceId:userId private spaceMutedUsers: Set<string> = new Set(); // Stores spaceId:userId
private serverDeafenedUsers: Set<string> = new Set(); // Stores spaceId:userId private spaceDeafenedUsers: Set<string> = new Set(); // Stores spaceId:userId
// Permission-muted users (SPEAK permission revoked while in voice) // Permission-muted users (SPEAK permission revoked while in voice)
private permissionMutedUsers: Set<string> = new Set(); // Stores spaceId:userId private permissionMutedUsers: Set<string> = new Set(); // Stores spaceId:userId
@@ -317,7 +317,7 @@ class ConnectionManager {
if (room.roomType === 'space') { if (room.roomType === 'space') {
const meta = room.metadata as SpaceRoomMeta; const meta = room.metadata as SpaceRoomMeta;
this.clearServerVoiceState(meta.spaceId, userId); this.clearSpaceVoiceState(meta.spaceId, userId);
} }
// Auto-cleanup empty space rooms (they're lazy-created) // Auto-cleanup empty space rooms (they're lazy-created)
@@ -400,29 +400,29 @@ class ConnectionManager {
this.voiceUserStates.delete(userId); this.voiceUserStates.delete(userId);
} }
setServerMuted(spaceId: string, userId: string, muted: boolean): void { setSpaceMuted(spaceId: string, userId: string, muted: boolean): void {
const key = `${spaceId}:${userId}`; const key = `${spaceId}:${userId}`;
if (muted) this.serverMutedUsers.add(key); if (muted) this.spaceMutedUsers.add(key);
else this.serverMutedUsers.delete(key); else this.spaceMutedUsers.delete(key);
} }
isServerMuted(spaceId: string, userId: string): boolean { isSpaceMuted(spaceId: string, userId: string): boolean {
return this.serverMutedUsers.has(`${spaceId}:${userId}`); return this.spaceMutedUsers.has(`${spaceId}:${userId}`);
} }
setServerDeafened(spaceId: string, userId: string, deafened: boolean): void { setSpaceDeafened(spaceId: string, userId: string, deafened: boolean): void {
const key = `${spaceId}:${userId}`; const key = `${spaceId}:${userId}`;
if (deafened) this.serverDeafenedUsers.add(key); if (deafened) this.spaceDeafenedUsers.add(key);
else this.serverDeafenedUsers.delete(key); else this.spaceDeafenedUsers.delete(key);
} }
isServerDeafened(spaceId: string, userId: string): boolean { isSpaceDeafened(spaceId: string, userId: string): boolean {
return this.serverDeafenedUsers.has(`${spaceId}:${userId}`); return this.spaceDeafenedUsers.has(`${spaceId}:${userId}`);
} }
clearServerVoiceState(spaceId: string, userId: string): void { clearSpaceVoiceState(spaceId: string, userId: string): void {
this.serverMutedUsers.delete(`${spaceId}:${userId}`); this.spaceMutedUsers.delete(`${spaceId}:${userId}`);
this.serverDeafenedUsers.delete(`${spaceId}:${userId}`); this.spaceDeafenedUsers.delete(`${spaceId}:${userId}`);
this.permissionMutedUsers.delete(`${spaceId}:${userId}`); this.permissionMutedUsers.delete(`${spaceId}:${userId}`);
} }
@@ -653,7 +653,7 @@ function buildReadyPayload(userId: string): {
folders: SpaceFolder[]; folders: SpaceFolder[];
voiceStates: Record<string, string[]>; voiceStates: Record<string, string[]>;
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
serverVoiceStates: Record<string, { serverMuted: boolean; serverDeafened: boolean; permissionMuted: boolean }>; spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }>;
readStates: ReadState[]; readStates: ReadState[];
activeCalls: ActiveCallInfo[]; activeCalls: ActiveCallInfo[];
} { } {
@@ -966,9 +966,9 @@ function buildReadyPayload(userId: string): {
} }
} }
// Build server mute/deafen states from DB (authoritative source for all spaces the user belongs to) // Build space mute/deafen states from DB (authoritative source for all spaces the user belongs to)
// Also includes ephemeral permission-mute state from in-memory Set // Also includes ephemeral permission-mute state from in-memory Set
const serverVoiceStates: Record<string, { serverMuted: boolean; serverDeafened: boolean; permissionMuted: boolean }> = {}; const spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }> = {};
if (spaceIds.length > 0) { if (spaceIds.length > 0) {
const allRestrictions = db.select() const allRestrictions = db.select()
.from(schema.voiceRestrictions) .from(schema.voiceRestrictions)
@@ -976,10 +976,10 @@ function buildReadyPayload(userId: string): {
.all(); .all();
for (const r of allRestrictions) { for (const r of allRestrictions) {
const key = `${r.spaceId}:${r.userId}`; const key = `${r.spaceId}:${r.userId}`;
const existing = serverVoiceStates[key] ?? { serverMuted: false, serverDeafened: false, permissionMuted: false }; const existing = spaceVoiceStates[key] ?? { spaceMuted: false, spaceDeafened: false, permissionMuted: false };
if (r.restrictionType === 'mute') existing.serverMuted = true; if (r.restrictionType === 'mute') existing.spaceMuted = true;
if (r.restrictionType === 'deafen') existing.serverDeafened = true; if (r.restrictionType === 'deafen') existing.spaceDeafened = true;
serverVoiceStates[key] = existing; spaceVoiceStates[key] = existing;
} }
// Include ephemeral permission-mute state for all voice participants in user's spaces // Include ephemeral permission-mute state for all voice participants in user's spaces
for (const [roomId, room] of connectionManager.getAllRooms()) { for (const [roomId, room] of connectionManager.getAllRooms()) {
@@ -989,9 +989,9 @@ function buildReadyPayload(userId: string): {
for (const participantId of room.participants) { for (const participantId of room.participants) {
if (connectionManager.isPermissionMuted(meta.spaceId, participantId)) { if (connectionManager.isPermissionMuted(meta.spaceId, participantId)) {
const key = `${meta.spaceId}:${participantId}`; const key = `${meta.spaceId}:${participantId}`;
const existing = serverVoiceStates[key] ?? { serverMuted: false, serverDeafened: false, permissionMuted: false }; const existing = spaceVoiceStates[key] ?? { spaceMuted: false, spaceDeafened: false, permissionMuted: false };
existing.permissionMuted = true; existing.permissionMuted = true;
serverVoiceStates[key] = existing; spaceVoiceStates[key] = existing;
} }
} }
} }
@@ -1008,7 +1008,7 @@ function buildReadyPayload(userId: string): {
lastReadMessageId: rs.lastReadMessageId, lastReadMessageId: rs.lastReadMessageId,
})); }));
return { user, spaces, dmChannels, folders, voiceStates, voiceUserStates, serverVoiceStates, readStates, activeCalls }; return { user, spaces, dmChannels, folders, voiceStates, voiceUserStates, spaceVoiceStates, readStates, activeCalls };
} }
export async function registerWebSocket(app: FastifyInstance): Promise<void> { export async function registerWebSocket(app: FastifyInstance): Promise<void> {
+5 -5
View File
@@ -247,15 +247,15 @@ export type ClientEvent =
| { type: 'dm_call_reject'; dmChannelId: string } | { type: 'dm_call_reject'; dmChannelId: string }
| { type: 'dm_call_end'; dmChannelId: string } | { type: 'dm_call_end'; dmChannelId: string }
| { type: 'voice_status'; isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean } | { type: 'voice_status'; isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }
| { type: 'voice_server_mute'; userId: string; muted: boolean } | { type: 'voice_space_mute'; userId: string; muted: boolean }
| { type: 'voice_server_deafen'; userId: string; deafened: boolean } | { type: 'voice_space_deafen'; userId: string; deafened: boolean }
| { type: 'voice_move'; userId: string; targetChannelId: string } | { type: 'voice_move'; userId: string; targetChannelId: string }
| { type: 'voice_disconnect'; userId: string } | { type: 'voice_disconnect'; userId: string }
| { type: 'ping' }; | { type: 'ping' };
// Server → Client Events // Server → Client Events
export type ServerEvent = export type ServerEvent =
| { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; serverVoiceStates?: Record<string, { serverMuted: boolean; serverDeafened: boolean }> } | { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }> }
| { type: 'message_created'; message: MessageWithUser } | { type: 'message_created'; message: MessageWithUser }
| { type: 'message_updated'; message: MessageWithUser } | { type: 'message_updated'; message: MessageWithUser }
| { type: 'message_deleted'; messageId: string; channelId: string } | { type: 'message_deleted'; messageId: string; channelId: string }
@@ -290,8 +290,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; spaceId: string; muted: boolean } | { type: 'voice_space_muted'; userId: string; channelId: string; spaceId: string; muted: boolean }
| { type: 'voice_server_deafened'; userId: string; channelId: string; spaceId: string; deafened: boolean } | { type: 'voice_space_deafened'; userId: string; channelId: string; spaceId: string; deafened: boolean }
| { type: 'voice_permission_muted'; userId: string; spaceId: string; muted: boolean } | { type: 'voice_permission_muted'; userId: string; spaceId: string; muted: boolean }
| { type: 'voice_moved'; userId: string; oldChannelId: string; newChannelId: string } | { type: 'voice_moved'; userId: string; oldChannelId: string; newChannelId: string }
| { type: 'voice_disconnected'; userId: string; channelId: string } | { type: 'voice_disconnected'; userId: string; channelId: string }
+3 -3
View File
@@ -178,9 +178,9 @@ export class SpeakingDetector {
if (spaceId) { if (spaceId) {
const myOriginId = getMyUserIdForOrigin(getChannelOrigin(channelId)); const myOriginId = getMyUserIdForOrigin(getChannelOrigin(channelId));
if (myOriginId) { if (myOriginId) {
const serverKey = `${spaceId}:${myOriginId}`; const spaceKey = `${spaceId}:${myOriginId}`;
effectiveMuted = effectiveMuted || store.serverMutedUserIds.has(serverKey); effectiveMuted = effectiveMuted || store.spaceMutedUserIds.has(spaceKey);
effectiveDeafened = effectiveDeafened || store.serverDeafenedUserIds.has(serverKey); effectiveDeafened = effectiveDeafened || store.spaceDeafenedUserIds.has(spaceKey);
} }
} }
} }
@@ -36,14 +36,14 @@ export function ChannelSidebar() {
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null); const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null);
const myOriginId = useSpaceStore((s) => currentVoiceChannelId ? getMyUserIdForOrigin(getChannelOrigin(currentVoiceChannelId)) : s.members.find(m => m.userId === user?.id)?.userId ?? user?.id); const myOriginId = useSpaceStore((s) => currentVoiceChannelId ? getMyUserIdForOrigin(getChannelOrigin(currentVoiceChannelId)) : s.members.find(m => m.userId === user?.id)?.userId ?? user?.id);
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds); const spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds);
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds); const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds);
const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds); const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds);
// Drag-and-drop state for moving users between voice channels // Drag-and-drop state for moving users between voice channels
const [voiceDragState, setVoiceDragState] = useState<{ userId: string; fromChannelId: string } | null>(null); const [voiceDragState, setVoiceDragState] = useState<{ userId: string; fromChannelId: string } | null>(null);
const isServerMuted = !!(myOriginId && spaceId && serverMutedUserIds.has(`${spaceId}:${myOriginId}`)); const isSpaceMuted = !!(myOriginId && spaceId && spaceMutedUserIds.has(`${spaceId}:${myOriginId}`));
const isServerDeafened = !!(myOriginId && spaceId && serverDeafenedUserIds.has(`${spaceId}:${myOriginId}`)); const isSpaceDeafened = !!(myOriginId && spaceId && spaceDeafenedUserIds.has(`${spaceId}:${myOriginId}`));
const isPermissionMuted = !!(myOriginId && spaceId && permissionMutedUserIds.has(`${spaceId}:${myOriginId}`)); const isPermissionMuted = !!(myOriginId && spaceId && permissionMutedUserIds.has(`${spaceId}:${myOriginId}`));
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -64,7 +64,7 @@ export function ChannelSidebar() {
}, [setFloatingPanelHeight]); }, [setFloatingPanelHeight]);
const handleMicToggle = async () => { const handleMicToggle = async () => {
if (isServerMuted || isServerDeafened || isPermissionMuted) return; if (isSpaceMuted || isSpaceDeafened || isPermissionMuted) return;
const wasDeafened = useVoiceStore.getState().isDeafened; const wasDeafened = useVoiceStore.getState().isDeafened;
toggleMic(); toggleMic();
broadcastVoiceStatus(); broadcastVoiceStatus();
@@ -75,7 +75,7 @@ export function ChannelSidebar() {
}; };
const handleDeafenToggle = async () => { const handleDeafenToggle = async () => {
if (isServerDeafened) return; if (isSpaceDeafened) return;
toggleDeafen(); toggleDeafen();
broadcastVoiceStatus(); broadcastVoiceStatus();
broadcastDeafenViaLiveKit(); broadcastDeafenViaLiveKit();
@@ -133,8 +133,8 @@ export function ChannelSidebar() {
user={user} user={user}
isMuted={isMuted} isMuted={isMuted}
isDeafened={isDeafened} isDeafened={isDeafened}
isServerMuted={isServerMuted} isSpaceMuted={isSpaceMuted}
isServerDeafened={isServerDeafened} isSpaceDeafened={isSpaceDeafened}
isPermissionMuted={isPermissionMuted} isPermissionMuted={isPermissionMuted}
onMicToggle={handleMicToggle} onMicToggle={handleMicToggle}
onDeafenToggle={handleDeafenToggle} onDeafenToggle={handleDeafenToggle}
@@ -496,8 +496,8 @@ function UserAreaPanel({
user, user,
isMuted, isMuted,
isDeafened, isDeafened,
isServerMuted, isSpaceMuted,
isServerDeafened, isSpaceDeafened,
isPermissionMuted, isPermissionMuted,
onMicToggle, onMicToggle,
onDeafenToggle, onDeafenToggle,
@@ -506,8 +506,8 @@ function UserAreaPanel({
user: any; user: any;
isMuted: boolean; isMuted: boolean;
isDeafened: boolean; isDeafened: boolean;
isServerMuted: boolean; isSpaceMuted: boolean;
isServerDeafened: boolean; isSpaceDeafened: boolean;
isPermissionMuted: boolean; isPermissionMuted: boolean;
onMicToggle: () => void; onMicToggle: () => void;
onDeafenToggle: () => void; onDeafenToggle: () => void;
@@ -823,15 +823,15 @@ function UserAreaPanel({
<button <button
onClick={onMicToggle} onClick={onMicToggle}
className={`w-8 h-8 flex items-center justify-center hover:bg-interactive-hover rounded-l-[4px] transition-colors ${ className={`w-8 h-8 flex items-center justify-center hover:bg-interactive-hover rounded-l-[4px] transition-colors ${
(isServerMuted || isServerDeafened || isPermissionMuted) ? 'text-accent-amber cursor-not-allowed' (isSpaceMuted || isSpaceDeafened || isPermissionMuted) ? 'text-accent-amber cursor-not-allowed'
: isMuted || isDeafened ? 'text-txt-danger' : 'text-txt-tertiary hover:text-txt-primary' : isMuted || isDeafened ? 'text-txt-danger' : 'text-txt-tertiary hover:text-txt-primary'
}`} }`}
title={(isPermissionMuted) ? 'Muted (No Speak Permission)' : (isServerMuted || isServerDeafened) ? 'Server Muted' : isMuted ? 'Unmute' : 'Mute'} title={(isPermissionMuted) ? 'Muted (No Speak Permission)' : (isSpaceMuted || isSpaceDeafened) ? 'Space Muted' : isMuted ? 'Unmute' : 'Mute'}
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" /> <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" /> <path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
{(isMuted || isDeafened || isServerMuted || isServerDeafened || isPermissionMuted) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />} {(isMuted || isDeafened || isSpaceMuted || isSpaceDeafened || isPermissionMuted) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
</svg> </svg>
</button> </button>
{/* Input chevron */} {/* Input chevron */}
@@ -851,14 +851,14 @@ function UserAreaPanel({
<button <button
onClick={onDeafenToggle} onClick={onDeafenToggle}
className={`w-8 h-8 flex items-center justify-center hover:bg-interactive-hover rounded-l-[4px] transition-colors ${ className={`w-8 h-8 flex items-center justify-center hover:bg-interactive-hover rounded-l-[4px] transition-colors ${
isServerDeafened ? 'text-accent-amber cursor-not-allowed' isSpaceDeafened ? 'text-accent-amber cursor-not-allowed'
: isDeafened ? 'text-txt-danger' : 'text-txt-tertiary hover:text-txt-primary' : isDeafened ? 'text-txt-danger' : 'text-txt-tertiary hover:text-txt-primary'
}`} }`}
title={isServerDeafened ? 'Server Deafened' : isDeafened ? 'Undeafen' : 'Deafen'} title={isSpaceDeafened ? 'Space Deafened' : isDeafened ? 'Undeafen' : 'Deafen'}
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" /> <path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
{(isDeafened || isServerDeafened) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />} {(isDeafened || isSpaceDeafened) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
</svg> </svg>
</button> </button>
{/* Output chevron */} {/* Output chevron */}
@@ -68,7 +68,7 @@ function AudioTrackElement({
export function GlobalAudioRenderer() { export function GlobalAudioRenderer() {
const participants = useVoiceStore((s) => s.participants); const participants = useVoiceStore((s) => s.participants);
const isDeafenedIntent = useVoiceStore((s) => s.isDeafened); const isDeafenedIntent = useVoiceStore((s) => s.isDeafened);
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds); const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const outputVolume = useVoiceStore((s) => s.outputVolume); const outputVolume = useVoiceStore((s) => s.outputVolume);
const participantVolumes = useVoiceStore((s) => s.participantVolumes); const participantVolumes = useVoiceStore((s) => s.participantVolumes);
@@ -83,8 +83,8 @@ export function GlobalAudioRenderer() {
// Compute effective deafened: user intent || server enforcement // Compute effective deafened: user intent || server enforcement
const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null); const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null);
const myOriginId = currentVoiceChannelId ? getMyUserIdForOrigin(getChannelOrigin(currentVoiceChannelId)) : undefined; const myOriginId = currentVoiceChannelId ? getMyUserIdForOrigin(getChannelOrigin(currentVoiceChannelId)) : undefined;
const serverKey = (spaceId && myOriginId) ? `${spaceId}:${myOriginId}` : ''; const spaceKey = (spaceId && myOriginId) ? `${spaceId}:${myOriginId}` : '';
const isDeafened = isDeafenedIntent || serverDeafenedUserIds.has(serverKey); const isDeafened = isDeafenedIntent || spaceDeafenedUserIds.has(spaceKey);
// Determine if someone is currently speaking (for stream attenuation) // Determine if someone is currently speaking (for stream attenuation)
const someoneIsSpeaking = participants.some((p) => !p.isLocal && speakingParticipantIds.has(p.identity)); const someoneIsSpeaking = participants.some((p) => !p.isLocal && speakingParticipantIds.has(p.identity));
@@ -31,8 +31,8 @@ export function VoiceChannel({ channelId, channelName, onClick, locked, dragStat
const localIsDeafened = useVoiceStore((s) => s.isDeafened); const localIsDeafened = useVoiceStore((s) => s.isDeafened);
const localIsMuted = useVoiceStore((s) => s.isMuted); const localIsMuted = useVoiceStore((s) => s.isMuted);
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates); const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds); const spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds);
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds); const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds);
const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds); const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds);
const participantMutes = useVoiceStore((s) => s.participantMutes); const participantMutes = useVoiceStore((s) => s.participantMutes);
const unwatchedCameras = useVoiceStore((s) => s.unwatchedCameras); const unwatchedCameras = useVoiceStore((s) => s.unwatchedCameras);
@@ -147,8 +147,8 @@ export function VoiceChannel({ channelId, channelName, onClick, locked, dragStat
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 spaceId = channelToSpaceMap.get(channelId); const spaceId = channelToSpaceMap.get(channelId);
const isServerMuted = serverMutedUserIds.has(`${spaceId}:${userId}`); const isSpaceMuted = spaceMutedUserIds.has(`${spaceId}:${userId}`);
const isServerDeafened = serverDeafenedUserIds.has(`${spaceId}:${userId}`); const isSpaceDeafened = spaceDeafenedUserIds.has(`${spaceId}:${userId}`);
const isPermissionMuted = permissionMutedUserIds.has(`${spaceId}:${userId}`); const isPermissionMuted = permissionMutedUserIds.has(`${spaceId}:${userId}`);
const isDraggable = canMoveMembers && userId !== myUser?.id; const isDraggable = canMoveMembers && userId !== myUser?.id;
@@ -181,8 +181,8 @@ export function VoiceChannel({ channelId, channelName, onClick, locked, dragStat
<span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0">{displayName}</span> <span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0">{displayName}</span>
{/* Status badges */} {/* Status badges */}
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-1 flex-shrink-0">
{(isServerMuted || isServerDeafened || isPermissionMuted) && ( {(isSpaceMuted || isSpaceDeafened || isPermissionMuted) && (
<span title={isPermissionMuted ? "Muted (No Speak Permission)" : isServerMuted ? "Server Muted" : "Muted (Server Deafened)"}> <span title={isPermissionMuted ? "Muted (No Speak Permission)" : isSpaceMuted ? "Space Muted" : "Muted (Space Deafened)"}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-accent-amber"> <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-accent-amber">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" /> <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" /> <path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
@@ -190,22 +190,22 @@ export function VoiceChannel({ channelId, channelName, onClick, locked, dragStat
</svg> </svg>
</span> </span>
)} )}
{isServerDeafened && ( {isSpaceDeafened && (
<span title="Server Deafened"> <span title="Space Deafened">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-accent-amber"> <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-accent-amber">
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" /> <path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /> <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg> </svg>
</span> </span>
)} )}
{!isServerMuted && !isServerDeafened && !isPermissionMuted && isMuted && ( {!isSpaceMuted && !isSpaceDeafened && !isPermissionMuted && isMuted && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-txt-danger"> <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-txt-danger">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" /> <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" /> <path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /> <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg> </svg>
)} )}
{!isServerDeafened && isParticipantDeafened && ( {!isSpaceDeafened && isParticipantDeafened && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-txt-danger"> <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-txt-danger">
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" /> <path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /> <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
@@ -32,10 +32,10 @@ export function VoiceControlBar() {
const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null); const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null);
const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : ''; const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : '';
const myOriginId = useSpaceStore((s) => currentVoiceChannelId ? getMyUserIdForOrigin(getChannelOrigin(currentVoiceChannelId)) : s.members.find(m => m.userId === myUser?.id)?.userId ?? myUser?.id); const myOriginId = useSpaceStore((s) => currentVoiceChannelId ? getMyUserIdForOrigin(getChannelOrigin(currentVoiceChannelId)) : s.members.find(m => m.userId === myUser?.id)?.userId ?? myUser?.id);
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds); const spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds);
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds); const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds);
const isServerMuted = !!(myOriginId && spaceId && serverMutedUserIds.has(`${spaceId}:${myOriginId}`)); const isSpaceMuted = !!(myOriginId && spaceId && spaceMutedUserIds.has(`${spaceId}:${myOriginId}`));
const isServerDeafened = !!(myOriginId && spaceId && serverDeafenedUserIds.has(`${spaceId}:${myOriginId}`)); const isSpaceDeafened = !!(myOriginId && spaceId && spaceDeafenedUserIds.has(`${spaceId}:${myOriginId}`));
const activeDmCall = useVoiceStore((s) => s.activeDmCall); const activeDmCall = useVoiceStore((s) => s.activeDmCall);
const channelPerms = useSpaceStore((s) => currentVoiceChannelId ? s.channelPermissions.get(currentVoiceChannelId) : undefined); const channelPerms = useSpaceStore((s) => currentVoiceChannelId ? s.channelPermissions.get(currentVoiceChannelId) : undefined);
const isDmCall = !!activeDmCall; const isDmCall = !!activeDmCall;
@@ -45,7 +45,7 @@ export function VoiceControlBar() {
const qualityBtnRef = useRef<HTMLButtonElement>(null); const qualityBtnRef = useRef<HTMLButtonElement>(null);
const handleMute = React.useCallback(async () => { const handleMute = React.useCallback(async () => {
if (isServerMuted || isServerDeafened) return; if (isSpaceMuted || isSpaceDeafened) return;
const wasDeafened = useVoiceStore.getState().isDeafened; const wasDeafened = useVoiceStore.getState().isDeafened;
toggleMic(); toggleMic();
broadcastVoiceStatus(); broadcastVoiceStatus();
@@ -53,14 +53,14 @@ export function VoiceControlBar() {
if (wasDeafened && !useVoiceStore.getState().isDeafened) { if (wasDeafened && !useVoiceStore.getState().isDeafened) {
broadcastDeafenViaLiveKit(); broadcastDeafenViaLiveKit();
} }
}, [isServerMuted, isServerDeafened, toggleMic]); }, [isSpaceMuted, isSpaceDeafened, toggleMic]);
const handleDeafen = React.useCallback(async () => { const handleDeafen = React.useCallback(async () => {
if (isServerDeafened) return; if (isSpaceDeafened) return;
toggleDeafen(); toggleDeafen();
broadcastVoiceStatus(); broadcastVoiceStatus();
broadcastDeafenViaLiveKit(); broadcastDeafenViaLiveKit();
}, [isServerDeafened, toggleDeafen]); }, [isSpaceDeafened, toggleDeafen]);
const handleCamera = async () => { const handleCamera = async () => {
const room = getActiveRoom(); const room = getActiveRoom();
@@ -147,35 +147,35 @@ export function VoiceControlBar() {
{/* Mute */} {/* Mute */}
<button <button
onClick={handleMute} onClick={handleMute}
className={(isServerMuted || isServerDeafened) className={(isSpaceMuted || isSpaceDeafened)
? `${btnBase} bg-accent-amber/20 text-accent-amber cursor-not-allowed` ? `${btnBase} bg-accent-amber/20 text-accent-amber cursor-not-allowed`
: isMuted || isDeafened : isMuted || isDeafened
? `${btnBase} bg-accent-rose/20 text-txt-danger hover:bg-accent-rose/30` ? `${btnBase} bg-accent-rose/20 text-txt-danger hover:bg-accent-rose/30`
: btnDefault : btnDefault
} }
title={(isServerMuted || isServerDeafened) ? (isMuted ? 'Server Muted (self-muted)' : 'Server Muted') : isMuted ? 'Unmute (M)' : 'Mute (M)'} title={(isSpaceMuted || isSpaceDeafened) ? (isMuted ? 'Space Muted (self-muted)' : 'Space Muted') : isMuted ? 'Unmute (M)' : 'Mute (M)'}
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" /> <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" /> <path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
{(isMuted || isDeafened || isServerMuted || isServerDeafened) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />} {(isMuted || isDeafened || isSpaceMuted || isSpaceDeafened) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
</svg> </svg>
</button> </button>
{/* Deafen */} {/* Deafen */}
<button <button
onClick={handleDeafen} onClick={handleDeafen}
className={isServerDeafened className={isSpaceDeafened
? `${btnBase} bg-accent-amber/20 text-accent-amber cursor-not-allowed` ? `${btnBase} bg-accent-amber/20 text-accent-amber cursor-not-allowed`
: isDeafened : isDeafened
? `${btnBase} bg-accent-rose/20 text-txt-danger hover:bg-accent-rose/30` ? `${btnBase} bg-accent-rose/20 text-txt-danger hover:bg-accent-rose/30`
: btnDefault : btnDefault
} }
title={isServerDeafened ? 'Server Deafened' : isDeafened ? 'Undeafen (D)' : 'Deafen (D)'} title={isSpaceDeafened ? 'Space Deafened' : isDeafened ? 'Undeafen (D)' : 'Deafen (D)'}
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" /> <path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
{(isDeafened || isServerDeafened) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />} {(isDeafened || isSpaceDeafened) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
</svg> </svg>
</button> </button>
@@ -18,8 +18,8 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
const isDeafened = useVoiceStore((s) => s.isDeafened); const isDeafened = useVoiceStore((s) => s.isDeafened);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
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 spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds);
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds); const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds);
const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds); const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds);
const unwatchedCameras = useVoiceStore((s) => s.unwatchedCameras); const unwatchedCameras = useVoiceStore((s) => s.unwatchedCameras);
const participantMutes = useVoiceStore((s) => s.participantMutes); const participantMutes = useVoiceStore((s) => s.participantMutes);
@@ -123,15 +123,15 @@ 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">
{(() => { {(() => {
const isServerMutedUser = spaceId ? serverMutedUserIds.has(`${spaceId}:${participant.userId}`) : false; const isSpaceMutedUser = spaceId ? spaceMutedUserIds.has(`${spaceId}:${participant.userId}`) : false;
const isServerDeafenedUser = spaceId ? serverDeafenedUserIds.has(`${spaceId}:${participant.userId}`) : false; const isSpaceDeafenedUser = spaceId ? spaceDeafenedUserIds.has(`${spaceId}:${participant.userId}`) : false;
const isPermissionMutedUser = spaceId ? permissionMutedUserIds.has(`${spaceId}:${participant.userId}`) : false; const isPermissionMutedUser = spaceId ? permissionMutedUserIds.has(`${spaceId}:${participant.userId}`) : false;
const effectivelyMuted = participant.isMuted || isServerMutedUser || isServerDeafenedUser || isPermissionMutedUser; const effectivelyMuted = participant.isMuted || isSpaceMutedUser || isSpaceDeafenedUser || isPermissionMutedUser;
const effectivelyDeafened = (isLocal ? isDeafened : participant.isDeafened) || isServerDeafenedUser; const effectivelyDeafened = (isLocal ? isDeafened : participant.isDeafened) || isSpaceDeafenedUser;
return ( return (
<> <>
{effectivelyMuted && ( {effectivelyMuted && (
<div className={`w-5 h-5 ${(isServerMutedUser || isServerDeafenedUser || isPermissionMutedUser) ? 'bg-accent-amber/90' : 'bg-accent-rose/90'} rounded-full flex items-center justify-center`}> <div className={`w-5 h-5 ${(isSpaceMutedUser || isSpaceDeafenedUser || isPermissionMutedUser) ? 'bg-accent-amber/90' : 'bg-accent-rose/90'} rounded-full flex items-center justify-center`}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="white"> <svg width="12" height="12" viewBox="0 0 24 24" fill="white">
<path d="M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" /> <path d="M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" />
<line <line
@@ -146,7 +146,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
</div> </div>
)} )}
{effectivelyDeafened && ( {effectivelyDeafened && (
<div className={`w-5 h-5 ${isServerDeafenedUser ? 'bg-accent-amber/90' : 'bg-accent-rose/90'} rounded-full flex items-center justify-center`}> <div className={`w-5 h-5 ${isSpaceDeafenedUser ? 'bg-accent-amber/90' : 'bg-accent-rose/90'} rounded-full flex items-center justify-center`}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="white"> <svg width="12" height="12" viewBox="0 0 24 24" fill="white">
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" /> <path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
<line <line
@@ -18,8 +18,8 @@ interface VoiceModMenuItemsProps {
* Use inside any container — no portal or positioning logic. * Use inside any container — no portal or positioning logic.
*/ */
export function VoiceModMenuItems({ targetUserId, channelId, onAction }: VoiceModMenuItemsProps) { export function VoiceModMenuItems({ targetUserId, channelId, onAction }: VoiceModMenuItemsProps) {
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds); const spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds);
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds); const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds);
const spacePermissions = useSpaceStore((s) => s.spacePermissions); const spacePermissions = useSpaceStore((s) => s.spacePermissions);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
@@ -38,18 +38,18 @@ export function VoiceModMenuItems({ targetUserId, channelId, onAction }: VoiceMo
const voiceOrigin = getChannelOrigin(channelId); const voiceOrigin = getChannelOrigin(channelId);
const spaceId = useSpaceStore((s) => s.channelToSpaceMap.get(channelId)); const spaceId = useSpaceStore((s) => s.channelToSpaceMap.get(channelId));
const isServerMuted = serverMutedUserIds.has(`${spaceId}:${targetUserId}`); const isSpaceMuted = spaceMutedUserIds.has(`${spaceId}:${targetUserId}`);
const isServerDeafened = serverDeafenedUserIds.has(`${spaceId}:${targetUserId}`); const isSpaceDeafened = spaceDeafenedUserIds.has(`${spaceId}:${targetUserId}`);
if (!canMuteMembers && !canDeafenMembers && !canMoveMembers && !canDisconnectMembers) return null; if (!canMuteMembers && !canDeafenMembers && !canMoveMembers && !canDisconnectMembers) return null;
const handleServerMute = () => { const handleSpaceMute = () => {
wsSend({ type: 'voice_server_mute', userId: targetUserId, muted: !isServerMuted }, voiceOrigin); wsSend({ type: 'voice_space_mute', userId: targetUserId, muted: !isSpaceMuted }, voiceOrigin);
onAction(); onAction();
}; };
const handleServerDeafen = () => { const handleSpaceDeafen = () => {
wsSend({ type: 'voice_server_deafen', userId: targetUserId, deafened: !isServerDeafened }, voiceOrigin); wsSend({ type: 'voice_space_deafen', userId: targetUserId, deafened: !isSpaceDeafened }, voiceOrigin);
onAction(); onAction();
}; };
@@ -69,26 +69,26 @@ export function VoiceModMenuItems({ targetUserId, channelId, onAction }: VoiceMo
return ( return (
<> <>
{canMuteMembers && ( {canMuteMembers && (
<button onClick={handleServerMute} className={btnClass} style={btnStyle}> <button onClick={handleSpaceMute} className={btnClass} style={btnStyle}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0"> <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" /> <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" /> <path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
{isServerMuted && ( {isSpaceMuted && (
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /> <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
)} )}
</svg> </svg>
{isServerMuted ? 'Server Unmute' : 'Server Mute'} {isSpaceMuted ? 'Space Unmute' : 'Space Mute'}
</button> </button>
)} )}
{canDeafenMembers && ( {canDeafenMembers && (
<button onClick={handleServerDeafen} className={btnClass} style={btnStyle}> <button onClick={handleSpaceDeafen} className={btnClass} style={btnStyle}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0"> <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" /> <path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
{isServerDeafened && ( {isSpaceDeafened && (
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /> <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
)} )}
</svg> </svg>
{isServerDeafened ? 'Server Undeafen' : 'Server Deafen'} {isSpaceDeafened ? 'Space Undeafen' : 'Space Deafen'}
</button> </button>
)} )}
{canDisconnectMembers && ( {canDisconnectMembers && (
+9 -9
View File
@@ -151,8 +151,8 @@ export function useLiveKit() {
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing); const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const screenShareConfig = useVoiceStore((s) => s.screenShareConfig); const screenShareConfig = useVoiceStore((s) => s.screenShareConfig);
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates); const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds); const spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds);
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds); const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds);
const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds); const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds);
const inputVolume = useVoiceStore((s) => s.inputVolume); const inputVolume = useVoiceStore((s) => s.inputVolume);
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId); const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
@@ -213,8 +213,8 @@ export function useLiveKit() {
const localMyId = cvId ? getMyUserIdForOrigin(localOrigin) : undefined; const localMyId = cvId ? getMyUserIdForOrigin(localOrigin) : undefined;
const localSpaceId = cvId ? useSpaceStore.getState().channelToSpaceMap.get(cvId) : null; const localSpaceId = cvId ? useSpaceStore.getState().channelToSpaceMap.get(cvId) : null;
const localKey = (localSpaceId && localMyId) ? `${localSpaceId}:${localMyId}` : ''; const localKey = (localSpaceId && localMyId) ? `${localSpaceId}:${localMyId}` : '';
isPartMuted = vs.isMuted || vs.serverMutedUserIds.has(localKey) || vs.permissionMutedUserIds.has(localKey); isPartMuted = vs.isMuted || vs.spaceMutedUserIds.has(localKey) || vs.permissionMutedUserIds.has(localKey);
isPartDeafened = vs.isDeafened || vs.serverDeafenedUserIds.has(localKey); isPartDeafened = vs.isDeafened || vs.spaceDeafenedUserIds.has(localKey);
} else { } else {
isPartDeafened = userState?.isDeafened ?? useVoiceStore.getState().deafenedUserIds.has(userId); isPartDeafened = userState?.isDeafened ?? useVoiceStore.getState().deafenedUserIds.has(userId);
if (userState) isPartMuted = userState.isMuted; if (userState) isPartMuted = userState.isMuted;
@@ -271,8 +271,8 @@ export function useLiveKit() {
const effMyId = cvId ? getMyUserIdForOrigin(effOrigin) : undefined; const effMyId = cvId ? getMyUserIdForOrigin(effOrigin) : undefined;
const effSpaceId = cvId ? useSpaceStore.getState().channelToSpaceMap.get(cvId) : null; const effSpaceId = cvId ? useSpaceStore.getState().channelToSpaceMap.get(cvId) : null;
const effKey = (effSpaceId && effMyId) ? `${effSpaceId}:${effMyId}` : ''; const effKey = (effSpaceId && effMyId) ? `${effSpaceId}:${effMyId}` : '';
const effectiveMuted = isMuted || serverMutedUserIds.has(effKey) || permissionMutedUserIds.has(effKey); const effectiveMuted = isMuted || spaceMutedUserIds.has(effKey) || permissionMutedUserIds.has(effKey);
const effectiveDeafened = isDeafened || serverDeafenedUserIds.has(effKey); const effectiveDeafened = isDeafened || spaceDeafenedUserIds.has(effKey);
const syncMic = async () => { const syncMic = async () => {
try { try {
@@ -338,7 +338,7 @@ export function useLiveKit() {
return () => { return () => {
unsubscribe(); unsubscribe();
}; };
}, [isMuted, isDeafened, serverMutedUserIds, serverDeafenedUserIds, permissionMutedUserIds, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled]); }, [isMuted, isDeafened, spaceMutedUserIds, spaceDeafenedUserIds, permissionMutedUserIds, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled]);
const connect = useCallback(async (channelId: string, isDm?: boolean) => { const connect = useCallback(async (channelId: string, isDm?: boolean) => {
const storedId = isDm ? `dm-${channelId}` : channelId; const storedId = isDm ? `dm-${channelId}` : channelId;
@@ -403,7 +403,7 @@ export function useLiveKit() {
const connMyId = cvIdConn ? getMyUserIdForOrigin(connOrigin) : undefined; const connMyId = cvIdConn ? getMyUserIdForOrigin(connOrigin) : undefined;
const connSpaceId = cvIdConn ? useSpaceStore.getState().channelToSpaceMap.get(cvIdConn) : null; const connSpaceId = cvIdConn ? useSpaceStore.getState().channelToSpaceMap.get(cvIdConn) : null;
const connKey = (connSpaceId && connMyId) ? `${connSpaceId}:${connMyId}` : ''; const connKey = (connSpaceId && connMyId) ? `${connSpaceId}:${connMyId}` : '';
const effDeaf = vsConn.isDeafened || vsConn.serverDeafenedUserIds.has(connKey); const effDeaf = vsConn.isDeafened || vsConn.spaceDeafenedUserIds.has(connKey);
if (effDeaf) { if (effDeaf) {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
newRoom.localParticipant.publishData( newRoom.localParticipant.publishData(
@@ -595,7 +595,7 @@ export function useLiveKit() {
useEffect(() => { useEffect(() => {
updateParticipants(); updateParticipants();
}, [voiceUserStates, isMuted, isDeafened, serverMutedUserIds, serverDeafenedUserIds, permissionMutedUserIds, updateParticipants]); }, [voiceUserStates, isMuted, isDeafened, spaceMutedUserIds, spaceDeafenedUserIds, permissionMutedUserIds, updateParticipants]);
useEffect(() => { useEffect(() => {
if (!room) return; if (!room) return;
+17 -17
View File
@@ -182,34 +182,34 @@ function handleEvent(origin: string, event: ServerEvent): void {
} }
} }
const nextServerMuted = new Set(vsState.serverMutedUserIds); const nextSpaceMuted = new Set(vsState.spaceMutedUserIds);
const nextServerDeafened = new Set(vsState.serverDeafenedUserIds); const nextSpaceDeafened = new Set(vsState.spaceDeafenedUserIds);
const nextPermissionMuted = new Set(vsState.permissionMutedUserIds); const nextPermissionMuted = new Set(vsState.permissionMutedUserIds);
// Clear existing restrictions that belong to spaces on THIS origin // Clear existing restrictions that belong to spaces on THIS origin
// (If a space was deleted while offline, its orphaned restrictions remain, which is harmless) // (If a space was deleted while offline, its orphaned restrictions remain, which is harmless)
for (const key of nextServerMuted) { for (const key of nextSpaceMuted) {
const spaceId = key.split(':')[0]; const spaceId = key.split(':')[0];
if (spaceId && originSpaceIds.has(spaceId)) nextServerMuted.delete(key); if (spaceId && originSpaceIds.has(spaceId)) nextSpaceMuted.delete(key);
} }
for (const key of nextServerDeafened) { for (const key of nextSpaceDeafened) {
const spaceId = key.split(':')[0]; const spaceId = key.split(':')[0];
if (spaceId && originSpaceIds.has(spaceId)) nextServerDeafened.delete(key); if (spaceId && originSpaceIds.has(spaceId)) nextSpaceDeafened.delete(key);
} }
for (const key of nextPermissionMuted) { for (const key of nextPermissionMuted) {
const spaceId = key.split(':')[0]; const spaceId = key.split(':')[0];
if (spaceId && originSpaceIds.has(spaceId)) nextPermissionMuted.delete(key); if (spaceId && originSpaceIds.has(spaceId)) nextPermissionMuted.delete(key);
} }
if (event.serverVoiceStates) { if (event.spaceVoiceStates) {
for (const [uid, state] of Object.entries(event.serverVoiceStates as Record<string, { serverMuted: boolean; serverDeafened: boolean; permissionMuted?: boolean }>)) { for (const [uid, state] of Object.entries(event.spaceVoiceStates as Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted?: boolean }>)) {
if (state.serverMuted) nextServerMuted.add(uid); if (state.spaceMuted) nextSpaceMuted.add(uid);
if (state.serverDeafened) nextServerDeafened.add(uid); if (state.spaceDeafened) nextSpaceDeafened.add(uid);
if (state.permissionMuted) nextPermissionMuted.add(uid); if (state.permissionMuted) nextPermissionMuted.add(uid);
} }
} }
// Single atomic update // Single atomic update
useVoiceStore.setState({ serverMutedUserIds: nextServerMuted, serverDeafenedUserIds: nextServerDeafened, permissionMutedUserIds: nextPermissionMuted }); useVoiceStore.setState({ spaceMutedUserIds: nextSpaceMuted, spaceDeafenedUserIds: nextSpaceDeafened, permissionMutedUserIds: nextPermissionMuted });
// With decoupled state, user intent is never force-set by the server. // With decoupled state, user intent is never force-set by the server.
// Effective state (intent || serverEnforcement) is computed reactively // Effective state (intent || serverEnforcement) is computed reactively
@@ -335,9 +335,9 @@ function handleEvent(origin: string, event: ServerEvent): void {
setVoiceUserStatus(event.userId, event.isMuted, event.isDeafened, event.isCameraOn, event.isScreenSharing); setVoiceUserStatus(event.userId, event.isMuted, event.isDeafened, event.isCameraOn, event.isScreenSharing);
break; break;
case 'voice_server_muted': { case 'voice_space_muted': {
const { setServerMutedUser } = useVoiceStore.getState(); const { setSpaceMutedUser } = useVoiceStore.getState();
setServerMutedUser(event.spaceId, event.userId, event.muted); setSpaceMutedUser(event.spaceId, event.userId, event.muted);
// Broadcast effective state if this targets the current user // Broadcast effective state if this targets the current user
const myMuteId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin); const myMuteId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin);
if (event.userId === myMuteId) broadcastVoiceStatus(); if (event.userId === myMuteId) broadcastVoiceStatus();
@@ -352,9 +352,9 @@ function handleEvent(origin: string, event: ServerEvent): void {
break; break;
} }
case 'voice_server_deafened': { case 'voice_space_deafened': {
const { setServerDeafenedUser } = useVoiceStore.getState(); const { setSpaceDeafenedUser } = useVoiceStore.getState();
setServerDeafenedUser(event.spaceId, event.userId, event.deafened); setSpaceDeafenedUser(event.spaceId, event.userId, event.deafened);
// Broadcast effective state if this targets the current user // Broadcast effective state if this targets the current user
const myDeafenId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin); const myDeafenId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin);
if (event.userId === myDeafenId) { if (event.userId === myDeafenId) {
+22 -22
View File
@@ -93,12 +93,12 @@ interface VoiceState {
voiceUserStates: Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; voiceUserStates: Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
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) // Space mute/deafen state (moderator action)
serverMutedUserIds: Set<string>; // Stores "spaceId:userId" spaceMutedUserIds: Set<string>; // Stores "spaceId:userId"
serverDeafenedUserIds: Set<string>; // Stores "spaceId:userId" spaceDeafenedUserIds: Set<string>; // Stores "spaceId:userId"
setServerMutedUser: (spaceId: string, userId: string, muted: boolean) => void; setSpaceMutedUser: (spaceId: string, userId: string, muted: boolean) => void;
setServerDeafenedUser: (spaceId: string, userId: string, deafened: boolean) => void; setSpaceDeafenedUser: (spaceId: string, userId: string, deafened: boolean) => void;
clearServerVoiceStates: () => void; clearSpaceVoiceStates: () => void;
// Permission mute state (SPEAK permission revoked while in voice) // Permission mute state (SPEAK permission revoked while in voice)
permissionMutedUserIds: Set<string>; // Stores "spaceId:userId" permissionMutedUserIds: Set<string>; // Stores "spaceId:userId"
setPermissionMutedUser: (spaceId: string, userId: string, muted: boolean) => void; setPermissionMutedUser: (spaceId: string, userId: string, muted: boolean) => void;
@@ -332,25 +332,25 @@ export const useVoiceStore = create<VoiceState>()(
}); });
}, },
serverMutedUserIds: new Set(), spaceMutedUserIds: new Set(),
serverDeafenedUserIds: new Set(), spaceDeafenedUserIds: new Set(),
setServerMutedUser: (spaceId, userId, muted) => { setSpaceMutedUser: (spaceId, userId, muted) => {
set((state) => { set((state) => {
const newSet = new Set(state.serverMutedUserIds); const newSet = new Set(state.spaceMutedUserIds);
const key = `${spaceId}:${userId}`; const key = `${spaceId}:${userId}`;
if (muted) newSet.add(key); else newSet.delete(key); if (muted) newSet.add(key); else newSet.delete(key);
return { serverMutedUserIds: newSet }; return { spaceMutedUserIds: newSet };
}); });
}, },
setServerDeafenedUser: (spaceId, userId, deafened) => { setSpaceDeafenedUser: (spaceId, userId, deafened) => {
set((state) => { set((state) => {
const newSet = new Set(state.serverDeafenedUserIds); const newSet = new Set(state.spaceDeafenedUserIds);
const key = `${spaceId}:${userId}`; const key = `${spaceId}:${userId}`;
if (deafened) newSet.add(key); else newSet.delete(key); if (deafened) newSet.add(key); else newSet.delete(key);
return { serverDeafenedUserIds: newSet }; return { spaceDeafenedUserIds: newSet };
}); });
}, },
clearServerVoiceStates: () => set({ serverMutedUserIds: new Set(), serverDeafenedUserIds: new Set(), permissionMutedUserIds: new Set() }), clearSpaceVoiceStates: () => set({ spaceMutedUserIds: new Set(), spaceDeafenedUserIds: new Set(), permissionMutedUserIds: new Set() }),
permissionMutedUserIds: new Set(), permissionMutedUserIds: new Set(),
setPermissionMutedUser: (spaceId, userId, muted) => { setPermissionMutedUser: (spaceId, userId, muted) => {
@@ -392,9 +392,9 @@ export const useVoiceStore = create<VoiceState>()(
streamMutes: new Map(), streamMutes: new Map(),
watchingStreams: new Set(), watchingStreams: new Set(),
unwatchedCameras: new Set(), unwatchedCameras: new Set(),
// Server-enforced restrictions // Space-enforced restrictions
serverMutedUserIds: new Set(), spaceMutedUserIds: new Set(),
serverDeafenedUserIds: new Set(), spaceDeafenedUserIds: new Set(),
permissionMutedUserIds: new Set(), permissionMutedUserIds: new Set(),
}), }),
@@ -502,8 +502,8 @@ export const useVoiceStore = create<VoiceState>()(
streamMutes: new Map(), streamMutes: new Map(),
watchingStreams: new Set(), watchingStreams: new Set(),
unwatchedCameras: new Set(), unwatchedCameras: new Set(),
serverMutedUserIds: new Set(), spaceMutedUserIds: new Set(),
serverDeafenedUserIds: new Set(), spaceDeafenedUserIds: new Set(),
permissionMutedUserIds: new Set(), permissionMutedUserIds: new Set(),
}), }),
}), }),
@@ -566,8 +566,8 @@ export const useVoiceStore = create<VoiceState>()(
merge: (persistedState: any, currentState: VoiceState) => { merge: (persistedState: any, currentState: VoiceState) => {
const merged = { ...currentState, ...persistedState }; const merged = { ...currentState, ...persistedState };
// Reconstruct non-persisted Sets/Maps to their defaults // Reconstruct non-persisted Sets/Maps to their defaults
merged.serverMutedUserIds = currentState.serverMutedUserIds; merged.spaceMutedUserIds = currentState.spaceMutedUserIds;
merged.serverDeafenedUserIds = currentState.serverDeafenedUserIds; merged.spaceDeafenedUserIds = currentState.spaceDeafenedUserIds;
merged.permissionMutedUserIds = currentState.permissionMutedUserIds; merged.permissionMutedUserIds = currentState.permissionMutedUserIds;
merged.voiceUsers = currentState.voiceUsers; merged.voiceUsers = currentState.voiceUsers;
merged.participants = currentState.participants; merged.participants = currentState.participants;
+7 -7
View File
@@ -15,16 +15,16 @@ import { wsSend } from '../hooks/useWebSocket';
*/ */
export function broadcastVoiceStatus(overrideOrigin?: string): void { export function broadcastVoiceStatus(overrideOrigin?: string): void {
const vs = useVoiceStore.getState(); const vs = useVoiceStore.getState();
const { isMuted, isDeafened, isCameraOn, isScreenSharing, currentVoiceChannelId, serverMutedUserIds, serverDeafenedUserIds } = vs; const { isMuted, isDeafened, isCameraOn, isScreenSharing, currentVoiceChannelId, spaceMutedUserIds, spaceDeafenedUserIds } = vs;
if (!currentVoiceChannelId) return; if (!currentVoiceChannelId) return;
const origin = overrideOrigin ?? getChannelOrigin(currentVoiceChannelId); const origin = overrideOrigin ?? getChannelOrigin(currentVoiceChannelId);
const myId = getMyUserIdForOrigin(origin); const myId = getMyUserIdForOrigin(origin);
const spaceId = useSpaceStore.getState().channelToSpaceMap.get(currentVoiceChannelId); const spaceId = useSpaceStore.getState().channelToSpaceMap.get(currentVoiceChannelId);
const serverKey = (spaceId && myId) ? `${spaceId}:${myId}` : ''; const spaceKey = (spaceId && myId) ? `${spaceId}:${myId}` : '';
const effectiveMuted = isMuted || serverMutedUserIds.has(serverKey); const effectiveMuted = isMuted || spaceMutedUserIds.has(spaceKey);
const effectiveDeafened = isDeafened || serverDeafenedUserIds.has(serverKey); const effectiveDeafened = isDeafened || spaceDeafenedUserIds.has(spaceKey);
wsSend({ type: 'voice_status', isMuted: effectiveMuted, isDeafened: effectiveDeafened, isCameraOn, isScreenSharing }, origin); wsSend({ type: 'voice_status', isMuted: effectiveMuted, isDeafened: effectiveDeafened, isCameraOn, isScreenSharing }, origin);
} }
@@ -35,14 +35,14 @@ export function broadcastVoiceStatus(overrideOrigin?: string): void {
*/ */
export function broadcastDeafenViaLiveKit(): void { export function broadcastDeafenViaLiveKit(): void {
const vs = useVoiceStore.getState(); const vs = useVoiceStore.getState();
const { isDeafened, currentVoiceChannelId, serverDeafenedUserIds } = vs; const { isDeafened, currentVoiceChannelId, spaceDeafenedUserIds } = vs;
if (!currentVoiceChannelId) return; if (!currentVoiceChannelId) return;
const origin = getChannelOrigin(currentVoiceChannelId); const origin = getChannelOrigin(currentVoiceChannelId);
const myId = getMyUserIdForOrigin(origin); const myId = getMyUserIdForOrigin(origin);
const spaceId = useSpaceStore.getState().channelToSpaceMap.get(currentVoiceChannelId); const spaceId = useSpaceStore.getState().channelToSpaceMap.get(currentVoiceChannelId);
const serverKey = (spaceId && myId) ? `${spaceId}:${myId}` : ''; const spaceKey = (spaceId && myId) ? `${spaceId}:${myId}` : '';
const effectiveDeafened = isDeafened || serverDeafenedUserIds.has(serverKey); const effectiveDeafened = isDeafened || spaceDeafenedUserIds.has(spaceKey);
import('../hooks/useLiveKit').then(({ getActiveRoom }) => { import('../hooks/useLiveKit').then(({ getActiveRoom }) => {
const room = getActiveRoom(); const room = getActiveRoom();