From e84daf57aa323806a92390cda4eea1c0a2798fd4 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:00:37 +0200 Subject: [PATCH] fix(voice): push voice presence to user on mid-session space join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voice presence (voiceStates/voiceUserStates/spaceVoiceStates) was only ever delivered in the WS `ready` payload — i.e. at connect/reload. A user joining a space mid-session got `member_joined` (no voice data) and a bare space object; `GET /api/spaces/:id` (the channel-sidebar hydrator) carries no voice state either. So members already sitting in a voice channel stayed invisible in the new member's sidebar until a full page reload. Fix at the systemic root: ConnectionManager.addUserSpace — the single chokepoint every join path funnels through (invite, public join, join-request approval), and which is NOT used on reconnect (that path uses setUserSpaces) — now pushes a scoped `space_voice_state` snapshot to the joining user. The snapshot is built by a new buildSpaceVoiceState(spaceId, userId) helper that is also the single source of truth feeding buildReadyPayload (refactored to use it), so the connect-time and join-time paths can never drift. Robustness: - Delivered over the same ordered WebSocket as voice_state_update deltas — no REST snapshot-vs-event-stream race. - VIEW_CHANNEL-filtered via computePermissions exactly like `ready`: a joiner is never told who occupies a voice channel they cannot see. - Client applies it scoped to the space (utils/voiceStateSync.applySpaceVoiceState): merges occupants/statuses and rebuilds only that space's restriction keys, never disturbing voice state in other spaces. - Skipped when the space has no active voice and no restrictions (e.g. space creation). Tests: server helper behavior, the join push, and private-channel exclusion; client scoped-apply. Specs updated (websocket.md, voice.md, spaces.md). --- docs/systems/spaces.md | 2 +- docs/systems/voice.md | 12 + docs/systems/websocket.md | 9 + packages/server/src/ws/handler.ts | 155 ++++++++--- .../server/src/ws/spaceVoiceState.test.ts | 243 ++++++++++++++++++ packages/shared/src/types.ts | 1 + packages/web/src/hooks/useWebSocket.ts | 10 + packages/web/src/utils/voiceStateSync.test.ts | 57 ++++ packages/web/src/utils/voiceStateSync.ts | 63 +++++ 9 files changed, 510 insertions(+), 42 deletions(-) create mode 100644 packages/server/src/ws/spaceVoiceState.test.ts create mode 100644 packages/web/src/utils/voiceStateSync.test.ts create mode 100644 packages/web/src/utils/voiceStateSync.ts diff --git a/docs/systems/spaces.md b/docs/systems/spaces.md index be64861b..531f25c3 100644 --- a/docs/systems/spaces.md +++ b/docs/systems/spaces.md @@ -170,7 +170,7 @@ Two endpoints serve the same purpose: **Side effects:** 1. Insert `space_members` row -2. `connectionManager.addUserSpace` for WS broadcasts +2. `connectionManager.addUserSpace` for WS broadcasts — also pushes a scoped `space_voice_state` snapshot to the joining user so voice-channel occupants appear without a reload (see `docs/systems/websocket.md` → "Mid-session space join") 3. `member_joined` WS event broadcast to space 4. Response: `Space` object diff --git a/docs/systems/voice.md b/docs/systems/voice.md index 873935ab..63230734 100644 --- a/docs/systems/voice.md +++ b/docs/systems/voice.md @@ -17,6 +17,18 @@ Source files: 5. Client calls `POST /api/livekit/token { channelId }` → gets JWT + LiveKit URL 6. Client connects to LiveKit room with token +### Voice presence bootstrap on mid-session space join + +A client learns who is sitting in a space's voice channels from the WS `ready` +payload at connect time. Joining a space *without reloading* therefore needs the +same bootstrap for the new space, or its voice channels render empty until a +refresh. The server pushes a scoped `space_voice_state` snapshot from +`ConnectionManager.addUserSpace` (the single join chokepoint), built by +`buildSpaceVoiceState(spaceId, userId)` — the same VIEW_CHANNEL-filtered helper +that feeds `ready`. The client applies it via `utils/voiceStateSync.applySpaceVoiceState`. +See `docs/systems/websocket.md` → "Mid-session space join" for the full rationale +(single ordered channel, no snapshot-vs-stream race). + ### Microphone pre-arm (iOS user-gesture discipline) `utils/voice.joinVoiceChannel` fires `AudioContext.resume()` and `AudioManager.setInputDevice(inputDeviceId)` (which ends in `getUserMedia({audio:…})`) **synchronously inside** the click handler, before the `connectFn(channelId)` call. iOS Safari only surfaces the microphone permission prompt when `getUserMedia` is invoked from inside an active user-gesture; the original flow only acquired the mic in `useLiveKit`'s `syncMic` effect, which fires AFTER `room.connect()` resolves (token fetch + WS handshake) — many awaits past the gesture window. iOS PWA standalone is especially strict and would silently never surface the prompt; the user would see "Waiting for others to join…" indefinitely until they locked/unlocked the device (which iOS treats as a fresh activation). diff --git a/docs/systems/websocket.md b/docs/systems/websocket.md index 70394d17..bd0ae788 100644 --- a/docs/systems/websocket.md +++ b/docs/systems/websocket.md @@ -156,6 +156,7 @@ Source: `packages/server/src/ws/handler.ts`, `packages/server/src/ws/events.ts` |------|--------|-------| | `voice_state_update` | channelId, userId, action: join/leave | space | | `voice_status_update` | userId, channelId, isMuted, isDeafened, isCameraOn, isScreenSharing | room | +| `space_voice_state` | spaceId, voiceStates, voiceUserStates, spaceVoiceStates | the joining user. Scoped per-space voice-presence snapshot pushed when a user joins a space mid-session (see below). | | `voice_space_muted` | userId, channelId, spaceId, muted | space | | `voice_space_deafened` | userId, channelId, spaceId, deafened | space | | `voice_permission_muted` | userId, spaceId, muted | space | @@ -226,3 +227,11 @@ reason: `'displaced'` (new tab) | `'session_closed'` ``` **Federation filtering:** When the connecting user is federated (`homeInstance` is set), the server omits all DM-related data from the ready payload. `dmChannels` and `activeCalls` are sent as empty arrays, and `readStates` is filtered to only include space channel entries. Federated users receive their DM data from their home instance's ready payload instead. + +**Voice-state assembly:** `voiceStates` / `voiceUserStates` / `spaceVoiceStates` for each of the user's spaces are produced by `ConnectionManager.buildSpaceVoiceState(spaceId, userId)` — the single source of truth shared with the mid-session join push (see below). Voice presence is VIEW_CHANNEL-filtered per `computePermissions`: a user is never told who occupies a voice channel they cannot see. + +### Mid-session space join — `space_voice_state` push + +The `ready` payload is the **only** carrier of voice presence at connect time. When a user joins a space *mid-session* (invite, public join, or join-request approval) without reloading, they would otherwise see empty voice channels until a refresh, because `member_joined` carries no voice state and `GET /api/spaces/:id` (the channel-sidebar hydrator) has none either. + +To close this, `ConnectionManager.addUserSpace(userId, spaceId)` — the single chokepoint every join path funnels through, and which is **not** used on reconnect (that path uses `setUserSpaces`) — builds the same per-space snapshot via `buildSpaceVoiceState` and pushes it to the joining user as a `space_voice_state` event. Delivery rides the same ordered WebSocket as the `voice_state_update` deltas, so there is no snapshot-vs-stream race. The push is skipped when the space has no active voice and no restrictions (e.g. space creation). The client applies it scoped to `spaceId` (`utils/voiceStateSync.applySpaceVoiceState`): it merges occupants/statuses and rebuilds only that space's restriction keys, never disturbing voice state in other spaces. diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 272c50f2..d740730b 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -392,12 +392,116 @@ class ConnectionManager { this.userSpaces.set(userId, new Set()); } this.userSpaces.get(userId)!.add(spaceId); + + // A user joining a space mid-session must be bootstrapped with that space's + // current voice presence. The `ready` payload only carries voice state at + // connect time (see buildReadyPayload), so without this push, members already + // sitting in a voice channel stay invisible in the new member's channel + // sidebar until a full page reload. We deliver a scoped snapshot over the same + // ordered WebSocket as the `voice_state_update` deltas, so there is no + // snapshot-vs-stream race (a join/leave that happens after this snapshot is + // emitted strictly afterwards on the same socket). `addUserSpace` is the single + // chokepoint every join path funnels through (invite, public join, join-request + // approval) and is NOT used on reconnect (that path uses setUserSpaces), so this + // fires exactly once per genuine join. Space creation hits this too but produces + // an empty snapshot and is skipped below. + const snapshot = this.buildSpaceVoiceState(spaceId, userId); + if (Object.keys(snapshot.voiceStates).length === 0 + && Object.keys(snapshot.spaceVoiceStates).length === 0) { + return; + } + this.sendToUser(userId, { + type: 'space_voice_state', + spaceId, + voiceStates: snapshot.voiceStates, + voiceUserStates: snapshot.voiceUserStates, + spaceVoiceStates: snapshot.spaceVoiceStates, + }); } getUserSpaces(userId: string): Set { return this.userSpaces.get(userId) ?? new Set(); } + /** + * Build the current voice-presence snapshot for a single space, from the + * perspective of `userId`: + * - which voice channels the user can VIEW have participants, and who they are, + * - each participant's per-user status (mute/deafen/camera/screenshare), + * - space-level mute/deafen (persisted) + permission-mute (ephemeral) + * restrictions, keyed `spaceId:userId`. + * + * Voice presence is VIEW_CHANNEL-filtered per `computePermissions` exactly as + * `buildReadyPayload` does — a user must never learn who is sitting in a voice + * channel they cannot see. + * + * Single source of truth shared by `buildReadyPayload` (connect-time bootstrap, + * looped across all of a user's spaces) and `addUserSpace` (mid-session join + * push). Keep these two consumers in sync by changing only this method. + */ + buildSpaceVoiceState(spaceId: string, userId: string): { + voiceStates: Record; + voiceUserStates: Record; + spaceVoiceStates: Record; + } { + const db = getDb(); + const voiceStates: Record = {}; + const voiceUserStates: Record = {}; + const spaceVoiceStates: Record = {}; + + // Who is currently in each of this space's voice channels the user can VIEW. + const voiceChannels = db.select({ id: schema.channels.id }) + .from(schema.channels) + .where(and(eq(schema.channels.spaceId, spaceId), eq(schema.channels.type, 'voice'))) + .all(); + for (const ch of voiceChannels) { + const chPerms = computePermissions(userId, spaceId, ch.id); + const hasView = (chPerms & PermissionBits.VIEW_CHANNEL) !== 0n || (chPerms & PermissionBits.ADMINISTRATOR) !== 0n; + if (!hasView) continue; + const participants = this.getRoomParticipants(ch.id); + if (participants.size > 0) { + const ids = Array.from(participants); + voiceStates[ch.id] = ids; + for (const uid of ids) { + const status = this.getVoiceUserStatus(uid); + if (status) voiceUserStates[uid] = status; + } + } + } + + // Space mute/deafen — persisted, authoritative (survives reconnect). These are + // space-level flags (they do not reveal which channel a user is in), so they + // are not channel-filtered, mirroring buildReadyPayload. + const restrictions = db.select() + .from(schema.voiceRestrictions) + .where(eq(schema.voiceRestrictions.spaceId, spaceId)) + .all(); + for (const r of restrictions) { + const key = `${r.spaceId}:${r.userId}`; + const existing = spaceVoiceStates[key] ?? { spaceMuted: false, spaceDeafened: false, permissionMuted: false }; + if (r.restrictionType === 'mute') existing.spaceMuted = true; + if (r.restrictionType === 'deafen') existing.spaceDeafened = true; + spaceVoiceStates[key] = existing; + } + // Permission-mute — ephemeral, derived from in-memory state for every + // participant currently in this space's voice rooms (mirrors buildReadyPayload). + for (const [, room] of this.voiceRooms) { + if (room.roomType !== 'space') continue; + const meta = room.metadata as SpaceRoomMeta; + if (meta.spaceId !== spaceId) continue; + for (const participantId of room.participants) { + if (this.isPermissionMuted(spaceId, participantId)) { + const key = `${spaceId}:${participantId}`; + const existing = spaceVoiceStates[key] ?? { spaceMuted: false, spaceDeafened: false, permissionMuted: false }; + existing.permissionMuted = true; + spaceVoiceStates[key] = existing; + } + } + } + + return { voiceStates, voiceUserStates, spaceVoiceStates }; + } + // ─── Unified VoiceRoom API ───────────────────────────────────────────────── /** Create a room. Returns false if room already exists. */ @@ -1414,18 +1518,18 @@ function buildReadyPayload(userId: string): { const spaceLayout: SpaceLayoutItem[] | null = layoutRow ? JSON.parse(layoutRow.layout) : null; const layoutUpdatedAt: number | null = layoutRow?.updatedAt ?? null; - // Build voice states — tell the client who is currently in voice channels - // across all their spaces + // Build voice states — who is currently in voice channels, plus space mute/ + // deafen and permission-mute, across all the user's spaces. Delegates to the + // shared per-space helper (also used for the mid-session join push in + // ConnectionManager.addUserSpace) so the two code paths can never diverge. + // The helper applies the same VIEW_CHANNEL filtering used when building the + // `spaces` array above. const voiceStates: Record = {}; + const spaceVoiceStates: Record = {}; for (const space of spaces) { - for (const ch of space.channels) { - if (ch.type === 'voice') { - const participants = connectionManager.getRoomParticipants(ch.id); - if (participants.size > 0) { - voiceStates[ch.id] = Array.from(participants); - } - } - } + const snap = connectionManager.buildSpaceVoiceState(space.id, userId); + Object.assign(voiceStates, snap.voiceStates); + Object.assign(spaceVoiceStates, snap.spaceVoiceStates); } // Build active calls from user's DM memberships @@ -1488,37 +1592,6 @@ function buildReadyPayload(userId: string): { } } - // 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 - const spaceVoiceStates: Record = {}; - if (spaceIds.length > 0) { - const allRestrictions = db.select() - .from(schema.voiceRestrictions) - .where(inArray(schema.voiceRestrictions.spaceId, spaceIds)) - .all(); - for (const r of allRestrictions) { - const key = `${r.spaceId}:${r.userId}`; - const existing = spaceVoiceStates[key] ?? { spaceMuted: false, spaceDeafened: false, permissionMuted: false }; - if (r.restrictionType === 'mute') existing.spaceMuted = true; - if (r.restrictionType === 'deafen') existing.spaceDeafened = true; - spaceVoiceStates[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 = spaceVoiceStates[key] ?? { spaceMuted: false, spaceDeafened: false, permissionMuted: false }; - existing.permissionMuted = true; - spaceVoiceStates[key] = existing; - } - } - } - } - // Fetch read states for unread tracking const readStateRows = db.select() .from(schema.readStates) diff --git a/packages/server/src/ws/spaceVoiceState.test.ts b/packages/server/src/ws/spaceVoiceState.test.ts new file mode 100644 index 00000000..cdc3c615 --- /dev/null +++ b/packages/server/src/ws/spaceVoiceState.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; +import { PermissionBits, permissionsToString } from '../utils/permissions.js'; + +setWorkerId(1); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +type TestDb = ReturnType>; + +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + schema, +})); + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sql = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sql.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedUser(id: string): void { + testDb.insert(schema.users).values({ + id, + username: id, + passwordHash: 'x', + homeUserId: id, + homeInstance: null, + createdAt: Date.now(), + }).run(); +} + +function seedSpace(spaceId: string): void { + seedUser('owner'); + testDb.insert(schema.spaces).values({ + id: spaceId, + name: 'Test Space', + ownerId: 'owner', + createdAt: Date.now(), + }).run(); +} + +function seedChannel(id: string, spaceId: string, type: 'text' | 'voice'): void { + testDb.insert(schema.channels).values({ + id, + spaceId, + name: type, + type, + position: 0, + createdAt: Date.now(), + }).run(); +} + +// @everyone role (id === spaceId) granting VIEW_CHANNEL, so non-owner members can +// see the space's channels (mirrors real space creation). +function seedEveryoneRole(spaceId: string): void { + testDb.insert(schema.roles).values({ + id: spaceId, + spaceId, + name: '@everyone', + color: '#b9bbbe', + position: 0, + permissions: permissionsToString(PermissionBits.VIEW_CHANNEL), + createdAt: Date.now(), + }).run(); +} + +// Make a channel private by denying VIEW_CHANNEL to @everyone (role override). +function seedDenyViewOverride(channelId: string, spaceId: string): void { + testDb.insert(schema.channelOverrides).values({ + channelId, + targetType: 'role', + targetId: spaceId, + allow: '0', + deny: permissionsToString(PermissionBits.VIEW_CHANNEL), + }).run(); +} + +function seedRestriction(spaceId: string, userId: string, restrictionType: 'mute' | 'deafen'): void { + testDb.insert(schema.voiceRestrictions).values({ + spaceId, + userId, + restrictionType, + createdAt: Date.now(), + }).run(); +} + +async function importManager() { + const mod = await import('./handler.js'); + return mod.connectionManager; +} + +interface FakeWs { + readyState: number; + send: ReturnType; +} + +function fakeWs(): FakeWs { + return { readyState: 1, send: vi.fn() }; +} + +beforeEach(() => { + const sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('connectionManager.buildSpaceVoiceState', () => { + it('returns participants, statuses, space-mute and permission-mute for one space', async () => { + const cm = await importManager(); + const spaceId = 'sp-build-1'; + const voiceCh = 'vc-build-1'; + const textCh = 'tc-build-1'; + seedSpace(spaceId); + seedChannel(voiceCh, spaceId, 'voice'); + seedChannel(textCh, spaceId, 'text'); + + // Two users actively connected to the voice channel. + cm.createRoom(voiceCh, 'space', { type: 'space', spaceId }); + cm.joinRoom(voiceCh, 'u-muted'); + cm.joinRoom(voiceCh, 'u-perm'); + cm.setVoiceUserStatus('u-muted', true, false, false, false); + cm.setVoiceUserStatus('u-perm', false, false, true, false); + + // u-muted is space-muted (persisted), u-perm is permission-muted (ephemeral). + seedUser('u-muted'); + seedRestriction(spaceId, 'u-muted', 'mute'); + cm.setPermissionMuted(spaceId, 'u-perm', true); + + // Query as the space owner (sees every channel). + const snap = cm.buildSpaceVoiceState(spaceId, 'owner'); + + expect(snap.voiceStates[voiceCh]?.sort()).toEqual(['u-muted', 'u-perm']); + // Text channels never appear. + expect(snap.voiceStates[textCh]).toBeUndefined(); + + expect(snap.voiceUserStates['u-muted']).toEqual({ isMuted: true, isDeafened: false, isCameraOn: false, isScreenSharing: false }); + expect(snap.voiceUserStates['u-perm']).toEqual({ isMuted: false, isDeafened: false, isCameraOn: true, isScreenSharing: false }); + + expect(snap.spaceVoiceStates[`${spaceId}:u-muted`]?.spaceMuted).toBe(true); + expect(snap.spaceVoiceStates[`${spaceId}:u-perm`]?.permissionMuted).toBe(true); + }); + + it('returns empty maps for a space with no active voice participants', async () => { + const cm = await importManager(); + const spaceId = 'sp-build-empty'; + seedSpace(spaceId); + seedChannel('vc-empty', spaceId, 'voice'); + + const snap = cm.buildSpaceVoiceState(spaceId, 'owner'); + expect(Object.keys(snap.voiceStates)).toHaveLength(0); + expect(Object.keys(snap.voiceUserStates)).toHaveLength(0); + expect(Object.keys(snap.spaceVoiceStates)).toHaveLength(0); + }); + + it('excludes voice channels the viewing user cannot VIEW (private channels)', async () => { + const cm = await importManager(); + const spaceId = 'sp-private-1'; + const publicCh = 'vc-public-1'; + const privateCh = 'vc-private-1'; + seedSpace(spaceId); + seedEveryoneRole(spaceId); + seedChannel(publicCh, spaceId, 'voice'); + seedChannel(privateCh, spaceId, 'voice'); + seedDenyViewOverride(privateCh, spaceId); + + cm.createRoom(publicCh, 'space', { type: 'space', spaceId }); + cm.joinRoom(publicCh, 'u-in-public'); + cm.createRoom(privateCh, 'space', { type: 'space', spaceId }); + cm.joinRoom(privateCh, 'u-in-private'); + + // 'u-viewer' is a plain @everyone member (no special roles, not the owner). + const snap = cm.buildSpaceVoiceState(spaceId, 'u-viewer'); + + expect(snap.voiceStates[publicCh]).toEqual(['u-in-public']); + expect(snap.voiceStates[privateCh]).toBeUndefined(); + // The hidden channel's occupant must not leak through voiceUserStates either. + expect(snap.voiceUserStates['u-in-private']).toBeUndefined(); + }); +}); + +describe('connectionManager.addUserSpace voice-state push', () => { + it('pushes space_voice_state to the joining user when the space has active voice', async () => { + const cm = await importManager(); + const spaceId = 'sp-push-1'; + const voiceCh = 'vc-push-1'; + seedSpace(spaceId); + seedEveryoneRole(spaceId); + seedChannel(voiceCh, spaceId, 'voice'); + + cm.createRoom(voiceCh, 'space', { type: 'space', spaceId }); + cm.joinRoom(voiceCh, 'u-already-here'); + cm.setVoiceUserStatus('u-already-here', false, false, false, false); + + const ws = fakeWs(); + cm.addConnection('u-joiner', ws as never); + + cm.addUserSpace('u-joiner', spaceId); + + const frames = ws.send.mock.calls + .map((c) => JSON.parse(c[0] as string)) + .filter((e) => e.type === 'space_voice_state'); + expect(frames).toHaveLength(1); + expect(frames[0].spaceId).toBe(spaceId); + expect(frames[0].voiceStates[voiceCh]).toEqual(['u-already-here']); + expect(frames[0].voiceUserStates['u-already-here']).toBeDefined(); + }); + + it('does not push a frame when the joined space has no active voice', async () => { + const cm = await importManager(); + const spaceId = 'sp-push-empty'; + seedSpace(spaceId); + seedChannel('vc-push-empty', spaceId, 'voice'); + + const ws = fakeWs(); + cm.addConnection('u-joiner-2', ws as never); + + cm.addUserSpace('u-joiner-2', spaceId); + + const frames = ws.send.mock.calls + .map((c) => JSON.parse(c[0] as string)) + .filter((e) => e.type === 'space_voice_state'); + expect(frames).toHaveLength(0); + }); +}); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 5fc261bd..e4202f71 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -442,6 +442,7 @@ export type ServerEvent = | { type: 'dm_call_ended'; dmChannelId: string } | { type: 'dm_call_undeliverable'; dmChannelId: string | null; federatedCallId: string; terminal: boolean; phase: DmCallPhase; failures: DmCallUndeliverableFailure[] } | { type: 'voice_status_update'; userId: string; channelId: string; isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean } + | { type: 'space_voice_state'; spaceId: string; voiceStates: Record; voiceUserStates: Record; spaceVoiceStates: Record } | { type: 'dm_channel_created'; dmChannel: DmChannel } | { type: 'dm_channel_closed'; dmChannelId: string } | { type: 'dm_channel_updated'; dmChannelId: string; name: string | null; icon: string | null } diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 9e8826d8..b4e8b718 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -8,6 +8,7 @@ import { useSettingsStore } from '../stores/settingsStore'; import type { ServerEvent, ClientEvent, ActiveCallInfo, Activity, User } from '@backspace/shared'; import { resolveAssetUrl, normalizeUserAssets, normalizeMessageAssets } from '../utils/assetUrls'; import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../utils/voice'; +import { applySpaceVoiceState } from '../utils/voiceStateSync'; import { sortDmChannels } from '../utils/dmSorting'; import { registerSelfId } from '../utils/identity'; import { getActiveRoom } from './useLiveKit'; @@ -550,6 +551,15 @@ function handleEvent(origin: string, event: ServerEvent): void { setVoiceUserStatus(event.userId, event.isMuted, event.isDeafened, event.isCameraOn, event.isScreenSharing); break; + case 'space_voice_state': + // A space the user just joined mid-session — bootstrap its current voice + // presence (occupants, statuses, space/permission mutes). The `ready` + // payload only carries this at connect time, so without it the new + // member's channel sidebar shows empty voice channels until a reload. + // Scoped to event.spaceId; never disturbs other spaces' live voice state. + applySpaceVoiceState(event); + break; + case 'voice_space_muted': { const { setSpaceMutedUser } = useVoiceStore.getState(); setSpaceMutedUser(event.spaceId, event.userId, event.muted); diff --git a/packages/web/src/utils/voiceStateSync.test.ts b/packages/web/src/utils/voiceStateSync.test.ts new file mode 100644 index 00000000..40fb5683 --- /dev/null +++ b/packages/web/src/utils/voiceStateSync.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Stub AudioManager — voiceStore imports it and AudioWorkletNode is absent in jsdom. +vi.mock('../audio/AudioManager', () => ({ + AudioManager: { + getInstance: vi.fn().mockReturnValue({ + setInputVolume: vi.fn(), + setOutputDevice: vi.fn(), + setVolume: vi.fn(), + }), + }, +})); + +import { useVoiceStore } from '../stores/voiceStore'; +import { applySpaceVoiceState } from './voiceStateSync'; + +beforeEach(() => { + useVoiceStore.getState().reset(); +}); + +describe('applySpaceVoiceState', () => { + it('populates voiceUsers, voiceUserStates and scoped restriction sets', () => { + applySpaceVoiceState({ + spaceId: 'sp1', + voiceStates: { ch1: ['uA', 'uB'] }, + voiceUserStates: { uA: { isMuted: true, isDeafened: false, isCameraOn: false, isScreenSharing: false } }, + spaceVoiceStates: { + 'sp1:uA': { spaceMuted: true, spaceDeafened: false, permissionMuted: false }, + 'sp1:uB': { spaceMuted: false, spaceDeafened: false, permissionMuted: true }, + }, + }); + + const s = useVoiceStore.getState(); + expect(s.getVoiceUsers('ch1')).toEqual(['uA', 'uB']); + expect(s.voiceUserStates.get('uA')).toEqual({ isMuted: true, isDeafened: false, isCameraOn: false, isScreenSharing: false }); + expect(s.spaceMutedUserIds.has('sp1:uA')).toBe(true); + expect(s.permissionMutedUserIds.has('sp1:uB')).toBe(true); + }); + + it('refreshes only its own space, leaving other spaces untouched', () => { + useVoiceStore.setState({ + spaceMutedUserIds: new Set(['sp-other:uX', 'sp1:uStale']), + }); + + applySpaceVoiceState({ + spaceId: 'sp1', + voiceStates: {}, + voiceUserStates: {}, + spaceVoiceStates: { 'sp1:uNew': { spaceMuted: true, spaceDeafened: false, permissionMuted: false } }, + }); + + const s = useVoiceStore.getState(); + expect(s.spaceMutedUserIds.has('sp-other:uX')).toBe(true); // untouched + expect(s.spaceMutedUserIds.has('sp1:uStale')).toBe(false); // cleared (authoritative re-sync) + expect(s.spaceMutedUserIds.has('sp1:uNew')).toBe(true); + }); +}); diff --git a/packages/web/src/utils/voiceStateSync.ts b/packages/web/src/utils/voiceStateSync.ts new file mode 100644 index 00000000..dd221abf --- /dev/null +++ b/packages/web/src/utils/voiceStateSync.ts @@ -0,0 +1,63 @@ +import { useVoiceStore } from '../stores/voiceStore'; + +/** + * Snapshot of a single space's voice presence, delivered by the server's + * `space_voice_state` WebSocket event when the user joins a space mid-session. + * Mirrors the per-space slice of the `ready` payload (see server + * `ConnectionManager.buildSpaceVoiceState`). + */ +export interface SpaceVoiceStateSnapshot { + spaceId: string; + voiceStates: Record; + voiceUserStates: Record; + spaceVoiceStates: Record; +} + +/** + * Apply a `space_voice_state` snapshot to the voice store. + * + * Scoped strictly to `snapshot.spaceId`: voice-channel occupants and per-user + * statuses are merged in (channel IDs are globally unique, so this never + * collides with other spaces), and the space-level restriction sets + * (`spaceMuted`/`spaceDeafened`/`permissionMuted`) are rebuilt for THIS space + * only — keys for other spaces are left untouched. This makes the apply + * idempotent and authoritative for the joined space without disturbing live + * voice state elsewhere (e.g. a channel the user is actively sitting in). + * + * The `ready` handler bootstraps the same data per-origin at connect time; this + * is the mid-session join counterpart and deliberately does NOT clear by origin. + */ +export function applySpaceVoiceState(snapshot: SpaceVoiceStateSnapshot): void { + const { setVoiceUsers, setVoiceUserStatus } = useVoiceStore.getState(); + + for (const [channelId, userIds] of Object.entries(snapshot.voiceStates)) { + setVoiceUsers(channelId, userIds); + } + for (const [userId, status] of Object.entries(snapshot.voiceUserStates)) { + setVoiceUserStatus(userId, status.isMuted, status.isDeafened, status.isCameraOn, status.isScreenSharing); + } + + const vs = useVoiceStore.getState(); + const nextSpaceMuted = new Set(vs.spaceMutedUserIds); + const nextSpaceDeafened = new Set(vs.spaceDeafenedUserIds); + const nextPermissionMuted = new Set(vs.permissionMutedUserIds); + + // Restriction Sets are keyed `spaceId:userId`. Drop this space's existing keys + // so a re-sync is authoritative, then re-add from the snapshot. + const prefix = `${snapshot.spaceId}:`; + for (const key of [...nextSpaceMuted]) if (key.startsWith(prefix)) nextSpaceMuted.delete(key); + for (const key of [...nextSpaceDeafened]) if (key.startsWith(prefix)) nextSpaceDeafened.delete(key); + for (const key of [...nextPermissionMuted]) if (key.startsWith(prefix)) nextPermissionMuted.delete(key); + + for (const [key, state] of Object.entries(snapshot.spaceVoiceStates)) { + if (state.spaceMuted) nextSpaceMuted.add(key); + if (state.spaceDeafened) nextSpaceDeafened.add(key); + if (state.permissionMuted) nextPermissionMuted.add(key); + } + + useVoiceStore.setState({ + spaceMutedUserIds: nextSpaceMuted, + spaceDeafenedUserIds: nextSpaceDeafened, + permissionMutedUserIds: nextPermissionMuted, + }); +}