Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions desktop/src/features/profile/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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<UsersBatchEntry>(
Expand Down
86 changes: 86 additions & 0 deletions desktop/src/features/profile/usersBatchCacheSync.test.mjs
Original file line number Diff line number Diff line change
@@ -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],
});
});
69 changes: 69 additions & 0 deletions desktop/src/features/profile/usersBatchCacheSync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { QueryClient } from "@tanstack/react-query";

import type { UsersBatchResponse } from "@/shared/api/types";

function normalizedPubkeySet(values: string[]): Set<string> {
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<UsersBatchResponse>(
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 },
);
}
}