SkateSpots/src/hooks/useSkaters.ts
shakespeare.diy b42c234709 Add Skater profiles (kind 35000) + /skaters directory
- 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>
2026-09-02 20:58:51 +07:00

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 };