diff --git a/desktop/src/features/profile/hooks.ts b/desktop/src/features/profile/hooks.ts index 7a456fb259..ff7ee2edfe 100644 --- a/desktop/src/features/profile/hooks.ts +++ b/desktop/src/features/profile/hooks.ts @@ -46,6 +46,7 @@ import { } from "@/features/profile/lib/userLabelStorage"; import { useCommunities } from "@/features/communities/useCommunities"; import { updateCachedChannelMemberDisplayName } from "@/features/channels/channelMemberProfileCache"; +import { syncUsersBatchQueryCaches } from "@/features/profile/usersBatchCacheSync"; export const profileQueryKey = ["profile"] as const; export const contactListQueryKey = (pubkey: string) => @@ -360,6 +361,7 @@ export function useUsersBatchQuery( if (relayUrl) { writeCachedUserLabels(relayUrl, fresh.profiles, fresh.missing); } + syncUsersBatchQueryCaches(queryClient, fresh); for (const pubkey of toFetch) { const summary = fresh.profiles[pubkey] ?? null; queryClient.setQueryData( diff --git a/desktop/src/features/profile/usersBatchCacheSync.test.mjs b/desktop/src/features/profile/usersBatchCacheSync.test.mjs new file mode 100644 index 0000000000..eefe8d99fc --- /dev/null +++ b/desktop/src/features/profile/usersBatchCacheSync.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { QueryClient } from "@tanstack/react-query"; + +async function loadSubject() { + try { + return await import("./usersBatchCacheSync.ts"); + } catch { + return {}; + } +} + +const ALICE = "alice-pubkey"; +const BOB = "bob-pubkey"; + +function profile(displayName, avatarUrl) { + return { + displayName, + name: null, + avatarUrl, + nip05Handle: null, + ownerPubkey: null, + }; +} + +test("fresh profile results update every overlapping users-batch query", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.syncUsersBatchQueryCaches, "function"); + + const queryClient = new QueryClient(); + const originalUpdatedAt = 1_234; + queryClient.setQueryData( + ["users-batch", ALICE, BOB], + { + profiles: {}, + missing: [ALICE, BOB], + }, + { updatedAt: originalUpdatedAt }, + ); + queryClient.setQueryData(["users-batch", BOB], { + profiles: { [BOB]: profile("Bob", null) }, + missing: [], + }); + + subject.syncUsersBatchQueryCaches(queryClient, { + profiles: { [ALICE]: profile("Alice", "https://cdn.example/alice.png") }, + missing: [], + }); + + assert.deepEqual(queryClient.getQueryData(["users-batch", ALICE, BOB]), { + profiles: { + [ALICE]: profile("Alice", "https://cdn.example/alice.png"), + }, + missing: [BOB], + }); + assert.deepEqual(queryClient.getQueryData(["users-batch", BOB]), { + profiles: { [BOB]: profile("Bob", null) }, + missing: [], + }); + assert.equal( + queryClient.getQueryState(["users-batch", ALICE, BOB]).dataUpdatedAt, + originalUpdatedAt, + ); +}); + +test("fresh missing results remove stale profiles from overlapping queries", async () => { + const subject = await loadSubject(); + assert.equal(typeof subject.syncUsersBatchQueryCaches, "function"); + + const queryClient = new QueryClient(); + queryClient.setQueryData(["users-batch", ALICE], { + profiles: { [ALICE]: profile("Old Alice", null) }, + missing: [], + }); + + subject.syncUsersBatchQueryCaches(queryClient, { + profiles: {}, + missing: [ALICE.toUpperCase()], + }); + + assert.deepEqual(queryClient.getQueryData(["users-batch", ALICE]), { + profiles: {}, + missing: [ALICE], + }); +}); diff --git a/desktop/src/features/profile/usersBatchCacheSync.ts b/desktop/src/features/profile/usersBatchCacheSync.ts new file mode 100644 index 0000000000..4be27ec11f --- /dev/null +++ b/desktop/src/features/profile/usersBatchCacheSync.ts @@ -0,0 +1,69 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import type { UsersBatchResponse } from "@/shared/api/types"; + +function normalizedPubkeySet(values: string[]): Set { + return new Set(values.map((value) => value.toLowerCase())); +} + +/** Keep long-lived aggregate profile queries in sync when another batch key + * refreshes one of the same people. */ +export function syncUsersBatchQueryCaches( + queryClient: QueryClient, + fresh: UsersBatchResponse, +): void { + const freshProfiles = Object.fromEntries( + Object.entries(fresh.profiles).map(([pubkey, profile]) => [ + pubkey.toLowerCase(), + profile, + ]), + ); + const freshMissing = normalizedPubkeySet(fresh.missing); + const refreshedPubkeys = new Set([ + ...Object.keys(freshProfiles), + ...freshMissing, + ]); + if (refreshedPubkeys.size === 0) return; + + const overlappingQueries = queryClient.getQueryCache().findAll({ + predicate: (query) => + query.queryKey[0] === "users-batch" && + query.queryKey.some( + (part) => + typeof part === "string" && refreshedPubkeys.has(part.toLowerCase()), + ), + }); + + for (const query of overlappingQueries) { + const queryPubkeys = normalizedPubkeySet( + query.queryKey.filter( + (part, index): part is string => index > 0 && typeof part === "string", + ), + ); + queryClient.setQueryData( + query.queryKey, + (current) => { + if (!current) return current; + + const profiles = { ...current.profiles }; + const missing = normalizedPubkeySet(current.missing); + for (const [pubkey, profile] of Object.entries(freshProfiles)) { + if (!queryPubkeys.has(pubkey)) continue; + profiles[pubkey] = profile; + missing.delete(pubkey); + } + for (const pubkey of freshMissing) { + if (!queryPubkeys.has(pubkey)) continue; + delete profiles[pubkey]; + missing.add(pubkey); + } + + return { profiles, missing: [...missing] }; + }, + // This is a partial cache merge, not a successful refresh of every + // pubkey in the aggregate query. Preserve its freshness timestamp so + // another participant's result cannot postpone stale entries fetching. + { updatedAt: query.state.dataUpdatedAt }, + ); + } +}