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: {