mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
343
desktop/src/features/channels/observedUnreadStorage.test.mjs
Normal file
343
desktop/src/features/channels/observedUnreadStorage.test.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
406
desktop/src/features/channels/observedUnreadStorage.ts
Normal file
406
desktop/src/features/channels/observedUnreadStorage.ts
Normal 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;
|
||||
}
|
||||
373
desktop/src/features/channels/observedUnreadTestHarness.mjs
Normal file
373
desktop/src/features/channels/observedUnreadTestHarness.mjs
Normal 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");
|
||||
}
|
||||
})(),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
209
desktop/src/features/channels/useObservedUnreadPersistence.ts
Normal file
209
desktop/src/features/channels/useObservedUnreadPersistence.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
175
desktop/src/features/channels/useUnreadChannels.test.mjs
Normal file
175
desktop/src/features/channels/useUnreadChannels.test.mjs
Normal 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();
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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:",
|
||||
|
||||
Reference in New Issue
Block a user