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
+35
View File
@@ -58,6 +58,41 @@ export function broadcastDeafenViaLiveKit(): void {
});
}
/**
* Clear the client-side presence of a *space* voice channel when the local user
* transitions into a DM call.
*
* A DM call and a space voice channel are mutually exclusive: the invariant is
* that a DM call has **no** `currentVoiceChannelId` (only `activeDmCall`). The
* space→DM transition must therefore drop the space channel's client state, the
* mirror of `joinVoiceChannel` clearing `activeDmCall` on the DM→space
* transition.
*
* Why this is necessary even though the server already drops us from the space
* room (`dm_call_start` / `dm_call_accept` → `leaveCurrentRoom` →
* `broadcastRoomLeave`): `VoiceChannel` renders the occupant list for the
* channel equal to `currentVoiceChannelId` from the *live LiveKit participants*
* (its "our channel is the source of truth" branch). If `currentVoiceChannelId`
* still points at the old space channel, the DM call's participants get mapped
* onto it and the local user appears to still be sitting in the space channel.
*
* Clears `currentVoiceChannelId` directly (not via `setCurrentVoiceChannel`,
* which would also wipe the `activeDmCall` the caller/acceptor just set) and
* optimistically removes self from the old channel's `voiceUsers` so the
* sidebar updates immediately, without waiting for the server's leave
* broadcast.
*/
export function clearSpaceVoiceForDmCall(): void {
const { currentVoiceChannelId, removeVoiceUser } = useVoiceStore.getState();
if (!currentVoiceChannelId) return;
const origin = getChannelOrigin(currentVoiceChannelId);
const myId = getMyUserIdForOrigin(origin);
if (myId) removeVoiceUser(currentVoiceChannelId, myId);
useVoiceStore.setState({ currentVoiceChannelId: null });
}
// ---------------------------------------------------------------------------
// Voice channel join
// ---------------------------------------------------------------------------