// 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. // // Covers four cases: // 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 GAP: 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 { IRTCNotificationContent } from 'matrix-js-sdk/lib/matrixrtc/types'; 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; const findLatestOwnRingNotifEvent = ( selfId: string, events: MatrixEvent[] ): MatrixEvent | undefined => { for (let i = events.length - 1; i >= 0; i -= 1) { const ev = events[i]; if (ev.getSender() === selfId && ev.getType() === EventType.RTCNotification) { const content = ev.getContent(); if (content.notification_type === 'ring') return ev; } } return undefined; }; 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; 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; let ownRingNotifEvent = findLatestOwnRingNotifEvent( selfId, callEmbed.room.getLiveTimeline()?.getEvents() ?? [] ); let pendingDeclineForNotifEventId: string | 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 maybeTrackOwnRing = (ev: MatrixEvent): void => { if (ev.getRoomId() !== roomId) return; if (ev.getType() !== EventType.RTCNotification) return; if (ev.getSender() !== selfId) return; const content = ev.getContent(); if (content.notification_type !== 'ring') return; ownRingNotifEvent = ev; if (pendingDeclineForNotifEventId === ev.getId()) { performHangup(); } }; 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; const declinedNotifEventId = ev.getRelation()?.event_id; if (!declinedNotifEventId) return; // Same-room retries can leave multiple historical ring events behind. // We only honor a decline that targets the ring this embed most recently // sent; older declines must not tear down the new attempt. if (!ownRingNotifEvent) { pendingDeclineForNotifEventId = declinedNotifEventId; return; } if (declinedNotifEventId !== ownRingNotifEvent.getId()) { if (ownRingNotifEvent.getAssociatedStatus() !== null) { pendingDeclineForNotifEventId = declinedNotifEventId; } 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(() => undefined); return; } maybeTrackOwnRing(ev); maybeHangupOnDecline(ev); }; const onDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (ev, err) => { if (err) return; maybeTrackOwnRing(ev); maybeHangupOnDecline(ev); }; const onLocalEchoUpdated: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (ev, room) => { if (!room || room.roomId !== roomId) return; if (ev !== ownRingNotifEvent) return; if (pendingDeclineForNotifEventId !== ev.getId()) return; performHangup(); }; session.on(MatrixRTCSessionEvent.MembershipsChanged, onMemberships); mx.on(RoomEvent.Timeline, onTimeline); mx.on(MatrixEventEvent.Decrypted, onDecrypted); mx.on(RoomEvent.LocalEchoUpdated, onLocalEchoUpdated); return () => { disposed = true; if (noAnswerTimer) clearTimeout(noAnswerTimer); clearPeerLeaveTimer(); session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships); mx.removeListener(RoomEvent.Timeline, onTimeline); mx.removeListener(MatrixEventEvent.Decrypted, onDecrypted); mx.removeListener(RoomEvent.LocalEchoUpdated, onLocalEchoUpdated); }; }, [mx, callEmbed, mDirect, setCallEmbed]); };