fix(voice): push voice presence to user on mid-session space join

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).
This commit is contained in:
Jannis Braun
2026-06-30 17:00:37 +02:00
parent f807524103
commit e84daf57aa
9 changed files with 510 additions and 42 deletions
+1 -1
View File
@@ -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
+12
View File
@@ -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).
+9
View File
@@ -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.
+114 -41
View File
@@ -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<string> {
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<string, string[]>;
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }>;
} {
const db = getDb();
const voiceStates: Record<string, string[]> = {};
const voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> = {};
const spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }> = {};
// 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<string, string[]> = {};
const spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }> = {};
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<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }> = {};
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)
@@ -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<typeof drizzle<typeof schema>>;
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<typeof vi.fn>;
}
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);
});
});
+1
View File
@@ -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<string, string[]>; voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }> }
| { type: 'dm_channel_created'; dmChannel: DmChannel }
| { type: 'dm_channel_closed'; dmChannelId: string }
| { type: 'dm_channel_updated'; dmChannelId: string; name: string | null; icon: string | null }
+10
View File
@@ -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);
@@ -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);
});
});
+63
View File
@@ -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<string, string[]>;
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }>;
}
/**
* 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,
});
}