The card showed the placeholder (now initials) even when the npub had a photo: useAuthor missed profiles that only live on the community relay and could drop a profile its strict schema rejected. Card pages now use a tolerant useCardProfile() that reads kind 0 via querySkateContent (community + user relays) and JSON.parses leniently. Also: useAuthor now queries the community relay as its own parallel branch (a flaky relay in the group used to reject the whole batch); and '_@domain' NIP-05 no longer renders a lone '@'. Verified by rendering a real profile's card headlessly.
83 lines
2.9 KiB
TypeScript
83 lines
2.9 KiB
TypeScript
import { type NostrEvent, type NostrMetadata, NSchema as n } from '@nostrify/nostrify';
|
|
import { useNostr } from '@nostrify/react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { SKATE_RELAY } from '@/lib/types';
|
|
|
|
// Fallback relays for profile aggregation — global indexers.
|
|
const FALLBACK_RELAYS = [
|
|
'wss://purplepag.es',
|
|
'wss://relay.nostr.band',
|
|
'wss://nos.lol',
|
|
'wss://relay.damus.io',
|
|
];
|
|
|
|
export function useAuthor(pubkey: string | undefined) {
|
|
const { nostr } = useNostr();
|
|
|
|
return useQuery<{ event?: NostrEvent; metadata?: NostrMetadata }>({
|
|
queryKey: ['author', pubkey ?? ''],
|
|
queryFn: async ({ signal }) => {
|
|
if (!pubkey) {
|
|
return {};
|
|
}
|
|
|
|
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(15000)]);
|
|
|
|
try {
|
|
// Query the main pool, the global aggregators, AND the app's own
|
|
// community relay — each INDEPENDENTLY. They must not be in one group:
|
|
// a single flaky relay there would reject the whole group and drop a
|
|
// profile that only lives on the community relay (the "generated name /
|
|
// missing avatar" bug).
|
|
const results = await Promise.allSettled([
|
|
nostr.query(
|
|
[{ kinds: [0], authors: [pubkey], limit: 1 }],
|
|
{ signal: querySignal },
|
|
),
|
|
(async () => {
|
|
const fallbackGroup = nostr.group(FALLBACK_RELAYS);
|
|
return fallbackGroup.query(
|
|
[{ kinds: [0], authors: [pubkey], limit: 1 }],
|
|
{ signal: AbortSignal.timeout(8000) },
|
|
);
|
|
})(),
|
|
nostr.relay(SKATE_RELAY).query(
|
|
[{ kinds: [0], authors: [pubkey], limit: 1 }],
|
|
{ signal: AbortSignal.timeout(8000) },
|
|
),
|
|
]);
|
|
|
|
// Collect every kind 0 event we found and pick the most recent one.
|
|
const candidates: NostrEvent[] = [];
|
|
for (const r of results) {
|
|
if (r.status === 'fulfilled' && Array.isArray(r.value)) {
|
|
candidates.push(...r.value);
|
|
}
|
|
}
|
|
|
|
const event = candidates
|
|
.filter((e) => e.kind === 0 && e.pubkey === pubkey)
|
|
.sort((a, b) => b.created_at - a.created_at)[0];
|
|
|
|
if (!event) {
|
|
// No profile found - this is okay, user may not have published one yet
|
|
return {};
|
|
}
|
|
|
|
try {
|
|
const metadata = n.json().pipe(n.metadata()).parse(event.content);
|
|
return { metadata, event };
|
|
} catch {
|
|
return { event };
|
|
}
|
|
} catch (error) {
|
|
// Network error or timeout - return empty to avoid breaking the UI
|
|
console.warn('[useAuthor] Failed to fetch profile for', pubkey?.slice(0, 8), error);
|
|
return {};
|
|
}
|
|
},
|
|
staleTime: 60 * 1000, // 1 minute — allows NostrSync to invalidate and refetch quickly
|
|
retry: 5,
|
|
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
|
|
});
|
|
}
|