diff --git a/src/hooks/useAuthor.ts b/src/hooks/useAuthor.ts index c5da199..c5545eb 100644 --- a/src/hooks/useAuthor.ts +++ b/src/hooks/useAuthor.ts @@ -1,8 +1,9 @@ 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 +// Fallback relays for profile aggregation — global indexers. const FALLBACK_RELAYS = [ 'wss://purplepag.es', 'wss://relay.nostr.band', @@ -23,12 +24,11 @@ export function useAuthor(pubkey: string | undefined) { const querySignal = AbortSignal.any([signal, AbortSignal.timeout(15000)]); try { - // Query the main pool AND the global profile aggregators IN PARALLEL, - // then keep the NEWEST kind 0 across all of them. Doing both at once - // (rather than only falling back when the main pool is empty) means a - // followed user's real name resolves even when their profile lives on a - // big indexer but not on the app's curated skate relay — the previous - // "generic npub name" bug. + // 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 }], @@ -41,6 +41,10 @@ export function useAuthor(pubkey: string | undefined) { { 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. diff --git a/src/hooks/useRiderCard.ts b/src/hooks/useRiderCard.ts index 2066415..bb6b9bf 100644 --- a/src/hooks/useRiderCard.ts +++ b/src/hooks/useRiderCard.ts @@ -6,6 +6,7 @@ */ import { useQuery } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; +import { useSkateRelays } from '@/hooks/useSkateRelays'; import type { NostrEvent } from '@nostrify/nostrify'; import { 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({ + 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; + 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 {}; + } + }, + }); +} diff --git a/src/lib/riderCardCanvas.ts b/src/lib/riderCardCanvas.ts index 35ccc20..6c7ab93 100644 --- a/src/lib/riderCardCanvas.ts +++ b/src/lib/riderCardCanvas.ts @@ -203,9 +203,13 @@ async function drawRiderFront(ctx: CanvasRenderingContext2D, data: CanvasCardDat y += 30; } if (data.nip05) { - ctx.fillStyle = p.muted; - ctx.font = 'italic 17px Georgia, serif'; - ctx.fillText(fitText(ctx, `@${data.nip05.split('@')[0]}`, TEXT_W), TEXT_X, y); + const [local, domain] = data.nip05.split('@'); + const handle = local && local !== '_' ? `@${local}` : domain ? domain : ''; + if (handle) { + ctx.fillStyle = p.muted; + ctx.font = 'italic 17px Georgia, serif'; + ctx.fillText(fitText(ctx, handle, TEXT_W), TEXT_X, y); + } } // Crew monogram circles on the lower band. @@ -431,10 +435,14 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba } else { ctx.fillStyle = p.sidebarBg; 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.font = '84px system-ui, sans-serif'; + ctx.font = 'bold 96px system-ui, sans-serif'; 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'; } const shade = ctx.createLinearGradient(0, CARD_H - 180, 0, CARD_H); diff --git a/src/pages/PublicCardPage.tsx b/src/pages/PublicCardPage.tsx index d46cb0c..54e5f6b 100644 --- a/src/pages/PublicCardPage.tsx +++ b/src/pages/PublicCardPage.tsx @@ -10,9 +10,8 @@ import { nip19 } from 'nostr-tools'; import { useSeoMeta } from '@unhead/react'; import { ArrowLeft, Sparkles, RefreshCw } from 'lucide-react'; -import { useAuthor } from '@/hooks/useAuthor'; 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 { useUserSkaterProfile } from '@/hooks/useSkaters'; import { useCurrentUser } from '@/hooks/useCurrentUser'; @@ -38,8 +37,8 @@ export default function PublicCardPage() { const pubkey = decodePubkey(npub); const { user } = useCurrentUser(); - const author = useAuthor(pubkey ?? ''); - const metadata = author.data?.metadata; + const cardProfile = useCardProfile(pubkey ?? undefined); + const metadata = cardProfile.data; const displayName = metadata?.name ?? (pubkey ? genUserName(pubkey) : ''); const { data: stats } = useRiderStats(pubkey ?? undefined); const { data: savedCard } = useRiderCard(pubkey ?? undefined); diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx index 03142d8..c33cfa3 100644 --- a/src/pages/RiderCardPage.tsx +++ b/src/pages/RiderCardPage.tsx @@ -35,7 +35,7 @@ import { Badge } from '@/components/ui/badge'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { useRiderStats } from '@/hooks/useRiderStats'; -import { useRiderCard, useRiderRoles } from '@/hooks/useRiderCard'; +import { useRiderCard, useRiderRoles, useCardProfile } from '@/hooks/useRiderCard'; import { useUserCrews } from '@/hooks/useCrews'; import { useUserSkaterProfile } from '@/hooks/useSkaters'; import { useSkateSpots } from '@/hooks/useSkateSpots'; @@ -164,8 +164,8 @@ export default function RiderCardPage() { }); const { user } = useCurrentUser(); - const author = useAuthor(user?.pubkey ?? ''); - const metadata = author.data?.metadata; + const cardProfile = useCardProfile(user?.pubkey); + const metadata = cardProfile.data; const { data: stats } = useRiderStats(user?.pubkey); const { data: savedCard } = useRiderCard(user?.pubkey); const { data: allSpots } = useSkateSpots(200);