From 9e5fa6be3f91983f99a90d7c113bd7769979d226 Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Thu, 23 Apr 2026 19:57:27 +0300 Subject: [PATCH] Track declined notification IDs and re-check them after resolveCallId await to block stale rings when a decline lands during the async yield. --- src/app/hooks/useIncomingRtcNotifications.ts | 40 ++++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/src/app/hooks/useIncomingRtcNotifications.ts b/src/app/hooks/useIncomingRtcNotifications.ts index f2a1e61a..1f825b12 100644 --- a/src/app/hooks/useIncomingRtcNotifications.ts +++ b/src/app/hooks/useIncomingRtcNotifications.ts @@ -135,6 +135,11 @@ export const useIncomingRtcNotifications = (): void => { 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) { @@ -161,6 +166,7 @@ export const useIncomingRtcNotifications = (): void => { useEffect(() => { const registry = registryRef.current; + const declinedTimers = declinedTimersRef.current; const removeByKey = (key: string) => { const entry = registry.get(key); @@ -205,10 +211,22 @@ export const useIncomingRtcNotifications = (): void => { 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) removeByNotifId(rel.event_id); + if (rel?.event_id) { + rememberDeclined(rel.event_id); + removeByNotifId(rel.event_id); + } return; } @@ -230,15 +248,20 @@ export const useIncomingRtcNotifications = (): void => { 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; + if (declinedTimers.has(evId)) return; - // Re-check membership after the (possibly networked) callId resolve — - // a join event from another device could have landed during the await. + const callId = await resolveCallId(mx, room, sender, rel.event_id); + if (callId === undefined) 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())) return; + if (declinedTimers.has(evId)) return; const key = getIncomingCallKey(callId, room.roomId); if (registry.has(key)) return; @@ -284,8 +307,9 @@ export const useIncomingRtcNotifications = (): void => { 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`. + // `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); }; @@ -313,6 +337,8 @@ export const useIncomingRtcNotifications = (): void => { entry.unsubMemberships?.(); }); registry.clear(); + declinedTimers.forEach((timer) => clearTimeout(timer)); + declinedTimers.clear(); }; }, [mx, setIncoming]); };