172 lines
6.5 KiB
TypeScript
172 lines
6.5 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 { 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<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): 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, 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<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);
|
|
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<void> => {
|
|
const enter = (): Promise<void> => {
|
|
if (inFlightRef.current) {
|
|
return inFlightRef.current.then(enter, enter);
|
|
}
|
|
const task = doSwitchOrStart(roomId);
|
|
const tracked: Promise<void> = task.finally(() => {
|
|
if (inFlightRef.current === tracked) inFlightRef.current = undefined;
|
|
});
|
|
inFlightRef.current = tracked;
|
|
return tracked;
|
|
};
|
|
return enter();
|
|
},
|
|
[doSwitchOrStart]
|
|
);
|
|
};
|