Files
backspace/packages/web/src/utils/voiceStateSync.test.ts
T
Jannis Braun e84daf57aa 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).
2026-06-30 17:00:37 +02:00

58 lines
2.1 KiB
TypeScript

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);
});
});