diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index c5e91811..34fa31a7 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -142,6 +142,18 @@ export function runMigrations(db: Database.Database): void { ); `); + // Ensure voice_restrictions table exists (idempotent) + db.exec(` + CREATE TABLE IF NOT EXISTS voice_restrictions ( + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + restriction_type TEXT NOT NULL, + moderator_id TEXT NOT NULL REFERENCES users(id), + created_at INTEGER NOT NULL, + PRIMARY KEY (space_id, user_id, restriction_type) + ); + `); + // ─── RBAC Migration: Ensure @everyone roles exist for all spaces ───────── migrateEveryoneRoles(db); diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 91e06837..f49b4dc1 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -217,3 +217,13 @@ export const joinRequests = sqliteTable('join_requests', { createdAt: integer('created_at').notNull(), decidedAt: integer('decided_at'), }); + +export const voiceRestrictions = sqliteTable('voice_restrictions', { + spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }), + userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + restrictionType: text('restriction_type').notNull(), // 'mute' | 'deafen' + moderatorId: text('moderator_id').notNull().references(() => users.id), + createdAt: integer('created_at').notNull(), +}, (table) => ({ + pk: primaryKey({ columns: [table.spaceId, table.userId, table.restrictionType] }), +})); diff --git a/packages/server/src/routes/spaces.ts b/packages/server/src/routes/spaces.ts index 284b5599..8dd3de40 100644 --- a/packages/server/src/routes/spaces.ts +++ b/packages/server/src/routes/spaces.ts @@ -768,6 +768,14 @@ export async function spaceRoutes(app: FastifyInstance): Promise { )) .run(); + // Clean up any voice restrictions for the removed member + db.delete(schema.voiceRestrictions).where( + and( + eq(schema.voiceRestrictions.spaceId, id), + eq(schema.voiceRestrictions.userId, uid), + ) + ).run(); + // Broadcast member_left event connectionManager.sendToSpace(id, { type: 'member_left', @@ -1038,6 +1046,12 @@ export async function spaceRoutes(app: FastifyInstance): Promise { eq(schema.memberRoles.spaceId, id), eq(schema.memberRoles.userId, targetId), )).run(); + + // Clean up any voice restrictions for the banned member + tx.delete(schema.voiceRestrictions).where(and( + eq(schema.voiceRestrictions.spaceId, id), + eq(schema.voiceRestrictions.userId, targetId), + )).run(); }); // Broadcast member_left event so other clients update their member list diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 6b5449db..0e57a4c7 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -476,6 +476,36 @@ function handleVoiceJoin(event: Record, userId: string): void { isScreenSharing: status.isScreenSharing, }); } + + // Load persistent voice restrictions for this user in this space + const db = getDb(); + const restrictions = db.select() + .from(schema.voiceRestrictions) + .where(and( + eq(schema.voiceRestrictions.spaceId, spaceId), + eq(schema.voiceRestrictions.userId, userId), + )) + .all(); + + for (const r of restrictions) { + if (r.restrictionType === 'mute') { + connectionManager.setServerMuted(userId, true); + connectionManager.sendToSpace(spaceId, { + type: 'voice_server_muted', + userId, + channelId, + muted: true, + }); + } else if (r.restrictionType === 'deafen') { + connectionManager.setServerDeafened(userId, true); + connectionManager.sendToSpace(spaceId, { + type: 'voice_server_deafened', + userId, + channelId, + deafened: true, + }); + } + } } function handleVoiceLeave(userId: string): void { @@ -1092,6 +1122,26 @@ function handleVoiceServerMute(event: Record, userId: string): connectionManager.setServerMuted(targetUserId, muted); + // Persist to DB + const db = getDb(); + if (muted) { + db.insert(schema.voiceRestrictions).values({ + spaceId: meta.spaceId, + userId: targetUserId, + restrictionType: 'mute', + moderatorId: userId, + createdAt: Date.now(), + }).onConflictDoNothing().run(); + } else { + db.delete(schema.voiceRestrictions).where( + and( + eq(schema.voiceRestrictions.spaceId, meta.spaceId), + eq(schema.voiceRestrictions.userId, targetUserId), + eq(schema.voiceRestrictions.restrictionType, 'mute'), + ) + ).run(); + } + // Broadcast to all space members connectionManager.sendToSpace(meta.spaceId, { type: 'voice_server_muted', @@ -1129,6 +1179,26 @@ function handleVoiceServerDeafen(event: Record, userId: string) connectionManager.setServerDeafened(targetUserId, deafened); + // Persist to DB + const db = getDb(); + if (deafened) { + db.insert(schema.voiceRestrictions).values({ + spaceId: meta.spaceId, + userId: targetUserId, + restrictionType: 'deafen', + moderatorId: userId, + createdAt: Date.now(), + }).onConflictDoNothing().run(); + } else { + db.delete(schema.voiceRestrictions).where( + and( + eq(schema.voiceRestrictions.spaceId, meta.spaceId), + eq(schema.voiceRestrictions.userId, targetUserId), + eq(schema.voiceRestrictions.restrictionType, 'deafen'), + ) + ).run(); + } + connectionManager.sendToSpace(meta.spaceId, { type: 'voice_server_deafened', userId: targetUserId, diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index c6ed0ae8..2fdcd388 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -890,6 +890,19 @@ function buildReadyPayload(userId: string): { } } + // Also include the connecting user's own DB-persisted restrictions + // (covers reconnect after disconnect timeout cleared in-memory state) + const myRestrictions = db.select() + .from(schema.voiceRestrictions) + .where(eq(schema.voiceRestrictions.userId, userId)) + .all(); + for (const r of myRestrictions) { + const existing = serverVoiceStates[userId] ?? { serverMuted: false, serverDeafened: false }; + if (r.restrictionType === 'mute') existing.serverMuted = true; + if (r.restrictionType === 'deafen') existing.serverDeafened = true; + serverVoiceStates[userId] = existing; + } + // Fetch read states for unread tracking const readStateRows = db.select() .from(schema.readStates) diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 56828021..c1791c4a 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -167,12 +167,25 @@ function handleEvent(origin: string, event: ServerEvent): void { setVoiceUserStatus(uid, status.isMuted, status.isDeafened, status.isCameraOn, status.isScreenSharing); } } - // Populate server mute/deafen states - if (event.serverVoiceStates) { - const { setServerMutedUser, setServerDeafenedUser } = useVoiceStore.getState(); - for (const [uid, state] of Object.entries(event.serverVoiceStates as Record)) { - if (state.serverMuted) setServerMutedUser(uid, true); - if (state.serverDeafened) setServerDeafenedUser(uid, true); + // Clear stale server voice states before applying fresh from ready payload + { + const { clearServerVoiceStates, setServerMutedUser, setServerDeafenedUser } = useVoiceStore.getState(); + clearServerVoiceStates(); + if (event.serverVoiceStates) { + for (const [uid, state] of Object.entries(event.serverVoiceStates as Record)) { + if (state.serverMuted) setServerMutedUser(uid, true); + if (state.serverDeafened) setServerDeafenedUser(uid, true); + } + } + // Enforce local mute/deafen to match server restrictions (one-directional: only force-mute, never auto-unmute) + const myReadyId = useAuthStore.getState().user?.id; + if (myReadyId) { + const vs = useVoiceStore.getState(); + if (vs.serverDeafenedUserIds.has(myReadyId) && !vs.isDeafened) { + useVoiceStore.setState({ isMuted: true, isDeafened: true }); + } else if (vs.serverMutedUserIds.has(myReadyId) && !vs.isMuted) { + useVoiceStore.setState({ isMuted: true }); + } } } @@ -266,10 +279,6 @@ function handleEvent(origin: string, event: ServerEvent): void { addVoiceUser(event.channelId, event.userId); } else { removeVoiceUser(event.channelId, event.userId); - // Clear server mute/deafen state for departed user - const { setServerMutedUser, setServerDeafenedUser } = useVoiceStore.getState(); - setServerMutedUser(event.userId, false); - setServerDeafenedUser(event.userId, false); } break; @@ -280,14 +289,26 @@ function handleEvent(origin: string, event: ServerEvent): void { case 'voice_server_muted': { const { setServerMutedUser } = useVoiceStore.getState(); setServerMutedUser(event.userId, event.muted); - // If the local user was server-muted, force-mute the mic const myUserId = useAuthStore.getState().user?.id; - if (event.userId === myUserId && event.muted) { - const vs = useVoiceStore.getState(); - if (!vs.isMuted) { - vs.toggleMic(); - const voiceOrigin = vs.currentVoiceChannelId ? getChannelOrigin(vs.currentVoiceChannelId) : ''; - wsSend({ type: 'voice_status', isMuted: true, isDeafened: vs.isDeafened, isCameraOn: vs.isCameraOn, isScreenSharing: vs.isScreenSharing }, voiceOrigin); + if (event.userId === myUserId) { + if (event.muted) { + // Force-mute the mic + const vs = useVoiceStore.getState(); + if (!vs.isMuted) { + useVoiceStore.setState({ isMuted: true }); + const fresh = useVoiceStore.getState(); + const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : ''; + wsSend({ type: 'voice_status', isMuted: true, isDeafened: fresh.isDeafened, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin); + } + } else { + // Server unmuted — auto-restore mic unless still server-deafened + const vs = useVoiceStore.getState(); + if (!vs.serverDeafenedUserIds.has(myUserId) && vs.isMuted) { + useVoiceStore.setState({ isMuted: false }); + const fresh = useVoiceStore.getState(); + const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : ''; + wsSend({ type: 'voice_status', isMuted: false, isDeafened: fresh.isDeafened, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin); + } } } break; @@ -296,15 +317,52 @@ function handleEvent(origin: string, event: ServerEvent): void { case 'voice_server_deafened': { const { setServerDeafenedUser } = useVoiceStore.getState(); setServerDeafenedUser(event.userId, event.deafened); - // If the local user was server-deafened, force-deafen (smart toggle sets both muted+deafened) const myUid = useAuthStore.getState().user?.id; - if (event.userId === myUid && event.deafened) { - const vs = useVoiceStore.getState(); - if (!vs.isDeafened) { - vs.toggleDeafen(); - const fresh = useVoiceStore.getState(); - const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : ''; - wsSend({ type: 'voice_status', isMuted: true, isDeafened: true, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin); + if (event.userId === myUid) { + if (event.deafened) { + // Force-deafen (smart toggle sets both muted+deafened) + const vs = useVoiceStore.getState(); + if (!vs.isDeafened) { + useVoiceStore.setState({ isMuted: true, isDeafened: true }); + const fresh = useVoiceStore.getState(); + const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : ''; + wsSend({ type: 'voice_status', isMuted: true, isDeafened: true, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin); + // Broadcast deafen to in-room participants via LiveKit data channel + import('./useLiveKit').then(({ getActiveRoom }) => { + const room = getActiveRoom(); + if (room) { + const encoder = new TextEncoder(); + room.localParticipant.publishData( + encoder.encode(JSON.stringify({ type: 'deafen', deafened: true })), + { reliable: true } + ).catch(() => {}); + } + }); + } + } else { + // Server un-deafened — auto-restore + const vs = useVoiceStore.getState(); + if (vs.isDeafened) { + const stillServerMuted = vs.serverMutedUserIds.has(myUid); + useVoiceStore.setState({ + isDeafened: false, + ...(stillServerMuted ? {} : { isMuted: false }), + }); + const fresh = useVoiceStore.getState(); + const voiceOrigin = fresh.currentVoiceChannelId ? getChannelOrigin(fresh.currentVoiceChannelId) : ''; + wsSend({ type: 'voice_status', isMuted: fresh.isMuted, isDeafened: false, isCameraOn: fresh.isCameraOn, isScreenSharing: fresh.isScreenSharing }, voiceOrigin); + // Broadcast undeafen via LiveKit data channel + import('./useLiveKit').then(({ getActiveRoom }) => { + const room = getActiveRoom(); + if (room) { + const encoder = new TextEncoder(); + room.localParticipant.publishData( + encoder.encode(JSON.stringify({ type: 'deafen', deafened: false })), + { reliable: true } + ).catch(() => {}); + } + }); + } } } break; diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index fc0b33e1..3183d661 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -3,6 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware'; import type { ParticipantInfo } from '../hooks/useLiveKit'; import { AudioManager } from '../audio/AudioManager'; import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from './spaceStore'; +import { useAuthStore } from './authStore'; export interface ScreenShareConfig { height: 1080 | 720 | 540; @@ -239,6 +240,11 @@ export const useVoiceStore = create()( setOutputDevice: (deviceId) => set({ outputDeviceId: deviceId }), toggleMic: () => set((state) => { + // Server-muted/deafened users cannot unmute themselves + const myId = useAuthStore.getState().user?.id; + if (myId && state.isMuted && (state.serverMutedUserIds.has(myId) || state.serverDeafenedUserIds.has(myId))) { + return {}; + } if (state.isMuted && state.isDeafened) { // Unmuting while deafened → clear both (Discord behavior) return { isMuted: false, isDeafened: false }; @@ -246,6 +252,11 @@ export const useVoiceStore = create()( return { isMuted: !state.isMuted }; }), toggleDeafen: () => set((state) => { + // Server-deafened users cannot undeafen themselves + const myId = useAuthStore.getState().user?.id; + if (myId && state.isDeafened && state.serverDeafenedUserIds.has(myId)) { + return {}; + } if (state.isDeafened) { // Undeafening → clear both return { isMuted: false, isDeafened: false }; @@ -358,8 +369,6 @@ export const useVoiceStore = create()( streamVolumes: new Map(), streamMutes: new Map(), watchingStreams: new Set(), - serverMutedUserIds: new Set(), - serverDeafenedUserIds: new Set(), voiceUsers, }; }); @@ -397,7 +406,7 @@ export const useVoiceStore = create()( }), { name: 'backspace-voice-settings', - version: 6, + version: 7, migrate: (persistedState: any, version: number) => { if (version === 0) { persistedState.streamAttenuationEnabled = false; @@ -427,6 +436,9 @@ export const useVoiceStore = create()( persistedState.screenShareConfig.customBitrateKbps = null; } } + if (version < 7) { + // No data migration needed — Sets will be populated from server on next connect + } return persistedState; }, storage: createJSONStorage(() => localStorage), @@ -447,7 +459,26 @@ export const useVoiceStore = create()( rnnoiseEnabled: state.rnnoiseEnabled, streamAttenuationEnabled: state.streamAttenuationEnabled, streamAttenuationStrength: state.streamAttenuationStrength, + _serverMutedArr: [...state.serverMutedUserIds], + _serverDeafenedArr: [...state.serverDeafenedUserIds], }), + merge: (persistedState: any, currentState: VoiceState) => { + const merged = { ...currentState, ...persistedState }; + // Reconstruct Sets from persisted arrays (Sets aren't JSON-serializable) + merged.serverMutedUserIds = new Set(persistedState?._serverMutedArr ?? []); + merged.serverDeafenedUserIds = new Set(persistedState?._serverDeafenedArr ?? []); + // Reconstruct non-persisted Sets/Maps to their defaults + merged.voiceUsers = currentState.voiceUsers; + merged.participants = currentState.participants; + merged.speakingParticipantIds = currentState.speakingParticipantIds; + merged.deafenedUserIds = currentState.deafenedUserIds; + merged.voiceUserStates = currentState.voiceUserStates; + merged.participantVolumes = currentState.participantVolumes; + merged.streamVolumes = currentState.streamVolumes; + merged.streamMutes = currentState.streamMutes; + merged.watchingStreams = currentState.watchingStreams; + return merged; + }, } ) );