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:
parent
9dc7e7429d
commit
0dd7e9bfa9
5 changed files with 75 additions and 19 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 {};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue