mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
perf(desktop): hydrate persisted channel heads
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz> Signed-off-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{isHydrated ? children : null}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -601,7 +631,11 @@ function CommunityApp({
|
||||
}, [communityApplied]);
|
||||
if (appContent === null && (!transaction || isEnteringCurtain)) {
|
||||
appContent = communityApplied ? (
|
||||
<CommunityQueryProvider key={communityKey}>
|
||||
<CommunityQueryProvider
|
||||
key={communityKey}
|
||||
pubkey={community.identityPubkey}
|
||||
relayUrl={activeCommunity?.relayUrl ?? null}
|
||||
>
|
||||
<CommunityIdentityReplacementSentinel
|
||||
onIdentityReplaced={bumpSignerEpoch}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RelayEvent[]>(queryKey) ?? [];
|
||||
}
|
||||
const previousMessages =
|
||||
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
|
||||
const events = await getChannelWindowEvents(channel.id);
|
||||
|
||||
99
desktop/src/features/messages/lib/channelHeadCache.test.mjs
Normal file
99
desktop/src/features/messages/lib/channelHeadCache.test.mjs
Normal file
@@ -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]);
|
||||
});
|
||||
76
desktop/src/features/messages/lib/channelHeadCache.ts
Normal file
76
desktop/src/features/messages/lib/channelHeadCache.ts
Normal file
@@ -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<QueryClient, Set<string>>();
|
||||
const cacheScopes = new WeakMap<QueryClient, ChannelHeadScope>();
|
||||
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<void> {
|
||||
if (!isChannelHeadCacheEnabled()) return;
|
||||
cacheScopes.set(queryClient, scope);
|
||||
const entries = await loadChannelHeadCache(scope, 12);
|
||||
const hydrated = new Set<string>();
|
||||
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);
|
||||
}
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -351,7 +351,7 @@ export function useUsersBatchQuery(
|
||||
const entry = queryClient.getQueryData<UsersBatchEntry>(
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
25
desktop/src/shared/api/tauriChannelHeadCache.ts
Normal file
25
desktop/src/shared/api/tauriChannelHeadCache.ts
Normal file
@@ -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<ChannelHeadEntry[]> {
|
||||
return invokeTauri("channel_head_cache_load", { scope, limit });
|
||||
}
|
||||
export function storeChannelHeadCache(
|
||||
scope: ChannelHeadScope,
|
||||
channelId: string,
|
||||
events: RelayEvent[],
|
||||
): Promise<void> {
|
||||
return invokeTauri("channel_head_cache_store", { scope, channelId, events });
|
||||
}
|
||||
export function clearChannelHeadCache(scope: ChannelHeadScope): Promise<void> {
|
||||
return invokeTauri("channel_head_cache_clear", { scope });
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user