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
Open
feat(read-state): wire right-click mark-read/unread to NIP-RS override layer (slice 3)#4005wpfleger96 wants to merge 5 commits into
wpfleger96 wants to merge 5 commits into
Conversation
…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>
This was referenced Jul 31, 2026
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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. DuplicateMarkResult/OverrideLivenesstype copies removed;overrideErrorMessageand toast policy moved toreadStateOverride.ts.readStateOverride.ts(new) —applyOverrideUnread/applyOverrideRead/persistForcedUnread/applyMarkUnreadDividerOverlayhelpers forming the seam betweenuseUnreadChannels/useChannelUnreadStateand the manager override APIs.OverrideAPIsincludesisReadStateReadyto distinguish manager-unavailable (fail-closed) from known-no-register (cleared).applyOverrideReadsemantics follow NIP-RS:537-539: register exists → always attempt C-bump regardless of active/inactive state, then inspect resulting liveness.nullliveness withisReadStateReady: true= known absence (cleared);isReadStateReady: false= fail closed.readState/useReadState.ts— exposesmarkChannelUnread,markChannelRead,getOverrideLiveness,isReadStateReadycallbacks proxying the manager. Additive proxying only.useUnreadChannels.ts— active-community unread memo derives forced-unread fromgetOverrideLiveness(channelId)?.active.markChannelReadandmarkAllChannelsReadgate local cache deletion onapplyOverrideReadreturning"overrideCleared".markChannelUnreadreturnsbooleanso callers can gate session-local overlays on success.useChannelUnreadState.ts—applyMarkUnreadDividerOverlayextracted as a pure helper: callsmarkFn(channelId)and adds to theforcedUnreadRefoverlay only ontrue.handleMarkUnreadcalls 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 viamax()per field. The staleforcedUnreadMapfallback 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 injectedOverrideAPIs:applyOverrideRead: manager-not-ready fails closed; ready+null = cleared (no C-bump); inactive register → C-bump attempted; active → C-bump → re-check;already_inactiverace; post-call null treated as cleared; partial-refusal loopapplyMarkUnreadDividerOverlay: success adds to overlay; refusal does notapplyOverrideUnreadsuccess and refusal pathspersistForcedUnreadnew entry, same-ts noop, different-ts refreshforcedUnreadStoreround-trip andresolveChannelReadMarkerboundary casescommunityUnreadObserver.test.mjs— updated to usereadStoredRegistersinjection; 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 infosjust desktop-typecheck— cleanjust desktop-test— 4038/4038 pass across 59 suitesMerged-in manager head:
e5bdaa9804aab1cd82ca3478d3b714f5ecfa4d37Stack: #3966 → #4002 → this PR