fix(desktop): derive unread state from NIP-RS + relay catch-up only#599
Merged
Conversation
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
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)
This was referenced May 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_atdependency, no schema extension.Why
useUnreadChannelshad two halves: a NIP-RS read marker (which persists fine, locally and cross-device) and an in-memorylatestByChannelRefMap that got built up only from the live subscription (since: now). On every restart the Map reset to empty, the comparisonlatest > readMarkeralways returnedfalse, 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 thatcontextscarries 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
latestMessageStorage.tsmodule added earlier in this branch.Channel.lastMessageAtopportunistic backfill effect and the read-state seeding effect fromuseUnreadChannels. Backend never populatedlast_message_at, both effects were dead branches.since: readMarker + 1(strict-newer; client-side> readAtbelt-and-suspenders),limit: 1000, in parallel. Take the max external-author event timestamp per channel; feed the existing predicate.channelIdsKeystring so React Query refetches with identical contents don't churn. Optimistic claim ofcaughtUpChannelsRefprevents 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.isCancelledguard around the post-settle mutation handles identity reset mid-flight.markChannelUnreadfalls back tolatestByChannelRef.current.get(channelId)whenChannel.lastMessageAtis null (always today), so right-click "mark unread" syncs across devices when we've observed any external message.CHANNEL_MESSAGE_EVENT_KINDSfromshared/constants/kindssouseLiveChannelUpdatesand 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) ✅Reviewers
Reviewed in #sprout-bugs by @max. Two rounds of review caught: (1)
limit: 1is 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.