Skip to content

fix(desktop): project home read-state from the shared NIP-RS marker#631

Merged
tlongwell-block merged 2 commits into
mainfrom
dawn/home-read-state-from-nip-rs
May 20, 2026
Merged

fix(desktop): project home read-state from the shared NIP-RS marker#631
tlongwell-block merged 2 commits into
mainfrom
dawn/home-read-state-from-nip-rs

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

Fixes the bug Tyler filed in #sprout-bugs: the Home panel kept items marked unread even after they'd been seen in their channel.

Why

Home maintained its own local "done"/"seen" sets in localStorage that were only advanced by interacting with Home itself. So a message read in-channel stayed unread in Home indefinitely. The NIP-RS read marker that the channel sidebar already uses was the right source of truth; Home just wasn't consulting it.

What

  • useUnreadChannels also returns getEffectiveTimestamp and readStateVersion from the shared ReadStateManager.
  • AppShellContext exposes them as getChannelReadAt(channelId) and readStateVersion.
  • New useHomeInboxReadState hook projects per-item read-state off the marker: channel-backed items are read iff latestActivityAt <= getChannelReadAt(channelId). Items without a channel (reminders) fall back to the existing local done-set. Mark/undo for channel-backed items routes through markChannelRead / markChannelUnread.
  • useHomeFeedNotificationState (sidebar Home badge) gets the same predicate. To preserve a single ReadStateManager, this hook is lifted out of useHomeFeedNotifications and called in AppShell after useUnreadChannels has mounted.

Single read-state source, hook boundaries preserved.

Behavioural note

Marking an older channel-backed Home item "unread" rolls the NIP-RS marker back to that item's timestamp for the whole channel — newer events in that channel become unread too. That matches NIP-RS's channel-level granularity; it isn't per-message granular. Surfaced in the commit message for future spelunkers.

Test plan

File-size overrides

Bumped two existing biome max-lines overrides with one-line justifications matching the surrounding style: HomeView.tsx 500→505 (new hook wiring), AppShell.tsx 800→815 (lifted notification-state call).

Reading a channel in-thread now clears the Home panel indicators for
items in that channel — both the row blue dot / bold styling and the
sidebar Home badge. Before, Home maintained its own local "done" set
(`sprout-home-feed-done.v1`) and "seen" set (`sprout-home-feed-seen.v1`)
that were only advanced by interacting with Home itself, so a message
read in-channel stayed unread in Home indefinitely.

Changes:

* `useUnreadChannels` now also returns `getEffectiveTimestamp` and
  `readStateVersion` from the underlying `ReadStateManager`, so other
  surfaces can project per-item read-state off the same NIP-RS marker
  without instantiating a second manager.
* `AppShellContext` exposes those two fields as `getChannelReadAt` and
  `readStateVersion` for any descendant.
* New `useHomeInboxReadState` hook in `features/home/` composes the
  projection: channel-backed items are read iff
  `latestActivityAt <= getChannelReadAt(channelId)`; items without a
  channel (reminders etc.) fall back to the local
  `useFeedItemState` done-set. Mark/undo for channel-backed items is
  routed through `markChannelRead`/`markChannelUnread`, keeping the
  storage hook policy-free.
* `useHomeFeedNotificationState` gains the same NIP-RS-aware predicate
  for the sidebar Home badge (using `FeedItem.createdAt`, not
  `latestActivityAt`, since the badge works over raw feed items).
* To preserve a single `ReadStateManager`, the badge state computation
  is lifted out of `useHomeFeedNotifications` and into `AppShell`,
  after `useUnreadChannels` has mounted.

Behavioural note: marking an older channel-backed Home item "unread"
rolls the NIP-RS read marker back to that item's timestamp for the
whole channel, so newer events in that channel become unread too.
That matches NIP-RS's channel-level granularity; it isn't per-message
granular.

No data migration: stale channel-id entries in the old localStorage
done-set are ignored by the new projection and age out naturally.

Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Copy link
Copy Markdown
Collaborator Author

@tlongwell-block tlongwell-block left a comment

Choose a reason for hiding this comment

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

Reviewed against the 9/10+ bar. This is the shape I wanted:

  • Single read-state source: Home consumes the AppShell-mounted useUnreadChannels / ReadStateManager; no second manager.
  • Hook boundaries stay clean: useFeedItemState remains local-storage-only, and the NIP-RS projection is isolated in useHomeInboxReadState.
  • Timestamp semantics look right: inbox rows use InboxItem.latestActivityAt; badge items use raw FeedItem.createdAt; both compare strictly/ inclusively in the expected direction.
  • Badge hook split is minimal and preserves the isHomeActive short-circuit / existing Home-seen behavior for non-channel items.
  • Mark read/unread routing is consistent with NIP-RS channel-level markers, including the documented rollback caveat.

Local verification: pnpm --dir desktop exec biome check on the touched TS/TSX files passed. Full local pnpm --dir desktop typecheck is blocked in my checkout by a missing @radix-ui/react-context-menu install, so I’m relying on CI for full desktop typecheck/e2e; current PR checks are still in progress for Desktop/Desktop E2E/Desktop Build/cross-compile, with the already-finished checks green.

No blocking comments from me. Approving, conditional on CI finishing green.

The Home badge predicate I added in b1f13f6 treated `getChannelReadAt`
returning `null` as "unread". That's correct for a freshly-arrived event
in a channel the user has never opened — but it broke two cases:

1. `profile.spec.ts:69` (mock mode, no relay client): every channel-backed
   feed item appears unread because the read-state hooks no-op. The test
   pushed one mock mention and saw count=2 (the preseeded `mock-feed-mention`
   plus the new push), expecting 1.

2. `integration.spec.ts:246` (live mention into the channel the user is
   currently reading): the in-channel marker auto-advances past the new
   mention, so the predicate correctly says "read". The test expected the
   badge to tick to 1 — that was the *old* design's behavior (driven by
   a local seen-set that didn't know about in-channel reads). The new
   design's behavior is what Tyler asked for: if you're reading the channel,
   the home badge shouldn't light up for messages arriving in it.

Fixes:
- `useHomeFeedNotificationState`: only consult the NIP-RS marker when it's
  non-null. When the channel has no marker yet (cold start, mock mode), fall
  back to the legacy `!seenFeedIdSet.has(id)` predicate so a freshly-seen
  feed item doesn't keep tripping the badge forever.
- `integration.spec.ts:246`: replace the `sidebar-home-count = "1"`
  assertion (which embodied the old design) with an assertion that the
  refetched home inbox contains the mention text after navigating Home.
  That preserves the test's actual purpose ("live mentions refetch the
  home feed without waiting for polling") under the new contract.

The sibling test for forum mentions (mention into a channel the user is
NOT viewing) is unaffected and still asserts the badge ticks to 1 — that
case goes through the seen-set fallback path.

Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
@tlongwell-block tlongwell-block merged commit a96cb48 into main May 20, 2026
15 checks passed
@tlongwell-block tlongwell-block deleted the dawn/home-read-state-from-nip-rs branch May 20, 2026 21:14
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.

1 participant