vojo/src/app/hooks/useCallerAutoHangup.ts

121 lines
4.7 KiB
TypeScript

// DM call auto-hangup.
//
// Scope despite the name: fires on any DM callEmbed, including when we're the
// B-side (answered a ring). Logic is symmetric: "leave if peer left or never
// arrived". Rename pending — see dm_calls_techdebt.md §5.15.
//
// Covers four cases (all §5.5):
// 1. Peer declines — RTCDecline timeline event → hangup immediately.
// 2. Peer never joins — no-answer timer fires (lifetime + grace).
// 3. Peer joins then leaves — memberships go empty → hangup after grace.
// 4. Peer membership flaps on LiveKit reconnect — grace absorbs the blip.
//
// KNOWN GAPS (also in dm_calls_techdebt.md):
// §5.9 Does NOT handle encrypted DMs. `ev.getType()` returns
// 'm.room.encrypted' on first fire; we early-exit. Needs
// `MatrixEventEvent.Decrypted` listener + `decryptEventIfNeeded`,
// pattern authoritative in CallEmbed.ts:233-234.
// §5.16 Decline is not tied to our specific ring event id. Any RTCDecline
// from peer in this room kills our call.
// §5.18 Grace constants are empirically chosen, may need tuning with
// real-world /sync + LiveKit reconnect metrics.
import { useEffect } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { EventType, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
import { useMatrixClient } from './useMatrixClient';
import { callEmbedAtom } from '../state/callEmbed';
import { mDirectAtom } from '../state/mDirectList';
import { RTC_NOTIFICATION_DEFAULT_LIFETIME } from '../utils/rtcNotification';
// Grace beyond the ring lifetime before giving up on no-answer. Covers /sync
// latency between B joining and A seeing the membership state event.
const NO_ANSWER_GRACE_MS = 10_000;
// Wait after the peer's last membership drops before tearing down. Matrix RTC
// memberships can flap on LiveKit reconnect / short network hiccups; without a
// grace we'd kill a live call on every blip.
const PEER_LEAVE_GRACE_MS = 8_000;
export const useCallerAutoHangup = (): void => {
const mx = useMatrixClient();
const callEmbed = useAtomValue(callEmbedAtom);
const setCallEmbed = useSetAtom(callEmbedAtom);
const mDirect = useAtomValue(mDirectAtom);
useEffect(() => {
if (!callEmbed) return undefined;
const roomId = callEmbed.roomId;
if (!mDirect.has(roomId)) return undefined;
const selfId = mx.getUserId();
if (!selfId) return undefined;
const selfDeviceId = mx.getDeviceId();
const isSelf = (m: CallMembership): boolean =>
m.sender === selfId && (!selfDeviceId || m.deviceId === selfDeviceId);
const session = mx.matrixRTC.getRoomSession(callEmbed.room);
let disposed = false;
let peerSeen = session.memberships.some((m) => !isSelf(m));
let peerLeaveTimer: ReturnType<typeof setTimeout> | undefined;
const performHangup = () => {
if (disposed) return;
disposed = true;
if (callEmbed.joined) {
callEmbed.hangup().catch(() => setCallEmbed(undefined));
} else {
setCallEmbed(undefined);
}
};
const clearPeerLeaveTimer = () => {
if (peerLeaveTimer) {
clearTimeout(peerLeaveTimer);
peerLeaveTimer = undefined;
}
};
const noAnswerTimer: ReturnType<typeof setTimeout> | undefined = peerSeen
? undefined
: setTimeout(performHangup, RTC_NOTIFICATION_DEFAULT_LIFETIME + NO_ANSWER_GRACE_MS);
const onMemberships = (_prev: CallMembership[], next: CallMembership[]) => {
const peerPresent = next.some((m) => !isSelf(m));
if (peerPresent) {
clearPeerLeaveTimer();
if (!peerSeen) {
peerSeen = true;
if (noAnswerTimer) clearTimeout(noAnswerTimer);
}
return;
}
if (peerSeen && !peerLeaveTimer) {
peerLeaveTimer = setTimeout(performHangup, PEER_LEAVE_GRACE_MS);
}
};
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, room, _s, _r, data) => {
if (!data.liveEvent || !room) return;
if (room.roomId !== roomId) return;
if (ev.getType() !== EventType.RTCDecline) return;
if (ev.getSender() === selfId) return;
performHangup();
};
session.on(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
mx.on(RoomEvent.Timeline, onTimeline);
return () => {
disposed = true;
if (noAnswerTimer) clearTimeout(noAnswerTimer);
clearPeerLeaveTimer();
session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
mx.removeListener(RoomEvent.Timeline, onTimeline);
};
}, [mx, callEmbed, mDirect, setCallEmbed]);
};