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