diff --git a/docs/systems/voice.md b/docs/systems/voice.md index 63230734..43721c23 100644 --- a/docs/systems/voice.md +++ b/docs/systems/voice.md @@ -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. diff --git a/packages/web/src/hooks/teardownDmCall.test.ts b/packages/web/src/hooks/teardownDmCall.test.ts new file mode 100644 index 00000000..b8b1f681 --- /dev/null +++ b/packages/web/src/hooks/teardownDmCall.test.ts @@ -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(); + }); +}); diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts index 95fa5491..ff716edb 100644 --- a/packages/web/src/hooks/useLiveKit.ts +++ b/packages/web/src/hooks/useLiveKit.ts @@ -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; diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index b4e8b718..590f8b7b 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -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); diff --git a/packages/web/src/utils/clearSpaceVoiceForDmCall.test.ts b/packages/web/src/utils/clearSpaceVoiceForDmCall.test.ts new file mode 100644 index 00000000..b11292bb --- /dev/null +++ b/packages/web/src/utils/clearSpaceVoiceForDmCall.test.ts @@ -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(); + }); +}); diff --git a/packages/web/src/utils/voice.ts b/packages/web/src/utils/voice.ts index 615b039d..65fd9892 100644 --- a/packages/web/src/utils/voice.ts +++ b/packages/web/src/utils/voice.ts @@ -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 // ---------------------------------------------------------------------------