From f1e2e4dc9d7f60fdfa400376223fbc6a8c412ac8 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 22 Aug 2026 11:49:14 -0400 Subject: [PATCH] perf(desktop): hydrate persisted channel heads Co-authored-by: Max Signed-off-by: Max --- desktop/src-tauri/src/app_state.rs | 4 +- desktop/src/app/App.tsx | 40 +++- .../src/app/useAppShellLifecycleEffects.ts | 28 +-- .../features/communities/useCommunities.tsx | 14 +- .../features/communities/useCommunityInit.ts | 8 +- desktop/src/features/messages/hooks.ts | 15 +- .../messages/lib/channelHeadCache.test.mjs | 99 +++++++++ .../features/messages/lib/channelHeadCache.ts | 76 +++++++ .../messages/lib/messageSnapshot.test.mjs | 199 ----------------- .../features/messages/lib/messageSnapshot.ts | 202 ------------------ desktop/src/features/profile/hooks.ts | 4 +- .../src/shared/api/tauriChannelHeadCache.ts | 25 +++ desktop/src/testing/e2eBridge.ts | 53 +++++ 13 files changed, 331 insertions(+), 436 deletions(-) create mode 100644 desktop/src/features/messages/lib/channelHeadCache.test.mjs create mode 100644 desktop/src/features/messages/lib/channelHeadCache.ts delete mode 100644 desktop/src/features/messages/lib/messageSnapshot.test.mjs delete mode 100644 desktop/src/features/messages/lib/messageSnapshot.ts create mode 100644 desktop/src/shared/api/tauriChannelHeadCache.ts diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 7c41f6bfe2..9cbb4444ab 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -194,8 +194,8 @@ pub fn build_app_state() -> AppState { identity_storage: AtomicU8::new(identity_storage as u8), http_client: reqwest::Client::builder() .resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0))) - .pool_idle_timeout(std::time::Duration::from_secs(10)) - .pool_max_idle_per_host(1) + .pool_idle_timeout(std::time::Duration::from_secs(300)) + .pool_max_idle_per_host(2) .build() .unwrap_or_else(|_| reqwest::Client::new()), media_fetch_client: build_media_fetch_client().expect( diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index bfaf2ba200..23899cfe14 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -63,6 +63,7 @@ import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChang import { setAvatarProfileSyncQueryClient } from "@/features/profile/avatarProfileSync"; import { EncryptedBackupProvider } from "@/features/settings/EncryptedBackupProvider"; import { createBuzzQueryClient } from "@/shared/api/queryClient"; +import { hydrateChannelHeads } from "@/features/messages/lib/channelHeadCache"; import { useIdentityQuery } from "@/shared/api/hooks"; import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri"; import { getProfile } from "@/shared/api/tauriProfiles"; @@ -213,11 +214,38 @@ function CommunitySwitchGate() { ); } -function CommunityQueryProvider({ children }: { children: ReactNode }) { +function CommunityQueryProvider({ + children, + pubkey, + relayUrl, +}: { + children: ReactNode; + pubkey: string | null; + relayUrl: string | null; +}) { const [queryClient] = useState(createBuzzQueryClient); + const [isHydrated, setIsHydrated] = useState(!pubkey || !relayUrl); useEffect(() => setAvatarProfileSyncQueryClient(queryClient), [queryClient]); + useEffect(() => { + let cancelled = false; + if (!pubkey || !relayUrl) { + setIsHydrated(true); + return; + } + void hydrateChannelHeads(queryClient, { pubkey, relayUrl }) + .catch((error) => { + console.warn("Failed to hydrate persisted channel heads", error); + }) + .finally(() => { + if (!cancelled) setIsHydrated(true); + }); + return () => { + cancelled = true; + }; + }, [pubkey, queryClient, relayUrl]); + useEffect(() => { const e2eWindow = window as Window & { __BUZZ_E2E__?: unknown; @@ -236,7 +264,9 @@ function CommunityQueryProvider({ children }: { children: ReactNode }) { }, [queryClient]); return ( - {children} + + {isHydrated ? children : null} + ); } @@ -601,7 +631,11 @@ function CommunityApp({ }, [communityApplied]); if (appContent === null && (!transaction || isEnteringCurtain)) { appContent = communityApplied ? ( - + diff --git a/desktop/src/app/useAppShellLifecycleEffects.ts b/desktop/src/app/useAppShellLifecycleEffects.ts index 969bf67ca6..71db052369 100644 --- a/desktop/src/app/useAppShellLifecycleEffects.ts +++ b/desktop/src/app/useAppShellLifecycleEffects.ts @@ -42,33 +42,13 @@ export function useAppShellLifecycleEffects({ React.useEffect(() => { let isCancelled = false; - - const startPreconnect = () => { - if (isCancelled) { - return; + void relayClient.preconnect().catch((error) => { + if (!isCancelled) { + console.error("Failed to preconnect to relay", error); } - - void relayClient.preconnect().catch((error) => { - if (!isCancelled) { - console.error("Failed to preconnect to relay", error); - } - }); - }; - - if ("requestIdleCallback" in window) { - const idleId = window.requestIdleCallback(startPreconnect, { - timeout: 1_500, - }); - return () => { - isCancelled = true; - window.cancelIdleCallback(idleId); - }; - } - - const timeoutId = globalThis.setTimeout(startPreconnect, 250); + }); return () => { isCancelled = true; - globalThis.clearTimeout(timeoutId); }; }, []); diff --git a/desktop/src/features/communities/useCommunities.tsx b/desktop/src/features/communities/useCommunities.tsx index e0a1001788..f30353af72 100644 --- a/desktop/src/features/communities/useCommunities.tsx +++ b/desktop/src/features/communities/useCommunities.tsx @@ -19,7 +19,8 @@ import { import { removeSelfProfileCachesForRelay } from "@/features/profile/lib/selfProfileStorage"; import { removeUserLabelCacheForRelay } from "@/features/profile/lib/userLabelStorage"; import { removeChannelSnapshotForRelay } from "@/features/channels/channelSnapshot"; -import { removeMessageSnapshotsForRelay } from "@/features/messages/lib/messageSnapshot"; +import { clearChannelHeadCache } from "@/shared/api/tauriChannelHeadCache"; +import { getIdentity } from "@/shared/api/tauriIdentity"; import { clearSavedCommunitySnapshot } from "@/features/agents/activeAgentTurnsStore"; import { clearCommunityDestinations, @@ -234,7 +235,16 @@ function useCommunitiesInternal(): UseCommunitiesReturn { removeSelfProfileCachesForRelay(removed.relayUrl); removeUserLabelCacheForRelay(removed.relayUrl); removeChannelSnapshotForRelay(removed.relayUrl); - removeMessageSnapshotsForRelay(removed.relayUrl); + void getIdentity() + .then((identity) => + clearChannelHeadCache({ + pubkey: identity.pubkey, + relayUrl: removed.relayUrl, + }), + ) + .catch((error) => { + console.warn("Failed to clear persisted channel heads", error); + }); clearSavedCommunitySnapshot(id); removeCommunityDestination(id); diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 5493a47b1e..c565ee0f7b 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -84,7 +84,12 @@ async function resetCommunityState({ } type CommunityInitResult = - | { isReady: true; needsSetup: false; appliedKey: string } + | { + isReady: true; + needsSetup: false; + appliedKey: string; + identityPubkey: string | null; + } | { isReady: false; needsSetup: true; @@ -342,6 +347,7 @@ export function useCommunityInit( isReady: true, needsSetup: false, appliedKey: communityKey, + identityPubkey, }); } } diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 8b457a7adf..ff022ab522 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -24,6 +24,11 @@ import { refreshChannelWindowMessages, } from "@/features/messages/lib/projectChannelWindow"; import { reconcileChannelWindowMessages } from "@/features/messages/lib/channelWindowReconciliation"; +import { + channelHeadCacheScope, + consumeHydratedChannel, +} from "@/features/messages/lib/channelHeadCache"; +import { storeChannelHeadCache } from "@/shared/api/tauriChannelHeadCache"; import { mergeMessages, mergeTimelineCacheMessages, @@ -257,18 +262,26 @@ export function reconcileFetchedChannelWindow( emptyChannelWindowStore(); const next = replaceNewestChannelWindow(current, page); queryClient.setQueryData(windowKey, next); + const scope = channelHeadCacheScope(queryClient); + if (scope) { + void storeChannelHeadCache(scope, channelId, events).catch((error) => { + console.warn("Failed to persist channel head", channelId, error); + }); + } return reconcileChannelWindowMessages(next, previousMessages); } export function useChannelMessagesQuery(channel: Channel | null) { const queryClient = useQueryClient(); const queryKey = channelMessagesKey(channel?.id ?? "none"); - return useQuery({ enabled: channel !== null && channel.channelType !== "forum", queryKey, queryFn: async ({ signal }) => { if (!channel) throw new Error("No channel selected."); + if (consumeHydratedChannel(queryClient, channel.id)) { + return queryClient.getQueryData(queryKey) ?? []; + } const previousMessages = queryClient.getQueryData(queryKey) ?? []; const events = await getChannelWindowEvents(channel.id); diff --git a/desktop/src/features/messages/lib/channelHeadCache.test.mjs b/desktop/src/features/messages/lib/channelHeadCache.test.mjs new file mode 100644 index 0000000000..31d7076d5e --- /dev/null +++ b/desktop/src/features/messages/lib/channelHeadCache.test.mjs @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { QueryClient } from "@tanstack/react-query"; +import { + consumeHydratedChannel, + hydrateChannelHeads, +} from "./channelHeadCache.ts"; +import { channelMessagesKey } from "./messageQueryKeys.ts"; +import { reconcileFetchedChannelWindow } from "../hooks.ts"; +const channelId = "channel-a"; +const root = { + id: "a".repeat(64), + pubkey: "b".repeat(64), + created_at: 10, + kind: 40002, + tags: [["h", channelId]], + content: "persisted", + sig: "", +}; +const replacement = { + ...root, + id: "c".repeat(64), + created_at: 11, + content: "relay", +}; +function bounds() { + return { + id: "d".repeat(64), + pubkey: "e".repeat(64), + created_at: 12, + kind: 39006, + tags: [ + ["h", channelId], + ["d", `${channelId}:head`], + ], + content: JSON.stringify({ has_more: false, next_cursor: null }), + sig: "", + }; +} +function install(entries) { + globalThis.window = { + localStorage: { getItem: () => null }, + __TAURI_INTERNALS__: { + invoke: async (command) => + command === "channel_head_cache_load" ? entries : null, + }, + }; +} +test("hydrates stale data and consumes its mount gate once", async () => { + install([ + { channelId, events: [root, bounds()], savedAt: 1, lastVisitedAt: 1 }, + ]); + const client = new QueryClient(); + await hydrateChannelHeads(client, { + pubkey: "f".repeat(64), + relayUrl: "wss://relay", + }); + assert.deepEqual(client.getQueryData(channelMessagesKey(channelId)), [root]); + assert.equal( + client.getQueryState(channelMessagesKey(channelId)).dataUpdatedAt, + 0, + ); + assert.equal(consumeHydratedChannel(client, channelId), true); + assert.equal(consumeHydratedChannel(client, channelId), false); +}); +test("authoritative refresh deletes a vanished hydrated row", async () => { + install([ + { channelId, events: [root, bounds()], savedAt: 1, lastVisitedAt: 1 }, + ]); + const client = new QueryClient(); + await hydrateChannelHeads(client, { + pubkey: "f".repeat(64), + relayUrl: "wss://relay", + }); + const next = reconcileFetchedChannelWindow( + client, + channelId, + [replacement, bounds()], + client.getQueryData(channelMessagesKey(channelId)), + new AbortController().signal, + ); + assert.deepEqual( + next.map((e) => e.id), + [replacement.id], + ); +}); +test("drops malformed entries independently", async () => { + install([ + { channelId: "bad", events: [root], savedAt: 1, lastVisitedAt: 2 }, + { channelId, events: [root, bounds()], savedAt: 1, lastVisitedAt: 1 }, + ]); + const client = new QueryClient(); + await hydrateChannelHeads(client, { + pubkey: "f".repeat(64), + relayUrl: "wss://relay", + }); + assert.equal(client.getQueryData(channelMessagesKey("bad")), undefined); + assert.deepEqual(client.getQueryData(channelMessagesKey(channelId)), [root]); +}); diff --git a/desktop/src/features/messages/lib/channelHeadCache.ts b/desktop/src/features/messages/lib/channelHeadCache.ts new file mode 100644 index 0000000000..409543be18 --- /dev/null +++ b/desktop/src/features/messages/lib/channelHeadCache.ts @@ -0,0 +1,76 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { + loadChannelHeadCache, + type ChannelHeadScope, +} from "@/shared/api/tauriChannelHeadCache"; +import { channelMessagesKey, channelWindowKey } from "./messageQueryKeys"; +import { parseChannelWindowResponse } from "./channelWindowResponse"; +import { + emptyChannelWindowStore, + replaceNewestChannelWindow, +} from "./channelWindowStore"; +import { reconcileChannelWindowMessages } from "./channelWindowReconciliation"; +const hydratedChannels = new WeakMap>(); +const cacheScopes = new WeakMap(); +export function isChannelHeadCacheEnabled(): boolean { + if (typeof window === "undefined") return false; + if (import.meta.env?.VITE_BUZZ_CHANNEL_HEAD_CACHE === "off") return false; + return window.localStorage.getItem("buzz-channel-head-cache") !== "off"; +} +export function channelHeadCacheScope( + queryClient: QueryClient, +): ChannelHeadScope | null { + return cacheScopes.get(queryClient) ?? null; +} +export function consumeHydratedChannel( + queryClient: QueryClient, + channelId: string, +): boolean { + const channels = hydratedChannels.get(queryClient); + if (!channels?.delete(channelId)) return false; + if (channels.size === 0) hydratedChannels.delete(queryClient); + return true; +} +export function hasHydratedChannel( + queryClient: QueryClient, + channelId: string, +): boolean { + return hydratedChannels.get(queryClient)?.has(channelId) ?? false; +} +export async function hydrateChannelHeads( + queryClient: QueryClient, + scope: ChannelHeadScope, +): Promise { + if (!isChannelHeadCacheEnabled()) return; + cacheScopes.set(queryClient, scope); + const entries = await loadChannelHeadCache(scope, 12); + const hydrated = new Set(); + for (const entry of entries) { + try { + const page = parseChannelWindowResponse( + entry.events, + entry.channelId, + null, + ); + const window = replaceNewestChannelWindow( + emptyChannelWindowStore(), + page, + ); + const messages = reconcileChannelWindowMessages(window, []); + queryClient.setQueryData(channelWindowKey(entry.channelId), window, { + updatedAt: 0, + }); + queryClient.setQueryData(channelMessagesKey(entry.channelId), messages, { + updatedAt: 0, + }); + hydrated.add(entry.channelId); + } catch (error) { + console.warn( + "Ignoring invalid persisted channel head", + entry.channelId, + error, + ); + } + } + if (hydrated.size > 0) hydratedChannels.set(queryClient, hydrated); +} diff --git a/desktop/src/features/messages/lib/messageSnapshot.test.mjs b/desktop/src/features/messages/lib/messageSnapshot.test.mjs deleted file mode 100644 index a188f51da0..0000000000 --- a/desktop/src/features/messages/lib/messageSnapshot.test.mjs +++ /dev/null @@ -1,199 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - mergeHistoryOverSnapshot, - messageSnapshotKey, - readMessageSnapshot, - removeMessageSnapshotsForRelay, - writeMessageSnapshot, -} from "./messageSnapshot.ts"; - -if (typeof globalThis.window === "undefined") { - const storage = new Map(); - globalThis.window = { - localStorage: { - getItem: (key) => storage.get(key) ?? null, - setItem: (key, value) => storage.set(key, value), - removeItem: (key) => storage.delete(key), - key: (index) => [...storage.keys()][index] ?? null, - get length() { - return storage.size; - }, - }, - }; -} - -function makeEvent(overrides = {}) { - return { - id: `event-${Math.random().toString(36).slice(2)}`, - pubkey: "pubkey-1", - created_at: 1_700_000_000, - kind: 9, - tags: [["h", "chan-1"]], - content: "hello", - sig: "sig", - ...overrides, - }; -} - -const RELAY = "wss://relay.example.com"; - -function clearRelay(relayUrl = RELAY) { - removeMessageSnapshotsForRelay(relayUrl); -} - -test("messageSnapshotKey: normalizes trailing slash and case", () => { - assert.equal( - messageSnapshotKey("WSS://Relay.Example.com/", "chan-1"), - messageSnapshotKey("wss://relay.example.com", "chan-1"), - ); -}); - -test("read after write returns the persisted events", () => { - clearRelay(); - const events = [makeEvent({ id: "a" }), makeEvent({ id: "b" })]; - writeMessageSnapshot(RELAY, "chan-1", events); - assert.deepEqual(readMessageSnapshot(RELAY, "chan-1"), events); -}); - -test("read for an unknown channel returns null", () => { - clearRelay(); - assert.equal(readMessageSnapshot(RELAY, "chan-never"), null); -}); - -test("read returns null for malformed JSON", () => { - window.localStorage.setItem( - messageSnapshotKey(RELAY, "chan-bad"), - "not-json{{{", - ); - assert.equal(readMessageSnapshot(RELAY, "chan-bad"), null); -}); - -test("read returns null for a wrong-version payload", () => { - window.localStorage.setItem( - messageSnapshotKey(RELAY, "chan-v2"), - JSON.stringify({ version: 2, updatedAt: 1, events: [makeEvent()] }), - ); - assert.equal(readMessageSnapshot(RELAY, "chan-v2"), null); -}); - -test("pending optimistic events are not persisted", () => { - clearRelay(); - const settled = makeEvent({ id: "settled" }); - writeMessageSnapshot(RELAY, "chan-1", [ - settled, - makeEvent({ id: "optimistic", pending: true }), - ]); - assert.deepEqual(readMessageSnapshot(RELAY, "chan-1"), [settled]); -}); - -test("write with only pending events persists nothing", () => { - clearRelay(); - writeMessageSnapshot(RELAY, "chan-1", [makeEvent({ pending: true })]); - assert.equal(readMessageSnapshot(RELAY, "chan-1"), null); -}); - -test("snapshot keeps only the newest slice of a long timeline", () => { - clearRelay(); - const events = Array.from({ length: 200 }, (_, i) => - makeEvent({ id: `event-${i}`, created_at: 1_700_000_000 + i }), - ); - writeMessageSnapshot(RELAY, "chan-1", events); - const persisted = readMessageSnapshot(RELAY, "chan-1"); - assert.equal(persisted.length, 80); - assert.equal(persisted[persisted.length - 1].id, "event-199"); - assert.equal(persisted[0].id, "event-120"); -}); - -test("per-relay channel cap evicts the least recently written snapshot", () => { - clearRelay(); - for (let i = 0; i < 21; i++) { - writeMessageSnapshot(RELAY, `chan-${i}`, [makeEvent({ id: `e-${i}` })]); - } - // chan-0 was written first (oldest updatedAt tie broken by insertion) — - // with 21 channels, at least one of the earliest must be evicted and the - // newest retained. - assert.notEqual(readMessageSnapshot(RELAY, "chan-20"), null); - const retained = Array.from({ length: 21 }, (_, i) => - readMessageSnapshot(RELAY, `chan-${i}`), - ).filter((snapshot) => snapshot !== null); - assert.equal(retained.length, 20); -}); - -test("remove clears every snapshot for that relay only", () => { - clearRelay(); - clearRelay("wss://other.example.com"); - writeMessageSnapshot(RELAY, "chan-1", [makeEvent({ id: "keep-other" })]); - writeMessageSnapshot("wss://other.example.com", "chan-1", [ - makeEvent({ id: "other" }), - ]); - removeMessageSnapshotsForRelay(RELAY); - assert.equal(readMessageSnapshot(RELAY, "chan-1"), null); - assert.notEqual( - readMessageSnapshot("wss://other.example.com", "chan-1"), - null, - ); -}); - -test("write is tolerant of storage failures", () => { - const original = window.localStorage.setItem; - window.localStorage.setItem = () => { - throw new Error("quota exceeded"); - }; - try { - assert.doesNotThrow(() => - writeMessageSnapshot(RELAY, "chan-1", [makeEvent()]), - ); - } finally { - window.localStorage.setItem = original; - } -}); - -test("cold snapshot load: merge keeps snapshot-only rows and widens aux backfill to them", () => { - const snapshotOnly = makeEvent({ id: "ghost", created_at: 1_700_000_000 }); - const fresh = makeEvent({ id: "fresh", created_at: 1_700_000_100 }); - const { merged, auxBackfillWindow } = mergeHistoryOverSnapshot({ - cached: undefined, - snapshot: [snapshotOnly], - history: [fresh], - }); - assert.deepEqual( - merged.map((event) => event.id), - ["ghost", "fresh"], - ); - assert.ok(auxBackfillWindow.some((event) => event.id === "ghost")); - assert.ok(auxBackfillWindow.some((event) => event.id === "fresh")); -}); - -test("warm load: aux backfill stays scoped to the fresh window", () => { - const cached = makeEvent({ id: "cached", created_at: 1_700_000_000 }); - const fresh = makeEvent({ id: "fresh", created_at: 1_700_000_100 }); - const { merged, auxBackfillWindow } = mergeHistoryOverSnapshot({ - cached: [cached], - snapshot: [makeEvent({ id: "stale-snapshot" })], - history: [fresh], - }); - assert.ok(merged.some((event) => event.id === "cached")); - assert.deepEqual( - auxBackfillWindow.map((event) => event.id), - ["fresh"], - ); -}); - -test("cold load without a snapshot backfills the fresh window only", () => { - const fresh = makeEvent({ id: "fresh" }); - const { merged, auxBackfillWindow } = mergeHistoryOverSnapshot({ - cached: undefined, - snapshot: null, - history: [fresh], - }); - assert.deepEqual( - merged.map((event) => event.id), - ["fresh"], - ); - assert.deepEqual( - auxBackfillWindow.map((event) => event.id), - ["fresh"], - ); -}); diff --git a/desktop/src/features/messages/lib/messageSnapshot.ts b/desktop/src/features/messages/lib/messageSnapshot.ts deleted file mode 100644 index d4a183c2ff..0000000000 --- a/desktop/src/features/messages/lib/messageSnapshot.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * Per-channel persisted message snapshots. - * - * A channel revisited after its React-Query cache entry is gone (app restart, - * gcTime expiry, community remount) goes fully cold and holds a skeleton for a - * relay round trip. This module persists the newest slice of each channel's - * timeline so a revisit can paint instantly from the snapshot while the - * history fetch revalidates behind it — the same stale-then-revalidate pattern - * the sidebar's channelSnapshot uses for the channel list. - * - * Keyed per relay URL + channel id so one relay's messages never bleed into - * another. Bounded two ways: only the newest MAX_EVENTS_PER_SNAPSHOT events - * per channel, and only the MAX_CHANNELS_PER_RELAY most recently written - * channels per relay (older ones are evicted LRU on write). - */ - -import { mergeTimelineHistoryMessages } from "@/features/messages/lib/messageQueryKeys"; -import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; -import type { RelayEvent } from "@/shared/api/types"; -import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; - -const STORAGE_KEY_PREFIX = "buzz-channel-messages.v1"; - -// Newest events kept per channel. The trailing slice of the sorted timeline -// cache, so recent auxiliary events (reactions/edits) ride along with the -// content rows they decorate. -const MAX_EVENTS_PER_SNAPSHOT = 80; - -const MAX_CHANNELS_PER_RELAY = 20; - -export function messageSnapshotKey(relayUrl: string, channelId: string) { - return `${STORAGE_KEY_PREFIX}:${normalizeRelayUrl(relayUrl)}:${channelId}`; -} - -type SnapshotPayload = { - version: 1; - updatedAt: number; - events: RelayEvent[]; -}; - -function parseSnapshotPayload(json: unknown): SnapshotPayload | null { - if (typeof json !== "object" || json === null) return null; - const obj = json as Record; - if (obj.version !== 1 || !Array.isArray(obj.events)) return null; - const updatedAt = - typeof obj.updatedAt === "number" && Number.isFinite(obj.updatedAt) - ? obj.updatedAt - : 0; - return { version: 1, updatedAt, events: obj.events as RelayEvent[] }; -} - -/** - * Reads the persisted message snapshot for a channel, or null when absent or - * malformed. - */ -export function readMessageSnapshot( - relayUrl: string, - channelId: string, -): RelayEvent[] | null { - try { - const raw = window.localStorage.getItem( - messageSnapshotKey(relayUrl, channelId), - ); - if (!raw) return null; - const parsed = parseSnapshotPayload(JSON.parse(raw)); - if (!parsed || parsed.events.length === 0) return null; - return parsed.events; - } catch { - return null; - } -} - -function relayPrefix(relayUrl: string) { - return `${STORAGE_KEY_PREFIX}:${normalizeRelayUrl(relayUrl)}:`; -} - -function collectKeysWithPrefix(prefix: string): string[] { - const keys: string[] = []; - for (let i = 0; i < window.localStorage.length; i++) { - const key = window.localStorage.key(i); - if (key?.startsWith(prefix)) { - keys.push(key); - } - } - return keys; -} - -function evictOldestSnapshots(prefix: string, keepingKey: string) { - const others = collectKeysWithPrefix(prefix).filter( - (key) => key !== keepingKey, - ); - if (others.length < MAX_CHANNELS_PER_RELAY) { - return; - } - - const byAge = others - .map((key) => { - let updatedAt = 0; - try { - const parsed = parseSnapshotPayload( - JSON.parse(window.localStorage.getItem(key) ?? ""), - ); - updatedAt = parsed?.updatedAt ?? 0; - } catch { - // Malformed entries sort oldest and get evicted first. - } - return { key, updatedAt }; - }) - .sort((a, b) => a.updatedAt - b.updatedAt); - - for (const { key } of byAge.slice( - 0, - others.length - (MAX_CHANNELS_PER_RELAY - 1), - )) { - window.localStorage.removeItem(key); - } -} - -/** - * Persists the newest slice of a channel's timeline. Pending optimistic events - * are dropped (they have no relay identity to revalidate against). Skips the - * write when unchanged so live-append churn does not re-serialize an identical - * snapshot. Non-fatal on storage failure (e.g. quota exceeded). - */ -export function writeMessageSnapshot( - relayUrl: string, - channelId: string, - events: RelayEvent[], -): void { - try { - const persistable = events - .filter((event) => !event.pending) - .slice(-MAX_EVENTS_PER_SNAPSHOT); - if (persistable.length === 0) { - return; - } - - const key = messageSnapshotKey(relayUrl, channelId); - const previous = window.localStorage.getItem(key); - if (previous) { - const parsed = parseSnapshotPayload(JSON.parse(previous)); - if ( - parsed && - JSON.stringify(parsed.events) === JSON.stringify(persistable) - ) { - return; - } - } - - evictOldestSnapshots(relayPrefix(relayUrl), key); - setLocalStorageItemWithRecovery( - key, - JSON.stringify({ - version: 1, - updatedAt: Date.now(), - events: persistable, - } satisfies SnapshotPayload), - ); - } catch { - // Storage access failures are non-fatal. - } -} - -/** - * Merge a fresh history window over the in-memory cache — or, when cold, over - * the persisted snapshot — and pick the window aux backfill must cover. - * - * The snapshot can hold events older than the fetch window; dropping them on - * settle would visibly shrink an already-painted timeline, so the merge keeps - * them. But a kept snapshot row deleted/edited while the app was closed never - * reappears in any history fetch (the relay soft-deletes), so its tombstone or - * edit is only reachable by `#e` over that row's id — cold snapshot loads must - * therefore backfill over the merged timeline, not just the fresh window. - * Otherwise the ghost paints, and the post-settle snapshot rewrite persists it - * forever. - */ -export function mergeHistoryOverSnapshot(input: { - cached: RelayEvent[] | undefined; - snapshot: RelayEvent[] | null; - history: RelayEvent[]; -}): { merged: RelayEvent[]; auxBackfillWindow: RelayEvent[] } { - const usedSnapshot = !input.cached && input.snapshot !== null; - const merged = mergeTimelineHistoryMessages( - input.cached ?? input.snapshot ?? [], - input.history, - ); - return { merged, auxBackfillWindow: usedSnapshot ? merged : input.history }; -} - -/** - * Removes every channel message snapshot for a relay. Called when a community - * is removed. - */ -export function removeMessageSnapshotsForRelay(relayUrl: string): void { - try { - for (const key of collectKeysWithPrefix(relayPrefix(relayUrl))) { - window.localStorage.removeItem(key); - } - } catch { - // Storage access failures are non-fatal. - } -} diff --git a/desktop/src/features/profile/hooks.ts b/desktop/src/features/profile/hooks.ts index f174d50408..233b128612 100644 --- a/desktop/src/features/profile/hooks.ts +++ b/desktop/src/features/profile/hooks.ts @@ -351,7 +351,7 @@ export function useUsersBatchQuery( const entry = queryClient.getQueryData( usersBatchEntryKey(pubkey), ); - if (entry && now - entry.fetchedAt < 60_000) { + if (entry && now - entry.fetchedAt < 10 * 60_000) { if (entry.summary) profiles[pubkey] = entry.summary; else missing.push(pubkey); } else { @@ -384,7 +384,7 @@ export function useUsersBatchQuery( relayUrl, normalizedPubkeys, ), - staleTime: 60_000, + staleTime: 10 * 60_000, gcTime: 5 * 60 * 1_000, }); diff --git a/desktop/src/shared/api/tauriChannelHeadCache.ts b/desktop/src/shared/api/tauriChannelHeadCache.ts new file mode 100644 index 0000000000..9431fced04 --- /dev/null +++ b/desktop/src/shared/api/tauriChannelHeadCache.ts @@ -0,0 +1,25 @@ +import { invokeTauri } from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; +export type ChannelHeadScope = { pubkey: string; relayUrl: string }; +export type ChannelHeadEntry = { + channelId: string; + events: RelayEvent[]; + savedAt: number; + lastVisitedAt: number; +}; +export function loadChannelHeadCache( + scope: ChannelHeadScope, + limit = 12, +): Promise { + return invokeTauri("channel_head_cache_load", { scope, limit }); +} +export function storeChannelHeadCache( + scope: ChannelHeadScope, + channelId: string, + events: RelayEvent[], +): Promise { + return invokeTauri("channel_head_cache_store", { scope, channelId, events }); +} +export function clearChannelHeadCache(scope: ChannelHeadScope): Promise { + return invokeTauri("channel_head_cache_clear", { scope }); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index e4028f0171..63f3a4ba38 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -13889,6 +13889,59 @@ export function maybeInstallE2eTauriMocks() { return null; case "fetch_persona_catalog": return mockPersonaCatalogPublications(); + case "channel_head_cache_load": { + const args = payload as { + scope: { pubkey: string; relayUrl: string }; + limit: number; + }; + const key = `buzz-e2e-channel-head:${args.scope.pubkey.toLowerCase()}:${args.scope.relayUrl.toLowerCase().replace(/\/$/, "")}`; + const entries = JSON.parse( + window.localStorage.getItem(key) ?? "[]", + ) as Array<{ + channelId: string; + events: RelayEvent[]; + savedAt: number; + lastVisitedAt: number; + }>; + return entries + .sort((left, right) => right.lastVisitedAt - left.lastVisitedAt) + .slice(0, args.limit); + } + case "channel_head_cache_store": { + const args = payload as { + scope: { pubkey: string; relayUrl: string }; + channelId: string; + events: RelayEvent[]; + }; + const key = `buzz-e2e-channel-head:${args.scope.pubkey.toLowerCase()}:${args.scope.relayUrl.toLowerCase().replace(/\/$/, "")}`; + const entries = JSON.parse( + window.localStorage.getItem(key) ?? "[]", + ) as Array<{ + channelId: string; + events: RelayEvent[]; + savedAt: number; + lastVisitedAt: number; + }>; + const now = Math.floor(Date.now() / 1000); + const next = entries + .filter((entry) => entry.channelId !== args.channelId) + .concat({ + channelId: args.channelId, + events: args.events, + savedAt: now, + lastVisitedAt: now, + }) + .sort((left, right) => right.lastVisitedAt - left.lastVisitedAt) + .slice(0, 32); + window.localStorage.setItem(key, JSON.stringify(next)); + return null; + } + case "channel_head_cache_clear": { + const args = payload as { scope: { pubkey: string; relayUrl: string } }; + const key = `buzz-e2e-channel-head:${args.scope.pubkey.toLowerCase()}:${args.scope.relayUrl.toLowerCase().replace(/\/$/, "")}`; + window.localStorage.removeItem(key); + return null; + } case "observed_unread_open_scope": { const request = payload as { request: {