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.
This commit is contained in:
SkateSpots 2026-10-02 12:18:49 +07:00
parent 9dc7e7429d
commit 0dd7e9bfa9
5 changed files with 75 additions and 19 deletions

View file

@ -1,8 +1,9 @@
import { type NostrEvent, type NostrMetadata, NSchema as n } from '@nostrify/nostrify'; import { type NostrEvent, type NostrMetadata, NSchema as n } from '@nostrify/nostrify';
import { useNostr } from '@nostrify/react'; import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { SKATE_RELAY } from '@/lib/types';
// Fallback relays for profile aggregation — global indexers // Fallback relays for profile aggregation — global indexers.
const FALLBACK_RELAYS = [ const FALLBACK_RELAYS = [
'wss://purplepag.es', 'wss://purplepag.es',
'wss://relay.nostr.band', 'wss://relay.nostr.band',
@ -23,12 +24,11 @@ export function useAuthor(pubkey: string | undefined) {
const querySignal = AbortSignal.any([signal, AbortSignal.timeout(15000)]); const querySignal = AbortSignal.any([signal, AbortSignal.timeout(15000)]);
try { try {
// Query the main pool AND the global profile aggregators IN PARALLEL, // Query the main pool, the global aggregators, AND the app's own
// then keep the NEWEST kind 0 across all of them. Doing both at once // community relay — each INDEPENDENTLY. They must not be in one group:
// (rather than only falling back when the main pool is empty) means a // a single flaky relay there would reject the whole group and drop a
// followed user's real name resolves even when their profile lives on a // profile that only lives on the community relay (the "generated name /
// big indexer but not on the app's curated skate relay — the previous // missing avatar" bug).
// "generic npub name" bug.
const results = await Promise.allSettled([ const results = await Promise.allSettled([
nostr.query( nostr.query(
[{ kinds: [0], authors: [pubkey], limit: 1 }], [{ kinds: [0], authors: [pubkey], limit: 1 }],
@ -41,6 +41,10 @@ export function useAuthor(pubkey: string | undefined) {
{ signal: AbortSignal.timeout(8000) }, { 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. // Collect every kind 0 event we found and pick the most recent one.

View file

@ -6,6 +6,7 @@
*/ */
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react'; import { useNostr } from '@nostrify/react';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
import { import {
RIDER_CARD_KIND, RIDER_CARD_KIND,
@ -103,3 +104,47 @@ export function useRiderRoles(pubkey: string | undefined) {
}, },
}); });
} }
export interface CardProfile {
name?: string;
picture?: string;
nip05?: string;
created_at?: number;
}
/**
* The profile fields a card needs (name/avatar/nip05), fetched from the app's
* OWN relays via `querySkateContent` with a tolerant JSON parse. `useAuthor`
* misses profiles that only live on the community relay and can drop a profile
* its strict schema doesn't like — so the card uses this instead.
*/
export function useCardProfile(pubkey: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery<CardProfile>({
queryKey: ['card-profile', pubkey, getQueryKeySuffix()],
enabled: !!pubkey,
staleTime: 5 * 60_000,
queryFn: async (c) => {
if (!pubkey) return {};
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(9000)]);
const events = await querySkateContent(
[{ kinds: [0], authors: [pubkey], limit: 1 }],
{ signal },
);
const ev = [...events].sort((a, b) => b.created_at - a.created_at)[0];
if (!ev) return {};
try {
const m = JSON.parse(ev.content) as Record<string, unknown>;
return {
name: (m.display_name as string) || (m.name as string),
picture: m.picture as string,
nip05: m.nip05 as string,
created_at: ev.created_at,
};
} catch {
return {};
}
},
});
}

View file

@ -203,9 +203,13 @@ async function drawRiderFront(ctx: CanvasRenderingContext2D, data: CanvasCardDat
y += 30; y += 30;
} }
if (data.nip05) { if (data.nip05) {
const [local, domain] = data.nip05.split('@');
const handle = local && local !== '_' ? `@${local}` : domain ? domain : '';
if (handle) {
ctx.fillStyle = p.muted; ctx.fillStyle = p.muted;
ctx.font = 'italic 17px Georgia, serif'; ctx.font = 'italic 17px Georgia, serif';
ctx.fillText(fitText(ctx, `@${data.nip05.split('@')[0]}`, TEXT_W), TEXT_X, y); ctx.fillText(fitText(ctx, handle, TEXT_W), TEXT_X, y);
}
} }
// Crew monogram circles on the lower band. // Crew monogram circles on the lower band.
@ -431,10 +435,14 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba
} else { } else {
ctx.fillStyle = p.sidebarBg; ctx.fillStyle = p.sidebarBg;
ctx.fillRect(0, 0, PORTRAIT_W, CARD_H); ctx.fillRect(0, 0, PORTRAIT_W, CARD_H);
// Initials monogram — more personal than a generic skateboard glyph.
const initials = (data.name || '?').trim().split(/\s+/).slice(0, 2).map(w => w[0] ?? '').join('').toUpperCase() || '?';
ctx.fillStyle = p.accent; ctx.fillStyle = p.accent;
ctx.font = '84px system-ui, sans-serif'; ctx.font = 'bold 96px system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText('🛹', PORTRAIT_W / 2, CARD_H / 2 + 26); ctx.globalAlpha = 0.85;
ctx.fillText(initials, PORTRAIT_W / 2, CARD_H / 2 + 34);
ctx.globalAlpha = 1;
ctx.textAlign = 'left'; ctx.textAlign = 'left';
} }
const shade = ctx.createLinearGradient(0, CARD_H - 180, 0, CARD_H); const shade = ctx.createLinearGradient(0, CARD_H - 180, 0, CARD_H);

View file

@ -10,9 +10,8 @@ import { nip19 } from 'nostr-tools';
import { useSeoMeta } from '@unhead/react'; import { useSeoMeta } from '@unhead/react';
import { ArrowLeft, Sparkles, RefreshCw } from 'lucide-react'; import { ArrowLeft, Sparkles, RefreshCw } from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor';
import { useRiderStats } from '@/hooks/useRiderStats'; import { useRiderStats } from '@/hooks/useRiderStats';
import { useRiderCard, useRiderBadges, useRiderRoles } from '@/hooks/useRiderCard'; import { useRiderCard, useRiderBadges, useRiderRoles, useCardProfile } from '@/hooks/useRiderCard';
import { useUserCrews } from '@/hooks/useCrews'; import { useUserCrews } from '@/hooks/useCrews';
import { useUserSkaterProfile } from '@/hooks/useSkaters'; import { useUserSkaterProfile } from '@/hooks/useSkaters';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
@ -38,8 +37,8 @@ export default function PublicCardPage() {
const pubkey = decodePubkey(npub); const pubkey = decodePubkey(npub);
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const author = useAuthor(pubkey ?? ''); const cardProfile = useCardProfile(pubkey ?? undefined);
const metadata = author.data?.metadata; const metadata = cardProfile.data;
const displayName = metadata?.name ?? (pubkey ? genUserName(pubkey) : ''); const displayName = metadata?.name ?? (pubkey ? genUserName(pubkey) : '');
const { data: stats } = useRiderStats(pubkey ?? undefined); const { data: stats } = useRiderStats(pubkey ?? undefined);
const { data: savedCard } = useRiderCard(pubkey ?? undefined); const { data: savedCard } = useRiderCard(pubkey ?? undefined);

View file

@ -35,7 +35,7 @@ import { Badge } from '@/components/ui/badge';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { useRiderStats } from '@/hooks/useRiderStats'; import { useRiderStats } from '@/hooks/useRiderStats';
import { useRiderCard, useRiderRoles } from '@/hooks/useRiderCard'; import { useRiderCard, useRiderRoles, useCardProfile } from '@/hooks/useRiderCard';
import { useUserCrews } from '@/hooks/useCrews'; import { useUserCrews } from '@/hooks/useCrews';
import { useUserSkaterProfile } from '@/hooks/useSkaters'; import { useUserSkaterProfile } from '@/hooks/useSkaters';
import { useSkateSpots } from '@/hooks/useSkateSpots'; import { useSkateSpots } from '@/hooks/useSkateSpots';
@ -164,8 +164,8 @@ export default function RiderCardPage() {
}); });
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const author = useAuthor(user?.pubkey ?? ''); const cardProfile = useCardProfile(user?.pubkey);
const metadata = author.data?.metadata; const metadata = cardProfile.data;
const { data: stats } = useRiderStats(user?.pubkey); const { data: stats } = useRiderStats(user?.pubkey);
const { data: savedCard } = useRiderCard(user?.pubkey); const { data: savedCard } = useRiderCard(user?.pubkey);
const { data: allSpots } = useSkateSpots(200); const { data: allSpots } = useSkateSpots(200);