fix(desktop): drive unread badges from live subscription, not refetched lastMessageAt#581
Merged
Merged
Conversation
65298ec to
ab46d6f
Compare
…ed lastMessageAt The Tauri `get_channels` command always returns `last_message_at: null` (the kind:40901 summary-sidecar path exists in `nostr_convert.rs` but isn't wired up). Live messages were bumping the React Query cache via `updateChannelLastMessageAt`, then the 60s `refetchInterval` on `useChannelsQuery` was replacing that cache with the server's null value, wiping the bump. The sidebar memo then dropped the channel from the unread set because `lastMessageAt` was null again. Visible symptom: a phantom unread indicator appears on a channel after a live message, then disappears within ~60s without user interaction. Stop using `channel.lastMessageAt` for the unread computation. Maintain an in-session `Map<channelId, unixSeconds>` inside `useUnreadChannels`, fed by a new `onChannelMessage` callback on `useLiveChannelUpdates`. Unread is now: latest live timestamp strictly greater than the NIP-RS read marker. The callback is gated to human-visible content kinds (chat + forum posts/comments) and skipped for the current user's own events — reactions, edits, deletions, and system messages must not create unread state, and your own outgoing messages must never make a channel unread. `channelCache.ts` and all `updateChannelLastMessageAt` call sites are deleted. The first-load read-state seed now reads `channel.lastMessageAt` directly (today a no-op; future-proof for when the backend wires it) rather than the live map, so a live event racing ahead of read-state readiness can't be silently swallowed as already-read. Does not fix: - backend `get_channels` returning a real `last_message_at` (separate work: wire kind:40901 or compute on read); - the separate first-load render-ordering issue where the unread memo treats `readAt === null` as unread before the seed effect commits; - KIND_STREAM_MESSAGE_V2 (40002) missing from the desktop `CHANNEL_EVENT_KINDS` filter (pre-existing). Signed-off-by: Tyler Longwell <tlongwell@squareup.com> Test fix: the e2e smoke tests "sidebar shows unread indicator for newly active channels" and "... for new forum posts" injected mock messages via `send_channel_message`, which authors events with the current identity's pubkey. They worked under the old code because nothing filtered self-authored events. Under this change, self-authored events correctly don't make a channel unread, so the tests need to inject as another user. Switched both to `__SPROUT_E2E_EMIT_MOCK_MESSAGE__` with `pubkey: TEST_IDENTITIES.alice.pubkey`, extending the helper to accept a `kind` argument (previously hardcoded to 9). The DM unread test already used this pattern.
* origin/main: docs(nips): NIP-AE — Agent Engrams (#575) Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
ab46d6f to
cca157a
Compare
tlongwell-block
added a commit
that referenced
this pull request
May 14, 2026
Pulls in 8 commits from origin/main: - 1858e98 fix(desktop): drive unread badges from live subscription, not refetched lastMessageAt (#581) - 9e76a08 fix(desktop): refine header scaling and shadow (#573) - b74ec95 fix(desktop): keep day dividers below header (#574) - aad564b Move agent activity below composer (#579) - bda98da docs(nips): NIP-AE — Agent Engrams (#575) - 1b87a09 refactor: extract shared @mention resolver into sprout-sdk (#580) - 2ee7356 fix: add default-run to sprout-relay so `cargo run -p sprout-relay` works (#577) - f0549b5 feat(desktop): channel hover state and right-click mark-unread context menu (#578) No conflicts. * origin/main: 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) fix: add default-run to sprout-relay so `cargo run -p sprout-relay` works (#577) feat(desktop): channel hover state and right-click mark-unread context menu (#578)
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>
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.
Bug
Phantom unread indicators on channels in the left sidebar: while sitting in one channel, a user sees an unread badge appear on another channel, and then some indeterminate amount of time later the badge disappears without the user ever viewing that channel.
Root cause
get_channelscommand (desktop/src-tauri/src/commands/channels.rs) always returnslast_message_at: null— it passesNonefor the summary argument tochannel_info_from_event. The kind:40901 summary-sidecar path innostr_convert.rsexists but is never wired up.updateChannelLastMessageAt(indesktop/src/features/channels/lib/channelCache.ts), making the unread memo see a non-nulllastMessageAt.refetchIntervalonuseChannelsQuerywas then replacing the cache with the server response (which isnull), wiping the live bump.lastMessageAtwas null again → badge disappears.So the user sees a badge that appeared ~immediately after someone else posted in a channel, and disappeared up to 60 s later when the next channels-query refetch happened.
Fix
Stop relying on
channel.lastMessageAtfor the unread computation. Maintain an in-sessionMap<channelId, unixSeconds>insideuseUnreadChannels, fed by a newonChannelMessagecallback onuseLiveChannelUpdates. Unread is now:The callback is gated to human-visible content kinds (
KIND_STREAM_MESSAGE,KIND_STREAM_MESSAGE_V2,KIND_FORUM_POST,KIND_FORUM_COMMENT) and skipped for the current user's own events:channelCache.tsand allupdateChannelLastMessageAtcall sites are removed.The first-load read-state seed now reads
channel.lastMessageAtdirectly (today a no-op; future-proof for when the backend wires it up) rather than the live map, so a live event racing ahead of NIP-RS read-state readiness can't be silently swallowed as already-read.Interaction with #578 (mark-unread context menu)
This PR was rebased onto main after #578 landed. The mark-unread feature exports
markChannelUnread(channelId, lastMessageAt). Under the old model it relied onchannel.lastMessageAtbeing populated by the live-event cache — broken when the backend returns null (always today).Under this PR, manual mark-unread is tracked via a dedicated
forcedUnreadRef: Set<channelId>separate from the live-timestamp map. The memo OR'sforcedUnreadRef.has(id)into the unread set.markChannelReadclears the entry on reopen. This avoids synthesizing a fake "latest message" timestamp (which would otherwise make mark-unread sticky on reopen) and preserves AppShell's documented contract that passingactiveReadAt: nullsuppresses read-marking until ChannelScreen reports a real timeline position. Net effect: mark-unread actually works under this PR, where it was previously broken on main.Diff stats
Out of scope
Tracked for follow-up, not addressed here:
get_channelsreturning a reallast_message_at. Either wire kind:40901 summary-sidecar (path already half-built innostr_convert.rs) or compute on read. Until that lands, channels that received messages while the app was closed won't show as unread until a live event arrives in-session. Same blind spot as today, no regression.useUnreadChannels— the memo treatsreadAt === nullas unread before the seed effect commits, causing a transient flash on app launch / identity switch. Different symptom from this bug; warrants its own fix.Verification
pnpm typecheck✓pnpm check(biome + file-size guard) ✓codexCLI through six rounds; final scores 9/9/9 on minimalism, elegance, correctness. Caught and fixed: a first-load race silently swallowing real unreads; reactions/edits triggering phantom unreads; sticky synthetic-timestamp on mark-unread reopen; a contract violation aroundactiveReadAt: null.Manual test plan