perf(desktop): coalesce thread-activity localStorage writes (#5693)

Each incoming thread reply drove a full `JSON.stringify` + `setItem` of
the ~600 KB thread-activity buffer. A burst of replies serialized the
whole blob once per event on the main thread, which is one of the
renderer stalls under load in the desktop-longevity arc.

This collapses the burst into a single debounced write, applying the
coalescing pattern Wes introduced for read-state persistence in #5591
(`readStateManager`) to the thread-activity path.

## What changed

- **`threadActivityStorage.ts`** — coalescing primitives:
- `scheduleThreadActivityWrite` — first-writer-wins (a pending timer is
*not* reset), 1s trailing edge. The timer reads the live buffer *at fire
time* and re-checks the loaded scope, so N replies within the window
persist exactly once with the burst's final state, and a write that
outlives a scope switch can neither land under the new key nor persist
the wrong buffer.
- `flushThreadActivityWrite` — synchronous persist + timer cancel; a
no-op when nothing is pending.
- `removeLegacyThreadActivityKey` — idempotent one-time cleanup of the
orphaned pre-relay-scoping `buzz-thread-activity.v1:<pubkey>` key.
- **`useThreadActivityPersistence.ts`** (new companion hook) — owns the
loaded scope, the write timer, the `pagehide` /
`visibilitychange`→hidden / unmount flush, and hydration + legacy
cleanup on identity/relay change. Mirrors the existing
`useObservedUnreadPersistence` sibling.
- **`useUnreadChannels.ts`** — rewired to instantiate the hook and call
`activityPersistence.schedule(...)` at both writer sites instead of
writing per event. The buffer (`threadActivityRef`) stays parent-owned;
the hook decides when it is durably persisted. Net **990** lines (was
1021), back under the 1000-line ceiling.

## Durability

`pagehide`, `visibilitychange`→hidden, unmount, and scope-reseed all
flush synchronously, so the last burst of replies survives a `Cmd+R` or
an idle reload that tears the webview down inside the coalescing window.

## Tests

- `threadActivityWriteScheduler.test.mjs` — fake-timer unit coverage:
burst→one `setItem`, live-buffer-at-fire-time, scope-mismatch rejection,
stale-scope timer abort, flush persists+cancels, flush no-op, legacy-key
removal.
- `useThreadActivityPersistence.test.mjs` — mounts the real hook via
`createRoot`+`act`: `pagehide` / visibility / unmount flush of the live
buffer, scope switch flushing A under A's key without leaking into B,
B-bucket rehydration, legacy-key cleanup, and the empty-scope write
fence.

## Related

Based on [#5591](https://github.com/block/buzz/pull/5591) (Wes) —
`perf(desktop): coalesce read state localStorage persistence`, the
proven first-writer-wins coalescing pattern this extends to thread
activity.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-08-12 12:06:43 -07:00
committed by GitHub
co-authored by Duncan
parent c3b0ccf383
commit c6c6e7eca7
5 changed files with 696 additions and 52 deletions
@@ -118,3 +118,91 @@ export function addThreadActivityItems(
return { didAdd: true, items: capped };
}
/**
* One-time removal of the orphaned pre-relay-scoping key
* `buzz-thread-activity.v1:<pubkey>`. The legacy pubkey-only writer was dropped
* when the key became relay-scoped, but the old ~400 KB blob is never read and
* is absent from the quota-sweep whitelist, so it lingers as dead weight.
* removeItem on an absent key is a no-op, so running this on every identity
* load is idempotent and self-terminating.
*/
export function removeLegacyThreadActivityKey(pubkey: string): void {
try {
window.localStorage.removeItem(`${ACTIVITY_STORAGE_PREFIX}:${pubkey}`);
} catch {
// Ignore storage errors.
}
}
// ─── Coalesced persistence writer (first-writer-wins + flush) ─────────────────
//
// The full item list is one ~600 KB JSON.stringify+setItem. Writing it on every
// incoming reply drives the renderer stall this coalescing exists to fix, so the
// scheduler collapses a burst of writes into one setItem ~1 s later. The live
// buffer (itemsRef) stays the source of truth between flushes; the timer reads
// it at fire time, so the persisted blob is always the burst's final state.
const WRITE_DEBOUNCE_MS = 1_000;
export type ThreadActivityRefs = {
itemsRef: { current: ThreadActivityItem[] };
scopeLoadedRef: { current: string };
timerRef: { current: ReturnType<typeof setTimeout> | null };
};
/**
* Inverse of activityScopeKey: split "pubkey:normalizedRelayUrl" back into its
* parts. The pubkey is colon-free, so the first colon is the boundary. Returns
* null for an empty or malformed scope.
*/
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;
}
/**
* Arm a trailing-edge write for `scope`, first-writer-wins: a pending timer is
* NOT reset, so a burst of N writes still fires once. The timer reads itemsRef
* at fire time and re-checks the loaded scope, so a write that outlives a scope
* switch can neither land under the new key nor persist the wrong buffer.
*/
export function scheduleThreadActivityWrite(
scope: string,
refs: ThreadActivityRefs,
): void {
if (!scope || refs.scopeLoadedRef.current !== scope) return;
if (refs.timerRef.current !== null) return;
const parts = decomposeScope(scope);
if (!parts) return;
const { pubkey, relayUrl } = parts;
refs.timerRef.current = setTimeout(() => {
refs.timerRef.current = null;
if (refs.scopeLoadedRef.current !== scope) return;
writeActivityToStorage(pubkey, relayUrl, refs.itemsRef.current);
}, WRITE_DEBOUNCE_MS);
}
/**
* Synchronously persist a pending write and cancel the timer. A no-op when no
* write is pending (the buffer is already durable), so flushing on hidden or
* pagehide costs a setItem only when there is unsaved state. Callers MUST flush
* before reseeding on a scope switch so the old scope's buffer lands under the
* old key.
*/
export function flushThreadActivityWrite(refs: ThreadActivityRefs): void {
if (refs.timerRef.current === null) return;
clearTimeout(refs.timerRef.current);
refs.timerRef.current = null;
const parts = decomposeScope(refs.scopeLoadedRef.current);
if (!parts) return;
writeActivityToStorage(parts.pubkey, parts.relayUrl, refs.itemsRef.current);
}
@@ -0,0 +1,198 @@
/**
* Unit tests for the coalesced thread-activity write scheduler.
*
* These exercise scheduleThreadActivityWrite / flushThreadActivityWrite /
* removeLegacyThreadActivityKey directly against a ref bag, using node:test
* fake timers to drive the 1s debounce deterministically. Hook lifecycle
* (pagehide/visibility flush, scope-switch hydration) is covered separately in
* useThreadActivityPersistence.test.mjs.
*/
import assert from "node:assert/strict";
import { afterEach, mock, test } from "node:test";
import {
activityScopeKey,
activityStorageKey,
flushThreadActivityWrite,
readActivityFromStorage,
removeLegacyThreadActivityKey,
scheduleThreadActivityWrite,
} from "./threadActivityStorage.ts";
const originalWindow = globalThis.window;
afterEach(() => {
mock.timers.reset();
if (originalWindow === undefined) delete globalThis.window;
else globalThis.window = originalWindow;
});
// Install an isolated in-memory localStorage with a setItem counter so a burst
// can be asserted to collapse to exactly one write.
function installStore() {
const store = new Map();
let setItemCalls = 0;
globalThis.window = {
localStorage: {
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => {
setItemCalls += 1;
store.set(key, value);
},
removeItem: (key) => store.delete(key),
},
};
return { store, setItemCalls: () => setItemCalls };
}
function makeRefs(scope, items = []) {
return {
itemsRef: { current: items },
scopeLoadedRef: { current: scope },
timerRef: { current: null },
};
}
const RELAY = "wss://relay.example.com";
// ── scheduleThreadActivityWrite ──────────────────────────────────────────────
test("scheduleThreadActivityWrite coalesces a burst of schedules into one setItem", () => {
mock.timers.enable({ apis: ["setTimeout"] });
const { setItemCalls } = installStore();
const scope = activityScopeKey("pk1", RELAY);
const refs = makeRefs(scope, [{ id: "r1" }]);
for (let i = 0; i < 5; i += 1) scheduleThreadActivityWrite(scope, refs);
assert.equal(setItemCalls(), 0, "no write before the debounce elapses");
mock.timers.tick(1_000);
assert.equal(
setItemCalls(),
1,
"a burst of 5 schedules fires exactly one write",
);
});
test("scheduleThreadActivityWrite persists the live buffer at fire time, not at schedule time", () => {
mock.timers.enable({ apis: ["setTimeout"] });
installStore();
const scope = activityScopeKey("pk1", RELAY);
const refs = makeRefs(scope, [{ id: "early" }]);
scheduleThreadActivityWrite(scope, refs);
// A second reply lands before the timer fires — the buffer grows in place.
refs.itemsRef.current = [{ id: "early" }, { id: "late" }];
mock.timers.tick(1_000);
assert.deepEqual(
readActivityFromStorage("pk1", RELAY).map((item) => item.id),
["early", "late"],
"the persisted blob reflects the buffer's final state",
);
});
test("scheduleThreadActivityWrite ignores a scope that does not match the loaded scope", () => {
mock.timers.enable({ apis: ["setTimeout"] });
const { setItemCalls } = installStore();
const refs = makeRefs(activityScopeKey("pkA", "wss://relay-a.example.com"), [
{ id: "a1" },
]);
scheduleThreadActivityWrite(
activityScopeKey("pkB", "wss://relay-b.example.com"),
refs,
);
assert.equal(
refs.timerRef.current,
null,
"no timer armed for a mismatched scope",
);
mock.timers.tick(1_000);
assert.equal(setItemCalls(), 0, "a mismatched scope never writes");
});
test("scheduleThreadActivityWrite timer aborts when the loaded scope changed before it fired", () => {
mock.timers.enable({ apis: ["setTimeout"] });
const { setItemCalls } = installStore();
const pkA = "pkA";
const relayA = "wss://relay-a.example.com";
const scopeA = activityScopeKey(pkA, relayA);
const refs = makeRefs(scopeA, [{ id: "a1" }]);
scheduleThreadActivityWrite(scopeA, refs);
// Scope switches out from under the pending timer without cancelling it.
refs.scopeLoadedRef.current = activityScopeKey(
"pkB",
"wss://relay-b.example.com",
);
mock.timers.tick(1_000);
assert.equal(setItemCalls(), 0, "a stale-scope timer must not write");
assert.equal(
readActivityFromStorage(pkA, relayA).length,
0,
"A's key must stay empty when the timer aborts",
);
});
// ── flushThreadActivityWrite ─────────────────────────────────────────────────
test("flushThreadActivityWrite persists synchronously and cancels the pending timer", () => {
mock.timers.enable({ apis: ["setTimeout"] });
const { setItemCalls } = installStore();
const scope = activityScopeKey("pk1", RELAY);
const refs = makeRefs(scope, [{ id: "f1" }]);
scheduleThreadActivityWrite(scope, refs);
flushThreadActivityWrite(refs);
assert.equal(setItemCalls(), 1, "flush writes immediately");
assert.equal(refs.timerRef.current, null, "flush cancels the timer");
assert.deepEqual(
readActivityFromStorage("pk1", RELAY).map((item) => item.id),
["f1"],
);
mock.timers.tick(1_000);
assert.equal(
setItemCalls(),
1,
"the cancelled timer never fires a second write",
);
});
test("flushThreadActivityWrite is a no-op when no write is pending", () => {
const { setItemCalls } = installStore();
const refs = makeRefs(activityScopeKey("pk1", RELAY), [{ id: "x" }]);
flushThreadActivityWrite(refs);
assert.equal(setItemCalls(), 0, "no pending timer means no write");
});
// ── removeLegacyThreadActivityKey ────────────────────────────────────────────
test("removeLegacyThreadActivityKey removes the orphaned pubkey-only key and preserves the scoped key", () => {
const { store } = installStore();
const pubkey = "pk1";
const legacyKey = `buzz-thread-activity.v1:${pubkey}`;
const scopedKey = activityStorageKey(pubkey, RELAY);
store.set(legacyKey, JSON.stringify([{ id: "legacy" }]));
store.set(scopedKey, JSON.stringify([{ id: "scoped" }]));
removeLegacyThreadActivityKey(pubkey);
assert.equal(store.has(legacyKey), false, "legacy pubkey-only key removed");
assert.equal(store.has(scopedKey), true, "relay-scoped key preserved");
// Idempotent: a second call on the absent key is a no-op that never throws.
removeLegacyThreadActivityKey(pubkey);
assert.equal(store.has(legacyKey), false);
});
@@ -0,0 +1,276 @@
/**
* Integration tests for useThreadActivityPersistence.
*
* These mount the REAL production hook via createRoot + act to exercise the
* actual lifecycle: pagehide flush, visibilitychange→hidden flush, unmount
* cleanup, scope-switch hydration (flush-before-reseed), legacy-key cleanup,
* and the read-live-buffer-at-flush-time contract that distinguishes this
* scheduler from a snapshot-at-schedule one. Debounce timing is covered by
* fake timers in threadActivityWriteScheduler.test.mjs.
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
installDOMShim,
installFreshStorage,
} from "./observedUnreadTestHarness.mjs";
installDOMShim();
installFreshStorage();
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import {
activityStorageKey,
readActivityFromStorage,
writeActivityToStorage,
} from "./threadActivityStorage.ts";
import { useThreadActivityPersistence } from "./useThreadActivityPersistence.ts";
const RELAY = "wss://relay.example.com";
// Mount the hook with a caller-owned buffer ref (mirrors useUnreadChannels,
// which owns threadActivityRef and passes it in).
async function mountHook(itemsRef, props) {
const apiRef = { current: null };
function Harness({ pubkey, relay }) {
apiRef.current = useThreadActivityPersistence(pubkey, relay, itemsRef);
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());
},
};
}
// ── flush contract: read the live buffer at flush time ───────────────────────
test("pagehide flush persists the live buffer's final state, not a schedule-time snapshot", async () => {
installFreshStorage();
const itemsRef = { current: [] };
const harness = await mountHook(itemsRef, { pubkey: "pk1", relay: RELAY });
// A reply lands, the writer buffers it in place and arms a coalesced write.
itemsRef.current = [{ id: "early" }];
harness.api.schedule(harness.api.currentScope);
// A second reply lands within the debounce window — buffer grows in place.
itemsRef.current = [{ id: "early" }, { id: "late" }];
await act(async () => {
globalThis.dispatchEvent({ type: "pagehide" });
});
assert.deepEqual(
readActivityFromStorage("pk1", RELAY).map((item) => item.id),
["early", "late"],
"flush must persist the buffer's final state including the late reply",
);
await harness.unmount();
});
test("visibilitychange to hidden flushes a pending write", async () => {
installFreshStorage();
const itemsRef = { current: [] };
const harness = await mountHook(itemsRef, { pubkey: "pk1", relay: RELAY });
itemsRef.current = [{ id: "hidden-flush" }];
harness.api.schedule(harness.api.currentScope);
await act(async () => {
globalThis.document.visibilityState = "hidden";
globalThis.document.dispatchEvent({ type: "visibilitychange" });
});
assert.deepEqual(
readActivityFromStorage("pk1", RELAY).map((item) => item.id),
["hidden-flush"],
"backgrounding the webview must persist the pending buffer",
);
await harness.unmount();
});
test("visibilitychange to visible does not write", async () => {
installFreshStorage();
const itemsRef = { current: [] };
const harness = await mountHook(itemsRef, { pubkey: "pk1", relay: RELAY });
itemsRef.current = [{ id: "still-buffered" }];
harness.api.schedule(harness.api.currentScope);
await act(async () => {
globalThis.document.visibilityState = "visible";
globalThis.document.dispatchEvent({ type: "visibilitychange" });
});
assert.deepEqual(
readActivityFromStorage("pk1", RELAY),
[],
"a visible transition must not flush",
);
await harness.unmount();
});
test("unmount with a pending write flushes before teardown", async () => {
installFreshStorage();
const itemsRef = { current: [] };
const harness = await mountHook(itemsRef, { pubkey: "pk1", relay: RELAY });
itemsRef.current = [{ id: "unmount-flush" }];
harness.api.schedule(harness.api.currentScope);
await harness.unmount();
assert.deepEqual(
readActivityFromStorage("pk1", RELAY).map((item) => item.id),
["unmount-flush"],
"unmount cleanup must flush the pending write",
);
});
// ── scope switch: flush-before-reseed under the OLD key ──────────────────────
test("scope switch flushes A synchronously under A's key and does not leak A into B", async () => {
installFreshStorage();
const pkA = "pkA";
const relayA = "wss://relay-a.example.com";
const pkB = "pkB";
const relayB = "wss://relay-b.example.com";
const itemsRef = { current: [] };
const harness = await mountHook(itemsRef, { pubkey: pkA, relay: relayA });
// A accumulates a buffered reply with a pending coalesced write.
itemsRef.current = [{ id: "a-only" }];
harness.api.schedule(harness.api.currentScope);
// Switch identity to B: the hydration effect must flush A first.
await harness.render({ pubkey: pkB, relay: relayB });
assert.deepEqual(
readActivityFromStorage(pkA, relayA).map((item) => item.id),
["a-only"],
"A's pending write must land under A's key on scope switch",
);
assert.deepEqual(
readActivityFromStorage(pkB, relayB),
[],
"B's bucket must not contain A's rows",
);
assert.ok(
harness.api.currentScope.includes(pkB),
"currentScope must reflect B after the switch",
);
await harness.unmount();
});
test("scope switch hydrates B's buffer from B's persisted bucket", async () => {
installFreshStorage();
const pkA = "pkA";
const relayA = "wss://relay-a.example.com";
const pkB = "pkB";
const relayB = "wss://relay-b.example.com";
writeActivityToStorage(pkB, relayB, [{ id: "b-persisted" }]);
const itemsRef = { current: [] };
const harness = await mountHook(itemsRef, { pubkey: pkA, relay: relayA });
itemsRef.current = [{ id: "a-only" }];
await harness.render({ pubkey: pkB, relay: relayB });
assert.deepEqual(
itemsRef.current.map((item) => item.id),
["b-persisted"],
"the buffer must be reseeded from B's bucket, dropping A's rows",
);
await harness.unmount();
});
// ── legacy key cleanup ───────────────────────────────────────────────────────
test("mounting removes the orphaned legacy pubkey-only key", async () => {
const ls = installFreshStorage();
const pubkey = "pk-legacy";
const legacyKey = `buzz-thread-activity.v1:${pubkey}`;
ls.setItem(legacyKey, JSON.stringify([{ id: "stale" }]));
const itemsRef = { current: [] };
const harness = await mountHook(itemsRef, { pubkey, relay: RELAY });
assert.equal(
ls.getItem(legacyKey),
null,
"hydration must drop the orphaned legacy key",
);
await harness.unmount();
});
// ── scope fence: never write before a valid scope is loaded ──────────────────
test("isScopeLoaded is false without an identity and true after hydration", async () => {
installFreshStorage();
const itemsRef = { current: [] };
const harness = await mountHook(itemsRef, { pubkey: null, relay: RELAY });
assert.equal(
harness.api.isScopeLoaded(),
false,
"an absent pubkey must never pass the scope fence",
);
await harness.render({ pubkey: "pk1", relay: RELAY });
assert.equal(
harness.api.isScopeLoaded(),
true,
"a valid scope must pass once its hydration effect commits",
);
await harness.unmount();
});
test("schedule under an empty scope never writes", async () => {
const ls = installFreshStorage();
const itemsRef = { current: [] };
const harness = await mountHook(itemsRef, { pubkey: null, relay: RELAY });
itemsRef.current = [{ id: "orphan" }];
harness.api.schedule(harness.api.currentScope);
await act(async () => {
globalThis.dispatchEvent({ type: "pagehide" });
});
assert.equal(
ls.getItem(activityStorageKey("", RELAY)),
null,
"no write may land when identity is unknown",
);
await harness.unmount();
});
@@ -0,0 +1,113 @@
import * as React from "react";
import {
activityScopeKey,
flushThreadActivityWrite,
readActivityFromStorage,
removeLegacyThreadActivityKey,
scheduleThreadActivityWrite,
type ThreadActivityItem,
type ThreadActivityRefs,
} from "@/features/channels/threadActivityStorage";
export type ThreadActivityPersistence = {
/** Scope key loaded into the buffer ("" until identity is known). */
scopeLoadedRef: React.MutableRefObject<string>;
/** Current scope derived from normalized pubkey + relay. */
currentScope: string;
/**
* True only when the hydration effect has committed for the current scope
* AND that scope is non-empty. Reads the ref at call time so it is never a
* stale snapshot. Use as the write/merge guard: an empty scope must never
* pass, or a writer could fire before the first valid scope is seeded.
*/
isScopeLoaded: () => boolean;
/** Arm a coalesced write after mutating the buffer for `scope`. */
schedule: (scope: string) => void;
};
/**
* Manages the thread-activity localStorage persistence layer for
* useUnreadChannels: owns the loaded-scope ref, the coalescing timer, the
* pagehide/visibility flush, and hydration on identity/relay change. The buffer
* itself (`itemsRef`) is owned by the parent and merged in place by its writers;
* this hook only decides when the buffer is durably persisted.
*
* Sibling of useObservedUnreadPersistence — same scope-fence and flush shape,
* minus marker-prune/removeChannel/clearAll, which thread activity has no
* analog for.
*/
export function useThreadActivityPersistence(
normalizedPubkey: string | null,
normalizedRelayUrl: string,
itemsRef: React.MutableRefObject<ThreadActivityItem[]>,
): ThreadActivityPersistence {
const currentScope = activityScopeKey(normalizedPubkey, normalizedRelayUrl);
const scopeLoadedRef = React.useRef<string>("");
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const persistRefs = React.useRef<ThreadActivityRefs>({
itemsRef,
scopeLoadedRef,
timerRef,
});
persistRefs.current.itemsRef = itemsRef;
// pagehide + visibilitychange→hidden: synchronously persist any pending write
// before the webview unloads or is backgrounded. Cmd+R and #5588's idle
// reload both tear the webview down within the coalescing window; without
// these flushes the last burst of replies would be lost.
React.useEffect(() => {
const refs = persistRefs.current;
const flush = () => flushThreadActivityWrite(refs);
const onVisibility = () => {
if (document.visibilityState === "hidden") flush();
};
window.addEventListener("pagehide", flush);
document.addEventListener("visibilitychange", onVisibility);
return () => {
window.removeEventListener("pagehide", flush);
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
// Hydrate the buffer whenever identity/relay changes. Flush the OLD scope
// first so an in-flight coalesced write lands under the old key before the
// buffer is clobbered, then drop the orphaned legacy key for this pubkey.
// biome-ignore lint/correctness/useExhaustiveDependencies: normalizedRelayUrl is an intentional reset signal alongside normalizedPubkey
React.useEffect(() => {
flushThreadActivityWrite(persistRefs.current);
if (normalizedPubkey && normalizedRelayUrl) {
removeLegacyThreadActivityKey(normalizedPubkey);
itemsRef.current = readActivityFromStorage(
normalizedPubkey,
normalizedRelayUrl,
);
} else {
itemsRef.current = [];
}
scopeLoadedRef.current = currentScope;
// Flush the current scope on unmount / before the next run so a pending
// write is never dropped when refs are clobbered.
return () => {
flushThreadActivityWrite(persistRefs.current);
};
}, [normalizedPubkey, normalizedRelayUrl]);
const schedule = React.useCallback(
(scope: string) => scheduleThreadActivityWrite(scope, persistRefs.current),
[],
);
const isScopeLoaded = React.useCallback(
() => currentScope !== "" && scopeLoadedRef.current === currentScope,
[currentScope],
);
return React.useMemo(
() => ({ scopeLoadedRef, currentScope, isScopeLoaded, schedule }),
[currentScope, isScopeLoaded, schedule],
);
}
@@ -38,11 +38,8 @@ import { useStableMap, useStableSet } from "@/shared/hooks/useStableReference";
import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage";
import { DM_NOTIFIABLE_EVENT_KINDS } from "./isDmNotifiableKind";
import {
activityScopeKey,
addThreadActivityItems,
projectActivityForScope,
readActivityFromStorage,
writeActivityToStorage,
type ThreadActivityItem,
} from "@/features/channels/threadActivityStorage";
export type { ThreadActivityItem } from "@/features/channels/threadActivityStorage";
@@ -55,6 +52,7 @@ export {
writeActivityToStorage,
} from "@/features/channels/threadActivityStorage";
import { useObservedUnreadPersistence } from "@/features/channels/useObservedUnreadPersistence";
import { useThreadActivityPersistence } from "@/features/channels/useThreadActivityPersistence";
type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & {
pubkey?: string;
@@ -147,14 +145,6 @@ export function useUnreadChannels(
const normalizedRelayUrl = relayUrlOption
? normalizeRelayUrl(relayUrlOption)
: "";
// Single identity for the in-memory thread-activity buffer — computed once
// per render and used at reset, both writers, and the return fence. The
// helper returns "" when either value is absent, which never matches a valid
// loaded scope, so the fence returns [] until the buffer is seeded.
const currentActivityScope = activityScopeKey(
normalizedPubkey,
normalizedRelayUrl,
);
const {
getEffectiveTimestamp,
@@ -227,12 +217,10 @@ export function useUnreadChannels(
mutedChannelIdsRef.current = mutedChannelIdsOption ?? new Set();
// Thread reply events that triggered notifications — surfaced in the Home
// activity feed as synthetic FeedItems.
// activity feed as synthetic FeedItems. The buffer is the source of truth
// between coalesced writes; useThreadActivityPersistence owns the loaded
// scope, the write timer, flush, and hydration.
const threadActivityRef = React.useRef<ThreadActivityItem[]>([]);
// Tracks the (pubkey:relayUrl) scope currently loaded into threadActivityRef.
// Writers guard against this before merging so in-flight writes from a prior
// scope cannot corrupt the new one; renders return [] until it matches.
const threadActivityScopeRef = React.useRef<string>("");
// Tracks which channels we've already issued a catch-up REQ for this
// session. Prevents re-fetching on every channels-list refetch, while still
@@ -266,6 +254,15 @@ export function useUnreadChannels(
{ onPruned: bumpLatestVersion },
);
// Thread-activity persistence: coalesced writes, pagehide/visibility flush,
// hydration + legacy-key cleanup. Owns the loaded scope for the buffer above.
const activityPersistence = useThreadActivityPersistence(
normalizedPubkey,
normalizedRelayUrl,
threadActivityRef,
);
const currentActivityScope = activityPersistence.currentScope;
// 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).
@@ -285,11 +282,6 @@ export function useUnreadChannels(
? mentionedStore.read(pubkey)
: new Set();
mutedRootIdsRef.current = pubkey ? mutedStore.read(pubkey) : new Set();
threadActivityRef.current =
normalizedPubkey && normalizedRelayUrl
? readActivityFromStorage(normalizedPubkey, normalizedRelayUrl)
: [];
threadActivityScopeRef.current = currentActivityScope;
bumpLatestVersion();
bumpMembershipVersion();
}, [pubkey, relayClient, normalizedRelayUrl]);
@@ -490,15 +482,10 @@ export function useUnreadChannels(
const handleThreadReplyNotification = React.useCallback(
(channelId: string, event: RelayEvent) => {
// Guard: don't merge into a ref whose scope has drifted from the current
// identity. Also reject an empty scope — activityScopeKey() returns ""
// when pubkey or relay is absent, and "" !== "" is false, so without this
// guard a writer could fire before the first valid scope is established.
if (
!currentActivityScope ||
threadActivityScopeRef.current !== currentActivityScope
)
return;
// Guard: don't merge into a buffer whose scope has drifted from the
// current identity. isScopeLoaded() also rejects an empty scope, so a
// writer can never fire before the first valid scope is seeded.
if (!activityPersistence.isScopeLoaded()) return;
const channelName =
channels.find((ch) => ch.id === channelId)?.name ?? "";
@@ -516,25 +503,13 @@ export function useUnreadChannels(
if (!added.didAdd) return;
const didRecordMentionedRoot = recordMentionedRoot(event);
threadActivityRef.current = added.items;
if (normalizedPubkey !== null && normalizedRelayUrl) {
writeActivityToStorage(
normalizedPubkey,
normalizedRelayUrl,
added.items,
);
}
activityPersistence.schedule(currentActivityScope);
if (didRecordMentionedRoot) {
bumpMembershipVersion();
}
bumpLatestVersion();
},
[
channels,
currentActivityScope,
normalizedPubkey,
normalizedRelayUrl,
recordMentionedRoot,
],
[channels, currentActivityScope, activityPersistence, recordMentionedRoot],
);
const muteThread = React.useCallback(
@@ -785,13 +760,7 @@ export function useUnreadChannels(
);
if (added.didAdd) {
threadActivityRef.current = added.items;
if (normalizedPubkey && normalizedRelayUrl) {
writeActivityToStorage(
normalizedPubkey,
normalizedRelayUrl,
added.items,
);
}
activityPersistence.schedule(currentActivityScope);
didAdvance = true;
}
}
@@ -1009,7 +978,7 @@ export function useUnreadChannels(
mentionedRootIds,
recordThreadInteraction,
threadActivityItems: projectActivityForScope(
threadActivityScopeRef.current,
activityPersistence.scopeLoadedRef.current,
currentActivityScope,
threadActivityRef.current,
),