Skip to content

fix(desktop): derive unread state from NIP-RS + relay catch-up only#599

Merged
tlongwell-block merged 1 commit into
mainfrom
fix/persist-unread-latest-message
May 15, 2026
Merged

fix(desktop): derive unread state from NIP-RS + relay catch-up only#599
tlongwell-block merged 1 commit into
mainfrom
fix/persist-unread-latest-message

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

What

Make GUI unread state depend solely on (a) the NIP-RS read marker and (b) a per-channel relay catch-up query that asks "are there messages newer than this marker?" — no localStorage cache, no Channel.last_message_at dependency, no schema extension.

Why

useUnreadChannels had two halves: a NIP-RS read marker (which persists fine, locally and cross-device) and an in-memory latestByChannelRef Map that got built up only from the live subscription (since: now). On every restart the Map reset to empty, the comparison latest > readMarker always returned false, and badges silently disappeared until new live activity hit the channel.

The original fix in this branch persisted the Map to localStorage. Tyler's review surfaced a better model: the relay already knows what's in each channel, so just ask it. The NIP-RS spec is explicit that contexts carries read positions — channel "latest" is the wrong shape for that blob. And we don't need any client-side latest cache if we can query "newer than marker?" on demand.

How

  • Delete the latestMessageStorage.ts module added earlier in this branch.
  • Delete the (no-op) Channel.lastMessageAt opportunistic backfill effect and the read-state seeding effect from useUnreadChannels. Backend never populated last_message_at, both effects were dead branches.
  • Add a catch-up effect: once NIP-RS is ready and channels are known, for each channel we haven't already caught up this session, REQ the channel's trigger-kind events with since: readMarker + 1 (strict-newer; client-side > readAt belt-and-suspenders), limit: 1000, in parallel. Take the max external-author event timestamp per channel; feed the existing predicate.
  • Effect is keyed on a sorted channelIdsKey string so React Query refetches with identical contents don't churn. Optimistic claim of caughtUpChannelsRef prevents duplicate REQs from re-renders; cleanup releases claims so the next effect run retries; individual failed fetches release their own claim so transient relay errors don't suppress unread for the rest of the session.
  • isCancelled guard around the post-settle mutation handles identity reset mid-flight.
  • markChannelUnread falls back to latestByChannelRef.current.get(channelId) when Channel.lastMessageAt is null (always today), so right-click "mark unread" syncs across devices when we've observed any external message.
  • Export CHANNEL_MESSAGE_EVENT_KINDS from shared/constants/kinds so useLiveChannelUpdates and the catch-up share one source of truth for "unread trigger kinds."

The live subscription remains since: now — unchanged.

Verification

  • pnpm --dir desktop typecheck
  • pnpm --dir desktop check (biome + file-size) ✅
  • All pre-commit and pre-push hooks pass.

Reviewers

Reviewed in #sprout-bugs by @max. Two rounds of review caught: (1) limit: 1 is unsafe when self-author filter discards the only returned event, (2) the original retry path left failed channels permanently "caught up" — both fixed. Approved at v3.

Closes

The "unread indicators disappear on restart" regression Tyler reported in #sprout-bugs.

Replaces the previous in-memory "latest message at" Map (which was
ephemeral and lost on every restart, causing badges to disappear) with a
model that depends solely on (a) the NIP-RS read marker, and (b) a
per-channel catch-up REQ against the relay asking "is there anything
newer than this marker?"

Concretely:

- Delete the localStorage cache and the (no-op) Channel.lastMessageAt
  backfill/seeding effects from useUnreadChannels.
- Add a catch-up effect that runs once NIP-RS is ready: for each channel
  not yet caught up this session, REQ the channel's trigger-kind events
  with since: readMarker + 1 (strict-newer; client-side > readAt for the
  inclusive-since edge), limit: 1000, in parallel. Pick the max
  external-author event timestamp per channel and feed it to the
  existing predicate.
- Effect is keyed on a sorted channelIdsKey string so React Query
  refetches with identical contents don't churn. Optimistic claim of
  caughtUpChannelsRef prevents duplicate REQs from re-renders; cleanup
  releases claims so the next run retries, and individual failed fetches
  release their claim too so transient relay errors don't suppress
  unread for the rest of the session.
- isCancelled guard around the post-settle mutation handles identity
  reset mid-flight.
- markChannelUnread falls back to latestByChannelRef when channel
  lastMessageAt is null (always today), so right-click "mark unread"
  syncs across devices when we've seen any external message.
- Export CHANNEL_MESSAGE_EVENT_KINDS from shared/constants/kinds so
  useLiveChannelUpdates and the catch-up share one source of truth.

No new persisted state. No backend changes. The only contract we depend
on is NIP-RS read markers plus standard NIP-01 relay queries.

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
@tlongwell-block tlongwell-block merged commit 7763072 into main May 15, 2026
15 checks passed
@tlongwell-block tlongwell-block deleted the fix/persist-unread-latest-message branch May 15, 2026 18:46
tlongwell-block added a commit that referenced this pull request May 15, 2026
* origin/main: (33 commits)
  dev-mcp: add view_image tool (#602)
  fix(relay,desktop): only advertise NIP-43 when enforced; probe pairing by supported_nips (#601)
  fix(desktop): derive unread state from NIP-RS + relay catch-up only (#599)
  docs(testing): rewrite TESTING.md for current API and CLI-first workflow (#597)
  fix(agent): fix OpenAI-compat request body serialization and max_tokens (#595)
  feat(desktop): per-persona and per-agent env var overrides (#594)
  fix(desktop): stop pinning agents to deprecated SPROUT_ACP_TURN_TIMEOUT (#592)
  fix(desktop): populate member_count in get_channels so channel browser shows real counts (#548)
  fix(desktop): autofocus message composer on channel/thread open (#572)
  refactor(cli): restructure flat commands into 12 subcommand groups (#585)
  feat(sdk): add builder functions for workflows, DMs, and presence (#589)
  feat(desktop): add message more-actions dropdown menu (#590)
  fix(mobile): preserve channel list across background/resume reconnection (#588)
  Redesign Home as an inbox (#582)
  fix(desktop): drive unread badges from live subscription, not refetched lastMessageAt (#581)
  fix(desktop): refine header scaling and shadow (#573)
  fix(desktop): keep day dividers below header (#574)
  Move agent activity below composer (#579)
  docs(nips): NIP-AE — Agent Engrams (#575)
  refactor: extract shared @mention resolver into sprout-sdk (#580)
  ...

Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
tlongwell-block added a commit that referenced this pull request May 15, 2026
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>

* origin/main:
  dev-mcp: add view_image tool (#602)
  fix(relay,desktop): only advertise NIP-43 when enforced; probe pairing by supported_nips (#601)
  fix(desktop): derive unread state from NIP-RS + relay catch-up only (#599)
  docs(testing): rewrite TESTING.md for current API and CLI-first workflow (#597)
  fix(agent): fix OpenAI-compat request body serialization and max_tokens (#595)
  feat(desktop): per-persona and per-agent env var overrides (#594)
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