vojo/src/app/hooks/useCallerAutoHangup.ts

150 lines
5.6 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.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.
//
// Encrypted DMs: RTCDecline arrives as m.room.encrypted first and Timeline
// does not re-emit post-decrypt (matrix-js-sdk 38.2). We mirror the
// CallEmbed.ts:233-234 pattern — listen to both Timeline (kick decryption)
// and MatrixEventEvent.Decrypted. `performHangup` is guarded by `disposed`,
// so double delivery for cleartext rooms is a no-op.
import { useEffect } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import {
EventType,
MatrixEvent,
MatrixEventEvent,
MatrixEventHandlerMap,
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 maybeHangupOnDecline = (ev: MatrixEvent): void => {
// onDecrypted fires for every decrypted event client-wide; gate on room
// first to avoid walking type/sender on unrelated rooms.
if (ev.getRoomId() !== roomId) return;
if (ev.getType() !== EventType.RTCDecline) return;
if (ev.getSender() === selfId) return;
performHangup();
};
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, room, _s, _r, data) => {
if (!data.liveEvent || !room) return;
if (room.roomId !== roomId) return;
// Encrypted RTCDecline lands as m.room.encrypted here; let the Decrypted
// handler see it once cleartext is available.
if (ev.isEncrypted()) {
mx.decryptEventIfNeeded(ev).catch(() => {});
return;
}
maybeHangupOnDecline(ev);
};
const onDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (ev, err) => {
if (err) return;
maybeHangupOnDecline(ev);
};
session.on(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
mx.on(RoomEvent.Timeline, onTimeline);
mx.on(MatrixEventEvent.Decrypted, onDecrypted);
return () => {
disposed = true;
if (noAnswerTimer) clearTimeout(noAnswerTimer);
clearPeerLeaveTimer();
session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
mx.removeListener(RoomEvent.Timeline, onTimeline);
mx.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
};
}, [mx, callEmbed, mDirect, setCallEmbed]);
};