Skip to content

fix(desktop): prune real-channel observed refs on thread/message read#1258

Draft
wpfleger96 wants to merge 1 commit into
mainfrom
duncan/unread-clearing-asymmetry
Draft

fix(desktop): prune real-channel observed refs on thread/message read#1258
wpfleger96 wants to merge 1 commit into
mainfrom
duncan/unread-clearing-asymmetry

Conversation

@wpfleger96

Copy link
Copy Markdown
Collaborator

What this is

A latent-liability cleanup in the channel unread bookkeeping. It is not the phantom-badge fix — the phantom Will reported is working-as-designed (NIP-RS Option 1: a new reply to an unopened thread correctly lights the numeric badge), and whether passive channel-open should also clear thread-tier badges is a separate UX decision currently with Will. This PR changes none of that behavior.

The asymmetry

markThreadRead/markMessageRead (AppShell.tsx) route through markChannelRead with a synthetic thread:<root>/msg:<id> key. Inside markChannelRead, the clearObserved prune looks up latestByChannelRef under that key — which is keyed by real channel id, so the lookup is always undefined, clearObserved resolves to false, and the prune never runs. Even if it did, it would delete the entry under the synthetic key, not the real channel's.

Net: after a thread/message read covers a channel's last badge event, the real channel's latestByChannelRef and observedUnreadEventsByChannelRef entries are never dropped. The badge count stays correct (the unread memo re-evaluates each observed event against the current markers), but the channel remains permanently eligible for recount with dead refs — a latent liability, not a visible bug.

The fix

A lazy prune effect in useUnreadChannels, driven off the unread memo's own output. A channel that is absent from unreadChannelIds, not forced-unread, and not active has no unread observed events left, so its observed refs are dead weight and get dropped. Pruning is invisible to the count (a covered channel and an absent one both contribute 0), so there is no readStateVersion bump and no re-render loop. The decision logic lives in a pure pruneCoveredObservedRefs in unreadChannelCounts.ts (the existing home for observed-ref mutation), making it unit-testable alongside the other observed-event helpers.

Regression coverage: a thread read that covers a channel's last badge event prunes both refs, and the badge stays 0 even when a catch-up REQ re-records the same already-covered event; plus the three retention paths (channel still unread, active channel, forced-unread) that must keep their refs.

Note for reviewers

useUnreadChannels.ts sits close to the repo's 1000-line file-size ceiling; the prune decision is therefore extracted to unreadChannelCounts.ts rather than inlined, which keeps the hook file under the limit and the logic testable as a pure function.

Related: #1253 (the Phase 1 dot-tier restore, already merged) — this is the Phase 2 cleanup track from the same investigation.

markThreadRead/markMessageRead route through markChannelRead with a
synthetic thread:<root>/msg:<id> key, so its clearObserved prune looks up
latestByChannelRef under that key — never the real channel — and the real
channel's observed refs survive a read that covers their last badge event.
The count stays correct (markers are evaluated per event), but the channel
stays permanently eligible for recount, a latent liability. A new lazy
prune effect, driven off the unread memo's own output, drops those dead
refs when the channel is no longer counted unread.

Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 marked this pull request as draft June 24, 2026 23:06
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