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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user