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/communities/useCommunities.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
18 changes: 17 additions & 1 deletion desktop/src/features/profile/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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())),
]
Expand Down Expand Up @@ -352,6 +358,9 @@ export function useUsersBatchQuery(
}
if (toFetch.length > 0) {
const fresh = await getUsersBatch(toFetch);
if (relayUrl) {
writeCachedUserLabels(relayUrl, fresh.profiles, fresh.missing);
}
Comment on lines +361 to +363

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 nit: this writes labels under the relayUrl captured at render, so a fetch resolving across a community switch could in theory file profiles under the old relay's cache key. In practice the communityKey remount tears the query down first, so I think this is theoretical — just noting it.

for (const pubkey of toFetch) {
const summary = fresh.profiles[pubkey] ?? null;
queryClient.setQueryData<UsersBatchEntry>(
Expand All @@ -368,13 +377,20 @@ 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,
Comment on lines +380 to +383

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 I think initialData here silently defeats the keepPreviousData right above it. In TanStack Query, placeholderData only applies when a query has no data — and once initialData seeds the new query, it counts as real data. So in the exact case the comment above describes (scroll-back grows the pubkey set → brand-new query key), any label-cache hit means the new query starts from label stubs (avatarUrl: null) instead of the previous batch's full profiles. Two symptoms from that one cause:

  1. Message avatars blank on scroll-back until the fetch completes — a single frame when the per-pubkey entry cache is fresh, but a full network round-trip when entries are >60s old. That partially reintroduces the flash Fix display names flashing to pubkeys when loading older messages #1137 fixed, in avatar form.
  2. ChannelScreen gates DM huddle-member resolution on isPending || isPlaceholderData; with initialData the query is instantly success and not placeholder, so the gate opens while only label stubs are loaded. Fail direction is benign (ownership absent, never wrongly granted), but the gate's intent is defeated.

Suggested fix — deliver the cache through the placeholder chain instead:

placeholderData: (prev) => prev ?? readCachedUserLabels(relayUrl, normalizedPubkeys),

and drop initialData/initialDataUpdatedAt. That preserves keepPreviousData semantics (previous full profiles win over label stubs), keeps isPlaceholderData true so the ChannelScreen gate behaves as before, still triggers the revalidating fetch, and the dataUpdatedAt === 0 seeding guard below still holds since placeholder data never advances dataUpdatedAt. Your new e2e test should pass unchanged. Bonus: the localStorage read+parse gets skipped whenever previous data exists instead of running on every scroll-back key growth.

staleTime: 60_000,
gcTime: 5 * 60 * 1_000,
});

// 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)) {
Expand All @@ -391,7 +407,7 @@ export function useUsersBatchQuery(
},
);
}
}, [query.data, queryClient]);
}, [query.data, query.dataUpdatedAt, queryClient]);

return query;
}
Expand Down
185 changes: 185 additions & 0 deletions desktop/src/features/profile/lib/userLabelStorage.test.mjs
Original file line number Diff line number Diff line change
@@ -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: "[email protected]",
updatedAt: 100,
},
},
}),
);

assert.deepEqual(
subject.readCachedUserLabels("WSS://Relay.Example/", ["ABCDEF", "missing"]),
{
profiles: {
abcdef: {
displayName: "Alice",
name: "alice",
avatarUrl: null,
nip05Handle: "[email protected]",
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,
);
});
Loading
Loading