feat: real-time SPEAK permission enforcement in voice channels

Permission changes now take effect immediately without requiring
disconnect/reconnect. Modeled as "permission mute" parallel to
server mute — server recomputes SPEAK for all voice participants
on role/override changes and broadcasts state via WebSocket.
Includes amber UI indicators and mic toggle blocking.
This commit is contained in:
Jannis Braun
2026-03-10 14:50:01 +01:00
parent 4d9454382c
commit ce63c5ed36
13 changed files with 173 additions and 28 deletions
+3
View File
@@ -6,6 +6,7 @@ import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, hasPermission, getChannelSpaceId, PermissionBits, computePermissions } from '../utils/permissions.js';
import { permissionsToString } from '@backspace/shared/src/permissions.js';
import { connectionManager } from '../ws/handler.js';
import { checkVoicePermissions } from '../ws/events.js';
import type {
CreateChannelRequest,
UpdateChannelRequest,
@@ -356,6 +357,7 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
// Notify all space members of the permission change
broadcastOverrideChange(channel.spaceId, id);
checkVoicePermissions(channel.spaceId);
return reply.code(200).send({ success: true });
});
@@ -387,6 +389,7 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
// Notify all space members of the permission change
broadcastOverrideChange(channel.spaceId, id);
checkVoicePermissions(channel.spaceId);
return reply.code(200).send({ success: true });
},
+5
View File
@@ -19,6 +19,7 @@ import type {
Role,
} from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js';
import { checkVoicePermissions } from '../ws/events.js';
function rowToSpace(row: typeof schema.spaces.$inferSelect): Space {
return {
@@ -662,6 +663,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
// Force target user's client to re-sync with their new permissions
connectionManager.pushReadyPayload(uid);
checkVoicePermissions(id);
// Build response with populated roles
const updatedMember = db.select()
@@ -829,6 +831,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
for (const m of memberRows) {
connectionManager.pushReadyPayload(m.userId);
}
checkVoicePermissions(id);
return reply.code(201).send(role);
});
@@ -871,6 +874,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
for (const m of memberRows) {
connectionManager.pushReadyPayload(m.userId);
}
checkVoicePermissions(id);
return reply.code(200).send(updated);
});
@@ -903,6 +907,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
for (const m of memberRows) {
connectionManager.pushReadyPayload(m.userId);
}
checkVoicePermissions(id);
return reply.code(200).send({ success: true });
});
+66 -3
View File
@@ -3,11 +3,41 @@ import { getDb, schema } from '../db/index.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { connectionManager } from './handler.js';
import type { VoiceRoom, DmRoomMeta, SpaceRoomMeta } from './handler.js';
import { isMember, getChannelSpaceId, isDmMember, hasPermission, PermissionBits } from '../utils/permissions.js';
import { isMember, getChannelSpaceId, isDmMember, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
import { broadcastDmMessage, getDmMessageWithUser } from '../routes/dm.js';
import type { MessageWithUser, Attachment, DmMessageWithUser } from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js';
/**
* Re-evaluate SPEAK permission for all participants in voice channels
* belonging to the given space. On transition, updates the in-memory
* permissionMutedUsers Set and broadcasts voice_permission_muted events.
*/
export function checkVoicePermissions(spaceId: string): void {
for (const [roomId, room] of connectionManager.getAllRooms()) {
if (room.roomType !== 'space') continue;
const meta = room.metadata as SpaceRoomMeta;
if (meta.spaceId !== spaceId) continue;
for (const userId of room.participants) {
const perms = computePermissions(userId, spaceId, roomId);
const canSpeak = (perms & PermissionBits.SPEAK) !== 0n || (perms & PermissionBits.ADMINISTRATOR) !== 0n;
const wasMuted = connectionManager.isPermissionMuted(spaceId, userId);
const shouldMute = !canSpeak;
if (shouldMute !== wasMuted) {
connectionManager.setPermissionMuted(spaceId, userId, shouldMute);
connectionManager.sendToSpace(spaceId, {
type: 'voice_permission_muted',
userId,
spaceId,
muted: shouldMute,
});
}
}
}
}
function getMessageWithUser(messageId: string): MessageWithUser | null {
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
@@ -459,6 +489,22 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
});
}
}
// Re-check permission mute on re-registration
{
const perms = computePermissions(userId, spaceId, channelId);
const canSpeak = (perms & PermissionBits.SPEAK) !== 0n || (perms & PermissionBits.ADMINISTRATOR) !== 0n;
const shouldPermMute = !canSpeak;
connectionManager.setPermissionMuted(spaceId, userId, shouldPermMute);
if (shouldPermMute) {
connectionManager.sendToUser(userId, {
type: 'voice_permission_muted',
userId,
spaceId,
muted: true,
});
}
}
return;
}
@@ -542,6 +588,21 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
});
}
}
// Check SPEAK permission and apply permission mute if needed
{
const perms = computePermissions(userId, spaceId, channelId);
const canSpeak = (perms & PermissionBits.SPEAK) !== 0n || (perms & PermissionBits.ADMINISTRATOR) !== 0n;
if (!canSpeak) {
connectionManager.setPermissionMuted(spaceId, userId, true);
connectionManager.sendToSpace(spaceId, {
type: 'voice_permission_muted',
userId,
spaceId,
muted: true,
});
}
}
}
function handleVoiceLeave(userId: string): void {
@@ -565,14 +626,16 @@ function handleVoiceStatus(event: Record<string, unknown>, userId: string): void
let isSpaceMuted = false;
let isSpaceDeafened = false;
let isPermMuted = 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);
isPermMuted = connectionManager.isPermissionMuted(meta.spaceId, userId);
}
// Server-side enforcement: prevent clients from bypassing server mute/deafen
const effectiveMuted = isSpaceMuted ? true : isMuted;
// Server-side enforcement: prevent clients from bypassing server mute/deafen/permission mute
const effectiveMuted = (isSpaceMuted || isPermMuted) ? true : isMuted;
const effectiveDeafened = isSpaceDeafened ? true : isDeafened;
connectionManager.setVoiceUserStatus(userId, effectiveMuted, effectiveDeafened, isCameraOn, isScreenSharing);
+31 -3
View File
@@ -80,6 +80,8 @@ class ConnectionManager {
// Server-muted/deafened users (moderator action)
private serverMutedUsers: Set<string> = new Set(); // Stores spaceId:userId
private serverDeafenedUsers: Set<string> = new Set(); // Stores spaceId:userId
// Permission-muted users (SPEAK permission revoked while in voice)
private permissionMutedUsers: Set<string> = new Set(); // Stores spaceId:userId
addConnection(userId: string, ws: WebSocket): void {
if (!this.connections.has(userId)) {
@@ -413,6 +415,17 @@ class ConnectionManager {
clearServerVoiceState(spaceId: string, userId: string): void {
this.serverMutedUsers.delete(`${spaceId}:${userId}`);
this.serverDeafenedUsers.delete(`${spaceId}:${userId}`);
this.permissionMutedUsers.delete(`${spaceId}:${userId}`);
}
setPermissionMuted(spaceId: string, userId: string, muted: boolean): void {
const key = `${spaceId}:${userId}`;
if (muted) this.permissionMutedUsers.add(key);
else this.permissionMutedUsers.delete(key);
}
isPermissionMuted(spaceId: string, userId: string): boolean {
return this.permissionMutedUsers.has(`${spaceId}:${userId}`);
}
getAllVoiceUserStates(): Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> {
@@ -569,7 +582,7 @@ function buildReadyPayload(userId: string): {
folders: SpaceFolder[];
voiceStates: Record<string, string[]>;
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
serverVoiceStates: Record<string, { serverMuted: boolean; serverDeafened: boolean }>;
serverVoiceStates: Record<string, { serverMuted: boolean; serverDeafened: boolean; permissionMuted: boolean }>;
readStates: ReadState[];
activeCalls: ActiveCallInfo[];
} {
@@ -882,7 +895,8 @@ function buildReadyPayload(userId: string): {
}
// Build server mute/deafen states from DB (authoritative source for all spaces the user belongs to)
const serverVoiceStates: Record<string, { serverMuted: boolean; serverDeafened: boolean }> = {};
// Also includes ephemeral permission-mute state from in-memory Set
const serverVoiceStates: Record<string, { serverMuted: boolean; serverDeafened: boolean; permissionMuted: boolean }> = {};
if (spaceIds.length > 0) {
const allRestrictions = db.select()
.from(schema.voiceRestrictions)
@@ -890,11 +904,25 @@ function buildReadyPayload(userId: string): {
.all();
for (const r of allRestrictions) {
const key = `${r.spaceId}:${r.userId}`;
const existing = serverVoiceStates[key] ?? { serverMuted: false, serverDeafened: false };
const existing = serverVoiceStates[key] ?? { serverMuted: false, serverDeafened: false, permissionMuted: false };
if (r.restrictionType === 'mute') existing.serverMuted = true;
if (r.restrictionType === 'deafen') existing.serverDeafened = true;
serverVoiceStates[key] = existing;
}
// Include ephemeral permission-mute state for all voice participants in user's spaces
for (const [roomId, room] of connectionManager.getAllRooms()) {
if (room.roomType !== 'space') continue;
const meta = room.metadata as SpaceRoomMeta;
if (!spaceIds.includes(meta.spaceId)) continue;
for (const participantId of room.participants) {
if (connectionManager.isPermissionMuted(meta.spaceId, participantId)) {
const key = `${meta.spaceId}:${participantId}`;
const existing = serverVoiceStates[key] ?? { serverMuted: false, serverDeafened: false, permissionMuted: false };
existing.permissionMuted = true;
serverVoiceStates[key] = existing;
}
}
}
}
// Fetch read states for unread tracking
+1
View File
@@ -282,6 +282,7 @@ export type ServerEvent =
| { type: 'join_request_declined'; request: JoinRequest }
| { type: 'voice_server_muted'; userId: string; channelId: string; spaceId: string; muted: boolean }
| { type: 'voice_server_deafened'; userId: string; channelId: string; spaceId: string; deafened: boolean }
| { type: 'voice_permission_muted'; userId: string; spaceId: string; muted: boolean }
| { type: 'voice_moved'; userId: string; oldChannelId: string; newChannelId: string }
| { type: 'voice_disconnected'; userId: string; channelId: string }
| { type: 'member_banned'; spaceId: string; reason: string | null }
@@ -37,27 +37,30 @@ export function ChannelSidebar() {
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 serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds);
const isServerMuted = !!(myOriginId && spaceId && serverMutedUserIds.has(`${spaceId}:${myOriginId}`));
const isServerDeafened = !!(myOriginId && spaceId && serverDeafenedUserIds.has(`${spaceId}:${myOriginId}`));
const isPermissionMuted = !!(myOriginId && spaceId && permissionMutedUserIds.has(`${spaceId}:${myOriginId}`));
const navigate = useNavigate();
const location = useLocation();
const floatingPanelRef = useRef<HTMLDivElement>(null);
const [panelHeight, setPanelHeight] = useState(140);
const floatingPanelHeight = useUIStore((s) => s.floatingPanelHeight);
const setFloatingPanelHeight = useUIStore((s) => s.setFloatingPanelHeight);
useEffect(() => {
const el = floatingPanelRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) setPanelHeight(entry.contentRect.height);
if (entry) setFloatingPanelHeight(entry.contentRect.height);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
}, [setFloatingPanelHeight]);
const handleMicToggle = async () => {
if (isServerMuted || isServerDeafened) return;
if (isServerMuted || isServerDeafened || isPermissionMuted) return;
const wasDeafened = useVoiceStore.getState().isDeafened;
toggleMic();
broadcastVoiceStatus();
@@ -128,6 +131,7 @@ export function ChannelSidebar() {
isDeafened={isDeafened}
isServerMuted={isServerMuted}
isServerDeafened={isServerDeafened}
isPermissionMuted={isPermissionMuted}
onMicToggle={handleMicToggle}
onDeafenToggle={handleDeafenToggle}
onSettingsClick={(tab) => openModal('userSettings', tab ? { tab } : {})}
@@ -145,7 +149,7 @@ export function ChannelSidebar() {
Find or start a conversation
</button>
</div>
<div className="flex-1 overflow-y-auto pt-4 px-2 no-scrollbar" style={{ paddingBottom: panelHeight + 24 }}>
<div className="flex-1 overflow-y-auto pt-4 px-2 no-scrollbar" style={{ paddingBottom: floatingPanelHeight + 24 }}>
<div
onClick={handleHomeClick}
className={`flex items-center gap-3 px-2 h-[42px] rounded-[4px] cursor-pointer mb-[2px] transition-colors group ${
@@ -325,7 +329,7 @@ export function ChannelSidebar() {
</div>
{/* Channels */}
<div className="flex-1 overflow-y-auto pt-3 px-2 space-y-[21px] no-scrollbar" style={{ paddingBottom: panelHeight + 24 }}>
<div className="flex-1 overflow-y-auto pt-3 px-2 space-y-[21px] no-scrollbar" style={{ paddingBottom: floatingPanelHeight + 24 }}>
{/* Text Channels */}
<div>
<div className="flex items-center justify-between px-1 mb-1 group cursor-pointer">
@@ -457,6 +461,7 @@ function UserAreaPanel({
isDeafened,
isServerMuted,
isServerDeafened,
isPermissionMuted,
onMicToggle,
onDeafenToggle,
onSettingsClick,
@@ -466,6 +471,7 @@ function UserAreaPanel({
isDeafened: boolean;
isServerMuted: boolean;
isServerDeafened: boolean;
isPermissionMuted: boolean;
onMicToggle: () => void;
onDeafenToggle: () => void;
onSettingsClick: (tab?: string) => void;
@@ -780,15 +786,15 @@ function UserAreaPanel({
<button
onClick={onMicToggle}
className={`w-8 h-8 flex items-center justify-center hover:bg-interactive-hover rounded-l-[4px] transition-colors ${
(isServerMuted || isServerDeafened) ? 'text-accent-amber cursor-not-allowed'
(isServerMuted || isServerDeafened || isPermissionMuted) ? 'text-accent-amber cursor-not-allowed'
: isMuted || isDeafened ? 'text-txt-danger' : 'text-txt-tertiary hover:text-txt-primary'
}`}
title={(isServerMuted || isServerDeafened) ? 'Server Muted' : isMuted ? 'Unmute' : 'Mute'}
title={(isPermissionMuted) ? 'Muted (No Speak Permission)' : (isServerMuted || isServerDeafened) ? 'Server Muted' : isMuted ? 'Unmute' : 'Mute'}
>
<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="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 || isServerMuted || isServerDeafened || isPermissionMuted) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
</svg>
</button>
{/* Input chevron */}
@@ -170,6 +170,7 @@ export function SpaceSidebar() {
const setShowDms = useUIStore((s) => s.setShowDms);
const openModal = useUIStore((s) => s.openModal);
const addToast = useUIStore((s) => s.addToast);
const floatingPanelHeight = useUIStore((s) => s.floatingPanelHeight);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const unreadChannels = useChatStore((s) => s.unreadChannels);
const instances = useInstanceStore((s) => s.instances);
@@ -246,7 +247,7 @@ export function SpaceSidebar() {
};
return (
<nav data-pip-obstacle="left" className="w-[72px] bg-surface-base flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 no-scrollbar select-none md:fixed md:inset-y-0 md:left-0 md:z-[100] md:glass-strip">
<nav data-pip-obstacle="left" className="w-[72px] bg-surface-base flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 no-scrollbar select-none md:fixed md:inset-y-0 md:left-0 md:z-[100] md:glass-strip" style={{ paddingBottom: floatingPanelHeight + 24 }}>
<SidebarItem
id="@me"
name="Direct Messages"
@@ -23,6 +23,7 @@ export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceC
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds);
const participantMutes = useVoiceStore((s) => s.participantMutes);
const unwatchedCameras = useVoiceStore((s) => s.unwatchedCameras);
const currentUserId = useVoiceStore((s) => {
@@ -98,6 +99,7 @@ export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceC
const spaceId = channelToSpaceMap.get(channelId);
const isServerMuted = serverMutedUserIds.has(`${spaceId}:${userId}`);
const isServerDeafened = serverDeafenedUserIds.has(`${spaceId}:${userId}`);
const isPermissionMuted = permissionMutedUserIds.has(`${spaceId}:${userId}`);
return (
<div
@@ -115,8 +117,8 @@ export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceC
<span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0">{displayName}</span>
{/* Status badges */}
<div className="flex items-center gap-1 flex-shrink-0">
{(isServerMuted || isServerDeafened) && (
<span title={isServerMuted ? "Server Muted" : "Muted (Server Deafened)"}>
{(isServerMuted || isServerDeafened || isPermissionMuted) && (
<span title={isPermissionMuted ? "Muted (No Speak Permission)" : isServerMuted ? "Server Muted" : "Muted (Server Deafened)"}>
<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="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" />
@@ -132,7 +134,7 @@ export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceC
</svg>
</span>
)}
{!isServerMuted && !isServerDeafened && isMuted && (
{!isServerMuted && !isServerDeafened && !isPermissionMuted && isMuted && (
<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="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" />
@@ -19,6 +19,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
const isSpeaking = useVoiceStore((s) => s.speakingParticipantIds.has(participant.identity));
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds);
const unwatchedCameras = useVoiceStore((s) => s.unwatchedCameras);
const participantMutes = useVoiceStore((s) => s.participantMutes);
const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null);
@@ -121,12 +122,13 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
{(() => {
const isServerMutedUser = spaceId ? serverMutedUserIds.has(`${spaceId}:${participant.userId}`) : false;
const isServerDeafenedUser = spaceId ? serverDeafenedUserIds.has(`${spaceId}:${participant.userId}`) : false;
const effectivelyMuted = participant.isMuted || isServerMutedUser || isServerDeafenedUser;
const isPermissionMutedUser = spaceId ? permissionMutedUserIds.has(`${spaceId}:${participant.userId}`) : false;
const effectivelyMuted = participant.isMuted || isServerMutedUser || isServerDeafenedUser || isPermissionMutedUser;
const effectivelyDeafened = (isLocal ? isDeafened : participant.isDeafened) || isServerDeafenedUser;
return (
<>
{effectivelyMuted && (
<div className={`w-5 h-5 ${(isServerMutedUser || isServerDeafenedUser) ? 'bg-accent-amber/90' : 'bg-accent-rose/90'} rounded-full flex items-center justify-center`}>
<div className={`w-5 h-5 ${(isServerMutedUser || isServerDeafenedUser || 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">
<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
+5 -4
View File
@@ -153,6 +153,7 @@ export function useLiveKit() {
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds);
const inputVolume = useVoiceStore((s) => s.inputVolume);
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
const echoCancellation = useVoiceStore((s) => s.echoCancellation);
@@ -212,7 +213,7 @@ export function useLiveKit() {
const localMyId = cvId ? getMyUserIdForOrigin(localOrigin) : undefined;
const localSpaceId = cvId ? useSpaceStore.getState().channelToSpaceMap.get(cvId) : null;
const localKey = (localSpaceId && localMyId) ? `${localSpaceId}:${localMyId}` : '';
isPartMuted = vs.isMuted || vs.serverMutedUserIds.has(localKey);
isPartMuted = vs.isMuted || vs.serverMutedUserIds.has(localKey) || vs.permissionMutedUserIds.has(localKey);
isPartDeafened = vs.isDeafened || vs.serverDeafenedUserIds.has(localKey);
} else {
isPartDeafened = userState?.isDeafened ?? useVoiceStore.getState().deafenedUserIds.has(userId);
@@ -270,7 +271,7 @@ export function useLiveKit() {
const effMyId = cvId ? getMyUserIdForOrigin(effOrigin) : undefined;
const effSpaceId = cvId ? useSpaceStore.getState().channelToSpaceMap.get(cvId) : null;
const effKey = (effSpaceId && effMyId) ? `${effSpaceId}:${effMyId}` : '';
const effectiveMuted = isMuted || serverMutedUserIds.has(effKey);
const effectiveMuted = isMuted || serverMutedUserIds.has(effKey) || permissionMutedUserIds.has(effKey);
const effectiveDeafened = isDeafened || serverDeafenedUserIds.has(effKey);
const syncMic = async () => {
@@ -337,7 +338,7 @@ export function useLiveKit() {
return () => {
unsubscribe();
};
}, [isMuted, isDeafened, serverMutedUserIds, serverDeafenedUserIds, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled]);
}, [isMuted, isDeafened, serverMutedUserIds, serverDeafenedUserIds, permissionMutedUserIds, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled]);
const connect = useCallback(async (channelId: string, isDm?: boolean) => {
const storedId = isDm ? `dm-${channelId}` : channelId;
@@ -594,7 +595,7 @@ export function useLiveKit() {
useEffect(() => {
updateParticipants();
}, [voiceUserStates, isMuted, isDeafened, serverMutedUserIds, serverDeafenedUserIds, updateParticipants]);
}, [voiceUserStates, isMuted, isDeafened, serverMutedUserIds, serverDeafenedUserIds, permissionMutedUserIds, updateParticipants]);
useEffect(() => {
if (!room) return;
+16 -2
View File
@@ -184,6 +184,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
const nextServerMuted = new Set(vsState.serverMutedUserIds);
const nextServerDeafened = new Set(vsState.serverDeafenedUserIds);
const nextPermissionMuted = new Set(vsState.permissionMutedUserIds);
// Clear existing restrictions that belong to spaces on THIS origin
// (If a space was deleted while offline, its orphaned restrictions remain, which is harmless)
@@ -195,15 +196,20 @@ function handleEvent(origin: string, event: ServerEvent): void {
const spaceId = key.split(':')[0];
if (spaceId && originSpaceIds.has(spaceId)) nextServerDeafened.delete(key);
}
for (const key of nextPermissionMuted) {
const spaceId = key.split(':')[0];
if (spaceId && originSpaceIds.has(spaceId)) nextPermissionMuted.delete(key);
}
if (event.serverVoiceStates) {
for (const [uid, state] of Object.entries(event.serverVoiceStates as Record<string, { serverMuted: boolean; serverDeafened: boolean }>)) {
for (const [uid, state] of Object.entries(event.serverVoiceStates as Record<string, { serverMuted: boolean; serverDeafened: boolean; permissionMuted?: boolean }>)) {
if (state.serverMuted) nextServerMuted.add(uid);
if (state.serverDeafened) nextServerDeafened.add(uid);
if (state.permissionMuted) nextPermissionMuted.add(uid);
}
}
// Single atomic update
useVoiceStore.setState({ serverMutedUserIds: nextServerMuted, serverDeafenedUserIds: nextServerDeafened });
useVoiceStore.setState({ serverMutedUserIds: nextServerMuted, serverDeafenedUserIds: nextServerDeafened, permissionMutedUserIds: nextPermissionMuted });
// With decoupled state, user intent is never force-set by the server.
// Effective state (intent || serverEnforcement) is computed reactively
@@ -316,6 +322,14 @@ function handleEvent(origin: string, event: ServerEvent): void {
break;
}
case 'voice_permission_muted': {
const { setPermissionMutedUser } = useVoiceStore.getState();
setPermissionMutedUser(event.spaceId, event.userId, event.muted);
const myPermMuteId = isHome ? useAuthStore.getState().user?.id : getMyUserIdForOrigin(origin);
if (event.userId === myPermMuteId) broadcastVoiceStatus();
break;
}
case 'voice_server_deafened': {
const { setServerDeafenedUser } = useVoiceStore.getState();
setServerDeafenedUser(event.spaceId, event.userId, event.deafened);
+4
View File
@@ -55,6 +55,8 @@ interface UIState {
toggleVoiceFullscreen: () => void;
setVoiceFullscreen: (fullscreen: boolean) => void;
setPipCollapsed: (collapsed: boolean) => void;
floatingPanelHeight: number;
setFloatingPanelHeight: (height: number) => void;
}
export const useUIStore = create<UIState>()(
@@ -124,6 +126,8 @@ export const useUIStore = create<UIState>()(
toggleVoiceFullscreen: () => set((state) => ({ voiceFullscreen: !state.voiceFullscreen })),
setVoiceFullscreen: (fullscreen) => set({ voiceFullscreen: fullscreen }),
setPipCollapsed: (collapsed) => set({ pipCollapsed: collapsed }),
floatingPanelHeight: 140,
setFloatingPanelHeight: (height) => set({ floatingPanelHeight: height }),
}),
{
name: 'backspace-ui-settings',
+16 -1
View File
@@ -99,6 +99,9 @@ interface VoiceState {
setServerMutedUser: (spaceId: string, userId: string, muted: boolean) => void;
setServerDeafenedUser: (spaceId: string, userId: string, deafened: boolean) => void;
clearServerVoiceStates: () => void;
// Permission mute state (SPEAK permission revoked while in voice)
permissionMutedUserIds: Set<string>; // Stores "spaceId:userId"
setPermissionMutedUser: (spaceId: string, userId: string, muted: boolean) => void;
getVoiceUsers: (channelId: string) => string[];
clearAllVoiceUsers: () => void;
clearVoiceUsersForOrigin: (origin: string) => void;
@@ -346,7 +349,17 @@ export const useVoiceStore = create<VoiceState>()(
return { serverDeafenedUserIds: newSet };
});
},
clearServerVoiceStates: () => set({ serverMutedUserIds: new Set(), serverDeafenedUserIds: new Set() }),
clearServerVoiceStates: () => set({ serverMutedUserIds: new Set(), serverDeafenedUserIds: new Set(), permissionMutedUserIds: new Set() }),
permissionMutedUserIds: new Set(),
setPermissionMutedUser: (spaceId, userId, muted) => {
set((state) => {
const newSet = new Set(state.permissionMutedUserIds);
const key = `${spaceId}:${userId}`;
if (muted) newSet.add(key); else newSet.delete(key);
return { permissionMutedUserIds: newSet };
});
},
getVoiceUsers: (channelId) => get().voiceUsers.get(channelId) ?? [],
@@ -458,6 +471,7 @@ export const useVoiceStore = create<VoiceState>()(
unwatchedCameras: new Set(),
serverMutedUserIds: new Set(),
serverDeafenedUserIds: new Set(),
permissionMutedUserIds: new Set(),
}),
}),
{
@@ -521,6 +535,7 @@ export const useVoiceStore = create<VoiceState>()(
// Reconstruct non-persisted Sets/Maps to their defaults
merged.serverMutedUserIds = currentState.serverMutedUserIds;
merged.serverDeafenedUserIds = currentState.serverDeafenedUserIds;
merged.permissionMutedUserIds = currentState.permissionMutedUserIds;
merged.voiceUsers = currentState.voiceUsers;
merged.participants = currentState.participants;
merged.speakingParticipantIds = currentState.speakingParticipantIds;