/** * useRiderCard — fetch a skater's published Rider Card + witnessed skill badges. * * The Rider Card is an addressable event (kind 32271, d="rider-card") signed by * the skater themselves, so it persists across devices and can be verified. */ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import type { NostrEvent } from '@nostrify/nostrify'; import { RIDER_CARD_KIND, RIDER_CARD_D, SKILL_BADGE_KIND, RIDER_ROLE_KINDS, RIDER_ROLE_META, PARK_ACCESS_CARD_KIND, parseRiderCard, type RiderCardConfig, } from '@/lib/riderCard'; export interface RiderCardResult { config: RiderCardConfig | null; event: NostrEvent | null; } export function useRiderCard(pubkey: string | undefined) { const { nostr } = useNostr(); return useQuery({ queryKey: ['rider-card', pubkey], enabled: !!pubkey, staleTime: 60_000, queryFn: async (c) => { if (!pubkey) return { config: null, event: null }; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); const events = await nostr.query( [{ kinds: [RIDER_CARD_KIND], authors: [pubkey], '#d': [RIDER_CARD_D], limit: 1 }], { signal }, ); const event = events[0] ?? null; return { config: event ? parseRiderCard(event) : null, event }; }, }); } export interface RiderBadge { skill: string; witnessPubkey: string; spotName?: string; timestamp: number; } /** Skill badges witnessed for a rider (kind 1293, `#p` = rider). */ export function useRiderBadges(pubkey: string | undefined) { const { nostr } = useNostr(); return useQuery({ queryKey: ['rider-badges', pubkey], enabled: !!pubkey, staleTime: 60_000, queryFn: async (c) => { if (!pubkey) return []; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); const events = await nostr.query( [{ kinds: [SKILL_BADGE_KIND], '#p': [pubkey], limit: 100 }], { signal }, ); return events .filter((e) => e.tags.some(([k]) => k === 'skill')) .flatMap((e) => { const spot = e.tags.find(([k]) => k === 'spot')?.[1]; return e.tags .filter(([k]) => k === 'skill') .map(([, skill]) => ({ skill, witnessPubkey: e.pubkey, spotName: spot, timestamp: e.created_at, })); }); }, }); } /** * The Lab roles a skater wears (kinds 35001–35014). Derived live from the * author's own Lab profiles, so the card never claims a role they don't have. */ export function useRiderRoles(pubkey: string | undefined) { const { nostr } = useNostr(); return useQuery({ queryKey: ['rider-roles', pubkey], enabled: !!pubkey, staleTime: 60_000, queryFn: async (c) => { if (!pubkey) return []; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); const events = await nostr.query( [{ kinds: RIDER_ROLE_KINDS, authors: [pubkey], limit: 50 }], { signal }, ); const kinds = new Set(events.map((e) => e.kind)); return RIDER_ROLE_META.filter((r) => kinds.has(r.kind)).map((r) => r.role); }, }); } 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 {}; } }, }); } export interface IssuedPass { id: string; d: string; coord: string; holder?: string; accessLevel?: string; issuer?: string; validThru?: string; status?: string; createdAt: number; event: NostrEvent; } /** Access passes this key has ISSUED (kind 35679, authored by them). */ export function useIssuedPasses(pubkey?: string) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); return useQuery({ queryKey: ['issued-passes', pubkey, getQueryKeySuffix()], enabled: !!pubkey, staleTime: 60_000, queryFn: async () => { if (!pubkey) return []; const signal = AbortSignal.timeout(9000); const events = await querySkateContent( [{ kinds: [PARK_ACCESS_CARD_KIND], authors: [pubkey], limit: 100 }], { signal }, ); // Newest version per d-tag — a revoke re-publishes the SAME d, so without // this the issued list would show both the original and the revoked pass. const byD = new Map(); for (const ev of events) { const d = ev.tags.find(([n]) => n === 'd')?.[1] ?? ev.id; const cur = byD.get(d); if (!cur || ev.created_at > cur.created_at) byD.set(d, ev); } return [...byD.values()] .map((ev) => ({ id: ev.id, d: ev.tags.find(([n]) => n === 'd')?.[1] ?? '', coord: ev.tags.find(([n]) => n === 'a')?.[1] ?? '', holder: ev.tags.find(([n]) => n === 'p')?.[1], accessLevel: ev.tags.find(([n]) => n === 'access-level')?.[1], issuer: ev.tags.find(([n]) => n === 'issuer')?.[1], validThru: ev.tags.find(([n]) => n === 'valid-thru')?.[1], status: ev.tags.find(([n]) => n === 'status')?.[1], createdAt: ev.created_at, event: ev, })) .sort((a, b) => b.createdAt - a.createdAt); }, }); } export interface VenuePassIndex { /** Venue coords (`::`) with at least one active pass. */ coords: Set; /** Pubkeys that own at least one venue with an active pass. */ owners: Set; } /** * One cached query for the whole app: which venues currently have active access * passes. Lets a card list badge shops without a relay query per card. A pass is * active unless its newest version carries `status=revoked` (addressable * supersede — the same rule `/verify` and "My passes" use). */ export function useVenuesWithPasses() { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); return useQuery({ queryKey: ['venues-with-passes', getQueryKeySuffix()], staleTime: 120_000, queryFn: async () => { const signal = AbortSignal.timeout(9000); const events = await querySkateContent( [{ kinds: [PARK_ACCESS_CARD_KIND], limit: 500 }], { signal }, ); const byD = new Map(); for (const ev of events) { const d = ev.tags.find(([n]) => n === 'd')?.[1] ?? ev.id; const cur = byD.get(d); if (!cur || ev.created_at > cur.created_at) byD.set(d, ev); } const coords = new Set(); const owners = new Set(); for (const ev of byD.values()) { if (ev.tags.find(([n]) => n === 'status')?.[1] === 'revoked') continue; const a = ev.tags.find(([n]) => n === 'a')?.[1]; if (!a) continue; coords.add(a); const owner = a.split(':')[1]; if (owner) owners.add(owner); } return { coords, owners }; }, }); } /** * Revoke an issued pass: re-publish it under the SAME `d` with `status=revoked`, * which supersedes it by addressable replacement (works on every relay, unlike * a NIP-09 deletion others may ignore). */ export function useRevokePass() { const { mutateAsync: publish, isPending } = useNostrPublish(); const queryClient = useQueryClient(); return { isPending, revoke: async (pass: IssuedPass) => { const tags = pass.event.tags.filter(([n]) => n !== 'status').map((t) => [...t]); tags.push(['status', 'revoked']); await publish({ kind: PARK_ACCESS_CARD_KIND, content: '', tags }); queryClient.invalidateQueries({ queryKey: ['issued-passes'] }); }, }; } export interface ReceivedPass { id: string; d: string; issuer: string; accessLevel?: string; validThru?: string; status?: string; targetCoord?: string; targetKind?: number; event: NostrEvent; } /** Access passes issued TO this key (kind 35679 with `#p` = them). */ export function useReceivedPasses(pubkey?: string) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); return useQuery({ queryKey: ['received-passes', pubkey, getQueryKeySuffix()], enabled: !!pubkey, staleTime: 60_000, queryFn: async () => { if (!pubkey) return []; const signal = AbortSignal.timeout(9000); const events = await querySkateContent( [{ kinds: [PARK_ACCESS_CARD_KIND], '#p': [pubkey], limit: 200 }], { signal }, ); // Newest version per d-tag (a pass can be superseded by a revocation). const byD = new Map(); for (const ev of events) { const d = ev.tags.find(([n]) => n === 'd')?.[1] ?? ev.id; const cur = byD.get(d); if (!cur || ev.created_at > cur.created_at) byD.set(d, ev); } return [...byD.values()] .map((ev) => { const targetCoord = ev.tags.find(([n]) => n === 'a')?.[1]; return { id: ev.id, d: ev.tags.find(([n]) => n === 'd')?.[1] ?? '', issuer: ev.tags.find(([n]) => n === 'issuer')?.[1] ?? '', accessLevel: ev.tags.find(([n]) => n === 'access-level')?.[1], validThru: ev.tags.find(([n]) => n === 'valid-thru')?.[1], status: ev.tags.find(([n]) => n === 'status')?.[1], targetCoord, targetKind: targetCoord ? Number(targetCoord.split(':')[0]) : undefined, event: ev, }; }) .sort((a, b) => b.event.created_at - a.event.created_at); }, }); }