Max
2026-08-22 11:49:14 -04:00
parent e1d48039f3
commit f1e2e4dc9d
13 changed files with 331 additions and 436 deletions

View File

@@ -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(

View File

@@ -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}
/>

View File

@@ -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);
};
}, []);

View File

@@ -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);

View File

@@ -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,
});
}
}

View File

@@ -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);

View 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]);
});

View 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);
}

View File

@@ -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"],
);
});

View File

@@ -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.
}
}

View File

@@ -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,
});

View 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 });
}

View File

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