Files
cinny/src/app/features/room/thread/threadReceipt.ts
T
jaredandClaude Opus 4.8 0bbdd7ce94 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>
2026-07-02 20:10:32 -04:00

29 lines
1.3 KiB
TypeScript

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);
};