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