Track declined notification IDs and re-check them after resolveCallId await to block stale rings when a decline lands during the async yield.

This commit is contained in:
v.lagerev 2026-04-23 19:57:27 +03:00
parent be2019daeb
commit 9e5fa6be3f

View file

@ -135,6 +135,11 @@ export const useIncomingRtcNotifications = (): void => {
const registryRef = useRef<Map<string, RegistryEntry>>(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<Map<string, ReturnType<typeof setTimeout>>>(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<void> => {
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]);
};