[decide] Threads: opening a room marks every thread in it read — chip/list unread badges never survive a room visit #217

Closed
opened 2026-09-18 21:31:31 -04:00 by jared · 2 comments
Owner

From the #165 audit. utils/notifications.ts markAsRead (fired by RoomTimeline whenever the room is at the bottom and focused) also sends a threaded receipt for every thread with unread replies. Result: a reply in a thread you participate in lights the chip / Threads-list badge only until you glance at the room — then it is gone, even though you never opened the thread.

Discord and Slack keep thread unreads until the thread itself is opened; the room-level receipt was added so the room dot does not stay lit forever. Options: (a) keep room-level clearing (current), (b) clear threads only when the thread panel/list is opened and subtract muted/unread threads from the room dot differently, (c) middle ground: room visit clears non-participating threads only. Product call — your pick, then it is a small change.

Side effect today: two identical receipts per reply (panel + room-level), harmless.

From the #165 audit. `utils/notifications.ts` `markAsRead` (fired by RoomTimeline whenever the room is at the bottom and focused) also sends a threaded receipt for every thread with unread replies. Result: a reply in a thread you participate in lights the chip / Threads-list badge only until you glance at the room — then it is gone, even though you never opened the thread. Discord and Slack keep thread unreads until the thread itself is opened; the room-level receipt was added so the room dot does not stay lit forever. Options: (a) keep room-level clearing (current), (b) clear threads only when the thread panel/list is opened and subtract muted/unread threads from the room dot differently, (c) middle ground: room visit clears *non-participating* threads only. Product call — your pick, then it is a small change. Side effect today: two identical receipts per reply (panel + room-level), harmless.
jared added this to the Features 2026-Q4 milestone 2026-09-18 21:31:31 -04:00
jared added the priority: mediumuxarea: threadsplanning labels 2026-09-18 21:31:31 -04:00
Author
Owner

Reproduced headlessly (2026-09-23) while auditing #165: with the room open, a bob reply in a thread alice participates in moved the chip from "1 reply" to "2 replies" with no unread marker anywhere — chip, Threads-list row and room dot all clean, without alice ever opening the thread. The Threads list showed 0 unread rows; to test the new "Mark all read" chip at all I had to scroll the room up so markAsRead wouldn't fire.

The code is utils/notifications.ts:53-66: markAsRead walks every thread with a non-zero unread count and sends a threaded receipt at its lastReply(). RoomTimeline calls it whenever the room is at the bottom and focused.

Recommendation: option (c). On a room visit, clear only threads where thread.hasCurrentUserParticipated === false — those only ever lit the room dot, and leaving them unread forever is the problem the room-level receipt was added to solve. Threads you have participated in stay unread until their panel is opened (which already sends its own receipt). That is Slack/Discord behaviour, keeps the room dot honest, and is a three-line change plus a test:

threads.map((thread) => {
  const unread = room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
  if (unread <= 0) return undefined;
  if (thread.hasCurrentUserParticipated) return undefined; // [#217] keep mine unread
  …
})

Pairs with #165 finding 3 (the chip has no unread affordance today, so the reply would be invisible on the root message until that's added — worth doing in the same batch).

Options (a) keep-as-is and (b) clear-only-on-thread-open are both one-liners too; say which and I'll ship it.

Reproduced headlessly (2026-09-23) while auditing #165: with the room open, a bob reply in a thread alice participates in moved the chip from "1 reply" to "2 replies" with **no unread marker anywhere** — chip, Threads-list row and room dot all clean, without alice ever opening the thread. The Threads list showed 0 unread rows; to test the new "Mark all read" chip at all I had to scroll the room up so `markAsRead` wouldn't fire. The code is `utils/notifications.ts:53-66`: `markAsRead` walks **every** thread with a non-zero unread count and sends a threaded receipt at its `lastReply()`. RoomTimeline calls it whenever the room is at the bottom and focused. **Recommendation: option (c).** On a room visit, clear only threads where `thread.hasCurrentUserParticipated === false` — those only ever lit the room dot, and leaving them unread forever is the problem the room-level receipt was added to solve. Threads you have participated in stay unread until their panel is opened (which already sends its own receipt). That is Slack/Discord behaviour, keeps the room dot honest, and is a three-line change plus a test: ```ts threads.map((thread) => { const unread = room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0; if (unread <= 0) return undefined; if (thread.hasCurrentUserParticipated) return undefined; // [#217] keep mine unread … }) ``` Pairs with #165 finding 3 (the chip has no unread affordance today, so the reply would be invisible on the root message until that's added — worth doing in the same batch). Options (a) keep-as-is and (b) clear-only-on-thread-open are both one-liners too; say which and I'll ship it.
Author
Owner

Shipped: option (c), a8db61f7

Just looking at a room no longer clears threads you follow. A followed thread is one you started, replied in, or were @mentioned in. It stays unread until you open its panel. Threads you don't follow are still cleared by a room visit, so they don't keep the room dot lit forever. An explicit Mark as read (room menu, Escape, bulk "mark all read") still clears everything.

While testing I found a second path besides the per-thread receipts. The room-level receipt is unthreaded, and an unthreaded receipt also reads every thread reply older than it. So the next main-timeline message wiped the thread anyway. The fix: when a followed thread has an unread reply, a room visit sends the main receipt scoped to thread_id: "main".

The "is it unread?" check had to be race-proof too. When a reply and a main message arrive in the same sync, the thread's count is still 0, so the check also asks whether the latest reply has been read.

Verified against the server's own per-thread counts (unread_thread_notifications), with two threads, Alice's and Bob's:

step Bob's thread (Alice not in it) Alice's thread
before visiting 1 1
Alice views the room 0 (cleared) 1 (kept)
Bob replies in Alice's thread, then posts a newer main message, room open 0 2 (kept, main count 0). This step was cleared before the second fix
Alice goes elsewhere sidebar row shows 2
comes back still 2
opens the thread 0

Duplicate receipts: with a thread you don't follow open in the panel, each new reply sent two identical receipts. It's now one: the room pass skips the thread the panel has open. Followed threads were already down to one.

Tests:

  • 7 new unit tests in notifications.test.ts, covering each rule, the lagging-count case and explicit mark-read.
  • A new local-homeserver e2e: "a thread I started keeps its unread replies while I view the room". It asserts on the chip's accessible name and the server count.
  • The full suite passes except #212, the known flaky test that also fails on the base branch.

chip keeps its unread dot while the room is open
sidebar badge while away

## Shipped: option (c), `a8db61f7` Just looking at a room no longer clears threads you **follow**. A followed thread is one you started, replied in, or were @mentioned in. It stays unread until you open its panel. Threads you don't follow are still cleared by a room visit, so they don't keep the room dot lit forever. An explicit **Mark as read** (room menu, Escape, bulk "mark all read") still clears everything. While testing I found a second path besides the per-thread receipts. The room-level receipt is *unthreaded*, and an unthreaded receipt also reads every thread reply older than it. So the next main-timeline message wiped the thread anyway. The fix: when a followed thread has an unread reply, a room visit sends the main receipt scoped to `thread_id: "main"`. The "is it unread?" check had to be race-proof too. When a reply and a main message arrive in the same sync, the thread's count is still 0, so the check also asks whether the latest reply has been read. **Verified against the server's own per-thread counts** (`unread_thread_notifications`), with two threads, Alice's and Bob's: | step | Bob's thread (Alice not in it) | Alice's thread | |---|---|---| | before visiting | 1 | 1 | | Alice views the room | **0** (cleared) | **1** (kept) | | Bob replies in Alice's thread, then posts a newer main message, room open | 0 | **2** (kept, main count 0). This step was **cleared** before the second fix | | Alice goes elsewhere | sidebar row shows **2** | | | comes back | | still 2 | | opens the thread | | **0** | **Duplicate receipts:** with a thread you don't follow open in the panel, each new reply sent two identical receipts. It's now one: the room pass skips the thread the panel has open. Followed threads were already down to one. Tests: - 7 new unit tests in `notifications.test.ts`, covering each rule, the lagging-count case and explicit mark-read. - A new local-homeserver e2e: "a thread I started keeps its unread replies while I view the room". It asserts on the chip's accessible name and the server count. - The full suite passes except #212, the known flaky test that also fails on the base branch. ![chip keeps its unread dot while the room is open](https://code.lotusguild.org/attachments/1091693d-3a14-485e-b72e-abf96ca1190b) ![sidebar badge while away](https://code.lotusguild.org/attachments/470c87e3-8be8-4734-90ef-4d725da413d7)
jared closed this issue 2026-09-26 19:31:38 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: LotusGuild/cinny#217