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