Skip to content

fix(desktop): fix workspace rail badge disappearance and persist mark-as-unread#1747

Merged
wpfleger96 merged 4 commits into
mainfrom
duncan/rail-unread-lifecycle-and-forced-unread-persist
Jul 11, 2026
Merged

fix(desktop): fix workspace rail badge disappearance and persist mark-as-unread#1747
wpfleger96 merged 4 commits into
mainfrom
duncan/rail-unread-lifecycle-and-forced-unread-persist

Conversation

@wpfleger96

Copy link
Copy Markdown
Collaborator

What

Two independent rail-unread fixes:

Fix 1: Clear URL on workspace switch (A→B→A→B badge vanishes)

The router is a module singleton ( outside any React component), so switching workspaces did not reset . ChannelScreen's read effect kept firing markChannelRead({ topLevelOnly: true }) for the previous workspace's channel — advancing its NIP-RS read markers via a 5s debounce publish. The next 30s rail poll saw the advanced markers and returned hasUnread:false, dropping A's badge after A→B→A→B.

Fix: wrap all three onSwitchWorkspace call sites in a memoized handleSwitchWorkspace that calls goHome() first, clearing the URL before the workspace teardown. ChannelScreen unmounts cleanly, no spurious marker advance.

Fix 2: Persist mark-as-unread to localStorage + show in rail

markChannelUnread (right-click → Mark as Unread) wrote only to an in-memory React.useRef flag — cleared on reload, invisible to the rail observer which reads only NIP-RS relay state.

Fix: introduce forcedUnreadStore — a per-pubkey localStorage store using the existing makeRootIdStore pattern. Three write-through clear points mirror the existing in-memory semantics: channel-open, identity change, and cross-device synced read advance. The rail observer reads the store and ORs matching channels into hasUnread (muted channels still win).

NIP-RS markers are monotonic and cannot represent a retrograde unread state, so localStorage is the honest best-effort for per-device persistence. No relay sync.

Tests

  • 4 new observer tests: forced-unread lights dot, non-member ignored, muted channel wins, empty-set falls through to relay gate
  • Existing 19 observer tests continue to pass (2496 total)

Gates

  • just desktop-typecheck
  • just desktop-build
  • just desktop-check ✅ (2 pre-existing warnings, 0 errors)
  • just desktop-test ✅ 2496 pass, 0 fail

npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 2 commits July 10, 2026 20:34
…ker advance

When switching workspaces, the router URL persisted into the new workspace
context because the router is a module singleton (createHashHistory lives
outside any React component). ChannelScreen's read effect kept firing
markChannelRead({ topLevelOnly: true }) for the previous workspace's channel,
advancing its NIP-RS read markers via a 5s debounce publish. The next 30s
rail poll of the now-inactive workspace saw the advanced markers and returned
hasUnread:false, dropping the rail badge.

Introduce handleSwitchWorkspace in AppShell that calls goHome() before
switchWorkspace(). Clearing the URL unmounts ChannelScreen for the previous
workspace before the relay is torn down, eliminating the spurious marker
advance. Applied to all three workspace-switch call sites (WorkspaceRail,
sidebar WorkspaceSwitcher, add-workspace flow) via a single memoized handler.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
mark-as-unread (right-click → Mark as Unread) previously stored only an
in-memory React.useRef flag (forcedUnreadRef) that was cleared on reload and
invisible to the workspace rail observer which reads only NIP-RS relay state.

Introduce forcedUnreadStore — a per-pubkey localStorage store using the
existing makeRootIdStore pattern — keyed by channelId. Three write-through
clear points mirror the existing in-memory clear semantics:
  - channel-open: markChannelRead (topLevelOnly or explicit)
  - identity/relay change: reset effect loads from store for new pubkey
  - cross-device synced read advance: drainSyncedAdvances effect
markAllChannelsRead also drains and persists the store.

The rail observer (fetchWorkspaceUnread) reads the forced-unread set for the
observed workspace's pubkey via a readForcedUnread injectable and ORs matching
channels into hasUnread before the relay fetch loop. Muted channels are still
skipped first so the mute wins. No extra relay fetches needed — the store is
already drained at write time.

Add 4 observer tests covering: forced-unread lights dot, non-member channel
ignored, muted channel wins, empty-set falls through to relay gate.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 requested a review from a team as a code owner July 11, 2026 00:35
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 2 commits July 10, 2026 20:50
…nt stale cross-device state

Thufir Pass 1 fixes:

1. Rewrite forcedUnreadStore from a makeRootIdStore id-set to a
   purpose-built per-pubkey record store (ForcedUnreadMap =
   Record<string, number | null>). Each entry stores the channel's own
   NIP-RS read marker at force-time (markerAtWhenForced) so the observer
   can detect when a later cross-device read has covered the force.

2. workspaceUnreadObserver: compute readAt first in the per-channel loop,
   then gate the forced-unread OR on
   readAt === null || (markerAtWhenForced !== null && readAt <= markerAtWhenForced).
   This ensures stale forced-unread entries do not light the rail after a
   cross-device read advances the NIP-RS marker past the stored baseline.
   The drain path in useUnreadChannels only runs while the workspace is
   active, so the observer cannot rely on the store being pruned for
   inactive workspaces.

3. useUnreadChannels: markChannelUnread captures getOwnTimestamp(channelId)
   as the baseline. All four write-through points (markChannelUnread,
   markChannelRead, drainSyncedAdvances, markAllChannelsRead) updated to
   operate on the record shape (delete key instead of Set.delete).
   forcedUnreadRef.current shape updated to ForcedUnreadMap throughout.
   Sidebar consumer updated from .has() to 'in' operator.

4. AppShell: guard handleSwitchWorkspace so goHome() only fires when the
   selected workspace differs from the currently active one, preventing
   unexpected channel-clear on re-click of the active workspace rail entry.

5. forcedUnreadStore comment corrected: identity change swaps the in-memory
   map to the current pubkey's data; it does NOT wipe persisted state.

6. Three new observer tests: forced+marker-advanced-past-baseline → false,
   forced+marker-not-advanced → true, forced-null-baseline+readAt-present →
   false. Existing four forced-unread tests updated for new record shape.
   File-size override updated to 1022 (gate uses split count, not wc -l).

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

Replace in-operator membership tests on ForcedUnreadMap plain objects with
Object.hasOwn(), which only matches own enumerable properties and cannot be
confused by inherited prototype keys such as toString or constructor.

The map is deserialized from user-writable localStorage, so own-property
checks are the correct guard even though real hex channel ids can't collide
with Object.prototype in practice.

Add ES2022 to tsconfig lib (not target) to type Object.hasOwn without
changing the ES2020 compilation output.

Five call sites updated:
- workspaceUnreadObserver.ts: channel.id in forcedUnreadMap
- useUnreadChannels.ts (x4): channelId/channel.id in forcedUnreadRef.current
  (drainSyncedAdvances loop, markChannelRead, markChannelUnread negated guard,
  sidebar unread memo)

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 merged commit e8df6ba into main Jul 11, 2026
25 checks passed
@wpfleger96 wpfleger96 deleted the duncan/rail-unread-lifecycle-and-forced-unread-persist branch July 11, 2026 01:15
tlongwell-block pushed a commit that referenced this pull request Jul 11, 2026
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>

* origin/main:
  fix(desktop): preserve archived observer history (#1752)
  fix(dev): install Lefthook hooks into shared .git/hooks dir (#1751)
  fix(desktop): surface codex config-parse failures and -32603 internal errors clearly (#1745)
  fix(desktop): fix workspace rail badge disappearance and persist mark-as-unread (#1747)
  chore(desktop): remove container-only npm-preflight E2E harness (#1749)
  fix(desktop): preflight npm prefix writability in doctor installs (#1732)
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