From 1f80d1d129bb831d50a0ff53d6ec8b35719405a7 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 18 Jul 2026 17:00:52 -0400 Subject: [PATCH] fix(correctness): call-join reset on embed swap + per-path notify dedupe COR-2 (useCallEmbed): useCallJoined only reset `joined` when the embed became undefined. Answering a 2nd call swaps the embed A->B directly (embed stays truthy), so `joined` stayed true and call B rendered as already-joined, skipping the loading/watchdog UI. Re-seed from `embed?.joined ?? false` on every embed identity change. COR-4 (ClientNonUIFeatures): the notify-dedupe used one Map slot shared by the main-timeline and per-thread paths, so a thread reply overwrote the room's slot and a re-fired main message (decrypt/edit re-emit, common in E2EE) then mismatched and double-notified. Key the slot by `${roomId}|${threadId ?? 'main'}` so each path dedupes independently. Both verified correct by two review passes (no missed-notification or missed-join regressions). Co-Authored-By: Claude Opus 4.8 --- src/app/hooks/useCallEmbed.ts | 8 ++++--- src/app/pages/client/ClientNonUIFeatures.tsx | 22 +++++++++++++------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/app/hooks/useCallEmbed.ts b/src/app/hooks/useCallEmbed.ts index 5180ff6b3..97b08defa 100644 --- a/src/app/hooks/useCallEmbed.ts +++ b/src/app/hooks/useCallEmbed.ts @@ -149,9 +149,11 @@ export const useCallJoined = (embed?: CallEmbed): boolean => { ); useEffect(() => { - if (!embed) { - setJoined(false); - } + // Re-seed from the (new) embed whenever it changes identity, not only when + // it becomes undefined. Answering a 2nd call swaps embed A→B directly (embed + // stays truthy), so a plain `if (!embed)` reset left `joined` stuck true and + // B rendered as already-joined, skipping the loading/watchdog UI. + setJoined(embed?.joined ?? false); }, [embed]); return joined; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 9c19a1c67..2bafcffbb 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -373,8 +373,10 @@ function StatusExpiryMonitor() { function MessageNotifications() { const audioRef = useRef(null); + // Notify dedupe, keyed `${roomId}|${threadId ?? 'main'}` -> last notified + // eventId, so the main timeline and each thread dedupe independently. const lastNotifiedEventRef = useRef>(new Map()); - // Per-thread dedupe: threadId -> last notified eventId. + // Per-thread dedupe (thread-detection gate): threadId -> last notified eventId. const lastNotifiedThreadRef = useRef>(new Map()); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); @@ -513,12 +515,16 @@ function MessageNotifications() { const eventId = mEvent.getId(); if (!sender || !eventId) return; - // Dedupe on the event id (per room): the same event can re-fire (decryption, - // edit, thread repopulation). This replaces the old unread-COUNT dedupe, - // which suppressed a genuinely-new message whenever its post-read count - // matched the previously-notified count — i.e. "read a DM, next message - // never notifies/sounds" (the common one-at-a-time cadence). - if (lastNotifiedEventRef.current.get(room.roomId) === eventId) return; + // Dedupe on the event id (per room AND per path): the same event can + // re-fire (decryption, edit, thread repopulation). The main timeline and + // each thread get their own slot — a shared per-room slot let a thread + // reply overwrite the main slot, so a re-fired main message then mismatched + // and double-notified. This replaces the old unread-COUNT dedupe, which + // suppressed a genuinely-new message whenever its post-read count matched + // the previously-notified count (the "read a DM, next message never + // notifies/sounds" one-at-a-time cadence). + const dedupeKey = `${room.roomId}|${threadId ?? 'main'}`; + if (lastNotifiedEventRef.current.get(dedupeKey) === eventId) return; // Main-timeline path respects push rules: don't notify when the room has no // notification count (e.g. a non-mention in a Mentions-only room). The @@ -527,7 +533,7 @@ function MessageNotifications() { // override in a Mentions-only room is silently dropped. if (!threadId && getUnreadInfo(room).total === 0) return; - lastNotifiedEventRef.current.set(room.roomId, eventId); + lastNotifiedEventRef.current.set(dedupeKey, eventId); const quietActive = focusAssistActive ||