feat(desktop): persist sidebar observed-unread across webview reload (#3976)

## Problem

`Command+R` (webview reload) wipes the two in-memory refs driving
sidebar channel unread badges: `observedUnreadEventsByChannelRef` and
`latestByChannelRef`. The boot catch-up REQ can only fetch events newer
than each channel's NIP-RS frontier, so thread replies that arrived
before the frontier was passively advanced (the common case) are never
re-discovered.

Inbox is unaffected because it rebuilds candidates from a relay feed
query and checks fine-grained `thread:`/`msg:` markers. The sidebar
badge path lacks an equivalent recovery mechanism.

## Solution

Persist the sidebar's per-event candidate set to localStorage as a
disposable, versioned projection cache
(`buzz-observed-unread.v1:<relay>:<pubkey>`) and hydrate it on boot
before the catch-up REQ runs.

### New files

**`observedUnreadStorage.ts`** — storage module for the cache:
- Keyed
`buzz-observed-unread.v1:<normalizedRelayUrl>:<normalizedPubkey>`
(relay-scoped to prevent cross-community leakage, matching
`threadActivityStorage`)
- Stores validated per-event `ObservedUnreadEvent` rows;
`latestByChannel` is derived at hydration — no divergent dual aggregate
- Age pruning (7d = `READ_STATE_HORIZON_SECONDS`), per-channel cap
(1000), global cap (5000) across all channels in a scope bucket
- Payload `updatedAt` for LRU ordering; registered in
`PURE_CACHE_KEY_PREFIXES` for 2 MiB eviction budget
- Field-level validation on decode; write failure is non-fatal
(session-only degradation)
- Snapshot-owning timers: `scheduleObservedUnreadWrite` deep-clones the
events map at schedule time — a late A-scope timer can never read B's
mutable refs or write under B's key

**`useObservedUnreadPersistence.ts`** — hook that owns all persistence
lifecycle:
- Scope fence: `normalized pubkey + normalized relay` identity;
`isScopeLoaded()` callback guards both projection (`rawUnread`) and
every **observed-cache mutation** (`recordUnreadEvent`, `removeChannel`,
`clearAll`) before touching refs or storage. Note: stale-scope calls to
`markChannelRead`/`markAllChannelsRead` can still affect
`forcedUnreadRef` and NIP-RS markers, which are pre-existing on `main`
and deferred to the NIP-RS arc (see Deferred below).
- Synchronous `pagehide` flush closes the Cmd+R timing gap
(`useReloadShortcut.ts` reloads within 500ms of teardown, before the
1-second debounce fires)
- Identity-reset effect: flushes old scope, resets refs, hydrates from
storage, stamps loaded scope — all atomic; cleanup flushes on unmount
- `clearAll` cancels the pending timer, resets both in-memory refs, and
clears storage in a single transactional operation; `removeChannel`
deletes the channel from both refs and replaces any pending snapshot
with the current full map — never cancel-without-replacement, preserving
sibling-channel events on reload
- Marker-prune effect on `readStateVersion`: evaluates each retained
event with `observedUnreadEventReadAt()` (the same evaluator used by the
projection memo) and removes covered events, rederiving per-channel
latest — never clears a whole channel for a single thread/msg marker
- Returns a stable `useMemo`-wrapped API object keyed on actual deps so
unrelated re-renders do not restart the catch-up REQ
- `isScopeLoaded` is a `useCallback` (not a memoized boolean) — always
reads the ref at call time, never stale

### Modified files

**`useUnreadChannels.ts`** — hook integration:
- Calls `useObservedUnreadPersistence` with all persistence wired
through the returned API
- `rawUnread`: `isScopeLoaded()` guard suppresses A-scope refs from
projecting under B
- `recordUnreadEvent`: `isScopeLoaded()` fence before touching refs;
schedules a debounced write on each successful record
- `markChannelRead` clearObserved path: calls `removeChannel` so the
cleared state survives reload
- `markAllChannelsRead`: delegates to the owner's fenced `clearAll` —
the parent does not reset the observed refs directly; `clearAll` owns
the transactional clear of both refs and storage, preventing a stale
scope-A callback from corrupting scope B

**`localStorageQuota.ts`** — registers `buzz-observed-unread.v1:` in
`PURE_CACHE_KEY_PREFIXES`

## Design constraints

The cache is a **disposable projection**: versioned key, read-through
only, safe to delete wholesale. It does not touch `ReadStateManager`,
marker semantics, or `forcedUnreadStore`. Zero overlap with the NIP-RS
manual mark-read/unread protocol work in progress in another channel;
migration path when that lands is "stop reading the key."

## Test coverage

**`observedUnreadStorage.test.mjs`** covers storage primitives:
- Key normalization, relay-scoped isolation, round-trip correctness
- Age-prune and per-channel cap on read and write; global cap across
channels
- `deriveLatestByChannel` correctness
- Thread-marker prune leaves sibling thread events persisted and lit
- Scope-isolation state machine: A rows visible in A, absent in B,
restored on A again; late A-scope write does not overwrite B's bucket
- Malformed structures/fields, relay/pubkey isolation, quota failure
degradation

**`useObservedUnreadPersistence.test.mjs`** exercises the real hook via
`createRoot` + `act`:
- pagehide flush: event recorded within debounce window survives reload
(headline regression)
- Unmount with pending write flushes before teardown
- `clearAll` cancels pending debounce so no resurrection after reload
- `removeChannel` replaces pending snapshot so sibling channel B
survives reload (two-channel repro)
- Marker prune: thread and channel markers prune covered events; sibling
channels survive
- `isScopeLoaded` returns false before identity-reset effect commits,
true after
- A→B scope switch: pending A-timer is cancelled by flush, A data
persisted synchronously (hydration round-trip)
- Stale `clearAll` from scope A rejects after scope B loads
(observed-cache scope fence)
- Stale `removeChannel` from scope A rejects after scope B loads
(observed-cache scope fence)
- API object identity stable across unrelated re-renders (catch-up
stability)

**`useUnreadChannels.test.mjs`** exercises the full parent-to-owner seam
with real hook mounts:
- Stale `markChannelRead` from scope A does not corrupt B's observed
bucket after flush
- Stale `markAllChannelsRead` from scope A does not overwrite B's bucket
after flush

## Deferred

Issues deferred to the NIP-RS arc (`#unread-messages-ux`) or future
hardening — not regressions introduced by this PR:

- **Stale-scope `forcedUnreadRef` / `markContextRead` exposure**: a
stale scope-A `markChannelRead` or `markAllChannelsRead` still deletes
B's `forcedUnreadRef` entries and advances B's NIP-RS markers via
`markContextRead` before the observed-cache fence rejects. This is
pre-existing on `origin/main` (identical shape at lines 316/330). Fix
requires touching `forcedUnreadStore` and marker paths — out of scope
for Fix A. Deferred to the NIP-RS work.
- **`isScopeLoaded` empty-scope hardening**: `isScopeLoaded()` returns
`true` when `pubkey` and `relay` are empty strings (no active session).
A guard could assert non-empty identity before stamping scope-loaded.
Low risk in practice since the hook is only mounted after auth, but
could be tightened.
- **Catch-up batch scheduling**: `handleChannelMessage` and the catch-up
loop each clone the full events map per event via
`scheduleObservedUnreadWrite`. For channels with large backlogs this
produces O(n) snapshot clones per catch-up batch. A batch-schedule API
(single snapshot at end of batch) would reduce allocations. Not
observable in normal use; deferred as a performance optimization.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-08-04 12:44:24 -04:00
committed by GitHub
parent f86dfc5883
commit 0afeac8a7c
9 changed files with 2114 additions and 57 deletions

View File

@@ -0,0 +1,343 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
clearObservedUnreadStorage,
deriveLatestByChannel,
observedUnreadStorageKey,
OBSERVED_UNREAD_STORAGE_PREFIX,
readObservedUnreadFromStorage,
writeObservedUnreadToStorage,
} from "./observedUnreadStorage.ts";
import { READ_STATE_HORIZON_SECONDS } from "./readState/readStateFormat.ts";
import {
withIsolatedStorage,
makeObservedEvent,
makeEventsByChannel,
} from "./observedUnreadTestHarness.mjs";
const NOW_SECONDS = Math.floor(Date.now() / 1_000);
const FRESH_AT = NOW_SECONDS - 100;
const STALE_AT = NOW_SECONDS - READ_STATE_HORIZON_SECONDS - 100;
// ── observedUnreadStorageKey ──────────────────────────────────────────────────
test("observedUnreadStorageKey: prefix, normalization, and isolation", () => {
const base = observedUnreadStorageKey("pk1", "wss://relay.example.com");
// Has correct prefix.
assert.ok(base.startsWith(`${OBSERVED_UNREAD_STORAGE_PREFIX}:`));
// URL normalizes (trailing slash, mixed case).
assert.equal(
base,
observedUnreadStorageKey("pk1", "WSS://Relay.Example.Com/"),
);
// Relay isolation.
assert.notEqual(
base,
observedUnreadStorageKey("pk1", "wss://relay-b.example.com"),
);
// Pubkey isolation.
assert.notEqual(
base,
observedUnreadStorageKey("pk2", "wss://relay.example.com"),
);
});
// ── write/read round-trip ─────────────────────────────────────────────────────
test("round-trip: events written are readable and intact", () => {
withIsolatedStorage(() => {
const pubkey = "pk1";
const relay = "wss://relay.example.com";
const event = makeObservedEvent({ id: "e1", createdAt: FRESH_AT });
const map = makeEventsByChannel([["channel-1", [event]]]);
writeObservedUnreadToStorage(pubkey, relay, map);
const result = readObservedUnreadFromStorage(pubkey, relay);
assert.ok(result !== null, "should return a map");
const ch = result.get("channel-1");
assert.ok(ch !== undefined, "channel-1 should be present");
assert.ok(ch.has("e1"), "event e1 should be present");
const e = ch.get("e1");
assert.equal(e.id, "e1");
assert.equal(e.createdAt, FRESH_AT);
assert.equal(e.rootId, "root-e1");
});
});
test("round-trip: relay A rows not readable under relay B", () => {
withIsolatedStorage(() => {
const pubkey = "pk1";
const relayA = "wss://relay-a.example.com";
const relayB = "wss://relay-b.example.com";
const map = makeEventsByChannel([
["channel-1", [makeObservedEvent({ id: "e1", createdAt: FRESH_AT })]],
]);
writeObservedUnreadToStorage(pubkey, relayA, map);
const result = readObservedUnreadFromStorage(pubkey, relayB);
assert.equal(result, null, "relay B should see no data");
});
});
test("read returns null for missing key, corrupt JSON, and non-object eventsByChannel", () => {
withIsolatedStorage(() => {
// Missing key.
assert.equal(
readObservedUnreadFromStorage("pk1", "wss://relay.example.com"),
null,
);
// Corrupt JSON.
const key = observedUnreadStorageKey("pk1", "wss://relay.example.com");
window.localStorage.setItem(key, "not-json{{{");
assert.equal(
readObservedUnreadFromStorage("pk1", "wss://relay.example.com"),
null,
);
// eventsByChannel is an array (not an object).
window.localStorage.setItem(
key,
JSON.stringify({ updatedAt: Date.now(), eventsByChannel: [1, 2, 3] }),
);
assert.equal(
readObservedUnreadFromStorage("pk1", "wss://relay.example.com"),
null,
);
});
});
test("read discards malformed events and keeps valid ones", () => {
withIsolatedStorage(() => {
const key = observedUnreadStorageKey("pk1", "wss://relay.example.com");
const valid = makeObservedEvent({ id: "e-ok", createdAt: FRESH_AT });
window.localStorage.setItem(
key,
JSON.stringify({
updatedAt: Date.now(),
eventsByChannel: {
"ch-1": [
{ createdAt: FRESH_AT }, // missing id
{
id: "e-inf",
createdAt: Infinity,
rootId: null,
highPriority: false,
countsTowardBadge: false,
countsTowardAppBadge: false,
},
valid,
],
},
}),
);
const result = readObservedUnreadFromStorage(
"pk1",
"wss://relay.example.com",
);
const ch = result?.get("ch-1");
assert.ok(ch !== undefined);
assert.equal(ch.size, 1, "only the valid event should survive");
assert.ok(ch.has("e-ok"), "valid event must be kept");
});
});
// ── age pruning ───────────────────────────────────────────────────────────────
test("write prunes stale events; all-stale write removes key; read prunes bypassed stale data", () => {
withIsolatedStorage(() => {
const pubkey = "pk1";
const relay = "wss://relay.example.com";
const key = observedUnreadStorageKey(pubkey, relay);
// write() prunes on encode path.
writeObservedUnreadToStorage(
pubkey,
relay,
makeEventsByChannel([
[
"ch-1",
[
makeObservedEvent({ id: "stale", createdAt: STALE_AT }),
makeObservedEvent({ id: "fresh", createdAt: FRESH_AT }),
],
],
]),
);
const ch = readObservedUnreadFromStorage(pubkey, relay)?.get("ch-1");
assert.ok(!ch?.has("stale"), "stale event should be pruned on write");
assert.ok(ch?.has("fresh"), "fresh event should survive");
// All-stale write removes the key entirely.
writeObservedUnreadToStorage(
pubkey,
relay,
makeEventsByChannel([
["ch-1", [makeObservedEvent({ id: "stale2", createdAt: STALE_AT })]],
]),
);
assert.equal(
window.localStorage.getItem(key),
null,
"key should be absent when all events stale",
);
// read() prunes bypassed stale data (written directly to bypass encode pruning).
window.localStorage.setItem(
key,
JSON.stringify({
updatedAt: Date.now(),
eventsByChannel: {
"ch-r": [
makeObservedEvent({ id: "stale-r", createdAt: STALE_AT }),
makeObservedEvent({ id: "fresh-r", createdAt: FRESH_AT }),
],
},
}),
);
const ch2 = readObservedUnreadFromStorage(pubkey, relay)?.get("ch-r");
assert.ok(!ch2?.has("stale-r"), "stale event should be pruned on read");
assert.ok(ch2?.has("fresh-r"), "fresh event should survive on read");
});
});
// ── caps ──────────────────────────────────────────────────────────────────────
test("write caps per channel at 1000 and globally at 5000 keeping newest", () => {
withIsolatedStorage(() => {
const pubkey = "pk1";
const relay = "wss://relay.example.com";
// Per-channel: 1100 events → capped to 1000, oldest evicted.
const events = Array.from({ length: 1100 }, (_, i) =>
makeObservedEvent({ id: `e-${i}`, createdAt: FRESH_AT + i }),
);
writeObservedUnreadToStorage(
pubkey,
relay,
makeEventsByChannel([["ch-1", events]]),
);
const ch = readObservedUnreadFromStorage(pubkey, relay)?.get("ch-1");
assert.equal(ch?.size, 1000, "should be capped at 1000");
assert.ok(!ch?.has("e-0"), "oldest event should be evicted");
assert.ok(ch?.has("e-1099"), "newest event should survive");
// Global: 6 channels × 1000 = 6000 → trimmed to ≤ 5000.
const entries = Array.from({ length: 6 }, (_, c) => [
`channel-${c}`,
Array.from({ length: 1000 }, (_, i) =>
makeObservedEvent({
id: `ch${c}-e${i}`,
createdAt: FRESH_AT + c * 10000 + i,
}),
),
]);
writeObservedUnreadToStorage(pubkey, relay, makeEventsByChannel(entries));
const result = readObservedUnreadFromStorage(pubkey, relay);
let total = 0;
for (const eventsById of result.values()) total += eventsById.size;
assert.ok(total <= 5000, `total ${total} should be <= 5000`);
});
});
// ── clearObservedUnreadStorage ────────────────────────────────────────────────
test("clearObservedUnreadStorage: removes scope bucket without affecting others", () => {
withIsolatedStorage(() => {
const relayA = "wss://relay-a.example.com";
const relayB = "wss://relay-b.example.com";
writeObservedUnreadToStorage(
"pk1",
relayA,
makeEventsByChannel([
["ch-a", [makeObservedEvent({ id: "ea", createdAt: FRESH_AT })]],
]),
);
writeObservedUnreadToStorage(
"pk1",
relayB,
makeEventsByChannel([
["ch-b", [makeObservedEvent({ id: "eb", createdAt: FRESH_AT })]],
]),
);
clearObservedUnreadStorage("pk1", relayA);
assert.equal(
readObservedUnreadFromStorage("pk1", relayA),
null,
"cleared bucket must be null",
);
assert.ok(
readObservedUnreadFromStorage("pk1", relayB)?.has("ch-b"),
"other scope must be unaffected",
);
});
});
// ── deriveLatestByChannel ─────────────────────────────────────────────────────
test("deriveLatestByChannel: max createdAt per channel, empty map returns empty", () => {
const map = makeEventsByChannel([
[
"ch-1",
[
makeObservedEvent({ id: "e1", createdAt: 100 }),
makeObservedEvent({ id: "e2", createdAt: 200 }),
makeObservedEvent({ id: "e3", createdAt: 150 }),
],
],
["ch-2", [makeObservedEvent({ id: "e4", createdAt: 50 })]],
]);
const latest = deriveLatestByChannel(map);
assert.equal(latest.get("ch-1"), 200);
assert.equal(latest.get("ch-2"), 50);
assert.equal(deriveLatestByChannel(new Map()).size, 0);
});
// ── quota / write failure ─────────────────────────────────────────────────────
test("writeObservedUnreadToStorage returns false when localStorage throws", () => {
const prevWindow = globalThis.window;
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
const prevLs = globalThis.window?.localStorage;
globalThis.window.localStorage = {
get length() {
return 0;
},
key: () => null,
getItem: () => null,
setItem: () => {
throw new DOMException("QuotaExceededError");
},
removeItem: () => {},
};
try {
const map = makeEventsByChannel([
["ch-1", [makeObservedEvent({ id: "e1", createdAt: FRESH_AT })]],
]);
const result = writeObservedUnreadToStorage(
"pk1",
"wss://relay.example.com",
map,
);
// setLocalStorageItemWithRecovery retries after eviction; on persistent
// failure it returns false. The exact return value depends on whether
// eviction succeeded, but the call must not throw.
assert.ok(
typeof result === "boolean",
"write failure must return boolean, not throw",
);
} finally {
if (prevWindow === undefined) {
delete globalThis.window;
} else {
globalThis.window.localStorage = prevLs;
}
}
});

View File

@@ -0,0 +1,406 @@
/**
* Disposable projection cache for the sidebar's observed-unread candidate set.
*
* The sidebar computes unread channel badges from two in-memory refs:
* - observedUnreadEventsByChannelRef (per-event metadata)
* - latestByChannelRef (max createdAt per channel, derived)
*
* Both refs are wiped on webview reload. The boot catch-up REQ can only fetch
* events *newer than* each channel's NIP-RS frontier, so thread replies that
* arrived before the frontier was advanced (the common case) are never
* re-discovered. This module persists the per-event map so reload restores
* candidate evidence that the relay query structurally cannot recover.
*
* Design constraints:
* - Storage key: "buzz-observed-unread.v1:<normalizedRelayUrl>:<normalizedPubkey>"
* (relay-scoped to prevent cross-community leakage, matching threadActivityStorage).
* - Registered in PURE_CACHE_KEY_PREFIXES so the 2 MiB LRU eviction budget applies.
* - Payload includes `updatedAt` (ms timestamp) for LRU ordering across scopes.
* - Field-level validation: malformed or non-finite values are discarded silently.
* - Age pruning: events older than READ_STATE_HORIZON_SECONDS (7 days) are dropped.
* - Per-channel cap: newest CATCH_UP_LIMIT events per channel.
* - Global cap: newest GLOBAL_OBSERVED_UNREAD_CAP events across all channels.
* - Write failure is non-fatal; callers degrade to session-only behavior.
* - NOT a source of truth: NIP-RS markers remain authoritative for read state.
* The projection memo re-evaluates every retained event against current markers.
*/
import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage";
import { READ_STATE_HORIZON_SECONDS } from "@/features/channels/readState/readStateFormat";
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
import {
observedUnreadEventReadAt,
type ObservedUnreadEvent,
} from "@/features/channels/unreadChannelCounts";
export const OBSERVED_UNREAD_STORAGE_PREFIX = "buzz-observed-unread.v1";
// Per-channel cap: matches CATCH_UP_LIMIT in useUnreadChannels.ts.
const PER_CHANNEL_CAP = 1000;
// Global cap across all channels in a scope bucket.
const GLOBAL_OBSERVED_UNREAD_CAP = 5000;
export type ObservedUnreadScope = {
pubkey: string;
relayUrl: string;
};
export function observedUnreadStorageKey(
pubkey: string,
relayUrl: string,
): string {
return `${OBSERVED_UNREAD_STORAGE_PREFIX}:${normalizeRelayUrl(relayUrl)}:${pubkey.toLowerCase()}`;
}
type PersistedPayload = {
updatedAt: number;
eventsByChannel: Record<string, ObservedUnreadEvent[]>;
};
function isValidEvent(v: unknown): v is ObservedUnreadEvent {
if (typeof v !== "object" || v === null) return false;
const e = v as Record<string, unknown>;
return (
typeof e.id === "string" &&
typeof e.createdAt === "number" &&
Number.isFinite(e.createdAt) &&
(e.rootId === null || typeof e.rootId === "string") &&
typeof e.highPriority === "boolean" &&
typeof e.countsTowardBadge === "boolean" &&
typeof e.countsTowardAppBadge === "boolean"
);
}
function nowUnixSeconds(): number {
return Math.floor(Date.now() / 1_000);
}
/**
* Prune a per-channel event map in place:
* 1. Drop events older than READ_STATE_HORIZON_SECONDS.
* 2. Per-channel cap: keep newest PER_CHANNEL_CAP events.
* 3. Global cap: if total exceeds GLOBAL_OBSERVED_UNREAD_CAP, keep the
* newest events across all channels.
*
* Returns the pruned map (same reference, mutated).
*/
function pruneEventsByChannel(
eventsByChannel: Map<string, Map<string, ObservedUnreadEvent>>,
nowSeconds: number,
): Map<string, Map<string, ObservedUnreadEvent>> {
const cutoff = nowSeconds - READ_STATE_HORIZON_SECONDS;
// Age prune and per-channel cap.
for (const [channelId, eventsById] of eventsByChannel) {
for (const [id, event] of eventsById) {
if (event.createdAt <= cutoff) {
eventsById.delete(id);
}
}
if (eventsById.size === 0) {
eventsByChannel.delete(channelId);
continue;
}
if (eventsById.size > PER_CHANNEL_CAP) {
const sorted = [...eventsById.values()].sort(
(a, b) => a.createdAt - b.createdAt,
);
const toRemove = sorted.slice(0, eventsById.size - PER_CHANNEL_CAP);
for (const e of toRemove) {
eventsById.delete(e.id);
}
}
}
// Global cap: if total events exceed limit, evict oldest across channels.
let total = 0;
for (const eventsById of eventsByChannel.values()) {
total += eventsById.size;
}
if (total > GLOBAL_OBSERVED_UNREAD_CAP) {
// Collect all events with their channel id, sort by createdAt ascending.
const all: Array<{ channelId: string; event: ObservedUnreadEvent }> = [];
for (const [channelId, eventsById] of eventsByChannel) {
for (const event of eventsById.values()) {
all.push({ channelId, event });
}
}
all.sort((a, b) => a.event.createdAt - b.event.createdAt);
const excess = total - GLOBAL_OBSERVED_UNREAD_CAP;
for (let i = 0; i < excess; i++) {
const entry = all[i];
if (!entry) break;
const eventsById = eventsByChannel.get(entry.channelId);
if (eventsById) {
eventsById.delete(entry.event.id);
if (eventsById.size === 0) {
eventsByChannel.delete(entry.channelId);
}
}
}
}
return eventsByChannel;
}
/**
* Read and decode the persisted observed-unread map for a scope.
* Returns null when no data exists or decoding fails.
* Applies age pruning and caps before returning.
*/
export function readObservedUnreadFromStorage(
pubkey: string,
relayUrl: string,
): Map<string, Map<string, ObservedUnreadEvent>> | null {
try {
const key = observedUnreadStorageKey(pubkey, relayUrl);
const raw = window.localStorage.getItem(key);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed)
) {
return null;
}
const payload = parsed as Record<string, unknown>;
const eventsByChannelRaw = payload.eventsByChannel;
if (
typeof eventsByChannelRaw !== "object" ||
eventsByChannelRaw === null ||
Array.isArray(eventsByChannelRaw)
) {
return null;
}
const result = new Map<string, Map<string, ObservedUnreadEvent>>();
for (const [channelId, events] of Object.entries(
eventsByChannelRaw as Record<string, unknown>,
)) {
if (!Array.isArray(events)) continue;
const eventsById = new Map<string, ObservedUnreadEvent>();
for (const e of events) {
if (isValidEvent(e)) {
eventsById.set(e.id, e);
}
}
if (eventsById.size > 0) {
result.set(channelId, eventsById);
}
}
if (result.size === 0) return null;
pruneEventsByChannel(result, nowUnixSeconds());
return result.size > 0 ? result : null;
} catch {
return null;
}
}
/**
* Serialize and write the observed-unread map for a scope to localStorage.
* Prunes before writing. Write failure is non-fatal.
* Returns true when the write succeeded.
*/
export function writeObservedUnreadToStorage(
pubkey: string,
relayUrl: string,
eventsByChannel: Map<string, Map<string, ObservedUnreadEvent>>,
): boolean {
try {
// Prune on a shallow copy so we don't mutate the live refs.
const copy = new Map<string, Map<string, ObservedUnreadEvent>>();
for (const [channelId, eventsById] of eventsByChannel) {
copy.set(channelId, new Map(eventsById));
}
pruneEventsByChannel(copy, nowUnixSeconds());
if (copy.size === 0) {
// Nothing worth persisting — remove any stale entry.
try {
window.localStorage.removeItem(
observedUnreadStorageKey(pubkey, relayUrl),
);
} catch {
// Ignore.
}
return true;
}
const payload: PersistedPayload = {
updatedAt: Date.now(),
eventsByChannel: {},
};
for (const [channelId, eventsById] of copy) {
payload.eventsByChannel[channelId] = [...eventsById.values()];
}
const key = observedUnreadStorageKey(pubkey, relayUrl);
return setLocalStorageItemWithRecovery(key, JSON.stringify(payload));
} catch {
return false;
}
}
/**
* Delete the entire observed-unread storage bucket for a scope.
* Used on mark-all-read or when all channels are cleared.
*/
export function clearObservedUnreadStorage(
pubkey: string,
relayUrl: string,
): void {
try {
window.localStorage.removeItem(observedUnreadStorageKey(pubkey, relayUrl));
} catch {
// Ignore.
}
}
/**
* Derive the latestByChannel map from the eventsByChannel map.
* This is used at hydration time to repopulate latestByChannelRef
* without storing a redundant aggregate.
*/
export function deriveLatestByChannel(
eventsByChannel: Map<string, Map<string, ObservedUnreadEvent>>,
): Map<string, number> {
const result = new Map<string, number>();
for (const [channelId, eventsById] of eventsByChannel) {
let max = 0;
for (const event of eventsById.values()) {
if (event.createdAt > max) max = event.createdAt;
}
if (max > 0) result.set(channelId, max);
}
return result;
}
// ─── Persistence writer (scope-safe debounce + flush) ─────────────────────────
//
// Factory used by useUnreadChannels. Returns schedule/flush bound to the
// provided refs. Kept here so the hook stays slim.
export type ObservedUnreadRefs = {
eventsRef: { current: Map<string, Map<string, ObservedUnreadEvent>> };
scopeLoadedRef: { current: string };
timerRef: { current: ReturnType<typeof setTimeout> | null };
};
/**
* Decompose a scope key ("pubkey:normalizedRelayUrl") into its components.
* Returns null when the scope is empty or malformed.
*/
function decomposeScope(
scope: string,
): { pubkey: string; relayUrl: string } | null {
if (!scope) return null;
const colonIdx = scope.indexOf(":");
if (colonIdx === -1) return null;
const pubkey = scope.slice(0, colonIdx);
const relayUrl = scope.slice(colonIdx + 1);
return pubkey && relayUrl ? { pubkey, relayUrl } : null;
}
/**
* Schedule a debounced 1-second write of the observed-unread refs for `scope`.
* A pending timer for any scope is replaced. The timer closure owns an
* immutable {scope, snapshot} captured at call time — a late A-scope timer
* can never read B's mutable refs or write under B's key.
*/
export function scheduleObservedUnreadWrite(
scope: string,
refs: ObservedUnreadRefs,
): void {
if (!scope || refs.scopeLoadedRef.current !== scope) return;
if (refs.timerRef.current !== null) clearTimeout(refs.timerRef.current);
const parts = decomposeScope(scope);
if (!parts) return;
const { pubkey, relayUrl } = parts;
// Capture an immutable deep clone of the events map at this moment.
// The timer callback owns this snapshot and never touches the live refs.
const snapshot = new Map<string, Map<string, ObservedUnreadEvent>>();
for (const [channelId, eventsById] of refs.eventsRef.current) {
snapshot.set(channelId, new Map(eventsById));
}
refs.timerRef.current = setTimeout(() => {
refs.timerRef.current = null;
writeObservedUnreadToStorage(pubkey, relayUrl, snapshot);
}, 1_000);
}
/**
* Synchronously flush any pending debounce write and cancel the timer.
* Called on pagehide and before scope reset to close the Cmd+R timing gap.
*/
export function flushObservedUnreadWrite(refs: ObservedUnreadRefs): void {
if (refs.timerRef.current !== null) {
clearTimeout(refs.timerRef.current);
refs.timerRef.current = null;
}
const scope = refs.scopeLoadedRef.current;
const parts = decomposeScope(scope);
if (!parts) return;
writeObservedUnreadToStorage(
parts.pubkey,
parts.relayUrl,
refs.eventsRef.current,
);
}
/**
* Prune persisted observed events that are now covered by read markers.
* Uses the same observedUnreadEventReadAt() the projection memo uses so the
* two are always in sync. Rederives per-channel latest after pruning.
*
* Returns true when any event was removed (caller should persist + bump).
*/
export function pruneObservedUnreadByMarkers(
eventsByChannel: Map<string, Map<string, ObservedUnreadEvent>>,
latestByChannel: Map<string, number>,
getChannelReadAt: (channelId: string) => number | null,
getOwnTimestamp: (contextId: string) => number | null,
): boolean {
let changed = false;
for (const [channelId, eventsById] of eventsByChannel) {
const channelReadAt = getChannelReadAt(channelId);
const toDelete: string[] = [];
for (const event of eventsById.values()) {
const readAt = observedUnreadEventReadAt(
event,
channelReadAt,
(rootId) => getOwnTimestamp(`thread:${rootId}`),
(messageId) => getOwnTimestamp(`msg:${messageId}`),
);
if (readAt !== null && event.createdAt <= readAt) toDelete.push(event.id);
}
for (const id of toDelete) {
eventsById.delete(id);
changed = true;
}
if (eventsById.size === 0) {
eventsByChannel.delete(channelId);
} else {
let max = 0;
for (const e of eventsById.values()) {
if (e.createdAt > max) max = e.createdAt;
}
if (max > 0) latestByChannel.set(channelId, max);
else latestByChannel.delete(channelId);
}
}
for (const channelId of latestByChannel.keys()) {
if (!eventsByChannel.has(channelId)) {
latestByChannel.delete(channelId);
changed = true;
}
}
return changed;
}

View File

@@ -0,0 +1,373 @@
/**
* Shared test infrastructure for observed-unread tests.
*
* Exported from a non-test file so the `src/**\/*.test.mjs` glob never picks
* this up as a test suite. Import into individual test files after installing
* the DOM shim (installDOMShim must run before any React imports).
*/
// ── DOM shim ──────────────────────────────────────────────────────────────────
// Minimal DOM with full event-listener support so createRoot + pagehide flush
// both work in a plain Node.js process.
export function installDOMShim() {
class ET {
constructor() {
this._ls = {};
}
addEventListener(t, fn) {
this._ls[t] ??= [];
this._ls[t].push(fn);
}
removeEventListener(t, fn) {
this._ls[t] = (this._ls[t] ?? []).filter((f) => f !== fn);
}
dispatchEvent(e) {
for (const fn of this._ls[e.type] ?? []) fn(e);
return true;
}
}
class Node extends ET {
constructor(tag) {
super();
this.tagName = tag;
this.children = [];
this.childNodes = [];
this.style = {};
this.nodeType = 1;
this.parentNode = null;
}
get ownerDocument() {
return globalThis.document;
}
get firstChild() {
return this.children[0] ?? null;
}
get lastChild() {
return this.children[this.children.length - 1] ?? null;
}
get nextSibling() {
return null;
}
get nodeValue() {
return null;
}
appendChild(c) {
this.children.push(c);
this.childNodes.push(c);
c.parentNode = this;
return c;
}
removeChild(c) {
this.children = this.children.filter((x) => x !== c);
this.childNodes = this.childNodes.filter((x) => x !== c);
return c;
}
insertBefore(n, r) {
if (!r) return this.appendChild(n);
const i = this.children.indexOf(r);
if (i < 0) return this.appendChild(n);
this.children.splice(i, 0, n);
this.childNodes.splice(i, 0, n);
n.parentNode = this;
return n;
}
contains(n) {
return this === n || this.children.some((c) => c?.contains?.(n));
}
}
class Doc extends ET {
constructor() {
super();
this.nodeType = 9;
}
createElement(t) {
return new Node(t);
}
createTextNode(v) {
const n = new Node("#text");
n.nodeType = 3;
n.nodeValue = v;
return n;
}
createComment(v) {
const n = new Node("#comment");
n.nodeType = 8;
n.nodeValue = v;
return n;
}
get activeElement() {
return null;
}
contains(n) {
return n != null;
}
}
const windowET = new ET();
globalThis.document = new Doc();
globalThis.HTMLIFrameElement = Node;
globalThis.HTMLElement = Node;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
process.env.IS_REACT_ACT_ENVIRONMENT = "true";
if (typeof globalThis.window === "undefined") {
Object.defineProperty(globalThis, "window", {
value: globalThis,
configurable: true,
});
}
globalThis.addEventListener = windowET.addEventListener.bind(windowET);
globalThis.removeEventListener = windowET.removeEventListener.bind(windowET);
globalThis.dispatchEvent = windowET.dispatchEvent.bind(windowET);
globalThis.MutationObserver = class {
observe() {}
disconnect() {}
takeRecords() {
return [];
}
};
globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0);
}
// ── localStorage helpers ──────────────────────────────────────────────────────
export function makeLocalStorage() {
const store = new Map();
return {
get length() {
return store.size;
},
key: (i) => [...store.keys()][i] ?? null,
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => store.set(key, value),
removeItem: (key) => store.delete(key),
clear: () => store.clear(),
_store: store,
};
}
/** Install a fresh isolated localStorage and return it. Configurable so each
* test can call this at the top to start with a clean slate. */
export function installFreshStorage() {
const ls = makeLocalStorage();
Object.defineProperty(globalThis, "localStorage", {
get: () => ls,
configurable: true,
});
return ls;
}
/** Swap in an isolated localStorage for the duration of a test, then restore.
* Returns { ls, restore }. Use in try/finally blocks. */
export function makeIsolatedStorage() {
const ls = makeLocalStorage();
const prevWindow = globalThis.window;
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
const prevLs = globalThis.window.localStorage;
globalThis.window.localStorage = ls;
return {
ls,
restore: () => {
if (prevWindow === undefined) {
delete globalThis.window;
} else {
globalThis.window.localStorage = prevLs;
}
},
};
}
/**
* Run fn with an isolated localStorage instance, then restore automatically.
* Equivalent to makeIsolatedStorage() + try/finally restore() but less noisy.
*/
export function withIsolatedStorage(fn) {
const { restore } = makeIsolatedStorage();
try {
fn();
} finally {
restore();
}
}
// ── Event factories ───────────────────────────────────────────────────────────
const _NOW_S = Math.floor(Date.now() / 1_000);
/** Build a minimal observed-unread event object. All fields optional. */
export function makeObservedEvent({
id = "event-1",
createdAt = _NOW_S,
rootId,
highPriority = false,
countsTowardBadge = true,
countsTowardAppBadge = false,
} = {}) {
return {
id,
createdAt,
rootId: rootId ?? `root-${id}`,
highPriority,
countsTowardBadge,
countsTowardAppBadge,
};
}
/** Build a Map<channelId, Map<eventId, event>> from [[channelId, [events]]]. */
export function makeEventsByChannel(entries) {
const map = new Map();
for (const [channelId, events] of entries) {
const byId = new Map();
for (const e of events) {
byId.set(e.id, e);
}
map.set(channelId, byId);
}
return map;
}
// ── Hook mount harnesses ──────────────────────────────────────────────────────
// These are imported AFTER the caller runs installDOMShim() and before any
// test body executes, so React/react-dom are resolved in the shimmed env.
import React from "react";
import { createRoot } from "react-dom/client";
import { act } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useObservedUnreadPersistence } from "./useObservedUnreadPersistence.ts";
import { useUnreadChannels } from "./useUnreadChannels.ts";
import { writeObservedUnreadToStorage } from "./observedUnreadStorage.ts";
/**
* Mount useObservedUnreadPersistence in a harness component.
* Returns { api, render, unmount }.
*/
export async function mountHook(props, refs) {
const apiRef = { current: null };
function Harness({
pubkey,
relay,
isReady,
readStateVersion,
getTs,
getOwn,
onPruned,
}) {
apiRef.current = useObservedUnreadPersistence(
pubkey,
relay,
isReady,
readStateVersion,
getTs,
getOwn,
refs.eventsRef,
refs.latestRef,
{ onPruned: onPruned ?? (() => {}) },
);
return null;
}
const root = createRoot(document.createElement("div"));
const render = async (p) => {
await act(async () => {
root.render(React.createElement(Harness, p));
});
};
await render(props);
return {
get api() {
return apiRef.current;
},
render,
unmount: async () => {
await act(async () => root.unmount());
},
};
}
/**
* Pre-seed storage for pubkey+relay with one channel's events, then return
* an ISO timestamp equal to the seeded event's createdAt (for use as readAt).
*/
export function seedStorage(pubkey, relay, channelId, eventId = "evt-1") {
const inner = new Map();
const ts = Math.floor(Date.now() / 1_000);
inner.set(eventId, makeObservedEvent({ id: eventId, createdAt: ts }));
const events = new Map();
events.set(channelId, inner);
writeObservedUnreadToStorage(pubkey, relay, events);
return new Date(ts * 1_000).toISOString();
}
/**
* Mount useUnreadChannels inside a QueryClientProvider harness.
* Returns { markChannelRead, markAllChannelsRead, render, unmount, flushStorage }.
*/
export async function mountUnreadChannels({
pubkey,
relay = "wss://relay.example.com",
}) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
let capturedMarkChannelRead = null;
let capturedMarkAllChannelsRead = null;
function Inner({ pubkey: pk }) {
const result = useUnreadChannels([], null, {
pubkey: pk,
relayClient: undefined,
relayUrl: relay,
});
capturedMarkChannelRead = result.markChannelRead;
capturedMarkAllChannelsRead = result.markAllChannelsRead;
return null;
}
function Harness({ pubkey: pk }) {
return React.createElement(
QueryClientProvider,
{ client: qc },
React.createElement(Inner, { pubkey: pk }),
);
}
const container = document.createElement("div");
const root = createRoot(container);
const render = async (pk) => {
await act(async () => {
root.render(React.createElement(Harness, { pubkey: pk }));
});
};
await render(pubkey);
return {
get markChannelRead() {
return capturedMarkChannelRead;
},
get markAllChannelsRead() {
return capturedMarkAllChannelsRead;
},
render,
unmount: async () => {
await act(async () => root.unmount());
},
flushStorage: () => {
dispatchEvent(
new (class extends Event {
constructor() {
super("pagehide");
}
})(),
);
},
};
}

View File

@@ -0,0 +1,513 @@
/**
* Integration tests for useObservedUnreadPersistence.
*
* These tests mount the REAL production hook via createRoot + act to exercise
* the actual lifecycle: pagehide flush, unmount cleanup, scope fence, timer
* ownership, and marker prune wiring. Storage-primitive behavior is covered
* separately in observedUnreadStorage.test.mjs.
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
installDOMShim,
installFreshStorage,
makeObservedEvent,
mountHook,
} from "./observedUnreadTestHarness.mjs";
// DOM shim must be installed before React is imported (happens inside mountHook
// on first call; the harness imports React at module parse time, so shim first).
installDOMShim();
// Install a module-level localStorage so React's scheduler doesn't choke.
installFreshStorage();
import {
readObservedUnreadFromStorage,
writeObservedUnreadToStorage,
} from "./observedUnreadStorage.ts";
import { act } from "react";
// ── Helpers ───────────────────────────────────────────────────────────────────
const PUBKEY = "aabbcc";
const RELAY = "wss://relay.example.com";
// Use a recent timestamp so age-pruning doesn't discard events before writing.
const NOW_S = Math.floor(Date.now() / 1_000);
const DEFAULT_PROPS = {
pubkey: PUBKEY,
relay: RELAY,
isReady: true,
readStateVersion: 0,
getTs: () => null,
getOwn: () => null,
};
function makeRefs() {
const eventsRef = { current: new Map() };
const latestRef = { current: new Map() };
const inner = new Map();
inner.set("evt-1", makeObservedEvent({ id: "evt-1", createdAt: NOW_S }));
eventsRef.current.set("channel-1", inner);
latestRef.current.set("channel-1", NOW_S);
return { eventsRef, latestRef };
}
async function mountDefaultHook(refs, overrides = {}) {
return mountHook({ ...DEFAULT_PROPS, ...overrides }, refs);
}
// ── Tests ─────────────────────────────────────────────────────────────────────
test("pagehide flush: event recorded within debounce window survives reload", async () => {
installFreshStorage();
const refs = makeRefs();
const harness = await mountDefaultHook(refs);
// Add a new event after mount (simulates a live message arriving mid-debounce).
const newInner = new Map(refs.eventsRef.current.get("channel-2") ?? []);
newInner.set(
"evt-new",
makeObservedEvent({ id: "evt-new", createdAt: NOW_S + 1 }),
);
refs.eventsRef.current.set("channel-2", newInner);
refs.latestRef.current.set("channel-2", NOW_S + 1);
harness.api.schedule(harness.api.currentScope);
await act(async () => {
globalThis.dispatchEvent({ type: "pagehide" });
});
const stored = readObservedUnreadFromStorage(PUBKEY, RELAY);
assert.ok(stored !== null, "storage should not be null after pagehide flush");
assert.ok(stored.has("channel-2"), "channel-2 must be in persisted storage");
assert.ok(
stored.get("channel-2")?.has("evt-new"),
"evt-new must be in channel-2's persisted events",
);
await harness.unmount();
});
test("unmount with pending write flushes before teardown", async () => {
installFreshStorage();
const refs = makeRefs();
const harness = await mountDefaultHook(refs);
const inner = new Map();
inner.set(
"evt-unmount",
makeObservedEvent({ id: "evt-unmount", createdAt: NOW_S + 2 }),
);
refs.eventsRef.current.set("ch-u", inner);
harness.api.schedule(harness.api.currentScope);
await harness.unmount();
const stored = readObservedUnreadFromStorage(PUBKEY, RELAY);
assert.ok(stored !== null, "storage must not be null after unmount flush");
assert.ok(stored.has("ch-u"), "ch-u must be persisted after unmount");
});
test("clearAll cancels pending debounce so no resurrection after reload", async () => {
installFreshStorage();
const refs = makeRefs();
const harness = await mountDefaultHook(refs);
harness.api.schedule(harness.api.currentScope);
harness.api.clearAll();
// Simulate parent reassigning refs after markAllChannelsRead.
refs.eventsRef.current = new Map();
refs.latestRef.current = new Map();
await act(async () => {
globalThis.dispatchEvent({ type: "pagehide" });
});
assert.equal(
readObservedUnreadFromStorage(PUBKEY, RELAY),
null,
"storage must be null after clearAll + empty flush",
);
await harness.unmount();
});
test("removeChannel replaces pending snapshot so sibling channel B survives reload", async () => {
installFreshStorage();
// Seed both channels so hydration loads them; removeChannel(ch1) mid-debounce
// must replace the snapshot so ch2 survives the next pagehide flush.
const seedMap = new Map();
const ch1 = new Map();
ch1.set("evt-1", makeObservedEvent({ id: "evt-1", createdAt: NOW_S }));
seedMap.set("channel-1", ch1);
const ch2 = new Map();
ch2.set("evt-2", makeObservedEvent({ id: "evt-2", createdAt: NOW_S + 1 }));
seedMap.set("channel-2", ch2);
writeObservedUnreadToStorage(PUBKEY, RELAY, seedMap);
const refs = {
eventsRef: { current: new Map() },
latestRef: { current: new Map() },
};
const harness = await mountDefaultHook(refs);
assert.ok(
refs.eventsRef.current.has("channel-1"),
"hydration must restore channel-1",
);
assert.ok(
refs.eventsRef.current.has("channel-2"),
"hydration must restore channel-2",
);
harness.api.schedule(harness.api.currentScope);
harness.api.removeChannel("channel-1");
assert.ok(
!refs.eventsRef.current.has("channel-1"),
"channel-1 removed from refs",
);
assert.ok(
refs.eventsRef.current.has("channel-2"),
"channel-2 still in refs after removeChannel",
);
await act(async () => {
globalThis.dispatchEvent({ type: "pagehide" });
});
const stored = readObservedUnreadFromStorage(PUBKEY, RELAY);
assert.ok(
stored !== null,
"storage must not be null — channel-2 events survive",
);
assert.ok(
!stored.has("channel-1"),
"channel-1 must not appear after removeChannel",
);
assert.ok(
stored.has("channel-2"),
"channel-2 must survive removeChannel(channel-1)",
);
await harness.unmount();
});
test("marker prune: thread and channel markers prune covered events; sibling channels survive", async () => {
installFreshStorage();
const eventsRef = { current: new Map() };
const latestRef = { current: new Map() };
// channel-1: evt-a covered by thread:root-a marker; evt-b NOT covered (newer).
// channel-a: two events covered by channel marker at NOW_S-5.
// channel-b: event at NOW_S+10 (survives).
const evtA = makeObservedEvent({
id: "evt-a",
createdAt: NOW_S - 10,
rootId: "root-a",
});
const evtB = makeObservedEvent({
id: "evt-b",
createdAt: NOW_S + 10,
rootId: "root-b",
});
const evtOld = makeObservedEvent({
id: "evt-old",
createdAt: NOW_S - 20,
rootId: "root-old",
});
const evtMid = makeObservedEvent({
id: "evt-mid",
createdAt: NOW_S - 10,
rootId: "root-mid",
});
const evtSurvivor = makeObservedEvent({
id: "evt-survivor",
createdAt: NOW_S + 10,
rootId: "root-sv",
});
const stored = new Map();
const ch1 = new Map();
ch1.set("evt-a", evtA);
ch1.set("evt-b", evtB);
stored.set("channel-1", ch1);
const chA = new Map();
chA.set("evt-old", evtOld);
chA.set("evt-mid", evtMid);
stored.set("channel-a", chA);
const chB = new Map();
chB.set("evt-survivor", evtSurvivor);
stored.set("channel-b", chB);
writeObservedUnreadToStorage(PUBKEY, RELAY, stored);
let pruneCount = 0;
const harness = await mountDefaultHook(
{ eventsRef, latestRef },
{ isReady: false },
);
await harness.render({
pubkey: PUBKEY,
relay: RELAY,
isReady: true,
readStateVersion: 1,
getTs: (channelId) => (channelId === "channel-a" ? NOW_S - 5 : null),
getOwn: (ctx) => (ctx === "thread:root-a" ? NOW_S - 5 : null),
onPruned: () => {
pruneCount += 1;
},
});
// Thread marker: evt-a pruned from channel-1; evt-b survives.
assert.ok(
!eventsRef.current.get("channel-1")?.has("evt-a"),
"evt-a must be pruned by thread marker",
);
assert.ok(
eventsRef.current.get("channel-1")?.has("evt-b"),
"evt-b must survive (newer than marker)",
);
// Channel marker: channel-a fully cleared.
assert.ok(
!eventsRef.current.has("channel-a"),
"channel-a must be fully pruned by channel marker",
);
// Sibling: channel-b unaffected.
assert.ok(eventsRef.current.has("channel-b"), "channel-b must survive");
assert.equal(pruneCount, 1, "onPruned must fire exactly once");
await harness.unmount();
});
test("isScopeLoaded returns false before identity-reset effect commits, true after", async () => {
installFreshStorage();
const refs = makeRefs();
const harness = await mountDefaultHook(refs, { isReady: false });
assert.ok(
harness.api.isScopeLoaded(),
"isScopeLoaded must be true after mount+effects",
);
const PUBKEY_B = "pubkey-b-scope-test";
await harness.render({
pubkey: PUBKEY_B,
relay: RELAY,
isReady: false,
readStateVersion: 0,
getTs: () => null,
getOwn: () => null,
});
assert.ok(
harness.api.isScopeLoaded(),
"isScopeLoaded must be true after scope switch + effects committed",
);
assert.ok(
harness.api.currentScope.includes(PUBKEY_B.toLowerCase()),
"currentScope must reflect the new pubkey",
);
assert.equal(
harness.api.scopeLoadedRef.current,
harness.api.currentScope,
"scopeLoadedRef must equal currentScope after effects commit",
);
await harness.unmount();
});
test("A→B scope switch: pending A-timer is cancelled by flush, A data persisted synchronously", async () => {
installFreshStorage();
const PUBKEY_AT = "pubkey-a-t";
const RELAY_AT = "wss://relay-a-t.example.com";
const PUBKEY_BT = "pubkey-b-t";
const RELAY_BT = "wss://relay-b-t.example.com";
writeObservedUnreadToStorage(
PUBKEY_AT,
RELAY_AT,
new Map([
[
"ch-at",
new Map([
["evt-at", makeObservedEvent({ id: "evt-at", createdAt: NOW_S })],
]),
],
]),
);
const refsAT = {
eventsRef: { current: new Map() },
latestRef: { current: new Map() },
};
const propsAT = {
pubkey: PUBKEY_AT,
relay: RELAY_AT,
isReady: true,
readStateVersion: 0,
getTs: () => null,
getOwn: () => null,
};
const harness = await mountHook(propsAT, refsAT);
assert.ok(
refsAT.eventsRef.current.has("ch-at"),
"hydration must restore ch-at from storage",
);
harness.api.schedule(harness.api.currentScope);
// Switch to B: flushes A synchronously, resets refs, loads B.
await harness.render({ ...propsAT, pubkey: PUBKEY_BT, relay: RELAY_BT });
assert.ok(
readObservedUnreadFromStorage(PUBKEY_AT, RELAY_AT)?.has("ch-at"),
"A must be flushed synchronously on scope switch",
);
assert.ok(
readObservedUnreadFromStorage(PUBKEY_BT, RELAY_BT) == null ||
!readObservedUnreadFromStorage(PUBKEY_BT, RELAY_BT)?.has("ch-at"),
"B's bucket must not contain A's channel",
);
// B schedules and flushes independently.
const chBT = new Map();
chBT.set(
"evt-bt",
makeObservedEvent({ id: "evt-bt", createdAt: NOW_S + 300 }),
);
refsAT.eventsRef.current.set("ch-bt", chBT);
refsAT.latestRef.current.set("ch-bt", NOW_S + 300);
harness.api.schedule(harness.api.currentScope);
await act(async () => {
globalThis.dispatchEvent({ type: "pagehide" });
});
assert.ok(
readObservedUnreadFromStorage(PUBKEY_BT, RELAY_BT)?.has("ch-bt"),
"B's scheduled write must persist independently",
);
await harness.unmount();
});
test("stale scope-A operations (clearAll + removeChannel) both reject after scope B loads (scope fence enforced)", async () => {
// Single test covering both fenced operations; each assertion names the
// operation so a failure pinpoints which one broke.
installFreshStorage();
const seedA = new Map([
[
"channel-seed",
new Map([
[
"evt-seed-a",
makeObservedEvent({ id: "evt-seed-a", createdAt: NOW_S }),
],
]),
],
]);
writeObservedUnreadToStorage(PUBKEY, RELAY, seedA);
const refsA = {
eventsRef: { current: new Map() },
latestRef: { current: new Map() },
};
const propsA = {
pubkey: PUBKEY,
relay: RELAY,
isReady: true,
readStateVersion: 0,
getTs: () => null,
getOwn: () => null,
};
const harness = await mountHook(propsA, refsA);
harness.api.schedule(harness.api.currentScope);
const staleClearAll = harness.api.clearAll;
const staleRemoveChannel = harness.api.removeChannel;
const PUBKEY_B = "pubkey-b-fence";
const RELAY_B = "wss://relay-b-fence.example.com";
writeObservedUnreadToStorage(
PUBKEY_B,
RELAY_B,
new Map([
[
"channel-b",
new Map([
[
"evt-seed-b",
makeObservedEvent({ id: "evt-seed-b", createdAt: NOW_S }),
],
]),
],
]),
);
await harness.render({ ...propsA, pubkey: PUBKEY_B, relay: RELAY_B });
const storedA_before = readObservedUnreadFromStorage(PUBKEY, RELAY);
assert.ok(storedA_before !== null, "A's bucket must survive scope switch");
const storedB_before = readObservedUnreadFromStorage(PUBKEY_B, RELAY_B);
assert.ok(
storedB_before !== null,
"B's bucket must be in storage after scope switch",
);
// stale clearAll must not delete A's bucket.
staleClearAll();
assert.deepEqual(
readObservedUnreadFromStorage(PUBKEY, RELAY),
storedA_before,
"stale clearAll from scope A must not delete A's bucket after scope B loads",
);
// stale removeChannel must not modify B's bucket.
staleRemoveChannel("channel-b");
assert.deepEqual(
readObservedUnreadFromStorage(PUBKEY_B, RELAY_B),
storedB_before,
"stale removeChannel from scope A must not delete channel-b from B's bucket",
);
await harness.unmount();
});
test("unrelated rerenders do not change API object identity (catch-up stability)", async () => {
installFreshStorage();
const refs = makeRefs();
const harness = await mountDefaultHook(refs);
const api1 = harness.api;
// readStateVersion changes on every read-state advance but must NOT change the API object.
await harness.render({ ...DEFAULT_PROPS, readStateVersion: 1 });
const api2 = harness.api;
assert.equal(
api1,
api2,
"API object must be the same reference on unrelated rerender",
);
assert.equal(api1.schedule, api2.schedule, "schedule must be stable");
assert.equal(
api1.removeChannel,
api2.removeChannel,
"removeChannel must be stable",
);
assert.equal(api1.clearAll, api2.clearAll, "clearAll must be stable");
assert.equal(
api1.isScopeLoaded,
api2.isScopeLoaded,
"isScopeLoaded must be stable",
);
await harness.unmount();
});

View File

@@ -0,0 +1,209 @@
import * as React from "react";
import {
flushObservedUnreadWrite,
pruneObservedUnreadByMarkers,
readObservedUnreadFromStorage,
scheduleObservedUnreadWrite,
deriveLatestByChannel,
clearObservedUnreadStorage,
type ObservedUnreadRefs,
} from "@/features/channels/observedUnreadStorage";
import { activityScopeKey } from "@/features/channels/threadActivityStorage";
import type { ObservedUnreadEvent } from "@/features/channels/unreadChannelCounts";
export type ObservedUnreadPersistence = {
/** Scope key loaded into the refs ("" until identity is known). */
scopeLoadedRef: React.MutableRefObject<string>;
/** Current scope derived from normalized pubkey + relay. */
currentScope: string;
/**
* Returns true when the identity-reset effect has committed and the observed
* refs hold data for the current scope. Always reads the ref at call time.
* Use as a scope guard before projecting or mutating the observed refs.
*/
isScopeLoaded: () => boolean;
/**
* Call after recording a new observed event to schedule a debounced write.
* @param scope - the currentScope value captured this render
*/
schedule: (scope: string) => void;
/** Remove a single channel from the persisted cache (clearObserved path). */
removeChannel: (channelId: string) => void;
/** Clear the entire persisted cache (mark-all-read path). */
clearAll: () => void;
};
/**
* Additional options for useObservedUnreadPersistence.
*/
export type UseObservedUnreadPersistenceOptions = {
/**
* Called when a marker-prune pass removes at least one event from the
* in-memory refs. The hook itself cannot bump the parent's version counter;
* pass a stable callback (e.g. useEvent-wrapped bumpLatestVersion) here so
* the UI re-renders when stale observed events are swept out.
*/
onPruned?: () => void;
};
/**
* Hook that manages the observed-unread localStorage persistence layer for
* useUnreadChannels. Owns the scope ref, debounce timer, pagehide flush, and
* marker-prune effect.
*
* Returns a stable API object; callers read `scopeLoadedRef` and `currentScope`
* to guard writes, and call `schedule`/`removeChannel`/`clearAll` on mutations.
*/
export function useObservedUnreadPersistence(
normalizedPubkey: string | null,
normalizedRelayUrl: string,
isReadStateReady: boolean,
readStateVersion: number,
getEffectiveTimestamp: (channelId: string) => number | null,
getOwnTimestamp: (contextId: string) => number | null,
observedUnreadEventsByChannelRef: React.MutableRefObject<
Map<string, Map<string, ObservedUnreadEvent>>
>,
latestByChannelRef: React.MutableRefObject<Map<string, number>>,
options: UseObservedUnreadPersistenceOptions = {},
): ObservedUnreadPersistence {
const currentScope = activityScopeKey(normalizedPubkey, normalizedRelayUrl);
const { onPruned } = options;
const scopeLoadedRef = React.useRef<string>("");
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const persistRefs = React.useRef<ObservedUnreadRefs>({
eventsRef: observedUnreadEventsByChannelRef,
scopeLoadedRef,
timerRef,
});
persistRefs.current.eventsRef = observedUnreadEventsByChannelRef;
// pagehide: synchronously flush any pending debounce before the webview
// unloads. Cmd+R reloads within 500ms of teardown; without this flush the
// last observed event would be lost within the debounce window.
React.useEffect(() => {
const refs = persistRefs.current;
const onPageHide = () => flushObservedUnreadWrite(refs);
window.addEventListener("pagehide", onPageHide);
return () => window.removeEventListener("pagehide", onPageHide);
}, []);
// Hydrate refs from storage whenever identity/relay changes. Flush the OLD
// scope first so no event is lost before we clobber the refs.
// biome-ignore lint/correctness/useExhaustiveDependencies: normalizedRelayUrl is intentional reset signal alongside normalizedPubkey
React.useEffect(() => {
flushObservedUnreadWrite(persistRefs.current);
observedUnreadEventsByChannelRef.current = new Map();
latestByChannelRef.current = new Map();
if (normalizedPubkey && normalizedRelayUrl) {
const stored = readObservedUnreadFromStorage(
normalizedPubkey,
normalizedRelayUrl,
);
if (stored && stored.size > 0) {
observedUnreadEventsByChannelRef.current = stored;
latestByChannelRef.current = deriveLatestByChannel(stored);
}
}
scopeLoadedRef.current = activityScopeKey(
normalizedPubkey,
normalizedRelayUrl,
);
// On unmount (or before next effect run), flush the current scope so any
// in-flight debounce is persisted before refs are clobbered.
return () => {
flushObservedUnreadWrite(persistRefs.current);
};
}, [normalizedPubkey, normalizedRelayUrl]);
// Marker prune: whenever read state advances, drop events now covered by
// their channel/thread/msg marker, persist if anything changed.
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion + isReadStateReady are intentional prune triggers
React.useEffect(() => {
if (!isReadStateReady) return;
const scope = scopeLoadedRef.current;
if (!scope || scope !== currentScope) return;
const changed = pruneObservedUnreadByMarkers(
observedUnreadEventsByChannelRef.current,
latestByChannelRef.current,
getEffectiveTimestamp,
getOwnTimestamp,
);
if (changed) {
scheduleObservedUnreadWrite(scope, persistRefs.current);
onPruned?.();
}
}, [readStateVersion, isReadStateReady]);
const schedule = React.useCallback(
(scope: string) => scheduleObservedUnreadWrite(scope, persistRefs.current),
[],
);
const removeChannel = React.useCallback(
(channelId: string) => {
// Reject if the loaded scope has drifted — a stale callback during A→B
// transitions must not cancel B's pending snapshot or corrupt B's refs.
if (scopeLoadedRef.current !== currentScope) return;
// Delete from both in-memory refs so the projection no longer sees this
// channel. Then replace any pending snapshot with a new snapshot of the
// current full map — never cancel-without-replacement, which would lose
// unsaved sibling-channel events on the next reload.
observedUnreadEventsByChannelRef.current.delete(channelId);
latestByChannelRef.current.delete(channelId);
scheduleObservedUnreadWrite(currentScope, persistRefs.current);
},
[currentScope, observedUnreadEventsByChannelRef, latestByChannelRef],
);
const clearAll = React.useCallback(() => {
// Reject if the loaded scope has drifted — a stale callback must not
// cancel the new scope's pending snapshot or clear the wrong bucket.
if (scopeLoadedRef.current !== currentScope) return;
// Cancel any pending snapshot and clear both in-memory refs before touching
// storage — the parent no longer resets the refs directly, so this is the
// single transactional clear path for mark-all-read.
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
observedUnreadEventsByChannelRef.current = new Map();
latestByChannelRef.current = new Map();
clearObservedUnreadStorage(normalizedPubkey ?? "", normalizedRelayUrl);
}, [
currentScope,
normalizedPubkey,
normalizedRelayUrl,
observedUnreadEventsByChannelRef,
latestByChannelRef,
]);
// isScopeLoaded reads the ref at call time — always fresh, never a stale
// snapshot from a closed-over useMemo value.
const isScopeLoaded = React.useCallback(
() => scopeLoadedRef.current === currentScope,
[currentScope],
);
// Stable API object: only reconstructed when scope or stable callbacks change.
// This prevents useCallback deps in the parent from seeing a new object each
// render, which would restart catch-up REQs on every unrelated re-render.
return React.useMemo(
() => ({
scopeLoadedRef,
currentScope,
isScopeLoaded,
schedule,
removeChannel,
clearAll,
}),
[currentScope, isScopeLoaded, schedule, removeChannel, clearAll],
);
}

View File

@@ -0,0 +1,175 @@
/**
* Boundary tests for useUnreadChannels — exercises the real parent-to-owner
* boundary between useUnreadChannels and useObservedUnreadPersistence.
*
* These tests mount the FULL production hook (via createRoot + act) to verify
* that markChannelRead and markAllChannelsRead are scope-safe: a stale callback
* captured under scope A cannot corrupt scope B's refs or storage after a
* scope switch. Coverage for both happy paths (current scope mutates correctly)
* and stale paths (scope-A callback rejects after B loads) is included.
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
installDOMShim,
installFreshStorage,
seedStorage,
mountUnreadChannels,
} from "./observedUnreadTestHarness.mjs";
// DOM shim must run before any React import (harness imports React at parse time).
installDOMShim();
installFreshStorage();
import { readObservedUnreadFromStorage } from "./observedUnreadStorage.ts";
import { act } from "react";
// ── Helpers ───────────────────────────────────────────────────────────────────
const RELAY = "wss://relay.example.com";
// ── Tests ─────────────────────────────────────────────────────────────────────
test("markChannelRead happy path: current scope removes channel from observed refs and persists snapshot", async () => {
installFreshStorage();
const PUBKEY = "pubkey-happy-mcr";
const readAt = seedStorage(PUBKEY, RELAY, "channel-1");
const harness = await mountUnreadChannels({ pubkey: PUBKEY });
// markChannelRead with readAt >= observed latest triggers clearObserved,
// which delegates to observedPersistence.removeChannel. act() needed because
// markChannelRead calls bumpLatestVersion (useReducer).
await act(async () => {
harness.markChannelRead("channel-1", readAt);
});
harness.flushStorage();
const stored = readObservedUnreadFromStorage(PUBKEY, RELAY);
assert.ok(
stored === null || !stored.has("channel-1"),
"channel-1 must be absent from storage after markChannelRead under current scope",
);
await harness.unmount();
});
test("markChannelRead happy path: topLevelOnly=true leaves observed refs intact (no clearObserved)", async () => {
installFreshStorage();
const PUBKEY = "pubkey-happy-mcr-tlo";
const readAt = seedStorage(PUBKEY, RELAY, "channel-tlo");
const harness = await mountUnreadChannels({ pubkey: PUBKEY });
// topLevelOnly=true passes observedLatest as undefined, so clearObserved stays false.
await act(async () => {
harness.markChannelRead("channel-tlo", readAt, { topLevelOnly: true });
});
harness.flushStorage();
const stored = readObservedUnreadFromStorage(PUBKEY, RELAY);
assert.ok(
stored?.has("channel-tlo"),
"channel-tlo must remain in storage when topLevelOnly=true",
);
await harness.unmount();
});
test("markChannelRead stale: scope-A callback rejects after scope B loads — B storage survives flush", async () => {
installFreshStorage();
const PUBKEY_A = "pubkey-a-mcr";
const PUBKEY_B = "pubkey-b-mcr";
// Shared channel ensures stale A callback targets a channel present in B's hydrated refs.
const SHARED_CHANNEL = "channel-shared";
const readAtA = seedStorage(PUBKEY_A, RELAY, SHARED_CHANNEL, "evt-a");
seedStorage(PUBKEY_B, RELAY, SHARED_CHANNEL, "evt-b");
const harness = await mountUnreadChannels({ pubkey: PUBKEY_A });
const staleMarkChannelRead = harness.markChannelRead;
// Switch to B; hydration flushes A and loads B's storage.
await harness.render(PUBKEY_B);
assert.ok(
readObservedUnreadFromStorage(PUBKEY_B, RELAY)?.has(SHARED_CHANNEL),
"B's channel-shared must be present before the stale call",
);
// Stale A call must be rejected by the scope fence; B's refs stay intact.
await act(async () => {
staleMarkChannelRead(SHARED_CHANNEL, readAtA);
});
harness.flushStorage();
const storedBAfter = readObservedUnreadFromStorage(PUBKEY_B, RELAY);
assert.ok(
storedBAfter?.has(SHARED_CHANNEL),
"B's channel-shared must survive the post-stale-call flush (stale scope-A markChannelRead must not corrupt B's refs)",
);
await harness.unmount();
});
test("markAllChannelsRead happy path: current scope clears all observed refs and clears storage bucket", async () => {
installFreshStorage();
const PUBKEY = "pubkey-happy-mar";
seedStorage(PUBKEY, RELAY, "channel-1");
const harness = await mountUnreadChannels({ pubkey: PUBKEY });
// clearAll cancels any pending write and removes the storage bucket.
await act(async () => {
harness.markAllChannelsRead();
});
const stored = readObservedUnreadFromStorage(PUBKEY, RELAY);
assert.ok(
stored === null || stored.size === 0,
"storage bucket must be empty after markAllChannelsRead under current scope",
);
await harness.unmount();
});
test("markAllChannelsRead stale: scope-A callback rejects after scope B loads — B storage survives flush", async () => {
installFreshStorage();
const PUBKEY_A = "pubkey-a-mar";
const PUBKEY_B = "pubkey-b-mar";
seedStorage(PUBKEY_A, RELAY, "channel-1");
seedStorage(PUBKEY_B, RELAY, "channel-2");
const harness = await mountUnreadChannels({ pubkey: PUBKEY_A });
const staleMarkAllChannelsRead = harness.markAllChannelsRead;
// Switch to B; hydration flushes A and loads B's storage (channel-2).
await harness.render(PUBKEY_B);
assert.ok(
readObservedUnreadFromStorage(PUBKEY_B, RELAY)?.has("channel-2"),
"B's channel-2 must be present before the stale call",
);
// Stale A call must be rejected by the scope fence; B's refs stay intact.
await act(async () => {
staleMarkAllChannelsRead();
});
harness.flushStorage();
const storedBAfter = readObservedUnreadFromStorage(PUBKEY_B, RELAY);
assert.ok(
storedBAfter?.has("channel-2"),
"B's channel-2 must survive the post-stale-call flush (stale scope-A markAllChannelsRead must not wipe B's refs)",
);
await harness.unmount();
});

View File

@@ -54,6 +54,7 @@ export {
readActivityFromStorage,
writeActivityToStorage,
} from "@/features/channels/threadActivityStorage";
import { useObservedUnreadPersistence } from "@/features/channels/useObservedUnreadPersistence";
type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & {
pubkey?: string;
@@ -165,13 +166,9 @@ export function useUnreadChannels(
getOwnTimestamp,
} = useReadState(pubkey, relayClient);
// Observed "latest external trigger event" per channel (unix seconds). This
// is *derived relay evidence*, not source-of-truth: it's populated from a
// one-shot catch-up REQ per channel (keyed on the NIP-RS read marker) plus
// ongoing live events. The only thing we ever do with it is compare against
// the NIP-RS read marker — see the unread memo below. Reset on identity
// change. Stale entries for channels the user has left are silently
// ignored by the memo (it iterates the current channels list, not the map).
// Per-channel latest observed external trigger timestamp (unix seconds) and
// per-event metadata. Derived relay evidence, not source-of-truth; the unread
// memo compares these against NIP-RS markers. Hydrated/reset by the persistence hook.
const latestByChannelRef = React.useRef(new Map<string, number>());
const observedUnreadEventsByChannelRef = React.useRef(
new Map<string, Map<string, ObservedUnreadEvent>>(),
@@ -180,13 +177,9 @@ export function useUnreadChannels(
const channelsRef = React.useRef(channels);
channelsRef.current = channels;
// Channels manually marked unread this session (e.g., right-click → "mark
// unread"). Because NIP-RS read markers are monotonic, this flag is what
// makes the badge appear without lowering synced read state. Each entry
// stores the channel's NIP-RS read marker (unix seconds) at force-time, or
// null if no marker existed yet. Persisted to localStorage
// (buzz-forced-unread.v1) so it survives reload and is visible to the rail
// observer for inactive communities.
// Channels manually marked unread this session. NIP-RS markers are monotonic,
// so this flag creates the badge without lowering synced read state.
// Persisted to buzz-forced-unread.v1 for cross-reload and rail-observer visibility.
const forcedUnreadRef = React.useRef<ForcedUnreadMap>(
pubkey ? forcedUnreadStore.read(pubkey) : {},
);
@@ -260,13 +253,24 @@ export function useUnreadChannels(
0,
);
// Persistence layer: hydration, pagehide flush, scope fence, write-through, marker-prune.
const observedPersistence = useObservedUnreadPersistence(
normalizedPubkey,
normalizedRelayUrl,
isReadStateReady,
readStateVersion,
getEffectiveTimestamp,
getOwnTimestamp,
observedUnreadEventsByChannelRef,
latestByChannelRef,
{ onPruned: bumpLatestVersion },
);
// Reset all in-session state when the identity or relay changes. In-memory
// caches are cleared; persisted stores are loaded for the new pubkey (so
// forced-unread, participation, etc. are correct for the new identity).
// biome-ignore lint/correctness/useExhaustiveDependencies: pubkey/relayClient are intentional reset signals
React.useEffect(() => {
latestByChannelRef.current = new Map();
observedUnreadEventsByChannelRef.current = new Map();
// Load persisted forced-unread map for the new pubkey (do NOT clear the
// store — another device's data should survive identity switches here).
forcedUnreadRef.current = pubkey ? forcedUnreadStore.read(pubkey) : {};
@@ -290,16 +294,9 @@ export function useUnreadChannels(
bumpMembershipVersion();
}, [pubkey, relayClient, normalizedRelayUrl]);
// `topLevelOnly` is the passive channel-open path (NIP-RS Option 1): the
// caller's `readAt` is already the newest TOP-LEVEL message, so the marker
// must land exactly there without folding in `observedLatest` (which counts
// thread replies) and without clearing observed refs. Leaving the refs intact
// keeps the sidebar dot lit for a channel whose only unread is an unopened
// thread reply — viewing the channel no longer absorbs the reply, so the dot
// persists until an explicit mark-read (Esc, sidebar, mark-all) or a newer
// top-level message advances the channel marker past it. Those explicit
// "mark read" actions omit the flag and keep the fold, since they mean
// "clear everything in this channel."
// `topLevelOnly`: passive channel-open path (NIP-RS Option 1) — marker lands at newest
// top-level msg without folding observed replies; leaves refs intact so the dot persists
// until an explicit mark-read. Explicit reads omit this flag and clear the refs.
const markChannelRead = React.useCallback(
(
channelId: string,
@@ -331,18 +328,17 @@ export function useUnreadChannels(
);
if (markAt === null) return;
markContextRead(channelId, markAt);
// Clear observed-latest refs when the read marker covers them so the
// unread memo sees `latest === undefined` until a genuinely new event
// arrives. Without this, `latest > readAt` resolves to `T > T` (false)
// but the channel lingers in the set when advanceContext's monotonic
// guard suppresses the readStateVersion bump.
// Delegate destructive observed-ref removal to the fenced owner operation —
// the parent must not delete from latestByChannelRef or
// observedUnreadEventsByChannelRef directly on the clear-observed path,
// or a stale scope-A callback could corrupt scope B before the fence rejects.
// (Fenced record writes in handleChannelMessage and catch-up remain in the parent.)
if (clearObserved) {
latestByChannelRef.current.delete(channelId);
observedUnreadEventsByChannelRef.current.delete(channelId);
observedPersistence.removeChannel(channelId);
bumpLatestVersion();
}
},
[markContextRead, pubkey],
[markContextRead, observedPersistence, pubkey],
);
const { clearChannelUnreadSource, markChannelUnread } =
@@ -375,21 +371,22 @@ export function useUnreadChannels(
[normalizedPubkey],
);
// Feed the in-session "latest external trigger" map from live channel
// events. Composes with any caller-supplied onChannelMessage handler.
// useLiveChannelUpdates already filters this callback to trigger kinds
// and external authors, so the map is always a strict subset of "newest
// external trigger message this client has observed."
// Records an external trigger event and schedules persistence.
const callerOnChannelMessage = liveUpdateOptions.onChannelMessage;
const recordUnreadEvent = React.useCallback(
(channelId: string, event: ObservedUnreadEvent) =>
recordObservedUnreadEvent(
(channelId: string, event: ObservedUnreadEvent): boolean => {
if (!observedPersistence.isScopeLoaded()) return false;
const didRecord = recordObservedUnreadEvent(
observedUnreadEventsByChannelRef.current,
channelId,
event,
CATCH_UP_LIMIT,
),
[],
);
if (didRecord)
observedPersistence.schedule(observedPersistence.currentScope);
return didRecord;
},
[observedPersistence],
);
const handleChannelMessage = React.useCallback(
(channelId: string, event: RelayEvent) => {
@@ -412,11 +409,14 @@ export function useUnreadChannels(
isThreadedReply,
}),
);
// Fence latestByChannelRef on the scope guard — a stale live callback
// during A→B drift must not write A's timestamp into B's hydrated ref.
const scopeOk = observedPersistence.isScopeLoaded();
const current = latestByChannelRef.current.get(channelId) ?? 0;
if (event.created_at > current) {
if (scopeOk && event.created_at > current) {
latestByChannelRef.current.set(channelId, event.created_at);
bumpLatestVersion();
} else if (didRecordUnreadEvent) {
}
if (didRecordUnreadEvent || (scopeOk && event.created_at > current)) {
bumpLatestVersion();
}
@@ -438,6 +438,7 @@ export function useUnreadChannels(
[
callerOnChannelMessage,
normalizedPubkey,
observedPersistence,
recordMentionedRoot,
recordUnreadEvent,
],
@@ -748,13 +749,9 @@ export function useUnreadChannels(
).then((results) => {
if (isCancelled) return;
// Guard: don't merge catch-up results into a ref whose scope has drifted
// (relay/pubkey changed while this async fetch was in flight). Also reject
// an empty scope for the same reason as the live writer above.
if (
!currentActivityScope ||
threadActivityScopeRef.current !== currentActivityScope
)
return;
// (relay/pubkey changed while this async fetch was in flight). Use the
// observed owner's loaded-scope predicate — one scope authority, not two.
if (!observedPersistence.isScopeLoaded()) return;
let didAdvance = false;
const allThreadReplies: ThreadActivityItem[] = [];
for (const result of results) {
@@ -837,7 +834,7 @@ export function useUnreadChannels(
const rawUnread =
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion and latestVersion are intentional invalidation signals
React.useMemo(() => {
if (!isReadStateReady) {
if (!isReadStateReady || !observedPersistence.isScopeLoaded()) {
return {
unreadChannelIds: new Set<string>(),
topLevelUnreadChannelIds: new Set<string>(),
@@ -957,14 +954,17 @@ export function useUnreadChannels(
if (unixSeconds !== null) {
markContextRead(channelId, unixSeconds);
}
latestByChannelRef.current.delete(channelId);
observedUnreadEventsByChannelRef.current.delete(channelId);
}
if (pubkey) {
forcedUnreadStore.write(pubkey, forcedUnreadRef.current);
}
// Delegate destructive observed-ref clearing to the fenced owner operation —
// the parent must not reset the observed Maps directly on this path, or a
// stale scope-A callback could corrupt scope B before the fence rejects.
// (Fenced record writes in handleChannelMessage and catch-up remain in the parent.)
observedPersistence.clearAll();
bumpLatestVersion();
}, [getEffectiveTimestamp, markContextRead, pubkey]);
}, [getEffectiveTimestamp, markContextRead, observedPersistence, pubkey]);
// Identity-stable snapshots of the membership sets for the notify gate.
// Re-derived only when membershipVersion bumps (a set actually changed), so

View File

@@ -202,3 +202,40 @@ test("returns false when eviction frees nothing", () => {
assert.equal(ls.getItem("k"), null);
assert.equal(ls.getItem("buzz-workspaces"), "keep");
});
test("buzz-observed-unread.v1: prefix participates in LRU eviction and durable state survives", () => {
// Sentinel: fails if buzz-observed-unread.v1: is removed from PURE_CACHE_KEY_PREFIXES —
// the bucket becomes invisible to LRU and the wrong entry is evicted instead.
const ls = makeQuotaLocalStorage({ maxEntries: 20 });
install(ls);
ls.store.set("buzz-communities", "keep");
const snapshot = (updatedAt) =>
JSON.stringify({ updatedAt, payload: "x".repeat(400_000) });
const observedKey = "buzz-observed-unread.v1:wss://relay.example.com:pk1";
const olderKey = "buzz-channel-messages.v1:relay:older";
const newestKey = "buzz-channel-messages.v1:relay:newest";
// Seed observed-unread (oldest updatedAt=1) and a sibling channel-messages entry (updatedAt=2).
assert.equal(setLocalStorageItemWithRecovery(observedKey, snapshot(1)), true);
assert.equal(setLocalStorageItemWithRecovery(olderKey, snapshot(2)), true);
// Writing a third pure-cache entry (updatedAt=3) pushes the total above the 2 MiB budget.
// The observed-unread entry must be evicted first (oldest LRU); the sibling survives.
assert.equal(setLocalStorageItemWithRecovery(newestKey, snapshot(3)), true);
assert.equal(
ls.getItem(observedKey),
null,
"observed-unread bucket must be evicted as the oldest LRU pure-cache entry",
);
assert.notEqual(
ls.getItem(olderKey),
null,
"channel-messages bucket with newer updatedAt must survive",
);
assert.equal(
ls.getItem("buzz-communities"),
"keep",
"durable state must survive",
);
});

View File

@@ -10,6 +10,7 @@
const PURE_CACHE_KEY_PREFIXES = [
"buzz-channel-messages.v1:",
"buzz-channels.v1:",
"buzz-observed-unread.v1:",
"buzz-sidebar-skeleton-shape.v1:",
"buzz-timeline-skeleton-shape.v1:",
"buzz-user-labels.v1:",