SkateSpots/src/hooks/useLabProfiles.ts
shakespeare.diy 56e2b17990 Lab/Marketplace/Stash audit: useLabProfile picks newest revision (1.2.38)
Audited all three remaining surfaces. Two of them needed no changes:

MARKETPLACE / GEARSWAP — sound. useMarkListingSold preserves every tag
and flips only `status` (author-gated, right comment explaining why it
doesn't rebuild). Queries validate + dedupe, exclude showcase-only Stash
items from the sellable market, and GearSwap filters out expired/sold
listings and separates WTB from WTS. No bugs.

STASH — sound, and notably careful. The private list is one NIP-44
encrypted-to-self blob; all three mutations (add/update/remove) use
fetchLatestStash() (fetch-before-write) which falls back to non-empty
cache and THROWS rather than risk clobbering the user's gear, and is
Amber-gated (staleTime: Infinity). The public/private showcase
reconciliation in StashPage is complete: going public publishes the
kind-30402 showcase event, and going private / deleting / converting to
a wishlist item all call unpublishItem — so a public event can't linger
after an item is made private (no privacy leak). No bugs.

THE LAB — the collective join-request clobber was already fixed in
1.2.37. One robustness gap remained: useLabProfile fetched the profile
with limit:1 and trusted whatever came back. Relays can hold multiple
revisions of an addressable event, so a stale revision could win —
showing an old bio or, worse, a roster missing recently-approved
members. Now fetches a few (limit:10) and reduces to the newest
created_at, matching the pickMostRecent discipline already in useCrew.
On detail pages an author filter is always set, so newest-of-one-author
is exactly right.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-08 00:09:00 +07:00

175 lines
6 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,
parseSkateCoach, parseSkateFilmer, parseSkateEditor, parseSkatePhotographer, parseSkateBuilder,
parseSkateArtist, parseSkateGraffiti, parseSkateTattoo, parseSkateTrainer, parseSkateMusician, parseSkateJudge,
isFeaturedActive,
type SkateCoach, type SkateFilmer, type SkateEditor, type SkatePhotographer, type SkateBuilder,
type SkateArtist, type SkateGraffiti, type SkateTattoo, type SkateTrainer, type SkateMusician, type SkateJudge,
} from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useAdminModeration, filterAdminHiddenMixed } from '@/hooks/useAdminModeration';
export type LabProfileType =
| 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder'
| 'artist' | 'graffiti' | 'tattoo' | 'trainer' | 'musician' | 'judge';
export type LabProfile = (
| SkateCoach | SkateFilmer | SkateEditor | SkatePhotographer | SkateBuilder
| SkateArtist | SkateGraffiti | SkateTattoo | SkateTrainer | SkateMusician | SkateJudge
) & {
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,
];
const LAB_SELF_TAGS = [
'skatecoach', 'skatefilmer', 'skateeditor', 'skatephotographer', 'skatebuilder',
'skateartist', 'skategraffiti', 'skatetattoo', 'skatetrainer', 'skatemusician', 'skatejudge',
];
/** 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;
}
default:
return null;
}
}
export function useLabProfiles(limit = 100) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['lab-profiles', limit, getQueryKeySuffix()],
select: (profiles: LabProfile[]) => 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, then newest.
return profiles.sort((a, b) => {
const aPro = isFeaturedActive(a.featuredUntil) ? 1 : 0;
const bPro = isFeaturedActive(b.featuredUntil) ? 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',
});
}