From d0d4acd4fa02893ad2460b447d7e13da00506be3 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Tue, 4 Aug 2026 11:36:42 -0400 Subject: [PATCH] fix(desktop): show cached display names on startup (#3317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Buzz restores cached channels and messages before profile lookups complete. On launch, that briefly exposes pubkey-derived labels in place of familiar display names. ## What - Persist a bounded, relay-scoped cache of last-known display names, NIP-01 names, and NIP-05 handles - Seed batch profile queries from those labels immediately, while keeping them stale so the existing relay request revalidates them - Keep cached data presentation-only: avatars and ownership metadata are not persisted or used to seed profile-detail caches - Remove cleared or missing profiles, purge a relay's labels when its community is removed, and include the cache in local-storage quota recovery - Add unit coverage for parsing, bounds, eviction, malformed data, and cleared profiles - Add an E2E regression that delays the relay profile response and verifies the cached name is rendered first ## Risk Assessment Low. The cache is disposable, capped at 1,000 entries per relay, scoped by normalized relay URL, and always revalidated. It contains only public label fields and does not restore avatars, agent ownership, or authorization state. ## Verification - `just ci` - `pnpm typecheck` - `pnpm test` — 3,727 passed - `pnpm exec playwright test tests/e2e/channels.spec.ts --grep "cached profile labels"` — passed Generated with Codex --- .../features/communities/useCommunities.tsx | 2 + desktop/src/features/profile/hooks.ts | 22 +- .../profile/lib/userLabelStorage.test.mjs | 225 ++++++++++++++++++ .../features/profile/lib/userLabelStorage.ts | 176 ++++++++++++++ .../src/shared/lib/localStorageQuota.test.mjs | 4 +- desktop/src/shared/lib/localStorageQuota.ts | 1 + desktop/tests/e2e/channels.spec.ts | 45 +++- 7 files changed, 469 insertions(+), 6 deletions(-) create mode 100644 desktop/src/features/profile/lib/userLabelStorage.test.mjs create mode 100644 desktop/src/features/profile/lib/userLabelStorage.ts diff --git a/desktop/src/features/communities/useCommunities.tsx b/desktop/src/features/communities/useCommunities.tsx index df019519b..9051552ad 100644 --- a/desktop/src/features/communities/useCommunities.tsx +++ b/desktop/src/features/communities/useCommunities.tsx @@ -17,6 +17,7 @@ import { saveCommunities, } from "./communityStorage"; 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 { clearSavedCommunitySnapshot } from "@/features/agents/activeAgentTurnsStore"; @@ -213,6 +214,7 @@ function useCommunitiesInternal(): UseCommunitiesReturn { const removed = communities.find((w) => w.id === id); if (removed) { removeSelfProfileCachesForRelay(removed.relayUrl); + removeUserLabelCacheForRelay(removed.relayUrl); removeChannelSnapshotForRelay(removed.relayUrl); removeMessageSnapshotsForRelay(removed.relayUrl); clearSavedCommunitySnapshot(id); diff --git a/desktop/src/features/profile/hooks.ts b/desktop/src/features/profile/hooks.ts index 04546804e..7a456fb25 100644 --- a/desktop/src/features/profile/hooks.ts +++ b/desktop/src/features/profile/hooks.ts @@ -5,7 +5,6 @@ import type { } from "@tanstack/react-query"; import * as React from "react"; import { - keepPreviousData, useInfiniteQuery, useMutation, useQuery, @@ -41,6 +40,10 @@ import { shouldFetchAvatar, resolveAvatarDataUrl, } from "@/features/profile/lib/selfProfileStorage"; +import { + resolveUserLabelPlaceholderData, + writeCachedUserLabels, +} from "@/features/profile/lib/userLabelStorage"; import { useCommunities } from "@/features/communities/useCommunities"; import { updateCachedChannelMemberDisplayName } from "@/features/channels/channelMemberProfileCache"; @@ -317,6 +320,8 @@ export function useUsersBatchQuery( }, ) { const queryClient = useQueryClient(); + const { activeCommunity } = useCommunities(); + const relayUrl = activeCommunity?.relayUrl ?? ""; const normalizedPubkeys = [ ...new Set(pubkeys.map((pubkey) => pubkey.toLowerCase())), ] @@ -352,6 +357,9 @@ export function useUsersBatchQuery( } if (toFetch.length > 0) { const fresh = await getUsersBatch(toFetch); + if (relayUrl) { + writeCachedUserLabels(relayUrl, fresh.profiles, fresh.missing); + } for (const pubkey of toFetch) { const summary = fresh.profiles[pubkey] ?? null; queryClient.setQueryData( @@ -367,7 +375,12 @@ export function useUsersBatchQuery( // Loading older messages grows the pubkey set, which changes this query's // key entirely. Without this, already-resolved authors would flash back // to their raw pubkey while the larger batch refetches. - placeholderData: keepPreviousData, + placeholderData: (previousData) => + resolveUserLabelPlaceholderData( + previousData, + relayUrl, + normalizedPubkeys, + ), staleTime: 60_000, gcTime: 5 * 60 * 1_000, }); @@ -375,6 +388,9 @@ export function useUsersBatchQuery( // Seed individual "user-profile" cache entries so avatar clicks are instant // cache hits instead of fresh network requests. React.useEffect(() => { + // Persisted labels are intentionally presentation-only. Wait for a relay + // result before seeding profile-detail caches that also carry ownership. + if (query.dataUpdatedAt === 0) return; const profiles = query.data?.profiles; if (!profiles) return; for (const [pubkey, summary] of Object.entries(profiles)) { @@ -391,7 +407,7 @@ export function useUsersBatchQuery( }, ); } - }, [query.data, queryClient]); + }, [query.data, query.dataUpdatedAt, queryClient]); return query; } diff --git a/desktop/src/features/profile/lib/userLabelStorage.test.mjs b/desktop/src/features/profile/lib/userLabelStorage.test.mjs new file mode 100644 index 000000000..c791b1f73 --- /dev/null +++ b/desktop/src/features/profile/lib/userLabelStorage.test.mjs @@ -0,0 +1,225 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +async function loadSubject() { + try { + return await import("./userLabelStorage.ts"); + } catch { + return {}; + } +} + +function installLocalStorage() { + const values = new Map(); + globalThis.window = { + localStorage: { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + removeItem: (key) => values.delete(key), + key: (index) => [...values.keys()][index] ?? null, + get length() { + return values.size; + }, + }, + }; + globalThis.localStorage = globalThis.window.localStorage; + return values; +} + +test("reads cached labels as safe stale profile summaries", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.readCachedUserLabels, "function"); + installLocalStorage(); + window.localStorage.setItem( + "buzz-user-labels.v1:wss://relay.example", + JSON.stringify({ + version: 1, + updatedAt: 100, + profiles: { + abcdef: { + displayName: "Alice", + name: "alice", + nip05Handle: "alice@example.com", + updatedAt: 100, + }, + }, + }), + ); + + assert.deepEqual( + subject.readCachedUserLabels("WSS://Relay.Example/", ["ABCDEF", "missing"]), + { + profiles: { + abcdef: { + displayName: "Alice", + name: "alice", + avatarUrl: null, + nip05Handle: "alice@example.com", + ownerPubkey: null, + }, + }, + missing: [], + }, + ); +}); + +test("keeps previous full profiles ahead of persisted label placeholders", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.resolveUserLabelPlaceholderData, "function"); + installLocalStorage(); + window.localStorage.setItem( + "buzz-user-labels.v1:wss://relay.example", + JSON.stringify({ + version: 1, + profiles: { + abcdef: { + displayName: "Cached Alice", + name: "alice", + nip05Handle: null, + updatedAt: 100, + }, + }, + }), + ); + const previous = { + profiles: { + abcdef: { + displayName: "Fresh Alice", + name: "alice", + avatarUrl: "https://relay.example/alice.png", + nip05Handle: null, + ownerPubkey: "owner", + }, + }, + missing: [], + }; + + assert.equal( + subject.resolveUserLabelPlaceholderData(previous, "wss://relay.example", [ + "abcdef", + ]), + previous, + ); +}); + +test("writes merge with existing labels and remain bounded", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.writeCachedUserLabels, "function"); + installLocalStorage(); + + subject.writeCachedUserLabels("wss://relay.example", { + existing: { + displayName: "Existing", + name: null, + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, + }); + subject.writeCachedUserLabels( + "wss://relay.example", + Object.fromEntries( + Array.from({ length: 1_005 }, (_, index) => [ + `pubkey-${index}`, + { + displayName: `Person ${index}`, + name: null, + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, + ]), + ), + ); + + const stored = JSON.parse( + window.localStorage.getItem( + subject.userLabelCacheKey("wss://relay.example"), + ), + ); + assert.equal(Object.keys(stored.profiles).length, 1_000); + assert.equal(stored.version, 1); + assert.equal(stored.updatedAt, undefined); +}); + +test("removes a stale label when the fresh profile clears all names", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.writeCachedUserLabels, "function"); + installLocalStorage(); + + subject.writeCachedUserLabels("wss://relay.example", { + abcdef: { + displayName: "Alice", + name: "alice", + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, + }); + subject.writeCachedUserLabels("wss://relay.example", { + abcdef: { + displayName: null, + name: null, + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, + }); + + assert.equal( + subject.readCachedUserLabels("wss://relay.example", ["abcdef"]), + undefined, + ); +}); + +test("removes stale labels for profiles the relay reports missing", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.writeCachedUserLabels, "function"); + installLocalStorage(); + + subject.writeCachedUserLabels("wss://relay.example", { + abcdef: { + displayName: "Alice", + name: "alice", + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, + }); + subject.writeCachedUserLabels("wss://relay.example", {}, ["ABCDEF"]); + + assert.equal( + subject.readCachedUserLabels("wss://relay.example", ["abcdef"]), + undefined, + ); +}); + +test("removes only the selected relay cache", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.removeUserLabelCacheForRelay, "function"); + installLocalStorage(); + const first = subject.userLabelCacheKey("wss://one.example"); + const second = subject.userLabelCacheKey("wss://two.example"); + window.localStorage.setItem(first, "{}"); + window.localStorage.setItem(second, "{}"); + + subject.removeUserLabelCacheForRelay("wss://one.example"); + + assert.equal(window.localStorage.getItem(first), null); + assert.equal(window.localStorage.getItem(second), "{}"); +}); + +test("ignores malformed cache payloads", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.readCachedUserLabels, "function"); + installLocalStorage(); + window.localStorage.setItem( + "buzz-user-labels.v1:wss://relay.example", + JSON.stringify({ version: 1, profiles: { abc: { displayName: 42 } } }), + ); + + assert.equal( + subject.readCachedUserLabels("wss://relay.example", ["abc"]), + undefined, + ); +}); diff --git a/desktop/src/features/profile/lib/userLabelStorage.ts b/desktop/src/features/profile/lib/userLabelStorage.ts new file mode 100644 index 000000000..b28c17da9 --- /dev/null +++ b/desktop/src/features/profile/lib/userLabelStorage.ts @@ -0,0 +1,176 @@ +import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; +import type { + UserProfileSummary, + UsersBatchResponse, +} from "@/shared/api/types"; +import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; + +const STORAGE_KEY_PREFIX = "buzz-user-labels.v1"; +const MAX_CACHED_LABELS = 1_000; + +type CachedUserLabel = { + displayName: string | null; + name: string | null; + nip05Handle: string | null; + updatedAt: number; +}; + +type UserLabelCache = { + version: 1; + profiles: Record; +}; + +export function userLabelCacheKey(relayUrl: string): string { + return `${STORAGE_KEY_PREFIX}:${normalizeRelayUrl(relayUrl)}`; +} + +function nullableString(value: unknown): string | null | undefined { + if (value === null || value === undefined) return null; + return typeof value === "string" ? value : undefined; +} + +function parseCachedUserLabel(value: unknown): CachedUserLabel | null { + if (typeof value !== "object" || value === null) return null; + const raw = value as Record; + const displayName = nullableString(raw.displayName); + const name = nullableString(raw.name); + const nip05Handle = nullableString(raw.nip05Handle); + if ( + displayName === undefined || + name === undefined || + nip05Handle === undefined + ) { + return null; + } + if (![displayName, name, nip05Handle].some((label) => label?.trim())) { + return null; + } + return { + displayName, + name, + nip05Handle, + updatedAt: + typeof raw.updatedAt === "number" && Number.isFinite(raw.updatedAt) + ? raw.updatedAt + : 0, + }; +} + +function readCache(relayUrl: string): UserLabelCache | null { + try { + const raw = window.localStorage.getItem(userLabelCacheKey(relayUrl)); + if (!raw) return null; + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== "object" || parsed === null) return null; + const payload = parsed as Record; + if ( + payload.version !== 1 || + typeof payload.profiles !== "object" || + payload.profiles === null + ) { + return null; + } + + const profiles: Record = {}; + for (const [pubkey, value] of Object.entries( + payload.profiles as Record, + )) { + const label = parseCachedUserLabel(value); + if (label) profiles[pubkey.toLowerCase()] = label; + } + return { + version: 1, + profiles, + }; + } catch { + return null; + } +} + +export function readCachedUserLabels( + relayUrl: string, + pubkeys: string[], +): UsersBatchResponse | undefined { + const cache = readCache(relayUrl); + if (!cache) return undefined; + + const profiles: UsersBatchResponse["profiles"] = {}; + for (const pubkey of pubkeys) { + const normalizedPubkey = pubkey.toLowerCase(); + const cached = cache.profiles[normalizedPubkey]; + if (!cached) continue; + profiles[normalizedPubkey] = { + displayName: cached.displayName, + name: cached.name, + avatarUrl: null, + nip05Handle: cached.nip05Handle, + ownerPubkey: null, + }; + } + + return Object.keys(profiles).length > 0 + ? { profiles, missing: [] } + : undefined; +} + +export function resolveUserLabelPlaceholderData( + previousData: UsersBatchResponse | undefined, + relayUrl: string, + pubkeys: string[], +): UsersBatchResponse | undefined { + return ( + previousData ?? + (relayUrl ? readCachedUserLabels(relayUrl, pubkeys) : undefined) + ); +} + +export function writeCachedUserLabels( + relayUrl: string, + profiles: Record, + missing: string[] = [], +): void { + try { + const now = Date.now(); + const merged = { ...(readCache(relayUrl)?.profiles ?? {}) }; + for (const [pubkey, profile] of Object.entries(profiles)) { + const label = parseCachedUserLabel({ + displayName: profile.displayName, + name: profile.name, + nip05Handle: profile.nip05Handle, + updatedAt: now, + }); + const normalizedPubkey = pubkey.toLowerCase(); + if (label) { + merged[normalizedPubkey] = label; + } else { + delete merged[normalizedPubkey]; + } + } + for (const pubkey of missing) { + delete merged[pubkey.toLowerCase()]; + } + + const boundedProfiles = Object.fromEntries( + Object.entries(merged) + .sort(([, left], [, right]) => right.updatedAt - left.updatedAt) + .slice(0, MAX_CACHED_LABELS), + ); + setLocalStorageItemWithRecovery( + userLabelCacheKey(relayUrl), + JSON.stringify({ + version: 1, + profiles: boundedProfiles, + } satisfies UserLabelCache), + ); + } catch { + // Storage access failures are non-fatal. + } +} + +export function removeUserLabelCacheForRelay(relayUrl: string): void { + try { + window.localStorage.removeItem(userLabelCacheKey(relayUrl)); + } catch { + // Storage access failures are non-fatal. + } +} diff --git a/desktop/src/shared/lib/localStorageQuota.test.mjs b/desktop/src/shared/lib/localStorageQuota.test.mjs index 64e351113..192944807 100644 --- a/desktop/src/shared/lib/localStorageQuota.test.mjs +++ b/desktop/src/shared/lib/localStorageQuota.test.mjs @@ -34,12 +34,13 @@ function install(ls) { } test("startup recovery removes disposable caches but preserves user state", () => { - const ls = makeQuotaLocalStorage({ maxEntries: 5 }); + const ls = makeQuotaLocalStorage({ maxEntries: 6 }); install(ls); ls.store.set("buzz-channel-messages.v1:relay:chan", "big"); ls.store.set("buzz-channels.v1:relay", "big"); ls.store.set("buzz-timeline-skeleton-shape.v1:chan", "small"); ls.store.set("buzz-sidebar-skeleton-shape.v1:community:user", "small"); + ls.store.set("buzz-user-labels.v1:relay", "small"); ls.store.set("buzz-communities", "keep"); recoverLocalStorageQuotaOnStartup(); @@ -51,6 +52,7 @@ test("startup recovery removes disposable caches but preserves user state", () = ls.getItem("buzz-sidebar-skeleton-shape.v1:community:user"), null, ); + assert.equal(ls.getItem("buzz-user-labels.v1:relay"), null); assert.equal(ls.getItem("buzz-communities"), "keep"); assert.equal(ls.getItem("buzz-local-storage-quota-recovery.v1"), "1"); }); diff --git a/desktop/src/shared/lib/localStorageQuota.ts b/desktop/src/shared/lib/localStorageQuota.ts index 538003ef6..e05307a26 100644 --- a/desktop/src/shared/lib/localStorageQuota.ts +++ b/desktop/src/shared/lib/localStorageQuota.ts @@ -12,6 +12,7 @@ const PURE_CACHE_KEY_PREFIXES = [ "buzz-channels.v1:", "buzz-sidebar-skeleton-shape.v1:", "buzz-timeline-skeleton-shape.v1:", + "buzz-user-labels.v1:", ]; const QUOTA_RECOVERY_MARKER_KEY = "buzz-local-storage-quota-recovery.v1"; diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index e84a61306..868763cf5 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -16,6 +16,7 @@ import { const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; const AGENTS_CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8); +const CACHED_PROFILE_LABELS_TAG = "@cached-profile-labels"; // Relay-only agent owned by the mock viewer (see e2eBridge.ts // OWNED_RELAY_AGENT_PUBKEY). Classified as a bot via mockRelayAgents and // owned-by-viewer via its mockProfiles owner_pubkey, so the sidebar @@ -487,8 +488,13 @@ async function expectIntroActionsShareRow( } } -test.beforeEach(async ({ page }) => { - await installMockBridge(page); +test.beforeEach(async ({ page }, testInfo) => { + await installMockBridge( + page, + testInfo.tags.includes(CACHED_PROFILE_LABELS_TAG) + ? { usersBatchDelayMs: 10_000 } + : undefined, + ); }); test("sidebar shows all channel types", async ({ page }) => { @@ -515,6 +521,41 @@ test("sidebar shows all channel types", async ({ page }) => { await expect(dmList).toContainText("bob-tyler"); }); +test("shows cached profile labels while relay profiles revalidate", { + tag: CACHED_PROFILE_LABELS_TAG, +}, async ({ page }) => { + await page.addInitScript( + ({ alicePubkey }) => { + window.localStorage.setItem( + "buzz-user-labels.v1:ws://localhost:3000", + JSON.stringify({ + version: 1, + updatedAt: Date.now(), + profiles: { + [alicePubkey]: { + displayName: "Cached Alice", + name: "alice", + nip05Handle: null, + }, + }, + }), + ); + }, + { alicePubkey: TEST_IDENTITIES.alice.pubkey }, + ); + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + + const aliceMessage = page + .getByTestId("message-row") + .filter({ hasText: "Hey team — checking in." }); + await expect(aliceMessage.getByTestId("message-author")).toHaveText( + "Cached Alice", + { timeout: 1_000 }, + ); +}); + test("shows presence in sidebar, DM header, and member list", async ({ page, }) => {