// Incoming DM ring: watches `m.rtc.notification` in the live timeline and // populates `incomingCallsAtom` so the bottom strip can render. // // Encrypted DMs (§5.9): `RoomEvent.Timeline` fires once with // `ev.getType() === 'm.room.encrypted'` and is never re-emitted after decrypt // (matrix-js-sdk 38.2 event-timeline-set.js:563). We listen to both Timeline // and `MatrixEventEvent.Decrypted` and kick decryption from the Timeline // handler — mirrors CallEmbed.ts:233-234. Dedup relies on `registry.has(key)` // for RTCNotification and on idempotent `removeByNotifId` for RTCDecline, so // double delivery (Timeline for cleartext + Decrypted for encrypted) is safe. // // The `registryRef` sync-effect below handles an asymmetry §5.6: REMOVE on the // atom can come from outside the hook (strip buttons), and we need to drop // timers/listeners for those keys to avoid leaks and to let a fresh ring with // the same dedup key re-trigger. import { useEffect, useRef } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; import { EventType, MatrixClient, MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap, RelationType, Room, RoomEvent, RoomEventHandlerMap, } from 'matrix-js-sdk'; import { IRTCNotificationContent } from 'matrix-js-sdk/lib/matrixrtc/types'; import { MatrixRTCSessionManagerEvents } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSessionManager'; import { CallMembership, SessionMembershipData, } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession'; import { useMatrixClient } from './useMatrixClient'; import { mDirectAtom } from '../state/mDirectList'; import { callEmbedAtom } from '../state/callEmbed'; import { incomingCallsAtom } from '../state/incomingCalls'; import { getIncomingCallKey, getNotificationEventSendTs, isRtcNotificationExpired, RTC_NOTIFICATION_DEFAULT_LIFETIME, } from '../utils/rtcNotification'; // Returns "" for room-scoped calls (MSC4143/MSC3401v2 — empty call_id means // "the only call in this room"). Returns undefined when no membership is known. const findCallIdSync = ( mx: MatrixClient, room: Room, sender: string, membershipEventId: string ): string | undefined => { const session = mx.matrixRTC.getRoomSession(room); const senderMembership = session.memberships.find((m) => m.sender === sender); if (senderMembership && typeof senderMembership.callId === 'string') { return senderMembership.callId; } const stateEvents = room.currentState.getStateEvents(EventType.GroupCallMemberPrefix); const senderStateEv = stateEvents.find((ev) => ev.getSender() === sender); if (senderStateEv) { return senderStateEv.getContent().call_id ?? ''; } const timelineEv = room.findEventById(membershipEventId); if (timelineEv) { return timelineEv.getContent().call_id ?? ''; } const stateEv = stateEvents.find((ev) => ev.getId() === membershipEventId); if (stateEv) { return stateEv.getContent().call_id ?? ''; } return undefined; }; const resolveCallId = async ( mx: MatrixClient, room: Room, sender: string, membershipEventId: string ): Promise => { const sync = findCallIdSync(mx, room, sender, membershipEventId); if (sync !== undefined) return sync; // Race: ring arrived before /sync delivered the membership state event. const session = mx.matrixRTC.getRoomSession(room); const waited = await new Promise((resolve) => { const timeout = setTimeout(() => { session.off(MatrixRTCSessionEvent.MembershipsChanged, handler); resolve(undefined); }, 5000); const handler = () => { const found = findCallIdSync(mx, room, sender, membershipEventId); if (found !== undefined) { clearTimeout(timeout); session.off(MatrixRTCSessionEvent.MembershipsChanged, handler); resolve(found); } }; session.on(MatrixRTCSessionEvent.MembershipsChanged, handler); }); if (waited !== undefined) return waited; try { const raw = await mx.fetchRoomEvent(room.roomId, membershipEventId); const fetched = new MatrixEvent(raw); return fetched.getContent().call_id ?? ''; } catch { return undefined; } }; type RegistryEntry = { roomId: string; notifEventId: string; timer: ReturnType; unsubMemberships?: () => void; }; export const useIncomingRtcNotifications = (): void => { const mx = useMatrixClient(); const mDirect = useAtomValue(mDirectAtom); const callEmbed = useAtomValue(callEmbedAtom); const incoming = useAtomValue(incomingCallsAtom); const setIncoming = useSetAtom(incomingCallsAtom); const mDirectRef = useRef(mDirect); mDirectRef.current = mDirect; const inCallRef = useRef(callEmbed !== undefined); inCallRef.current = callEmbed !== undefined; const registryRef = useRef>(new Map()); // When local user joins any call (via header / other UI), drop any toast for that room. useEffect(() => { if (callEmbed) { setIncoming({ type: 'REMOVE_BY_ROOM', roomId: callEmbed.roomId }); } }, [callEmbed, setIncoming]); // Any key dropped from the atom (external REMOVE via strip accept/decline, etc.) // must also drop the matching registry entry — otherwise its expiry timer and // memberships listener leak, and a fresh ring for the same dedup key would be // swallowed by `registry.has(key)` until the timer finally fires. useEffect(() => { const registry = registryRef.current; Array.from(registry.keys()).forEach((key) => { if (!incoming.has(key)) { const entry = registry.get(key); if (!entry) return; clearTimeout(entry.timer); entry.unsubMemberships?.(); registry.delete(key); } }); }, [incoming]); useEffect(() => { const registry = registryRef.current; const removeByKey = (key: string) => { const entry = registry.get(key); if (!entry) return; clearTimeout(entry.timer); entry.unsubMemberships?.(); registry.delete(key); setIncoming({ type: 'REMOVE', key }); }; const removeByRoom = (roomId: string) => { Array.from(registry.entries()) .filter(([, entry]) => entry.roomId === roomId) .forEach(([key]) => removeByKey(key)); }; const removeByNotifId = (notifEventId: string) => { Array.from(registry.entries()) .filter(([, entry]) => entry.notifEventId === notifEventId) .forEach(([key]) => removeByKey(key)); }; const subscribeMemberships = (room: Room): (() => void) => { const session = mx.matrixRTC.getRoomSession(room); const handler = ( _old: CallMembership[], next: CallMembership[] ) => { if (next.some((m) => m.sender === mx.getUserId())) { removeByRoom(room.roomId); } }; session.on(MatrixRTCSessionEvent.MembershipsChanged, handler); return () => session.off(MatrixRTCSessionEvent.MembershipsChanged, handler); }; const scheduleExpiry = (key: string, ev: MatrixEvent): ReturnType => { const content = ev.getContent(); const lifetime = content.lifetime ?? RTC_NOTIFICATION_DEFAULT_LIFETIME; const expireAt = getNotificationEventSendTs(ev) + lifetime; const delay = Math.max(0, expireAt - Date.now()); return setTimeout(() => removeByKey(key), delay); }; const processEvent = async (ev: MatrixEvent, room: Room): Promise => { if (ev.getType() === EventType.RTCDecline) { const rel = ev.getRelation(); if (rel?.event_id) removeByNotifId(rel.event_id); return; } if (ev.getType() !== EventType.RTCNotification) return; if (ev.getSender() === mx.getSafeUserId()) return; const content = ev.getContent(); // Only DM ring — group call notifications use 'notification' type and are out of scope. if (content.notification_type !== 'ring') return; if (!mDirectRef.current.has(room.roomId)) return; if (inCallRef.current) return; if (isRtcNotificationExpired(ev)) return; // Already participating in the room session → suppress duplicate toast. const session = mx.matrixRTC.getRoomSession(room); if (session.memberships.some((m) => m.sender === mx.getUserId())) return; const rel = ev.getRelation(); if (rel?.rel_type !== RelationType.Reference || !rel.event_id) return; const sender = ev.getSender(); if (!sender) return; const callId = await resolveCallId(mx, room, sender, rel.event_id); if (callId === undefined) return; const evId = ev.getId(); if (!evId) return; // Re-check membership after the (possibly networked) callId resolve — // a join event from another device could have landed during the await. if (session.memberships.some((m) => m.sender === mx.getUserId())) return; if (inCallRef.current) return; const key = getIncomingCallKey(callId, room.roomId); if (registry.has(key)) return; const timer = scheduleExpiry(key, ev); const unsubMemberships = subscribeMemberships(room); registry.set(key, { roomId: room.roomId, notifEventId: evId, timer, unsubMemberships, }); setIncoming({ type: 'ADD', key, call: { notifEvent: ev, roomId: room.roomId, callId }, }); }; const handleTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = ( ev, room, _toStartOfTimeline, _removed, data ) => { if (!data.liveEvent || !room) return; // Encrypted events land here as m.room.encrypted; kick decryption and let // the Decrypted handler pick them up once the cleartext is available. if (ev.isEncrypted()) { mx.decryptEventIfNeeded(ev).catch(() => {}); return; } processEvent(ev, room); }; const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (ev, err) => { if (err) return; const roomId = ev.getRoomId(); if (!roomId) return; const room = mx.getRoom(roomId); if (!room) return; // No liveEvent flag on Decrypted. Backfill safety relies on // `isRtcNotificationExpired` (ring branch) and `registry.has(key)` dedup; // a stale decline just no-ops via `removeByNotifId`. processEvent(ev, room); }; const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (ev) => { const redacted = ev.event.redacts; if (redacted) removeByNotifId(redacted); }; const handleSessionEnded = (roomId: string) => { removeByRoom(roomId); }; mx.on(RoomEvent.Timeline, handleTimeline); mx.on(MatrixEventEvent.Decrypted, handleDecrypted); mx.on(RoomEvent.Redaction, handleRedaction); mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded); return () => { mx.removeListener(RoomEvent.Timeline, handleTimeline); mx.removeListener(MatrixEventEvent.Decrypted, handleDecrypted); mx.removeListener(RoomEvent.Redaction, handleRedaction); mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded); registry.forEach((entry) => { clearTimeout(entry.timer); entry.unsubMemberships?.(); }); registry.clear(); }; }, [mx, setIncoming]); };