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<roomId,eventId>
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 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 17:00:52 -04:00
co-authored by Claude Opus 4.8
parent 726cefb5ab
commit 1f80d1d129
2 changed files with 19 additions and 11 deletions
+5 -3
View File
@@ -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;
+14 -8
View File
@@ -373,8 +373,10 @@ function StatusExpiryMonitor() {
function MessageNotifications() {
const audioRef = useRef<HTMLAudioElement>(null);
// Notify dedupe, keyed `${roomId}|${threadId ?? 'main'}` -> last notified
// eventId, so the main timeline and each thread dedupe independently.
const lastNotifiedEventRef = useRef<Map<string, string>>(new Map());
// Per-thread dedupe: threadId -> last notified eventId.
// Per-thread dedupe (thread-detection gate): threadId -> last notified eventId.
const lastNotifiedThreadRef = useRef<Map<string, string>>(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 ||