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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user