SkateSpots/src/hooks/useAuthor.ts
SkateSpots 0dd7e9bfa9 fix(card,profile): resolve the card's profile via the app's own relays
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.
2026-10-02 12:18:49 +07:00

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),
});
}