// DM call entry point that unifies start, join and switch flows. // // Contract: // - no prev embed → start a new DM call // - prev.roomId === arg, healthy (joined + peer present) → no-op // - prev.roomId === arg, zombie (never joined, peer gone, or stuck) → // dispose prev directly and start fresh; waitLeave is skipped because // the widget is likely unresponsive // - prev.roomId !== arg → switch: hangup prev, wait for clean leave, // dispose prev, then start new // // Switch barrier waits for one of two success signals: // 1. MembershipsChanged removing our (user, device) from prev room session // — server-authoritative, this is what the peer actually observes // 2. action:im.vector.hangup from the widget — widget-side confirmation, // including pre-join / not-yet-member states where there is nothing to // observe via MembershipsChanged // 3. 3s timeout — fail-closed: abort switch, do not start the new call // // Listeners are armed BEFORE hangup() to avoid missing a fast action ack. // // The function is serialized via inFlightRef: concurrent double-taps await the // same promise and then re-enter. After re-entry the same-room branch usually // makes the second tap a noop, matching user intent. import { useCallback, useRef } from 'react'; import { useAtomValue, useStore } from 'jotai'; import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession'; import { useMatrixClient } from './useMatrixClient'; import { useTheme } from './useTheme'; import { createCallEmbed, useCallEmbedRef } from './useCallEmbed'; import { useCallPreferencesAtom } from '../state/hooks/callPreferences'; import { callEmbedAtom } from '../state/callEmbed'; import { CallEmbed } from '../plugins/call'; const SWITCH_LEAVE_TIMEOUT_MS = 3000; const createSwitchTimeoutError = (roomId: string): Error => new Error(`[dm-call] switch timed out waiting for clean leave in ${roomId}`); export const useSwitchOrStartDmCall = (): ((roomId: string) => Promise) => { const mx = useMatrixClient(); const theme = useTheme(); const store = useStore(); const callEmbedRef = useCallEmbedRef(); const callPref = useAtomValue(useCallPreferencesAtom()); const inFlightRef = useRef | undefined>(undefined); const waitLeave = useCallback( (prev: CallEmbed): Promise => { const session = mx.matrixRTC.getRoomSession(prev.room); const selfUserId = mx.getSafeUserId(); const selfDeviceId = mx.getDeviceId(); const selfPresent = (memberships: CallMembership[]): boolean => memberships.some( (m) => m.userId === selfUserId && (!selfDeviceId || m.deviceId === selfDeviceId) ); return new Promise((resolve, reject) => { let done = false; // Assigned once listeners are wired — set to a real cleanup below // so the pre-wire window is never reachable (finish can only fire // after the listeners/timer exist). let cleanup: () => void = () => undefined; const finish = () => { if (done) return; done = true; cleanup(); resolve(); }; const fail = () => { if (done) return; done = true; cleanup(); reject(createSwitchTimeoutError(prev.roomId)); }; const onMemberships = (_old: CallMembership[], next: CallMembership[]): void => { if (!selfPresent(next)) finish(); }; const onHangupAction = (): void => finish(); session.on(MatrixRTCSessionEvent.MembershipsChanged, onMemberships); prev.call.on('action:im.vector.hangup', onHangupAction); const timer = setTimeout(fail, SWITCH_LEAVE_TIMEOUT_MS); cleanup = () => { session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships); prev.call.off('action:im.vector.hangup', onHangupAction); clearTimeout(timer); }; prev.hangup().catch((err: unknown) => { // eslint-disable-next-line no-console console.warn('[dm-call] hangup transport fail (barrier still armed)', err); }); }); }, [mx] ); const doSwitchOrStart = useCallback( async (roomId: string): Promise => { const room = mx.getRoom(roomId); if (!room) { // eslint-disable-next-line no-console console.warn('[dm-call] room not found', roomId); return; } const prev = store.get(callEmbedAtom); if (prev?.roomId === roomId) { const selfUserId = mx.getSafeUserId(); const selfDeviceId = mx.getDeviceId(); const peerPresent = mx.matrixRTC .getRoomSession(prev.room) .memberships.some( (m: CallMembership) => m.userId !== selfUserId || (!!selfDeviceId && m.deviceId !== selfDeviceId) ); if (prev.joined && peerPresent) return; } const container = callEmbedRef.current; if (!container) { throw new Error('Failed to start call, No embed container element found!'); } if (prev) { if (prev.roomId === roomId) { // Zombie same-room embed (widget never joined, peer gone, or stuck // after LiveKit reconnect fail). waitLeave would hang on the // unresponsive widget. Best-effort widget hangup before direct // dispose so the widget's MembershipManager gets a chance to send // the server-side leave event; if it doesn't, the SDK's delayed // leave fires a few seconds after dispose. if (prev.joined) { prev.hangup().catch(() => undefined); } prev.dispose(); } else { await waitLeave(prev); prev.dispose(); } } const embed = createCallEmbed(mx, room, true, theme.kind, container, callPref, true); store.set(callEmbedAtom, embed); }, [mx, store, callEmbedRef, callPref, theme, waitLeave] ); return useCallback( (roomId: string): Promise => { const enter = (): Promise => { if (inFlightRef.current) { return inFlightRef.current.then(enter, enter); } const task = doSwitchOrStart(roomId); const tracked: Promise = task.finally(() => { if (inFlightRef.current === tracked) inFlightRef.current = undefined; }); inFlightRef.current = tracked; return tracked; }; return enter(); }, [doSwitchOrStart] ); };