150 lines
5.6 KiB
TypeScript
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]);
|
|
};
|