Skip to content

feat(read-state): wire right-click mark-read/unread to NIP-RS override layer (slice 3) - #4005

Open
wpfleger96 wants to merge 5 commits into
duncan/nip-rs-unread-managerfrom
hayt/nip-rs-unread-ui
Open

feat(read-state): wire right-click mark-read/unread to NIP-RS override layer (slice 3)#4005
wpfleger96 wants to merge 5 commits into
duncan/nip-rs-unread-managerfrom
hayt/nip-rs-unread-ui

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jul 31, 2026

Copy link
Copy Markdown
Member

Wire the right-click mark-as-unread and mark-as-read paths to the NIP-RS ov_* override layer implemented in slice 2.

What changes

forcedUnreadStore.ts — store is the local-device cache of the NIP-RS override layer. Duplicate MarkResult/OverrideLiveness type copies removed; overrideErrorMessage and toast policy moved to readStateOverride.ts.

readStateOverride.ts (new) — applyOverrideUnread / applyOverrideRead / persistForcedUnread / applyMarkUnreadDividerOverlay helpers forming the seam between useUnreadChannels / useChannelUnreadState and the manager override APIs. OverrideAPIs includes isReadStateReady to distinguish manager-unavailable (fail-closed) from known-no-register (cleared). applyOverrideRead semantics follow NIP-RS:537-539: register exists → always attempt C-bump regardless of active/inactive state, then inspect resulting liveness. null liveness with isReadStateReady: true = known absence (cleared); isReadStateReady: false = fail closed.

readState/useReadState.ts — exposes markChannelUnread, markChannelRead, getOverrideLiveness, isReadStateReady callbacks proxying the manager. Additive proxying only.

useUnreadChannels.ts — active-community unread memo derives forced-unread from getOverrideLiveness(channelId)?.active. markChannelRead and markAllChannelsRead gate local cache deletion on applyOverrideRead returning "overrideCleared". markChannelUnread returns boolean so callers can gate session-local overlays on success.

useChannelUnreadState.tsapplyMarkUnreadDividerOverlay extracted as a pure helper: calls markFn(channelId) and adds to the forcedUnreadRef overlay only on true. handleMarkUnread calls this helper — refused marks no longer spuriously suppress the timeline "New" divider.

communityUnreadObserver.ts — override state is exempt from finite-horizon fetching. Authoritative sources: (a) readStoredReadState(pubkey).overrideRegisters (persisted full-state projection) and (b) coordinate-deduped registers from fetched events, merged via max() per field. The stale forcedUnreadMap fallback that produced false positives/negatives on old or tombstoned registers is removed.

nip-rs-unread-ui.test.mjs — 32 behaviour-focused tests exercising production code via injected OverrideAPIs:

  • applyOverrideRead: manager-not-ready fails closed; ready+null = cleared (no C-bump); inactive register → C-bump attempted; active → C-bump → re-check; already_inactive race; post-call null treated as cleared; partial-refusal loop
  • applyMarkUnreadDividerOverlay: success adds to overlay; refusal does not
  • applyOverrideUnread success and refusal paths
  • persistForcedUnread new entry, same-ts noop, different-ts refresh
  • forcedUnreadStore round-trip and resolveChannelReadMarker boundary cases

communityUnreadObserver.test.mjs — updated to use readStoredRegisters injection; new witnesses: stored active register lights rail; stored tombstoned register does not; old active register beyond 7-day horizon still lights rail; frontier advance deactivates stored register; muted channel with active register stays dark.

Gates (head 3b4b511a3a1cebf33de9c6a060947ebf3baa3aa4)

  • just desktop-check — 0 errors, 8 pre-existing infos
  • just desktop-typecheck — clean
  • just desktop-test — 4038/4038 pass across 59 suites

Merged-in manager head: e5bdaa9804aab1cd82ca3478d3b714f5ecfa4d37

Stack: #3966#4002 → this PR

…e layer (slice 3)

Right-click mark-as-unread now publishes a NIP-RS kind:30078 override event
(S bump + B set to effective frontier) via ReadStateManager before updating
the local forced-unread cache. Mark-as-read clears the override (C bump) if
an active override exists. Both operations surface user-visible toasts on
failure: budget exhaustion, counter overflow, or incomplete load.

forcedUnreadStore becomes a local-device cache of the synced override layer
rather than the sole source of truth; its doc comment is updated accordingly.

Adds readStateOverride.ts with the applyOverrideUnread / applyOverrideRead /
persistForcedUnread helper seam, and extends useReadState to expose the three
manager override callbacks (markChannelUnread / markChannelRead /
getOverrideLiveness) used by useUnreadChannels. Includes nip-rs-unread-ui.test.mjs
with 22 behaviour-focused tests covering all refusal paths and the local-cache
contract.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw and others added 4 commits July 31, 2026 17:13
…ice 3)

CRITICAL 1: Make manager liveness sole verdict for manual-unread.
- Active-community unread memo now derives forced-unread from
  getOverrideLiveness(channelId)?.active, not forcedUnreadRef membership.
  forcedUnreadRef is an optimistic local cache; liveness is authoritative.
- communityUnreadObserver: switch from mergeReadStateEvents (frontier-only)
  to mergeReadStateEventsStructured; evaluate isOverrideActive(S,C,B,F) from
  the merged override registers instead of gating the local cache against the
  frontier baseline.

CRITICAL 2: Explicit mark-read as one outcome-bearing transition.
- applyOverrideRead now returns OverrideReadOutcome instead of void.
  Order: frontier advance → C-bump → re-read liveness.
  Clears local presentation only when liveness is confirmed inactive.
  null ≠ inactive: pre-init returns overrideStillActive (fail-closed).
  already_inactive race: silent success (overrideCleared).
- markChannelRead and markAllChannelsRead gate the local cache delete
  on outcome === "overrideCleared".

IMPORTANT 1: No local mutation on refusal.
- useChannelUnreadState.handleMarkUnread now calls markChannelUnread first
  and does not add to forcedUnreadRef; the session-level timeline overlay
  is not set before or on refusal.

IMPORTANT 2: Tests exercise production code.
- Delete handwritten simulations and assert.ok(true) placeholder.
- Import and invoke applyOverrideUnread, applyOverrideRead,
  persistForcedUnread, overrideErrorMessage directly with injected deps.
- Witnesses: null-liveness pre-init, inactive-without-C-bump, active-success,
  active-uint32_overflow refusal, active-load_incomplete refusal,
  already_inactive race, persistForcedUnread refresh-on-existing-entry.
- Test count: 19 → 27.

MINOR 1: OverrideAPIs functions now required (no optional-chain fail-open).
MINOR 2: Remove duplicate MarkResult/OverrideLiveness from forcedUnreadStore;
  move overrideErrorMessage/toast policy to readStateOverride.ts.
Also fix persistForcedUnread to refresh existing entries (re-mark after
a dead override uses the current frontier as baseline).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Add two tests that were missing from the pass-1 fix commit:

- applyOverrideUnread_refusal_caller_must_not_mutate_overlay: witnesses
  that applyOverrideUnread returns false on budget_exhausted and that
  the caller does not mutate its session overlay (covers the
  useChannelUnreadState.handleMarkUnread active-channel path).

- applyOverrideRead_partial_refusal_pattern_clears_only_inactive:
  witnesses the per-channel gating pattern used by markAllChannelsRead —
  a channel whose C-bump resolves to inactive liveness is removed from
  the forced map; a channel whose C-bump refuses while liveness stays
  active is retained.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/duncan/nip-rs-unread-manager:
  fix(nip-rs): add fencedLoader integration and manager/storage fixes
  fix(nip-rs): correct fencedLoader lapse detection and manager semantics
  fix(read-state): close Thufir pass-1 findings in readStateManager/Storage

Signed-off-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
Four findings closed:

CRITICAL: inactive rail — remove seven-day horizon and forcedUnreadMap
fallback from fetchCommunityUnread. Override state is exempt from finite-
horizon fetching. Authoritative sources are now (a) readStoredReadState
overrideRegisters (persisted full-state projection) and (b) coordinate-
deduped fetched events merged via max() per field. The stale-cache fallback
that produced false positives and false negatives on old/tombstoned registers
is removed; the observer test is updated to match the new authoritative model.

IMPORTANT: restore divider suppression — markChannelUnread returns boolean;
applyMarkUnreadDividerOverlay (extracted pure helper in useChannelUnreadState)
gates forcedUnreadRef.add() on success. handleMarkUnread calls this helper
instead of the old unconditional/void path. AppShellContext default updated.

IMPORTANT: explicit read always attempts C-bump (NIP-RS:537-539) —
applyOverrideRead step logic reordered: !isReadStateReady fails closed;
liveness===null (ready, no register) = known absence, cleared; register
exists → always call markChannelRead regardless of active/inactive, then
inspect resulting liveness. isReadStateReady in OverrideAPIs distinguishes
manager-unavailable (fail closed) from known-no-register (cleared).

IMPORTANT: hook witnesses drive production code — applyOverrideRead tests
replaced with seven new tests using makeApis() reflecting the new semantics;
two new applyMarkUnreadDividerOverlay tests exercise the extracted production
transition (success adds to overlay, refusal does not).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.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