vojo/src/app/hooks/useSwitchOrStartDmCall.ts

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]
);
};