vojo/src/app/hooks/useSwitchOrStartDmCall.ts
heaven 0f5bc33619 fix(calls): resolve call glare via co-presence, not answer
Both users starting a call in the same DM at once collapsed both attempts: two useSwitchOrStartDmCall instances (header outgoing + strip answer) race on the shared callEmbedAtom, the counter-ring surfaces during the outgoing embed's forming window, and answering it disposes+recreates the embed — putting two MembershipManagers on one (user,device) state-key, driving the SDK 'Missing own membership: force re-join' thrash. Fix per MatrixRTC canon (a call is co-presence, there is no 'answer'): a formingCallRoomIdAtom set synchronously before the outgoing awaits marks the room as forming; an incoming call for a room we already hold/are forming becomes a no-op (element-x model), the counter-ring is suppressed while forming, and the native-answer consumer won't tear down our forming call. Root + design from the internal research workflow. Not yet validated on device.
2026-07-04 02:40:47 +03:00

313 lines
14 KiB
TypeScript

// 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<void>) => {
const mx = useMatrixClient();
const theme = useTheme();
const store = useStore();
const callEmbedRef = useCallEmbedRef();
const callPref = useAtomValue(useCallPreferencesAtom());
const inFlightRef = useRef<Promise<void> | undefined>(undefined);
const waitLeave = useCallback(
(prev: CallEmbed, timeoutMs: number = SWITCH_LEAVE_TIMEOUT_MS): Promise<void> => {
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<void>((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<void> => {
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<void>((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<void> => {
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<void> => {
const enter = (): Promise<void> => {
if (inFlightRef.current) {
return inFlightRef.current.then(enter, enter);
}
const task = doSwitchOrStart(roomId, incoming);
const tracked: Promise<void> = task.finally(() => {
if (inFlightRef.current === tracked) inFlightRef.current = undefined;
});
inFlightRef.current = tracked;
return tracked;
};
return enter();
},
[doSwitchOrStart]
);
};