// 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 { Room } from 'matrix-js-sdk'; 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, formingCallRoomIdAtom } from '../state/callEmbed'; import { CallEmbed } from '../plugins/call'; const SWITCH_LEAVE_TIMEOUT_MS = 3000; // A joined zombie widget (peer gone / stuck) is given a SHORTER window to send // its clean leave before we dispose it — it may be unresponsive, so we don't // burn the full switch budget. If it does respond, our membership clears and the // re-dial's ring survives the SDK first-member gate (F2). const ZOMBIE_LEAVE_TIMEOUT_MS = 1500; // Before starting an OUTGOING DM call, wait (bounded) for our own membership from // a prior call in this room to actually leave the session, so the EC widget joins // with oldMemberships empty and its ring notification fires (F1). Resolves // instantly when we hold no membership (the common fresh-call case). const OWN_LEAVE_TIMEOUT_MS = 2500; const createSwitchTimeoutError = (roomId: string): Error => new Error(`[dm-call] switch timed out waiting for clean leave in ${roomId}`); export const useSwitchOrStartDmCall = (): (( roomId: string, incoming?: boolean ) => 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, timeoutMs: number = SWITCH_LEAVE_TIMEOUT_MS): 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, timeoutMs); 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] ); // Resolve once our own (user, device) membership is gone from the room's RTC // session, or after `timeoutMs` (fail-open). Used before (re)starting an // outgoing DM call so the EC widget joins as the first member — matrix-js-sdk // sends the ring notification ONLY when oldMemberships.length === 0, so a // lingering own membership from a just-ended call silently drops the ring // (F1). Instant when we already hold no membership. const waitOwnMembershipGone = useCallback( (room: Room, timeoutMs: number): Promise => { const session = mx.matrixRTC.getRoomSession(room); const selfUserId = mx.getSafeUserId(); const selfDeviceId = mx.getDeviceId(); const ownGone = (memberships: CallMembership[]): boolean => !memberships.some( (m) => m.userId === selfUserId && (!selfDeviceId || m.deviceId === selfDeviceId) ); if (ownGone(session.memberships)) return Promise.resolve(); return new Promise((resolve) => { let done = false; // Assigned once the listener/timer exist (mirrors waitLeave) so `finish` // never references them before they are defined. let cleanup: () => void = () => undefined; const finish = () => { if (done) return; done = true; cleanup(); resolve(); }; const onMemberships = (_old: CallMembership[], next: CallMembership[]): void => { if (ownGone(next)) finish(); }; session.on(MatrixRTCSessionEvent.MembershipsChanged, onMemberships); const timer = setTimeout(finish, timeoutMs); cleanup = () => { session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships); clearTimeout(timer); }; }); }, [mx] ); const doSwitchOrStart = useCallback( async (roomId: string, incoming: boolean): 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); // Glare co-presence. MatrixRTC has no "answer" — a DM call is co-presence in // the RTC session, so when we already hold OR are forming an embed for this // room, a counter-ring for it is just the peer joining the SAME session. // Answering it would dispose+recreate our embed, putting a second // MembershipManager on the same (user, device) state-key → the "Missing own // membership: force re-join" thrash that collapses both calls. Mirror // element-x-android (any active/forming call → ignore the incoming), matched // by roomId. `formingCallRoomIdAtom` is set synchronously below before the // outgoing path's awaits, so this beats the callEmbedAtom-empty window. if (incoming && (prev?.roomId === roomId || store.get(formingCallRoomIdAtom) === roomId)) { return; } if (prev?.roomId === roomId) { // Healthy same-room call → no-op. "Peer present" is USER-scoped: only the // OTHER user answering makes the call healthy. A device-scoped check // counted our own second device as a peer, so a lingering own membership // from another device (non-MSC4140 HS) wedged every re-dial tap into a // silent no-op for the membership's whole life (F8). Mirrors isPeer in // useCallerAutoHangup. const selfUserId = mx.getSafeUserId(); const peerPresent = mx.matrixRTC .getRoomSession(prev.room) .memberships.some((m: CallMembership) => m.userId !== selfUserId); if (prev.joined && peerPresent) return; } const container = callEmbedRef.current; if (!container) { throw new Error('Failed to start call, No embed container element found!'); } // Mark this room "call forming" synchronously, before any await, so a // concurrent incoming-ring instance (a separate useSwitchOrStartDmCall with // its own inFlightRef) and the ring-suppress hook see co-presence during the // outgoing embed's forming window — otherwise the glare counter-ring surfaces // while callEmbedAtom is still empty. Only the outgoing path forms; an answer // reuses/joins. Cleared in `finally` once callEmbedAtom carries the embed (or // on failure) so ring suppression can't stick. if (!incoming) { store.set(formingCallRoomIdAtom, roomId); } try { // Tracks a same-room zombie whose waitLeave TIMED OUT (unresponsive widget). // In that case the widget can never send its leave, so the own-membership // wait below would just burn its full fail-open budget for nothing — a 2nd // black-screen delay stacked on the zombie timeout (self-review regression). // We skip it there; a widget that DID respond (or a fresh start / switch) // still runs the wait, which is instant once our membership is gone. let zombieLeaveTimedOut = false; if (prev) { if (prev.roomId === roomId) { // Zombie same-room embed (widget never joined, peer gone, or stuck // after LiveKit reconnect fail). Give a JOINED widget a bounded chance // to send its server-side leave before disposing: waitLeave arms the // membership/hangup barrier, then hangs up. If the widget responds our // membership clears (so the re-dial rings — F1/F2); if it's // unresponsive the short timeout fails open and we dispose anyway. A // never-joined zombie has no membership to leave, so skip straight to // dispose. if (prev.joined) { try { await waitLeave(prev, ZOMBIE_LEAVE_TIMEOUT_MS); } catch { // fail-open: unresponsive widget — dispose + start regardless, and // don't wait again below (its membership won't clear either way). zombieLeaveTimedOut = true; } } prev.dispose(); } else { // Wait for a clean leave, but NEVER let the 3s barrier timeout strand // the previous embed. The SDK's delayed-leave (~8s) reconciles the // server-side membership regardless, so on timeout we dispose and // switch anyway instead of throwing — which previously left `prev` in // callEmbedAtom, never started the new call, and made every retry // re-arm waitLeave on the now-unresponsive widget (switch wedged until // reload). Fail-open beats fail-stuck. try { await waitLeave(prev); } catch (err) { // eslint-disable-next-line no-console console.warn( '[dm-call] waitLeave timed out; disposing prev and switching anyway', err ); } finally { prev.dispose(); } } } // Outgoing (not answering): make sure our own membership from a prior call // in this room has actually left before creating the widget, or the SDK's // first-member ring gate drops the outgoing ring (F1). Bounded + fail-open; // instant when we hold no membership. Skipped when answering (join_existing // intent never rings) and after a timed-out zombie leave (dead widget — the // membership can't clear, so don't stack a second black-screen wait). if (!incoming && !zombieLeaveTimedOut) { await waitOwnMembershipGone(room, OWN_LEAVE_TIMEOUT_MS); } const embed = createCallEmbed( mx, room, true, theme.kind, container, callPref, true, incoming ); store.set(callEmbedAtom, embed); } finally { // Once the embed is in the atom, co-presence rides callEmbedAtom; if we // threw before that, clear the marker so a failed start doesn't wedge ring // suppression for this room. Guarded so a newer start for another room // (which reset the marker) isn't clobbered. if (store.get(formingCallRoomIdAtom) === roomId) { store.set(formingCallRoomIdAtom, undefined); } } }, [mx, store, callEmbedRef, callPref, theme, waitLeave, waitOwnMembershipGone] ); return useCallback( (roomId: string, incoming = false): Promise => { const enter = (): Promise => { if (inFlightRef.current) { return inFlightRef.current.then(enter, enter); } const task = doSwitchOrStart(roomId, incoming); const tracked: Promise = task.finally(() => { if (inFlightRef.current === tracked) inFlightRef.current = undefined; }); inFlightRef.current = tracked; return tracked; }; return enter(); }, [doSwitchOrStart] ); };