The in-thread "New" divider never appeared for replies first revealed by
expanding a branch after a close->reopen cycle (E2E 04-thread-deep-nested-unread).
Two compounding defects: markRevealedRepliesRead advanced a reveal-child's
msg:<id> marker synchronously in the expand event handler, before React
re-rendered with that child visible, so the render-time snapshot captured it as
already read; and the divider resolver used `snapshot?.get(id) ?? live`, whose
nullish-coalescing discarded a legitimately-captured null (never-read) and
re-read the now-advanced live marker.
Centralize capture in captureDividerReadState, call it before the marker advance
in the reveal path, and switch the resolver to has-vs-?? so a captured null is
honored. The badge predicates still read effective(msg:<id>) live and never
consult the divider snapshot.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The thread-open divider missed replies revealed after first open: the
open-time read snapshot captured only the initially visible set, so a
reply expanded later was both marked read and absent from the snapshot,
collapsing the divider. Grow the snapshot per reveal — each reply's read
state is captured the first render it becomes visible, before the
mark-read effect — so the divider anchors to what was unread when each
reply first appeared.
The 05/07/14 E2E cases asserted the deleted whole-subtree-on-open
contract (single expand clearing a 2-levels-deep badge); rewritten to
the v3 direct-children-only contract, with comments updated to drop the
removed machinery. AppShell trimmed under the 1000-line gate with no
logic change; MessageThreadPanel carries an approved size override for
the onMarkRead prop-pair completion.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The single-per-root frontier snapshot made thread-unread badges flaky:
reading an ancestor or re-entering a channel could clear a descendant's
badge, and the open-time snapshot was a second source of truth for the
same read-line (the original Face-2 bug). Replace it with per-message
msg:<id> markers read live through getReadAt: a reply lights iff
createdAt > effective(msg:<id>), so reading one reply never clears
another.
Open and expand now mark ONLY the revealed set read (direct children),
deliberately reversing #1118's whole-subtree-on-open — a collapsed branch
keeps its badge until revealed. Mark-read/mark-unread are symmetric over
a message + its subtree; mark-unread is a session-local OR-overlay since
markers are monotonic. Deletes the frontier/seed layer
(threadBadgeFrontier.ts) and the orphaned subtree-max open-ceiling path
(subtreeMaxCreatedAt) rather than re-keying them.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
First step of the v3 per-message read-state redesign. Adds the msg:<id>
context-key constructor and validator alongside the existing thread:/channel
keys, plus exports MAX_CONTEXTS for the upcoming msg:* eviction path. The
validator rejects a thread key re-prefixed as a message key so the parent
resolver and eviction can keep the two families distinct.
No behavior change yet: nothing constructs msg: keys until the predicate swap
(P1.3) and useChannelUnreadState wiring (P1.4) land.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The seed read getThreadReadAt(rootId, activeChannelId), which AppShell folds
to max(thread_own, channel) via the NIP-RS hierarchical resolver. Channel-open
markChannelRead advances the channel term to the newest top-level message, and
the monotonic seed (Math.max in nextThreadBadgeFrontier) then bleeds that
channel timestamp back past an unread reply -- planting the frontier ahead of
it so computeThreadBadgeCounts reads zero and the badge vanishes (LP4 Case 3,
seed-timing face; the flaky 'no badge' correlated with newer channel messages).
Omit the channelId so the seed reads the thread's OWN marker (getOwnTimestamp,
no parent fold). Thread badges are channel-independent by design (NIP-RS Option
1: channel-open leaves them intact until each thread is read); the own marker
advances only via markThreadRead, preserving advance-on-read. This is immune to
the channel bleed by construction -- no channel term ever enters -- so it needs
no render-time ref capture.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Thread-unread badges relied on the direct-parent adjacency map to roll up
replies under their root. When the loaded timeline window dropped a middle
ancestor, a deep reply keyed under its absent parent and was unreachable
from the root, so the channel-root badge under-counted (Case 1) and, when a
root's only reply was such an orphan, the root was never seed-eligible and
got no frontier at all (Case 3, seed face).
Group replies by their resolved rootId instead. The root e-tag travels with
every event (getThreadReference), so a reply reaches its true root even with
the intermediate ancestor unloaded. For an intact chain every descendant
carries the root's rootId, so counts are identical to the old walk. The
thread-open mark-read ceiling gets a root-scoped variant that folds in
rootId-matched orphans; the expand caller keeps its branch-scoped walk, since
a branch node owns no rootId bucket and must advance only its own branch.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
P1 characterization net for the badge redesign. Pins the contracts the
roll-up and frontier hold today so a rootId re-key can't silently regress
them, and adds falsifiable expected-red tests for the two open defects.
threadBadgeInvariants: (a)-(g) green-on-current invariants; (g) locks that
two distinct roots keep independent frontiers and never collapse into one.
threadOpenCeiling: branch-vs-root ceiling scope (locks the call-site split)
plus the Case-1 orphan-misses-ceiling defect. The orphan-only-root seed
gate is the Case-3 second face. The two defect tests assert DESIRED behavior
and are marked todo so they are expected-red without breaking CI; they flip
to passing once P2 re-keys on rootId. Fixtures carry rootId so they stay
falsifiable after the re-key.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Investigation guards for the LP4 thread-unread badge flakiness. Each new
test passes against current behavior to pin the exact trigger, labeled
DEFECT (documents the bug) or DESIRED/control.
Case 1 (badge at level N, not root): a deep reply whose intermediate
ancestor is absent from the loaded timeline is keyed under that absent
parent, so collectSubtreeReplies never reaches it from the root bucket.
The root undercounts or shows no badge while the reply is genuinely unread.
Case 3 (no badge anywhere): seedThreadBadgeFrontiers seeds via the
effective thread marker max(thread_own, channel_marker); once channel-open
mark-read folds the channel marker past the unread reply, the seed lands
past it and computeThreadBadgeCounts yields zero. The pre-mark-read marker
control keeps the badge.
These are demonstration tests only; no fix code changes.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>