Skip to content

fix(desktop): drive unread badges from live subscription, not refetched lastMessageAt#581

Merged
tlongwell-block merged 2 commits into
mainfrom
fix/phantom-unread-live-driven
May 14, 2026
Merged

fix(desktop): drive unread badges from live subscription, not refetched lastMessageAt#581
tlongwell-block merged 2 commits into
mainfrom
fix/phantom-unread-live-driven

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

@tlongwell-block tlongwell-block commented May 14, 2026

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

  1. The Tauri get_channels command (desktop/src-tauri/src/commands/channels.rs) always returns last_message_at: null — it passes None for the summary argument to channel_info_from_event. The kind:40901 summary-sidecar path in nostr_convert.rs exists but is never wired up.
  2. Live messages were bumping the React Query cache for the channel list via updateChannelLastMessageAt (in desktop/src/features/channels/lib/channelCache.ts), making the unread memo see a non-null lastMessageAt.
  3. The 60 s refetchInterval on useChannelsQuery was then replacing the cache with the server response (which is null), wiping the live bump.
  4. The unread memo dropped the channel because lastMessageAt was 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.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
or the channel was manually marked unread this session

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:

  • reactions, edits, deletions, diffs, and system messages would otherwise create phantom unreads when they arrive after the last real message
  • your own outgoing messages should never mark a channel unread

channelCache.ts and all updateChannelLastMessageAt call sites are removed.

The first-load read-state seed now reads channel.lastMessageAt directly (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 on channel.lastMessageAt being 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's forcedUnreadRef.has(id) into the unread set. markChannelRead clears 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 passing activeReadAt: null suppresses 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

 4 files changed, 142 insertions(+), 111 deletions(-)
 desktop/src/features/channels/lib/channelCache.ts        |  66 ----------
 desktop/src/features/channels/useLiveChannelUpdates.ts   |  39 ++++--
 desktop/src/features/channels/useUnreadChannels.ts       | 134 ++++++++++++++++---
 desktop/src/features/messages/hooks.ts                   |  14 ---

Out of scope

Tracked for follow-up, not addressed here:

  • Backend get_channels returning a real last_message_at. Either wire kind:40901 summary-sidecar (path already half-built in nostr_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.
  • First-load render-ordering bug in useUnreadChannels — the memo treats readAt === null as 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) ✓
  • Pre-commit hooks (rust-fmt, desktop-tauri-fmt, desktop-check, web-check, mobile-check) ✓
  • Pre-push hooks (full rust tests, clippy, desktop-build, web-build, mobile-test, all checks) ✓
  • Iteratively reviewed via codex CLI 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 around activeReadAt: null.

Manual test plan

  1. Have two identities. Sit in channel A on identity 1.
  2. Post a message in channel B from identity 2.
  3. Identity 1's sidebar shows an unread dot on B. ✅ (this PR ensures it stays.)
  4. Wait 60+ seconds without clicking B. Dot should remain. ✅ (previously: dot vanished here.)
  5. Click into B. Dot clears (NIP-RS read marker advances).
  6. Switch back to A. Have identity 2 post a reaction or edit in B. Dot does not appear (kind filter). ✅
  7. Send a message yourself in B from identity 1. After switching back to A, no dot appears for B (self-author filter). ✅
  8. Right-click a channel → "Mark unread". Dot appears immediately. ✅
  9. Click that channel. Dot clears. Re-open from sidebar. Dot stays cleared (no sticky synthetic timestamp). ✅

@tlongwell-block tlongwell-block force-pushed the fix/phantom-unread-live-driven branch from 65298ec to ab46d6f Compare May 14, 2026 15:59
…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>
@tlongwell-block tlongwell-block force-pushed the fix/phantom-unread-live-driven branch from ab46d6f to cca157a Compare May 14, 2026 16:18
@tlongwell-block tlongwell-block merged commit 1858e98 into main May 14, 2026
15 checks passed
@tlongwell-block tlongwell-block deleted the fix/phantom-unread-live-driven branch May 14, 2026 17:25
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>
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