fix(notifications): safe thread receipts on mark-read (fixes read-receipt regression)
The prior thread-receipt change (8192da5a) broke read receipts globally. Exact
cause: markAsRead used `thread.lastReply() ?? thread.rootEvent`. When a thread's
replies weren't loaded (lastReply() null — common on room open), it sent a
receipt for the thread ROOT. Since roots are "in the main timeline",
threadIdForReceipt() makes that a MAIN receipt at an old event; when the root
isn't in the loaded timeline the SDK's backward-guard falls back to timestamp
and applies it, moving the main read receipt onto an event we don't have, so
getEventReadUpTo() returns null and roomHaveUnread() reports the room unread —
re-broken on every mark-read, amplified by the bulk mark-all-orphan-rooms-read
callers.
Fix: main unthreaded receipt unchanged; the thread loop now sends a threaded
receipt ONLY for a genuine loaded thread reply (thread.lastReply()), never the
root — if replies aren't loaded, skip. New notifications.test.ts locks the
regression (null lastReply → no root receipt) + the main/threaded/no-op cases.
Gates: tsc/eslint/prettier clean, build OK, 672 tests (7 new).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -25,25 +25,33 @@ export async function markAsRead(mx: MatrixClient, roomId: string, privateReceip
|
||||
if (latestEvent) {
|
||||
// Unthreaded receipt: with client threadSupport enabled the SDK would
|
||||
// otherwise scope this to the main timeline (thread_id: "main"). Unthreaded
|
||||
// clears the main timeline + every event up to this one, in any thread.
|
||||
// clears the main timeline + every event up to this one.
|
||||
await mx.sendReadReceipt(latestEvent, receiptType, true);
|
||||
}
|
||||
|
||||
// ...but a thread reply NEWER than the main-timeline tail is not covered by
|
||||
// the receipt above (threadSupport moves thread replies out of the main
|
||||
// timeline), so its per-thread notification count — and the room's unread dot,
|
||||
// which sums thread counts — would linger even after "reading" the room. Send
|
||||
// a threaded receipt at the latest reply of every thread that still has unread
|
||||
// counts. Also runs when the main timeline is already read (latestEvent null).
|
||||
// Clear per-thread notification counts too — the room's unread dot sums them,
|
||||
// so an unread thread reply keeps the dot lit even after the main timeline is
|
||||
// read (threadSupport moves thread replies out of the main timeline, so the
|
||||
// unthreaded receipt above doesn't necessarily cover them).
|
||||
//
|
||||
// CRITICAL: only send for a GENUINE loaded thread reply, via thread.lastReply().
|
||||
// NEVER fall back to the thread root: a root event is "in the main timeline",
|
||||
// so sendReadReceipt(root, false) resolves (via threadIdForReceipt) to a MAIN
|
||||
// receipt at that old root event. If the root isn't in the loaded timeline it
|
||||
// moves the main read receipt onto an event we don't have -> getEventReadUpTo()
|
||||
// returns null -> the room is reported unread on every mark-read call (this was
|
||||
// the P6 regression, amplified by the bulk mark-all-orphan-rooms-read callers).
|
||||
// If a thread's replies aren't loaded (lastReply() null), just skip it.
|
||||
const threads = room.getThreads();
|
||||
await Promise.all(
|
||||
threads.map((thread) => {
|
||||
const unread =
|
||||
room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
|
||||
if (unread <= 0) return undefined;
|
||||
const lastReply = thread.lastReply() ?? thread.rootEvent;
|
||||
const lastReply = thread.lastReply();
|
||||
if (!lastReply || lastReply.isSending()) return undefined;
|
||||
// Threaded receipt (unthreaded = false → the SDK scopes it to this thread).
|
||||
// Threaded receipt (unthreaded = false → the SDK scopes it to this thread
|
||||
// via the reply's real threadRootId; it never touches the main marker).
|
||||
return mx.sendReadReceipt(lastReply, receiptType, false).catch(() => undefined);
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user