fix(voice): consistent state on DM-call ↔ space-channel transitions

Two mirror-image bugs from voice/DM-call transitions leaving stale state.

DM call → space channel (stuck "Connecting…"):
The last participant to leave a DM call for a space channel receives a
`dm_call_ended` echo (server empties the DM room on their `voice_join`).
The handlers called `disconnectFn()` unconditionally, tearing down the
space room they had just connected to. Route `dm_call_ended` /
`dm_call_rejected` / terminal `dm_call_undeliverable` through a new
`teardownDmCall()` that only disconnects LiveKit when not in a space
channel (`currentVoiceChannelId` null).

Space channel → DM call (still shown as "in" the voice channel):
1. Entering a DM call never cleared `currentVoiceChannelId`, so
   `VoiceChannel` mapped the DM call's live LiveKit participants onto the
   old space channel. Add `clearSpaceVoiceForDmCall()`, called in
   `connect()` when `isDm`, restoring the invariant that a DM call has no
   `currentVoiceChannelId`.
2. `dm_call_accepted` gated the caller's connect on `!isLiveKitConnected`,
   so a caller already in a space channel was never connected to the DM
   room. Gate on `wasOutgoingCall` only (connect() de-dupes same-room).

Tests: teardownDmCall.test.ts, clearSpaceVoiceForDmCall.test.ts.
Docs: docs/systems/voice.md.
This commit is contained in:
Jannis Braun
2026-07-01 02:08:53 +02:00
parent 7a3d892c6e
commit ac20f22c72
6 changed files with 236 additions and 20 deletions
+6
View File
@@ -169,6 +169,12 @@ When `findOrCreateDmChannel` creates a local DM channel during an active federat
**Passive ready handler:** On page refresh/restart, the ready payload includes active calls but the client does NOT auto-connect to LiveKit. Users must re-accept. This prevents identity slot wars when the same user has multiple sessions.
**A DM call has no `currentVoiceChannelId` (space↔DM are mutually exclusive).** Entering a space channel clears `activeDmCall` (`setCurrentVoiceChannel`); entering a DM call must clear `currentVoiceChannelId`. The latter is done by `clearSpaceVoiceForDmCall()` (`utils/voice.ts`), invoked synchronously at the top of `connect()` when `isDm`. Without it, `VoiceChannel` renders the occupant list for `currentVoiceChannelId` from the **live LiveKit participants**, so a lingering space `currentVoiceChannelId` maps the DM call's participants onto the old space channel — the caller/acceptor appears to still be sitting in it. The server already drops the user from the space room (`dm_call_start` / `dm_call_accept``leaveCurrentRoom``broadcastRoomLeave`), so this is a client-state fix; it also optimistically removes self from the old channel's `voiceUsers` for an immediate sidebar update. Regression test: `utils/clearSpaceVoiceForDmCall.test.ts`.
**Caller connect guard.** In `dm_call_accepted`, the caller connects to the DM room gated on `wasOutgoingCall` (only the initiating session ever sets `outgoingCall`) — **not** on `!isLiveKitConnected`. A caller already sitting in a space voice channel is LiveKit-connected; gating on that would skip the DM connect and strand them in the space channel. `connect()` de-dupes an already-connected same room, so `wasOutgoingCall` alone is sufficient.
**DM-call teardown never disconnects a space connection (`teardownDmCall`).** The `dm_call_ended` / `dm_call_rejected` / terminal `dm_call_undeliverable` handlers all route through `teardownDmCall()` (`useWebSocket.ts`), which clears the call UI/federation state and tears down LiveKit **only when `currentVoiceChannelId` is null**. `disconnectFn()` tears down whatever room is active, and a space channel and a DM call are mutually exclusive (`setCurrentVoiceChannel` clears `activeDmCall`). The load-bearing case: when the **last** participant in a DM call joins a space voice channel, their post-connect `voice_join` empties the server-side DM room, so `broadcastRoomLeave` (`events.ts`) broadcasts `dm_call_ended` back to every DM member — including them. Without the guard, that echo would `disconnectFn()` the space room they just connected to, stranding the UI on "Connecting…" until a manual rejoin. The first participant to leave is unaffected (room still occupied → no `dm_call_ended`). Regression test: `hooks/teardownDmCall.test.ts`.
### SoundController Federation Awareness
The `SoundController` uses `isSelf(id)` which checks against BOTH `currentUser.id` (local snowflake) and `currentUser.homeUserId` (federated home ID). In federated calls, `updateParticipants` resolves identity to the local snowflake when `activeDmCall` is set, but reverts to raw `homeUserId` when it's cleared during disconnect. Both formats must be recognized as "self" to prevent phantom join/leave sounds.