fix(notifications/threads): Wave-1 audit fixes (🔴 + web 🟠)
- T1 (🔴): markThreadAsRead no longer receipts the thread ROOT (a 2nd instance of the read-marker-corruption regression — opening a thread whose root is old re-lit the whole room). Extracted to a pure threadReceipt.ts + 5 regression tests. - N1 (🔴): favicon/tab-title unread count now sums only leaf rooms (was double- counting every ancestor-space aggregate in roomToUnread). - N2 (🔴): notifications/sounds dedupe on the event id, not the unread count — fixes "read a DM, next message never notifies again". - T4 (🟠): the thread notification path no longer re-gates on the room count, so an explicit per-thread "All replies" override in a Mentions-only room fires. - N3 (🟠): getUnreadInfos skips phantom {0,0} entries (muted-thread-only rooms no longer light the nav row / pollute unread filters). - N4 (🟠): the Receipt handler recomputes unread instead of blanket-DELETE, so a threaded receipt can't wipe a room's valid main-timeline badge. - T2 (🟠): thread "Jump to Latest" re-anchors the virtual window (was landing on a stale mid/old event). Gates: tsc/eslint/prettier clean, build OK, 678 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -460,12 +460,17 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
}, [scrollToBottomCount]);
|
||||
|
||||
const handleJumpToBottom = useCallback(() => {
|
||||
// Re-anchor the virtual window at the thread tail first. While scrolled up,
|
||||
// live replies deliberately don't extend the window, so without this the chip
|
||||
// would scroll to the bottom of the STALE window (a mid/old event) instead of
|
||||
// the newest reply. Mirrors the main timeline's handleJumpToLatest.
|
||||
setTimeline(getInitialThreadTimeline(thread, getLinkedTimelines(thread.liveTimeline)));
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = true;
|
||||
// Flip atBottom so the layout effect re-runs (count re-read) and live
|
||||
// events resume sticking to the bottom.
|
||||
setAtBottom(true);
|
||||
}, []);
|
||||
}, [thread]);
|
||||
|
||||
// Scroll in-place editor into view.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ReceiptType } from 'matrix-js-sdk';
|
||||
import { markThreadAsRead } from './threadReceipt';
|
||||
|
||||
// The regression this guards: sending a receipt for the thread ROOT (when
|
||||
// replies aren't loaded, lastReply() is null / equals the root) becomes a MAIN
|
||||
// receipt at an old event and drags the room's read marker backwards. It must
|
||||
// only ever receipt a genuine loaded reply.
|
||||
|
||||
const evt = (id: string, sending = false) => ({ getId: () => id, isSending: () => sending }) as any;
|
||||
|
||||
const setup = (lastReply: any) => {
|
||||
const calls: Array<{ eventId: string; type: ReceiptType }> = [];
|
||||
const thread = { id: '$root', lastReply: () => lastReply } as any;
|
||||
const mx = {
|
||||
sendReadReceipt: async (e: any, type: ReceiptType) => {
|
||||
calls.push({ eventId: e.getId(), type });
|
||||
return {};
|
||||
},
|
||||
} as any;
|
||||
return { mx, thread, calls };
|
||||
};
|
||||
|
||||
test('REGRESSION: no loaded reply (lastReply null) → NO receipt (never the root)', async () => {
|
||||
const { mx, thread, calls } = setup(null);
|
||||
await markThreadAsRead(mx, thread, false);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('REGRESSION: lastReply IS the root → NO receipt', async () => {
|
||||
const { mx, thread, calls } = setup(evt('$root'));
|
||||
await markThreadAsRead(mx, thread, false);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('genuine loaded reply → threaded receipt at that reply', async () => {
|
||||
const { mx, thread, calls } = setup(evt('$reply'));
|
||||
await markThreadAsRead(mx, thread, false);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].eventId, '$reply');
|
||||
assert.equal(calls[0].type, ReceiptType.Read);
|
||||
});
|
||||
|
||||
test('sending reply is skipped', async () => {
|
||||
const { mx, thread, calls } = setup(evt('$reply', true));
|
||||
await markThreadAsRead(mx, thread, false);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('private flag uses ReadPrivate', async () => {
|
||||
const { mx, thread, calls } = setup(evt('$reply'));
|
||||
await markThreadAsRead(mx, thread, true);
|
||||
assert.equal(calls[0].type, ReceiptType.ReadPrivate);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MatrixClient, ReceiptType, Thread } from 'matrix-js-sdk';
|
||||
|
||||
/**
|
||||
* Send a threaded read receipt for a thread, clearing its per-thread unread
|
||||
* count.
|
||||
*
|
||||
* CRITICAL: never receipt the thread ROOT. A thread's liveTimeline is
|
||||
* `[root, reply1, …]`, so the latest event IS the root when replies aren't
|
||||
* loaded yet (common — the thread panel fires this on mount before replies
|
||||
* fetch). The root is "in the main timeline", so a receipt for it is written by
|
||||
* the SDK with `thread_id:"main"` at the old root, dragging the room's MAIN read
|
||||
* marker backwards (`getEventReadUpTo` → an old/unloaded event) and re-lighting
|
||||
* the whole room. We only receipt a genuine loaded reply (`thread.lastReply()`);
|
||||
* if none is loaded we bail (the per-thread count clears when the reply loads
|
||||
* and this runs again). Mirrors the root guard in `utils/notifications.ts`.
|
||||
*
|
||||
* Pure (no React/CSS) so it can be unit-tested — see `threadReceipt.test.ts`.
|
||||
*/
|
||||
export const markThreadAsRead = async (
|
||||
mx: MatrixClient,
|
||||
thread: Thread,
|
||||
privateReceipt: boolean,
|
||||
): Promise<void> => {
|
||||
const lastReply = thread.lastReply();
|
||||
if (!lastReply || lastReply.isSending() || lastReply.getId() === thread.id) return;
|
||||
|
||||
await mx.sendReadReceipt(lastReply, privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read);
|
||||
};
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
EventTimeline,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
ReceiptType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
RoomEventHandlerMap,
|
||||
@@ -146,32 +145,6 @@ export const useThreadPendingEvents = (
|
||||
return pending;
|
||||
};
|
||||
|
||||
/**
|
||||
* Send a threaded read receipt up to the latest confirmed event in the thread.
|
||||
*
|
||||
* The receipt is threaded by default (scoped to this thread), which clears the
|
||||
* per-thread unread count. Mirrors the latest-valid-event scan in
|
||||
* `utils/notifications.ts`.
|
||||
*/
|
||||
export const markThreadAsRead = async (
|
||||
mx: MatrixClient,
|
||||
thread: Thread,
|
||||
privateReceipt: boolean,
|
||||
): Promise<void> => {
|
||||
const events = thread.liveTimeline.getEvents();
|
||||
|
||||
let latestEvent: MatrixEvent | undefined;
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const evt = events[i];
|
||||
if (evt && !evt.isSending()) {
|
||||
latestEvent = evt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!latestEvent) return;
|
||||
|
||||
await mx.sendReadReceipt(
|
||||
latestEvent,
|
||||
privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read,
|
||||
);
|
||||
};
|
||||
// markThreadAsRead moved to ./threadReceipt (pure + unit-tested); re-exported
|
||||
// here for existing import sites.
|
||||
export { markThreadAsRead } from './threadReceipt';
|
||||
|
||||
Reference in New Issue
Block a user