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
@@ -0,0 +1,83 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Stub heavy import leaves so useWebSocket can be imported in jsdom without
// pulling in the LiveKit SDK / AudioWorklet graph. teardownDmCall only touches
// voiceStore, which we exercise for real.
vi.mock('../audio/AudioManager', () => ({
AudioManager: { getInstance: () => ({}) },
}));
vi.mock('./useLiveKit', () => ({
getActiveRoom: () => null,
}));
import { teardownDmCall } from './useWebSocket';
import { useVoiceStore } from '../stores/voiceStore';
describe('teardownDmCall', () => {
beforeEach(() => {
useVoiceStore.setState({
currentVoiceChannelId: null,
activeDmCall: null,
incomingCall: null,
outgoingCall: null,
federatedCallToken: null,
federatedCallUrl: null,
federatedCallId: null,
callOrigin: null,
disconnectFn: null,
});
});
// Regression: the last participant to leave a DM call for a space voice
// channel receives a `dm_call_ended` echo (the server emptied the DM room).
// teardownDmCall must NOT tear down the space connection that was just
// established — otherwise the UI is stranded on "Connecting…".
it('does NOT disconnect when the user has joined a space voice channel', () => {
const disconnectFn = vi.fn().mockResolvedValue(undefined);
useVoiceStore.setState({
currentVoiceChannelId: 'space-voice-1',
activeDmCall: null,
disconnectFn,
});
teardownDmCall();
expect(disconnectFn).not.toHaveBeenCalled();
// The space voice intent is preserved.
expect(useVoiceStore.getState().currentVoiceChannelId).toBe('space-voice-1');
});
it('DOES disconnect when still in the DM call (no space channel)', () => {
const disconnectFn = vi.fn().mockResolvedValue(undefined);
useVoiceStore.setState({
currentVoiceChannelId: null,
activeDmCall: { dmChannelId: 'dm-1' },
disconnectFn,
});
teardownDmCall();
expect(disconnectFn).toHaveBeenCalledTimes(1);
// DM call state is cleared.
expect(useVoiceStore.getState().activeDmCall).toBeNull();
});
it('clears residual incoming/outgoing/federated call state regardless', () => {
const disconnectFn = vi.fn().mockResolvedValue(undefined);
useVoiceStore.setState({
currentVoiceChannelId: 'space-voice-1',
incomingCall: { dmChannelId: 'dm-2', callerId: 'u9', callerName: 'Nine' },
outgoingCall: { dmChannelId: 'dm-3' },
federatedCallId: 'fed-1',
disconnectFn,
});
teardownDmCall();
const s = useVoiceStore.getState();
expect(s.incomingCall).toBeNull();
expect(s.outgoingCall).toBeNull();
expect(s.federatedCallId).toBeNull();
expect(disconnectFn).not.toHaveBeenCalled();
});
});
+8 -1
View File
@@ -19,7 +19,7 @@ import { useVoiceStore } from '../stores/voiceStore';
import { useAuthStore } from '../stores/authStore';
import { useUIStore } from '../stores/uiStore';
import type { User } from '@backspace/shared';
import { broadcastVoiceStatus } from '../utils/voice';
import { broadcastVoiceStatus, clearSpaceVoiceForDmCall } from '../utils/voice';
import { consumeIntentionalCameraOff, markIntentionalCameraOff } from '../utils/voiceActions';
import { AudioManager } from '../audio/AudioManager';
import { SpeakingDetector } from '../audio/SpeakingDetector';
@@ -573,6 +573,13 @@ export function useLiveKit() {
const storedId = isDm ? `dm-${channelId}` : channelId;
if (connectedChannelRef.current === storedId && roomRef.current?.state === ConnectionState.Connected) return;
// Entering a DM call: drop any space voice channel we're still "in" on the
// client. Done synchronously (before any await) so the sidebar updates
// immediately. See clearSpaceVoiceForDmCall for why currentVoiceChannelId
// must be cleared here — otherwise the DM call's participants render against
// the old space channel and we appear to still be sitting in it.
if (isDm) clearSpaceVoiceForDmCall();
// Register voice state with the WS server after LiveKit connects (not for DM calls)
const registerWithServer = () => {
if (isDm) return;
+39 -19
View File
@@ -126,6 +126,35 @@ export { buildCallUndeliverableToast };
const HOME_ORIGIN = '';
/**
* Tear down local state for a DM call that ended, was rejected, or became
* terminally undeliverable. Clears the call UI/federation state, and tears
* down the LiveKit session **only when the active voice connection still
* belongs to the DM call**.
*
* The guard is load-bearing: `disconnectFn()` tears down whatever LiveKit room
* is currently active, regardless of which channel it is. Once the user has
* joined a *space* voice channel, `currentVoiceChannelId` is set and the active
* room is the space channel — NOT the DM call (the two are mutually exclusive;
* `setCurrentVoiceChannel` clears `activeDmCall`). A stale `dm_call_ended` echo
* must never disconnect that space connection.
*
* This is exactly what happens to the **last** participant to leave a DM call
* for a space channel: their `voice_join` empties the server-side DM room, the
* server broadcasts `dm_call_ended` back to every DM member (including them),
* and an unguarded `disconnectFn()` would tear down the space room they just
* connected to — stranding the UI on "Connecting…" until a manual rejoin.
*/
export function teardownDmCall(): void {
const voice = useVoiceStore.getState();
voice.setIncomingCall(null);
voice.setOutgoingCall(null);
voice.setActiveDmCall(null);
voice.clearFederatedCallData();
// Never tear down a space voice connection in response to a DM-call signal.
if (voice.disconnectFn && !voice.currentVoiceChannelId) voice.disconnectFn();
}
function handleEvent(origin: string, event: ServerEvent): void {
const isHome = origin === HOME_ORIGIN;
const { setUser } = useAuthStore.getState();
@@ -1027,7 +1056,13 @@ function handleEvent(origin: string, event: ServerEvent): void {
if (wasOutgoingCall || isLiveKitConnected) {
setActiveDmCall({ dmChannelId: callDmId });
}
if (connectFn && !isLiveKitConnected && wasOutgoingCall && callDmId) {
// The caller connects to the DM room. `wasOutgoingCall` alone identifies
// the caller session (other sessions/tabs never set outgoingCall), and
// `connect()` de-dupes an already-connected same room — so we must NOT
// also gate on `!isLiveKitConnected`: a caller who is currently sitting in
// a space voice channel is LiveKit-connected, and gating on it would skip
// the DM connect entirely, stranding them in the space channel.
if (connectFn && wasOutgoingCall && callDmId) {
connectFn(callDmId, true).catch((err: unknown) => {
console.error('[WS] DM call connect failed:', err);
});
@@ -1037,39 +1072,24 @@ function handleEvent(origin: string, event: ServerEvent): void {
case 'dm_call_rejected': {
if (!isHome && !activePeerOrigins.has(origin)) break;
const { setIncomingCall, setOutgoingCall, setActiveDmCall, disconnectFn, clearFederatedCallData } = useVoiceStore.getState();
setIncomingCall(null);
setOutgoingCall(null);
setActiveDmCall(null);
clearFederatedCallData();
if (disconnectFn) disconnectFn();
teardownDmCall();
break;
}
case 'dm_call_ended': {
if (!isHome && !activePeerOrigins.has(origin)) break;
const { setIncomingCall, setOutgoingCall, setActiveDmCall, disconnectFn, clearFederatedCallData } = useVoiceStore.getState();
setIncomingCall(null);
setOutgoingCall(null);
setActiveDmCall(null);
clearFederatedCallData();
if (disconnectFn) disconnectFn();
teardownDmCall();
break;
}
case 'dm_call_undeliverable': {
if (!isHome && !activePeerOrigins.has(origin)) break;
const { setIncomingCall, setOutgoingCall, setActiveDmCall, disconnectFn, clearFederatedCallData } = useVoiceStore.getState();
const { addToast } = useUIStore.getState();
if (event.terminal) {
// Tear down local outbound call state — mirrors dm_call_ended.
setIncomingCall(null);
setOutgoingCall(null);
setActiveDmCall(null);
clearFederatedCallData();
if (disconnectFn) disconnectFn();
teardownDmCall();
}
const msg = buildCallUndeliverableToast(event.failures, event.terminal, event.phase);