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,65 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Stub heavy / side-effectful imports pulled in transitively by utils/voice.
vi.mock('../audio/AudioManager', () => ({
AudioManager: { getInstance: () => ({}) },
}));
vi.mock('../hooks/useWebSocket', () => ({
wsSend: vi.fn(),
}));
vi.mock('../stores/instanceStore', async () => {
const { create } = await import('zustand');
const store = create<{ instances: unknown[] }>()(() => ({ instances: [] }));
return { useInstanceStore: store };
});
import { clearSpaceVoiceForDmCall } from './voice';
import { useVoiceStore } from '../stores/voiceStore';
import { useAuthStore } from '../stores/authStore';
describe('clearSpaceVoiceForDmCall', () => {
beforeEach(() => {
useAuthStore.setState({ user: { id: 'me' } as never });
useVoiceStore.setState({
currentVoiceChannelId: null,
activeDmCall: null,
voiceUsers: new Map(),
});
});
it('clears currentVoiceChannelId and removes self from the space channel', () => {
useVoiceStore.setState({
currentVoiceChannelId: 'space-1',
voiceUsers: new Map([['space-1', ['me', 'other']]]),
});
clearSpaceVoiceForDmCall();
const s = useVoiceStore.getState();
expect(s.currentVoiceChannelId).toBeNull();
expect(s.voiceUsers.get('space-1')).toEqual(['other']);
});
it('preserves activeDmCall (the caller/acceptor just set it)', () => {
useVoiceStore.setState({
currentVoiceChannelId: 'space-1',
activeDmCall: { dmChannelId: 'dm-9' },
voiceUsers: new Map([['space-1', ['me']]]),
});
clearSpaceVoiceForDmCall();
const s = useVoiceStore.getState();
expect(s.currentVoiceChannelId).toBeNull();
expect(s.activeDmCall).toEqual({ dmChannelId: 'dm-9' });
});
it('is a no-op when not in a space voice channel', () => {
useVoiceStore.setState({ currentVoiceChannelId: null, activeDmCall: { dmChannelId: 'dm-1' } });
clearSpaceVoiceForDmCall();
expect(useVoiceStore.getState().activeDmCall).toEqual({ dmChannelId: 'dm-1' });
expect(useVoiceStore.getState().currentVoiceChannelId).toBeNull();
});
});
+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
// ---------------------------------------------------------------------------