Skip to content

feat(desktop): thread unread indicators#1023

Merged
wpfleger96 merged 5 commits into
duncan/in-channel-unread-pillfrom
duncan/thread-unread-indicators
Jun 13, 2026
Merged

feat(desktop): thread unread indicators#1023
wpfleger96 merged 5 commits into
duncan/in-channel-unread-pillfrom
duncan/thread-unread-indicators

Conversation

@wpfleger96

Copy link
Copy Markdown
Collaborator

Summary

Extends the channel-level unread system (PR #1008) to threads with three affordances:

  1. Thread read-state modelgetThreadReadAt(rootId) and markThreadRead(rootId, timestamp) in AppShellContext, delegating to ReadStateManager with thread:<rootId> context keys. Cross-device sync via NIP-RS for free.

  2. Thread unread count badgecomputeThreadUnreadMarker in unreadMarker.ts counts replies after the thread read frontier. MessageThreadSummaryRow renders a blue numeric badge when unreadCount > 0.

  3. In-thread "New" dividerMessageThreadPanel captures the thread frontier on open (same openFrontierRef pattern as the channel screen), computes firstUnreadReplyId, and renders UnreadDivider above it. Suppressed when the first unread is the first reply (index 0).

Behavior

  • Mark-read fires immediately on thread panel open using the latest reply timestamp
  • Thread with 0 replies → no divider, no badge, mark-read is a no-op
  • frontierSeconds === null (thread never read) → all replies are unread
  • First unread is first reply → divider suppressed (meaningless boundary)

Files Changed

  • desktop/src/app/AppShellContext.tsx — added getThreadReadAt, markThreadRead to context type
  • desktop/src/app/AppShell.tsx — wired thread read-state callbacks
  • desktop/src/features/messages/lib/unreadMarker.ts — added computeThreadUnreadMarker
  • desktop/src/features/messages/lib/unreadMarker.test.mjs — 8 new tests for thread marker
  • desktop/src/features/messages/ui/MessageThreadSummaryRow.tsxunreadCount prop + badge
  • desktop/src/features/messages/ui/MessageThreadPanel.tsxfirstUnreadReplyId prop + UnreadDivider
  • desktop/src/features/channels/ui/ChannelScreen.tsx — thread frontier capture, mark-read effect, unread count computation
  • desktop/src/features/channels/ui/ChannelPane.tsx — prop threading
  • desktop/src/features/messages/ui/MessageTimeline.tsx — prop threading
  • desktop/src/features/messages/ui/TimelineMessageList.tsx — prop threading + pass to summary row

Stack

This PR is stacked on #1008 (duncan/in-channel-unread-pill).

Stack: #1008this PR

npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 4 commits June 12, 2026 19:31
Extend the channel-level unread system (PR #1008) to threads:

1. Thread read-state model: getThreadReadAt/markThreadRead in AppShellContext
   delegate to ReadStateManager with thread:<rootId> context keys, giving
   cross-device sync via NIP-RS for free.

2. Thread unread count badge: computeThreadUnreadMarker counts replies after
   the thread read frontier. MessageThreadSummaryRow renders a blue numeric
   badge when unreadCount > 0.

3. In-thread "New" divider: MessageThreadPanel captures the thread frontier
   on open (openFrontierRef pattern matching channel screen), computes
   firstUnreadReplyId, and renders UnreadDivider above it. Suppressed when
   the first unread is the first reply (index 0) to avoid a meaningless
   boundary.

Mark-read fires immediately on thread panel open using the latest reply
timestamp. Thread with 0 replies is a no-op.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Only persist thread read-state for threads the user has notification
interest in (participated, authored, or followed). Opening a thread
with no stake no longer creates a context key, preventing unnecessary
blob growth.

Add bounded eviction in currentContexts(): when publishable contexts
exceed 8,000, evict oldest thread:* entries by timestamp. Channel keys
are never evicted. This prevents silent blob rejection when the 10,000
MAX_CONTEXTS cap is approached.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Apply biome formatter rules: multi-line imports, multi-line dependency
arrays, single-line short conditions. Replace eslint-disable comment
with biome-ignore for useExhaustiveDependencies (readStateVersion is
an intentional invalidation signal for stable callbacks).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Captures 3 screenshots for PR documentation:
- Thread unread badge on summary row (participated thread)
- New divider inside thread panel above unread replies
- No badge for casual browsing (interest gate working)

Follows the same pattern as unread-pill-screenshots.spec.ts.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96

Copy link
Copy Markdown
Collaborator Author

E2E screenshots — thread unread indicators

Captured from desktop/tests/e2e/thread-unread-screenshots.spec.ts. Each shot maps 1:1 to a distinct asserted state.

Thread unread badge

Returning to a channel with new thread replies shows a blue numeric badge on the thread summary row — only for threads the user has notification interest in (participated, authored, or followed).

01-thread-unread-badge

New divider in thread panel

Opening a thread with unread replies shows the inline "New" divider above the first unread reply, marking where the user left off.

02-thread-new-divider

No badge for casual browsing

Opening a thread the user has no stake in (not participated, authored, or followed) does NOT show a badge — the interest gate prevents noise from casual browsing.

03-thread-no-badge-casual-browse

wpfleger96 pushed a commit that referenced this pull request Jun 13, 2026
The nested reply visibility assertion (nestedReplyVisibleTopMaxPx) needs
to accommodate the UnreadDivider that now renders above unread replies
when reopening a thread with a read frontier.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 merged commit 112e170 into duncan/in-channel-unread-pill Jun 13, 2026
23 checks passed
@wpfleger96 wpfleger96 deleted the duncan/thread-unread-indicators branch June 13, 2026 03:18
@wpfleger96

Copy link
Copy Markdown
Collaborator Author

Folded into #1008 — its branch now contains all of this PR's thread-unread commits so the channel-level and thread-level unread work test together. Superseded by #1008.

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