diff --git a/desktop/src/features/communities/useCommunities.tsx b/desktop/src/features/communities/useCommunities.tsx index df019519b6..9051552ad1 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 04546804eb..2cfcce2eb2 100644 --- a/desktop/src/features/profile/hooks.ts +++ b/desktop/src/features/profile/hooks.ts @@ -41,6 +41,10 @@ import { shouldFetchAvatar, resolveAvatarDataUrl, } from "@/features/profile/lib/selfProfileStorage"; +import { + readCachedUserLabels, + writeCachedUserLabels, +} from "@/features/profile/lib/userLabelStorage"; import { useCommunities } from "@/features/communities/useCommunities"; import { updateCachedChannelMemberDisplayName } from "@/features/channels/channelMemberProfileCache"; @@ -317,6 +321,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 +358,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( @@ -368,6 +377,10 @@ export function useUsersBatchQuery( // key entirely. Without this, already-resolved authors would flash back // to their raw pubkey while the larger batch refetches. placeholderData: keepPreviousData, + initialData: relayUrl + ? () => readCachedUserLabels(relayUrl, normalizedPubkeys) + : undefined, + initialDataUpdatedAt: 0, 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 0000000000..e169595ba0 --- /dev/null +++ b/desktop/src/features/profile/lib/userLabelStorage.test.mjs @@ -0,0 +1,185 @@ +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("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); +}); + +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 0000000000..b6e579c745 --- /dev/null +++ b/desktop/src/features/profile/lib/userLabelStorage.ts @@ -0,0 +1,172 @@ +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; + updatedAt: number; + 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, + updatedAt: + typeof payload.updatedAt === "number" && + Number.isFinite(payload.updatedAt) + ? payload.updatedAt + : 0, + 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 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, + updatedAt: now, + 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 64e351113a..1929448079 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 538003ef6f..e05307a268 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 07adada09f..fefdd261bf 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -487,8 +487,13 @@ async function expectIntroActionsShareRow( } } -test.beforeEach(async ({ page }) => { - await installMockBridge(page); +test.beforeEach(async ({ page }, testInfo) => { + await installMockBridge( + page, + testInfo.title.includes("cached profile labels") + ? { usersBatchDelayMs: 10_000 } + : undefined, + ); }); test("sidebar shows all channel types", async ({ page }) => { @@ -515,6 +520,41 @@ test("sidebar shows all channel types", async ({ page }) => { await expect(dmList).toContainText("bob-tyler"); }); +test("shows cached profile labels while relay profiles revalidate", 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, }) => {