// Incoming DM ring: watches `m.rtc.notification` in the live timeline and // populates `incomingCallsAtom` so the bottom strip can render. // // Encrypted DMs: `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: 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'; import { callForegroundService } from '../plugins/call/callForegroundService'; // 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) => { let timeout: ReturnType; const handler = () => { const found = findCallIdSync(mx, room, sender, membershipEventId); if (found !== undefined) { clearTimeout(timeout); session.off(MatrixRTCSessionEvent.MembershipsChanged, handler); resolve(found); } }; timeout = setTimeout(() => { session.off(MatrixRTCSessionEvent.MembershipsChanged, handler); resolve(undefined); }, 5000); 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; // Sender-reported origin timestamp (ms since epoch). Used for // latest-wins-by-senderTs on same-room second rings: a late-arriving older // ring must not evict a newer one already seated. See processEvent below. senderTs: number; 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 registryRef = useRef>(new Map()); // Notification IDs whose RTCDecline already arrived — stops a late-decrypted // RTCNotification from resurrecting an already-declined ring when decline is // cleartext (killed-state receiver path) but notification is encrypted. const declinedTimersRef = 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 hook-local 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. // // Also catches the external-remove paths for the Android native ring // registry: removals issued by strip buttons, pendingCallActionConsumer, // and the callEmbed join effect go through setIncoming directly without // touching the hook-local registry — this effect is the single place we can // call removeIncomingRing on the bridge for those paths. Internal removals // (removeByKey) call removeIncomingRing inline so they don't rely on this // effect seeing the registry entry. 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?.(); callForegroundService.removeIncomingRing(entry.notifEventId).catch(() => { /* best-effort — registry tombstones the eventId regardless */ }); registry.delete(key); } }); }, [incoming]); useEffect(() => { const registry = registryRef.current; const declinedTimers = declinedTimersRef.current; const removeByKey = (key: string) => { const entry = registry.get(key); if (!entry) return; clearTimeout(entry.timer); entry.unsubMemberships?.(); // Bottom of the internal-removal stack (removeByRoom / removeByNotifId // funnel through here). Bridge the native ring registry remove before // clearing the hook-local registry: the [incoming] sync-effect above // only sees entries still present at atom-change time, so if we // deleted here and then setIncoming, the effect would miss this removal // and the native registry would keep the entry. External paths (strip // buttons, pendingCallActionConsumer) keep registry intact across the // atom change and are handled by the sync-effect. callForegroundService.removeIncomingRing(entry.notifEventId).catch(() => { /* best-effort — registry tombstones the eventId regardless */ }); 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 rememberDeclined = (notifEventId: string) => { const existing = declinedTimers.get(notifEventId); if (existing) clearTimeout(existing); const timer = setTimeout(() => { declinedTimers.delete(notifEventId); }, RTC_NOTIFICATION_DEFAULT_LIFETIME); declinedTimers.set(notifEventId, timer); }; const processEvent = async (ev: MatrixEvent, room: Room): Promise => { if (ev.getType() === EventType.RTCDecline) { const rel = ev.getRelation(); if (rel?.event_id) { rememberDeclined(rel.event_id); removeByNotifId(rel.event_id); // Explicit forget for the decline-first race: if the notification // processEvent hasn't run yet (or hasn't reached ADD because // resolveCallId is mid-await), registry doesn't own the notifEventId // and removeByNotifId is a no-op. The Java registry may still hold // the FCM seed — a render on next backgrounding would resurrect // an already-declined ring. callForegroundService.removeIncomingRing(rel.event_id).catch(() => { /* best-effort */ }); } return; } if (ev.getType() !== EventType.RTCNotification) return; if (ev.getSender() === mx.getSafeUserId()) return; const evId = ev.getId(); // The Java-side ring registry may already hold an entry for this // eventId from an FCM seed that landed before /sync delivered the event // to us. Every suppress-return below must remove+tombstone the entry so // the next MainActivity.onPause renderRegistry doesn't surface native // CallStyle for a ring JS decided not to ring. Paths we don't explicitly // remove here (missing evId / malformed rel): registry lifetime check // in isExpired remains the only safety net. const removeFromRegistry = () => { if (evId) { callForegroundService.removeIncomingRing(evId).catch(() => { /* best-effort */ }); } }; const content = ev.getContent(); // Only DM ring — group call notifications use 'notification' type and are out of scope. // FCM path only seeds registry for 'ring' content_notification_type, so no entry to remove. if (content.notification_type !== 'ring') return; if (!mDirectRef.current.has(room.roomId)) { removeFromRegistry(); return; } if (isRtcNotificationExpired(ev)) { removeFromRegistry(); 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())) { removeFromRegistry(); return; } const rel = ev.getRelation(); if (rel?.rel_type !== RelationType.Reference || !rel.event_id) return; const sender = ev.getSender(); if (!sender) return; if (!evId) return; if (declinedTimers.has(evId)) { removeFromRegistry(); return; } const callId = await resolveCallId(mx, room, sender, rel.event_id); if (callId === undefined) { removeFromRegistry(); return; } // Re-check anything that can change during the await. resolveCallId can // yield for seconds (5s MembershipsChanged wait, then fetchRoomEvent) — // a membership join or a matching decline can land meanwhile and must be // observed before we commit the ADD. if (session.memberships.some((m) => m.sender === mx.getUserId())) { removeFromRegistry(); return; } if (declinedTimers.has(evId)) { removeFromRegistry(); return; } const key = getIncomingCallKey(callId, room.roomId); const senderTs = getNotificationEventSendTs(ev); // Latest-wins per-room, compared by senderTs. A late FCM retry / // reordered /sync / decrypt lag can deliver an older ring AFTER a // newer same-room ring already seated in registry. Naive last-observed // wins would let the stale event evict the newer one. Only evict when // the incoming event's senderTs is newer than the existing same-room // entry's; if incoming is older, drop it (return) so the newer ring // keeps the slot. Native registry enforces the symmetric invariant in // VojoFirebaseMessagingService.upsertIncomingRing. const sameRoomStaler = Array.from(registry.values()).filter( (e) => e.roomId === room.roomId && e.notifEventId !== evId ); const incomingIsOlder = sameRoomStaler.some( (e) => e.senderTs > 0 && senderTs > 0 && senderTs < e.senderTs ); if (incomingIsOlder) { // Stale same-room event — don't ADD, don't evict. Native side will // tombstone via its own check when our upsert hits the bridge; here // we also bridge the removeIncomingRing so any FCM seed for this // stale eventId goes away. callForegroundService.removeIncomingRing(evId).catch(() => { /* best-effort */ }); return; } Array.from(registry.entries()) .filter(([, entry]) => entry.roomId === room.roomId && entry.notifEventId !== evId) .forEach(([supersededKey]) => removeByKey(supersededKey)); // Duplicate processEvent invocation (e.g., timeline + decrypted path for // the same event). The earlier one owns the ring lifecycle — do NOT // remove here or we'd tombstone an eventId the first invocation is // about to ADD. if (registry.has(key)) return; const timer = scheduleExpiry(key, ev); const unsubMemberships = subscribeMemberships(room); registry.set(key, { roomId: room.roomId, notifEventId: evId, senderTs, timer, unsubMemberships, }); // Happy-path upsert into the native ring registry. Idempotent // with any prior FCM seed for the same eventId — Java merges metadata // append-only, so we pass only the fields JS has reliable access to. const senderMember = room.getMember(sender); const callerName = senderMember?.rawDisplayName || room.name || sender || 'Vojo'; const lifetime = content.lifetime ?? RTC_NOTIFICATION_DEFAULT_LIFETIME; callForegroundService .upsertIncomingRing({ eventId: evId, roomId: room.roomId, callerName, senderTs, lifetime, }) .catch(() => { /* best-effort — FCM seed likely already populated it anyway */ }); 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(() => undefined); 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), `registry.has(key)` dedup, // and `declinedTimersRef` (blocks a late-decrypted notification whose // decline already landed cleartext-first on the timeline). processEvent(ev, room); }; const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (ev) => { // v11+ rooms moved `redacts` into content; matrix-js-sdk mirrors the // older top-level field when possible but the content fallback is // the safety net. const redacted = ev.event.redacts ?? (ev.getContent() as { redacts?: string } | undefined)?.redacts; if (!redacted) return; // Symmetric with RTCDecline: if the redaction arrives before // the notification's processEvent reaches ADD (race via // handleTimeline / handleDecrypted), registry has no entry → // removeByNotifId is a no-op, but the Java ring registry may hold // an FCM seed that would render on next onPause. // - rememberDeclined blocks any late ADD attempt for this eventId. // - bridge removeIncomingRing tombstones the native-side entry. // - removeByNotifId covers the common case where atom already has it. rememberDeclined(redacted); removeByNotifId(redacted); callForegroundService.removeIncomingRing(redacted).catch(() => { /* best-effort */ }); }; 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); // Sync the Java ring registry on teardown: process-global state // outlives this hook, so any live entry left unremoved will be rendered // by the next MainActivity.onPause for a ring no JS owner exists for. // Reasons to hit this path: logout, client swap, provider remount, // hot-reload in dev. Best-effort bridge — registry tombstones each // eventId for the ring lifetime so a late FCM re-delivery can't // resurrect the zombie. registry.forEach((entry) => { clearTimeout(entry.timer); entry.unsubMemberships?.(); callForegroundService.removeIncomingRing(entry.notifEventId).catch(() => { /* best-effort */ }); }); registry.clear(); declinedTimers.forEach((timer) => clearTimeout(timer)); declinedTimers.clear(); }; }, [mx, setIncoming]); };