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