- New app-native Skater Profile kind 35000 with `skateskater` marker tag, the anti-spam boundary that keeps generic kind-0 Nostr profiles out of the directory (queried only from the community relay). - Skater type, stance/style/level constants, and parser in types.ts. - useSkaters + useUserSkaterProfile hooks. - /skaters directory page: immersive hero, live count, search, style filter chips, ranked skater grid with NIP-05 verification badges and lazy author metadata. - /become-skater opt-in flow (bio, photo, styles, stance, level, years, home spot, location w/ geohash, socials). - Wire "Skaters" into the mobile nav and a cross-link banner in The Lab so plain skaters finally have a home. - Document kind 35000 in NIP.md. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
67 lines
2.3 KiB
TypeScript
67 lines
2.3 KiB
TypeScript
import { useQuery } from '@tanstack/react-query';
|
|
import { SKATE_SKATER_KIND, SKATER_TAG, parseSkater, type Skater } from '@/lib/types';
|
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
|
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
|
|
|
const SKATER_D = 'skater-profile';
|
|
|
|
/**
|
|
* List all SkateSpots skater profiles (kind 35000, tagged skateskater).
|
|
* Queried from the skate relays only — this is the anti-spam boundary:
|
|
* plain kind-0 Nostr profiles never appear here, only opted-in app skaters.
|
|
* Deduplicated per author (newest wins), newest-first.
|
|
*/
|
|
export function useSkaters(limit = 200) {
|
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
|
const hidden = useAdminModeration();
|
|
|
|
return useQuery({
|
|
queryKey: ['skaters', limit, getQueryKeySuffix()],
|
|
select: (skaters: Skater[]) => filterAdminHidden(skaters, SKATE_SKATER_KIND, hidden),
|
|
queryFn: async () => {
|
|
const signal = AbortSignal.timeout(10000);
|
|
|
|
const events = await querySkateContent(
|
|
[{ kinds: [SKATE_SKATER_KIND], '#t': [SKATER_TAG], limit }],
|
|
{ signal },
|
|
);
|
|
|
|
// Deduplicate: keep the newest profile per author.
|
|
const byAuthor = new Map<string, Skater>();
|
|
for (const event of events) {
|
|
const s = parseSkater(event);
|
|
if (!s) continue;
|
|
const existing = byAuthor.get(s.pubkey);
|
|
if (!existing || s.createdAt > existing.createdAt) byAuthor.set(s.pubkey, s);
|
|
}
|
|
|
|
return [...byAuthor.values()].sort((a, b) => b.createdAt - a.createdAt);
|
|
},
|
|
staleTime: 30000,
|
|
refetchOnMount: 'always',
|
|
});
|
|
}
|
|
|
|
/** Fetch a single user's skater profile (or null). */
|
|
export function useUserSkaterProfile(pubkey: string | undefined) {
|
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
|
|
|
return useQuery({
|
|
queryKey: ['skater-profile', pubkey, getQueryKeySuffix()],
|
|
enabled: !!pubkey,
|
|
queryFn: async () => {
|
|
if (!pubkey) return null;
|
|
const signal = AbortSignal.timeout(10000);
|
|
const events = await querySkateContent(
|
|
[{ kinds: [SKATE_SKATER_KIND], authors: [pubkey], '#d': [SKATER_D], limit: 1 }],
|
|
{ signal },
|
|
);
|
|
if (events.length === 0) return null;
|
|
return parseSkater(events[0]);
|
|
},
|
|
staleTime: 30000,
|
|
refetchOnMount: 'always',
|
|
});
|
|
}
|
|
|
|
export { SKATER_D };
|