248 lines
9.4 KiB
TypeScript
248 lines
9.4 KiB
TypeScript
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<LabProfileType, number> = {
|
|
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,
|
|
});
|
|
}
|