Skip to content

Commit 37e4aa6

Browse files
spe1020spe1020claude
authored
fix(profile): correct anon fallback in feeds, comments, notifications (#368)
Three bugs converged to make valid profiles render as Anon Chef across feeds, comments, AuthorName, and ProfileLink — even though the same profiles loaded fine elsewhere on Nostr. All three are fixed in this PR. 1) getUsername now checks display_name before name. Mirrors the priority order getDisplayName already uses. Previously getUsername (and formatDisplayName, which wraps it) ignored display_name entirely and fell straight to anon for profiles that set only display_name. This affected most rendering paths in the app since formatDisplayName is the default helper used by feed, comments, AuthorName, and ProfileLink. Notifications were unaffected because they call getDisplayName directly. 2) AuthorName drops its 3s outer Promise.race timeout. The inner resolveProfileByPubkey + fetchProfileFromRelays already cap the fetch at 5s. The 3s outer race fired before the underlying 5s could complete on slow relays — the profile arrived in the cache afterward, but AuthorName had already rendered the anon fallback and uses onMount, not reactive, so it never re-read. User refresh = re-mount = cache hit = correct render. That's the symptom pattern reported. 3) fetchProfileFromRelays now uses an explicit relay set unioning the connected NDK pool with canonical profile relays (purplepag.es, relay.nostr.band, nostr.wine). NDK's user.fetchProfile() doesn't let us override the relay set; we drop down to fetchEvent + manual JSON parse to get the wider net. Profiles that live only on purplepag.es or relays not in our default pool are now reachable. Plus we now read displayName (camelCase) in addition to display_name (snake_case) since some clients write only the camelCase form. Test plan: - Profile with display_name only renders correctly in feed, comments, AuthorName, ProfileLink (Bug 1). - Profile that takes 4s to resolve no longer flashes anon and stick (Bug 2). Cache eventually warms and second-mount components show the right name without a refresh. - Profile that lives only on purplepag.es renders without needing the user to manually add the relay (Bug 3). Validation: - pnpm test 77 / 77 passing - pnpm check 0 errors - pnpm build clean Out of scope: - Reactive re-read when cache populates after the component mounts. Would let "anon → real name" upgrade live without re-mount, but needs a Svelte store-backed cache. Tracked as a follow-up; the cache + faster lookups land in this PR cover most cases already. Co-authored-by: spe1020 <sethsager@Seths-MacBook-Air.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5fe45bc commit 37e4aa6

3 files changed

Lines changed: 97 additions & 29 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "zap.cooking",
33
"license": "MIT",
4-
"version": "4.2.340",
4+
"version": "4.2.341",
55
"private": true,
66
"scripts": {
77
"dev": "vite dev",

src/components/AuthorName.svelte

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,14 @@
5454
return;
5555
}
5656
57-
// Simple profile fetch with timeout
58-
const profile = await Promise.race([
59-
resolveProfileByPubkey(pubkey, ndkInstance),
60-
new Promise<null>((r) => setTimeout(() => r(null), 3000))
61-
]);
57+
// No outer timeout — `resolveProfileByPubkey` (and the
58+
// `fetchProfileFromRelays` it calls) already cap the fetch at
59+
// 5s and return null on timeout. The previous 3s outer race
60+
// here fired *before* the underlying 5s could complete, so
61+
// slow-but-eventual profile fetches landed in the cache while
62+
// this component had already rendered the anon fallback —
63+
// requiring a refresh to pick up the now-cached profile.
64+
const profile = await resolveProfileByPubkey(pubkey, ndkInstance);
6265
6366
if (profile) {
6467
// formatDisplayName already returns a stable anon-chef name when

src/lib/profileResolver.ts

Lines changed: 88 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,21 @@ export function decodeNostrProfile(nostrString: string): string | null {
7979
// Profile fetch timeout
8080
const PROFILE_FETCH_TIMEOUT = 5000; // 5 seconds
8181

82+
/**
83+
* Relays we'll always consult for kind:0 lookups, in addition to NDK's
84+
* default pool. Purplepag.es is the de-facto Nostr profile relay —
85+
* many users publish kind:0 there (or it's the only relay their
86+
* NIP-65 client targeted). Without including it, profiles that don't
87+
* happen to live on garden / nos.lol / damus / primal silently 404
88+
* and render as the anon-chef fallback. nostr.band + nostr.wine are
89+
* common secondary mirrors.
90+
*/
91+
const PROFILE_RELAY_URLS = [
92+
'wss://purplepag.es',
93+
'wss://relay.nostr.band',
94+
'wss://nostr.wine'
95+
];
96+
8297
// Fetch profile data from relays
8398
async function fetchProfileFromRelays(pubkey: string, ndkInstance: NDK): Promise<ProfileData | null> {
8499
try {
@@ -90,34 +105,74 @@ async function fetchProfileFromRelays(pubkey: string, ndkInstance: NDK): Promise
90105
return null;
91106
}
92107

93-
// Use NDK's built-in fetchProfile method
94-
// It handles relay selection via outbox model when enabled
95-
const user = ndkInstance.getUser({ hexpubkey: pubkey });
96-
97-
// Don't clear cached profile - let NDK use its cache
98-
// This prevents unnecessary network requests
99-
100-
// Add timeout to prevent hanging
101-
const fetchPromise = user.fetchProfile();
102-
const timeoutPromise = new Promise<null>((resolve) =>
108+
// Build an explicit relay set: NDK's connected pool relays
109+
// (whatever the page already has open — garden, nos.lol, damus,
110+
// primal in default mode) plus the canonical profile relays.
111+
// NDK's `user.fetchProfile()` doesn't let us widen the relay set,
112+
// so we drop down to fetchEvent + parse the kind:0 manually.
113+
const { NDKRelaySet } = await import('@nostr-dev-kit/ndk');
114+
const relayUrls = new Set<string>();
115+
if (ndkInstance.pool?.relays) {
116+
for (const [url] of ndkInstance.pool.relays) relayUrls.add(url);
117+
}
118+
for (const url of PROFILE_RELAY_URLS) relayUrls.add(url);
119+
120+
const relays = [];
121+
for (const url of relayUrls) {
122+
// autoConnect=true, createIfMissing=true — purplepag.es etc.
123+
// may not be in the pool yet; this opens a connection in the
124+
// background. fetchEvent will queue against not-yet-ready
125+
// relays and resolve when any of them returns.
126+
const relay = ndkInstance.pool?.getRelay(url, true, true);
127+
if (relay) relays.push(relay);
128+
}
129+
const relaySet = relays.length > 0 ? new NDKRelaySet(new Set(relays), ndkInstance) : undefined;
130+
131+
const fetchPromise = ndkInstance.fetchEvent(
132+
{ kinds: [0], authors: [pubkey] },
133+
undefined,
134+
relaySet
135+
);
136+
const timeoutPromise = new Promise<null>((resolve) =>
103137
setTimeout(() => resolve(null), PROFILE_FETCH_TIMEOUT)
104138
);
105-
106-
await Promise.race([fetchPromise, timeoutPromise]);
107-
108-
const profile = user.profile;
109-
if (!profile) {
139+
140+
const event = await Promise.race([fetchPromise, timeoutPromise]);
141+
if (!event) {
142+
return null;
143+
}
144+
145+
let parsed: Record<string, unknown> = {};
146+
try {
147+
parsed = JSON.parse(event.content || '{}');
148+
} catch {
149+
// Malformed kind:0 content — return null rather than a
150+
// half-populated profile that would obscure the user's identity.
110151
return null;
111152
}
112153

113-
const profileData = {
154+
// Nostr profile field naming has historically varied: NIP-01 says
155+
// `name`, but many clients also (or only) populate `display_name`,
156+
// and a few use camelCase `displayName`. Read all three so we
157+
// surface the user's identity regardless of which client wrote
158+
// their kind:0.
159+
const name =
160+
typeof parsed.name === 'string' ? parsed.name : undefined;
161+
const displayName =
162+
typeof parsed.display_name === 'string'
163+
? (parsed.display_name as string)
164+
: typeof parsed.displayName === 'string'
165+
? (parsed.displayName as string)
166+
: undefined;
167+
168+
const profileData: ProfileData = {
114169
pubkey,
115-
name: profile.name,
116-
display_name: profile.displayName,
117-
picture: profile.image,
118-
about: profile.bio,
119-
nip05: profile.nip05,
120-
lud16: profile.lud16,
170+
name,
171+
display_name: displayName,
172+
picture: typeof parsed.picture === 'string' ? parsed.picture : undefined,
173+
about: typeof parsed.about === 'string' ? parsed.about : undefined,
174+
nip05: typeof parsed.nip05 === 'string' ? parsed.nip05 : undefined,
175+
lud16: typeof parsed.lud16 === 'string' ? parsed.lud16 : undefined,
121176
lastFetched: Date.now()
122177
};
123178

@@ -229,12 +284,22 @@ export function getDisplayName(profile: ProfileData | null): string {
229284
}
230285

231286
// Get username for a profile (without @ prefix). Same fallback policy
232-
// as getDisplayName.
287+
// as getDisplayName: display_name → name → anon. Previously this only
288+
// checked `name`, which silently fell back to the anon helper for
289+
// profiles that set only `display_name` (a common shape on Nostr —
290+
// "display_name" is the human-readable identity, "name" is the optional
291+
// short handle). formatDisplayName, AuthorName, ProfileLink, and the
292+
// feed all flow through here, so the fix lights up display_name-only
293+
// profiles everywhere at once.
233294
export function getUsername(profile: ProfileData | null): string {
234295
if (!profile) {
235296
return getAnonChefName(null);
236297
}
237298

299+
if (profile.display_name) {
300+
return profile.display_name;
301+
}
302+
238303
if (profile.name) {
239304
return profile.name;
240305
}

0 commit comments

Comments
 (0)