Skip to content

feat(unread): opening a channel quiets its sidebar row - #2998

Open
baxen wants to merge 6 commits into
mainfrom
mbot/channel-seen
Open

baxen wants to merge 6 commits into
mainfrom
mbot/channel-seen

Conversation

@baxen

@baxen baxen commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

What

Opening a channel or DM now clears its sidebar row: the bold name, the mention dot and the thread dot. The Dock dot follows the rows. Nothing gets marked read. Unread threads keep their dots in the timeline, in Activity → Threads and in the channel activity popover, and New messages still marks the entry boundary. Anything that arrives after you leave lights the row again.

Requested in Buzz: clear the channel dot on click, but leave threads nominally unread (clear mentions too; Dock follows the row; top-level messages are not marked read on click).

How

  • seen:<channel> frontier (unread.ts). enterChannel hides everything in the row in memory for the whole visit, including arrivals during it. leaveChannel records the newest retained verified event time and saves it as seen:<channel>, but only when that save would actually hide something the saved mark doesn't already hide. Revisiting a quiet channel writes nothing.
  • UnreadSnapshot.row: channel snapshots gain row counts (observedCount, attentionCount, threadCount) that leave out evidence through the seen cut. Under explicit unread intent (manual channel unread, a message marked unread) row is omitted, so the row shows the whole channel again, thread dot included. observedCount/attentionCount are unchanged.
  • Consumers that follow the row: UnreadBadge (bold, unread state, mention dot, thread dot), ordinaryUnread, the Dock indicator, useUnreadChannelIds(…, shown = true) (only the sidebar Unreads filter). The floating unread cues read the badge DOM, so they follow automatically. Unread-first search, the Solo inventory, mark-read menus and shortcuts, the timeline boundary, Activity and the popover keep the raw unread.
  • No boundary regressions: the seen save goes through a new reads.see(), which bumps the journal but not unread.revision() (intentRevision). Otherwise a seen save landing just after the next channel's visit capture would drop that channel's New messages divider.
  • Retention: seen marks rank last, and override ancestry never pins them. A channel read covers them.

Sync cost: seen:<channel> rides the existing n-blob NIP-RS publication (#1286), debounced at 5s; one ~55-byte key per channel, overwritten in place. Attention receipts (seen:msg:<id>) stay on this device and are never published.

Tests

  • unread.test.ts: row quiets on open while observed counts, Activity and per-message attention stay unread; arrivals during the visit; seen: saved on leave; revision() unchanged by the seen save (fails with reads.revision); a later reply relights; a preloaded mark quiets after reload; marking a message or the channel unread omits row; a quiet revisit writes nothing.
  • read-state-retention.test.ts: seen marks are not override ancestry and go last (fails without the change: "Read override ancestry capacity reached").
  • ChannelSidebarItem.test.tsx: row counts drive the badge markers.
  • indicator-unread.test.ts: the Dock clears on open and relights on a later arrival.
  • Browser journeys: the fixtures open with Alpha selected, so journeys that asserted Alpha's row unread while it was open now read the engine (tests/browser/unread-state.mjs) or read state, and assert the row is quiet. sidebar-unread uses Beta for the ordinary-unread row and cue; sidebar-collapsed-unread leaves Alpha and publishes a later broadcast reply to relight it. The selected-unread-row ink check is gone: an open row is only unread through Mark unread now.
  • pnpm typecheck, full vitest run (1031 files), and the changed journeys in chromium and webkit locally.

Deferred

  • Not exercised in the running app yet. Human test: open a channel with an unread followed-thread reply and a mention; the row clears at once; the thread dot still shows on its reply summary and in Activity → Threads; leave, post a new reply from another account, and the row relights; relaunch, and quiet rows stay quiet.
  • Older clients observe seen: from peers but ignore it, and never republish another device's value (feat(read-state): n-blob NIP-RS sync with a 30-day floor, frontier only #1286 own-frontier rule).

Attention receipts (907b5a46, Lain + Meli)

The row cut was a timestamp, so a mention delivered after the visit at the same second, or backdated by a slower sender clock, stayed hidden in the sidebar and Dock. Now:

row hides an unread entry when
  visit open                      -> everything
  ordinary                        -> created_at <= seen:<channel>   (synced)
  attention (mention, broadcast,  -> its own seen:msg:<id> receipt  (local, like msg:)
    DM, conversation reply)
  • The row cut is the seen argument isUnread already takes, so explicit unread intent and overrides still show.
  • Seen marks save in one quiet readMessages batch, which replaces see/saveQuiet. The journal counts quiet saves (quiet, validated as 0 <= quiet <= revision), so another window's seen save no longer drops this window's New messages divider. That was the deferred two-window gap.
  • The local reserve no longer protects row marks as inherited floors (found by Meli: 1,000 receipts evicted real read receipts under an override).
  • Accepted tradeoffs, in docs: an ordinary message with an equal or older timestamp stays quiet (bold only, like activity:). A fresh or second device relights attention rows until they are read (feat(read-state): n-blob NIP-RS sync with a 30-day floor, frontier only #1286's msg: limitation).

Validation at 907b5a46:

  • Full vitest: 19,753 passed, 22 skipped.
  • 88/88 unread browser journeys pass in Chromium and WebKit (unread, thread-unread, sidebar-unread, sidebar-collapsed-unread, membership, navigation-mute-read, activity-navigation-focus, large-channel-unread).
  • A production-frontend + production-broker journey in both engines: same-second and backdated mentions relight, reload stays quiet, a fresh install restores the channel cut from the relay, and published blobs carry only channel keys.
  • Each new unit test fails under its mutant first.

Not run: a native Tauri build, a real relay, or mobile.

Opening a channel now clears its sidebar row: bold name, mention dot and
thread dot, and the Dock indicator follows. Nothing is read: thread
replies stay unread in the timeline, Activity and the channel activity
popover, and the New messages divider still marks the entry boundary.

A new `seen:<channel>` frontier records the row's cut. The visit hides
everything in memory, and leaving saves the newest retained event time,
but only when the row would otherwise show something. Channel snapshots
gain `row` counts that leave out evidence through that cut; the sidebar,
Unreads filter, unread-first search and Dock read them. Seen saves do not
advance the unread intent revision, so a reader's entry evidence outlives
them. Retention keeps seen marks last and never as override ancestry; a
channel read covers them.

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Signed-off-by: Bradley Axen <baxen@squareup.com>
@baxen
baxen force-pushed the mbot/channel-seen branch from 5b2766f to 59700e9 Compare November 7, 2026 19:55
baxen and others added 4 commits November 7, 2026 11:57
The row's `seen:<channel>` cut is a timestamp, so a mention delivered
after the visit at the same second, or backdated by a slower clock,
stayed hidden from the sidebar and Dock. Attention now goes by exact
receipts: leaving saves `seen:msg:<id>` for each unread attention
message the visit showed, kept on this device like `msg:` marks, and
any other attention lights the row. Ordinary messages keep the synced
channel cut.

The row's cut is the `seen` argument `isUnread` already takes, so
marked-unread and overrides still show. Seen marks save in one quiet
`readMessages` batch, which replaces `see`; the journal counts quiet
saves (`quiet`), so another window's seen save no longer advances this
window's intent revision. The local reserve no longer protects row
marks as inherited floors.

Co-authored-by: Lain <7d83e66e949bde01875eadd5794011c24c661ec1ddb489cc759d075f4440315a@buzz.block.builderlab.xyz>
Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@squareup.com>
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
@tlongwell-block
tlongwell-block marked this pull request as ready for review November 7, 2026 21:53
@tlongwell-block
tlongwell-block requested a review from a team as a code owner November 7, 2026 21:53
Integrate main at fc2dd27 while preserving the channel-row receipt fix.
Keep the durable quiet revision alongside main's local read floor.
Ensure dropped seen hints never advance that floor or displace read
receipts, with a retention-pressure regression.

Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@squareup.com>
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>

@kalvinnchau kalvinnchau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Two findings: background arrivals in the selected conversation are suppressed from unread indicators, and channel reads leave redundant local receipts.

snapshot.manual !== "none" || (snapshot.observedCount ?? 0) > 0
);
// The dock follows the sidebar rows, which opening a channel quiets.
const count = snapshot.row?.observedCount ?? snapshot.observedCount;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [P2] Preserve the Dock indication for background arrivals

With a channel or DM selected, switch to another app or hide Buzz, then receive a peer DM or mention in that conversation. enterChannel keeps its row cut at Infinity for the whole visit (unread.ts:2765,738), so this row count stays zero even though reading is disabled while unfocused (use-reading.ts:95–96) and the raw unread count increases. The previous raw-count projection lit the Dock; this projection suppresses it. Gate advancing the seen cut on reader visibility/focus, or preserve the raw Dock count for background arrivals. Add an unfocused-arrival regression case. This is source-traced; native Dock execution was not performed.

(frontier(other) ?? -1) >= through ? other : undefined;
if (kind === "activity") return by(id, value);
// A channel read reads everything its row has seen.
if (kind === "activity" || kind === "seen") return by(id, value);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [P3] Prune message seen receipts covered by channel reads

covered() checks a seen:msg:<id> receipt only against an exact msg:<id> frontier. Channel catch-up advances the channel frontier instead, leaving receipts for already-read messages in the journal. A targeted test reproduced: mention at t=100 → enter/leave → receipt saved → markChannelRead("room") advances beyond 100 → receipt remains, including after another channel read. These redundant keys consume the bounded journal/reserve and compete in recency retention; production-scale impact is unmeasured. Apply the same message/channel coverage logic used for msg: marks and add a channel-read pruning regression test.

@tellaho

tellaho commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

🤖 Design review at a1100968. Opening a channel or DM should dismiss its sidebar/Dock cues while keeping messages and threads unread.

Must fix — new attention must still reach you while Buzz is in the background. Leave a conversation selected, switch to another app, then receive a DM or mention there: its sidebar and Dock remain quiet. This is the existing code-review finding. Leaving the conversation also saves those arrivals as seen, so they can stay absent from sidebar Unreads afterward. Limit ongoing quieting to the visible active visit and preserve cues for background arrivals; cover background arrival → switch channel.

Evidence: unread.ts:730–788,2750–2768, MessageManagement.tsx:352–395, use-reading.ts:90–98, and indicator-unread.ts:80–82.

Activity and thread-level unread remain available; Mark unread restores the row. Another device can relight unread attention, as documented.

Source review only. No supplied screenshots; native Dock, multiple devices, keyboard/focus, themes, window sizes and enlarged text were not exercised.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants