import { useQuery } from '@tanstack/react-query'; import { SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND, SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND, SKATE_JUDGE_KIND, SKATE_ORGANIZER_KIND, SKATE_BARBER_KIND, SKATE_DESIGNER_KIND, SKATE_TRANSPORT_KIND, parseSkateCoach, parseSkateFilmer, parseSkateEditor, parseSkatePhotographer, parseSkateBuilder, parseSkateArtist, parseSkateGraffiti, parseSkateTattoo, parseSkateTrainer, parseSkateMusician, parseSkateJudge, parseSkateOrganizer, parseSkateBarber, parseSkateDesigner, parseSkateTransport, isFeaturedActive, type SkateCoach, type SkateFilmer, type SkateEditor, type SkatePhotographer, type SkateBuilder, type SkateArtist, type SkateGraffiti, type SkateTattoo, type SkateTrainer, type SkateMusician, type SkateJudge, type SkateOrganizer, type SkateBarber, type SkateDesigner, type SkateTransport, } from '@/lib/types'; import type { NostrEvent } from '@nostrify/nostrify'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useAdminModeration, filterAdminHiddenMixed } from '@/hooks/useAdminModeration'; import { verifiedFeaturedCoords } from '@/lib/featuredVerification'; export type LabProfileType = | 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder' | 'artist' | 'graffiti' | 'tattoo' | 'trainer' | 'musician' | 'judge' | 'organizer' | 'barber' | 'designer' | 'transport'; export type LabProfile = ( | SkateCoach | SkateFilmer | SkateEditor | SkatePhotographer | SkateBuilder | SkateArtist | SkateGraffiti | SkateTattoo | SkateTrainer | SkateMusician | SkateJudge | SkateOrganizer | SkateBarber | SkateDesigner | SkateTransport ) & { profileType: LabProfileType; }; const LAB_KINDS = [ SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND, SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND, SKATE_JUDGE_KIND, SKATE_ORGANIZER_KIND, SKATE_BARBER_KIND, SKATE_DESIGNER_KIND, SKATE_TRANSPORT_KIND, ]; const LAB_SELF_TAGS = [ 'skatecoach', 'skatefilmer', 'skateeditor', 'skatephotographer', 'skatebuilder', 'skateartist', 'skategraffiti', 'skatetattoo', 'skatetrainer', 'skatemusician', 'skatejudge', 'skateorganizer', 'skatebarber', 'skateclothing', 'skatetransport', ]; /** profileType → addressable kind, for building a coordinate. */ const KIND_BY_TYPE: Record = { coach: SKATE_COACH_KIND, filmer: SKATE_FILMER_KIND, editor: SKATE_EDITOR_KIND, photographer: SKATE_PHOTOGRAPHER_KIND, builder: SKATE_BUILDER_KIND, artist: SKATE_ARTIST_KIND, graffiti: SKATE_GRAFFITI_KIND, tattoo: SKATE_TATTOO_KIND, trainer: SKATE_TRAINER_KIND, musician: SKATE_MUSICIAN_KIND, judge: SKATE_JUDGE_KIND, organizer: SKATE_ORGANIZER_KIND, barber: SKATE_BARBER_KIND, designer: SKATE_DESIGNER_KIND, transport: SKATE_TRANSPORT_KIND, }; /** Parse any Lab event into a LabProfile, or null if invalid / unknown kind. */ export function parseLabProfile(event: NostrEvent): LabProfile | null { switch (event.kind) { case SKATE_COACH_KIND: { const p = parseSkateCoach(event); return p ? { ...p, profileType: 'coach' } : null; } case SKATE_FILMER_KIND: { const p = parseSkateFilmer(event); return p ? { ...p, profileType: 'filmer' } : null; } case SKATE_EDITOR_KIND: { const p = parseSkateEditor(event); return p ? { ...p, profileType: 'editor' } : null; } case SKATE_PHOTOGRAPHER_KIND: { const p = parseSkatePhotographer(event); return p ? { ...p, profileType: 'photographer' } : null; } case SKATE_BUILDER_KIND: { const p = parseSkateBuilder(event); return p ? { ...p, profileType: 'builder' } : null; } case SKATE_ARTIST_KIND: { const p = parseSkateArtist(event); return p ? { ...p, profileType: 'artist' } : null; } case SKATE_GRAFFITI_KIND: { const p = parseSkateGraffiti(event); return p ? { ...p, profileType: 'graffiti' } : null; } case SKATE_TATTOO_KIND: { const p = parseSkateTattoo(event); return p ? { ...p, profileType: 'tattoo' } : null; } case SKATE_TRAINER_KIND: { const p = parseSkateTrainer(event); return p ? { ...p, profileType: 'trainer' } : null; } case SKATE_MUSICIAN_KIND: { const p = parseSkateMusician(event); return p ? { ...p, profileType: 'musician' } : null; } case SKATE_JUDGE_KIND: { const p = parseSkateJudge(event); return p ? { ...p, profileType: 'judge' } : null; } case SKATE_ORGANIZER_KIND: { const p = parseSkateOrganizer(event); return p ? { ...p, profileType: 'organizer' } : null; } case SKATE_BARBER_KIND: { const p = parseSkateBarber(event); return p ? { ...p, profileType: 'barber' } : null; } case SKATE_DESIGNER_KIND: { const p = parseSkateDesigner(event); return p ? { ...p, profileType: 'designer' } : null; } case SKATE_TRANSPORT_KIND: { const p = parseSkateTransport(event); return p ? { ...p, profileType: 'transport' } : null; } default: return null; } } export function useLabProfiles(limit = 100, options: { includeHidden?: boolean } = {}) { const { includeHidden = false } = options; const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const hidden = useAdminModeration(); return useQuery({ queryKey: ['lab-profiles', limit, getQueryKeySuffix()], // `includeHidden`: admin tooling only (review queue + undo). select: (profiles: LabProfile[]) => includeHidden ? profiles : filterAdminHiddenMixed(profiles, hidden), queryFn: async () => { const signal = AbortSignal.timeout(15000); const events = await querySkateContent( [ { kinds: LAB_KINDS, '#t': LAB_SELF_TAGS, limit: limit * 2, }, ], { signal } ); const profiles: LabProfile[] = []; for (const event of events) { const parsed = parseLabProfile(event); if (parsed) profiles.push(parsed); } // Featured (Pro) profiles first — but only when backed by a provider // receipt (a self-asserted tag must not buy placement). Fallback to the // tag when verification is unavailable. const coordOf = (p: LabProfile) => `${KIND_BY_TYPE[p.profileType]}:${p.pubkey}:${p.identifier}`; const featuredCoords = profiles.filter((p) => isFeaturedActive(p.featuredUntil)).map(coordOf); const verified = await verifiedFeaturedCoords(featuredCoords, (f) => querySkateContent([f], { signal })); const isPro = (p: LabProfile) => isFeaturedActive(p.featuredUntil) && (verified === null || verified.has(coordOf(p))); return profiles.sort((a, b) => { const aPro = isPro(a) ? 1 : 0; const bPro = isPro(b) ? 1 : 0; if (aPro !== bPro) return bPro - aPro; return b.createdAt - a.createdAt; }); }, staleTime: 30000, refetchOnMount: 'always', }); } export function useLabProfile(identifier: string, pubkey?: string) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const hidden = useAdminModeration(); return useQuery({ queryKey: ['lab-profile', identifier, pubkey, getQueryKeySuffix()], select: (profile: LabProfile | null) => profile && hidden.has({ coord: `${profile.event.kind}:${profile.pubkey}:${profile.identifier}`, id: profile.id, }) ? null : profile, queryFn: async () => { const signal = AbortSignal.timeout(10000); const filter: { kinds: number[]; '#d': string[]; authors?: string[]; limit: number; } = { kinds: LAB_KINDS, '#d': [identifier], // Relays can hold multiple revisions of an addressable event; fetch a // few and pick the newest rather than trusting whatever `limit: 1` // returns (which may be a stale revision, dropping members/edits). limit: 10, }; if (pubkey) filter.authors = [pubkey]; const events = await querySkateContent([filter], { signal }); if (events.length === 0) return null; // Newest revision wins. const newest = events.reduce((latest, current) => current.created_at > latest.created_at ? current : latest, ); return parseLabProfile(newest); }, enabled: !!identifier, staleTime: 30000, refetchOnMount: 'always', }); } /** Lab provider profiles authored by one pubkey (profile-page services strip). */ export function useUserLabProfiles(pubkey: string | undefined) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const hidden = useAdminModeration(); return useQuery({ queryKey: ['user-lab-profiles', pubkey, getQueryKeySuffix()], enabled: !!pubkey, select: (profiles: LabProfile[]) => filterAdminHiddenMixed(profiles, hidden), queryFn: async () => { if (!pubkey) return []; const signal = AbortSignal.timeout(12_000); const events = await querySkateContent( [{ kinds: LAB_KINDS, authors: [pubkey], '#t': LAB_SELF_TAGS, limit: 50, }], { signal }, ); return events .map(parseLabProfile) .filter((profile): profile is LabProfile => !!profile) .sort((a, b) => b.createdAt - a.createdAt); }, staleTime: 60_000, }); }