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.
313 lines
14 KiB
TypeScript
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]
|
|
);
|
|
};
|