SkateSpots/src/hooks/useCrews.ts
shakespeare.diy 13eece3a1e Admins can delete/take down any listing (spot, crew, tour, event, shop)
The two SkateSpots admin pubkeys (already in src/lib/admins.ts) can now remove
any listing, not just their own. Because a NIP-09 deletion signed by a
non-author isn't honored by most relays, admin takedowns are made reliable with
a client-side moderation layer:

- useDeleteEntity: owners still publish a plain NIP-09 kind:5. Admins acting on
  someone else's content publish the kind:5 AND a NIP-32 kind:1985 `hidden`
  label referencing the target coordinate/id, authored by the admin.
- useAdminModeration (new): fetches admin-authored `hidden` labels app-wide and
  exposes a fast lookup + filterAdminHidden() helper. Only labels whose author
  is in the hardcoded ADMIN_PUBKEYS are honored (author = trust boundary,
  re-verified client-side; never derived from a d-tag).
- Wired the hide filter into the list + detail hooks for spots, crews (incl.
  by-focus), tours, events, and shops via React Query `select`, so removed
  content disappears from lists and 404s on its detail page.

UI:
- DeleteEntityDialog now renders for owner OR admin, with distinct
  "Remove (admin)" framing and copy (used on Crew, Coach, Tour, Event pages).
- SpotDetailPage & ShopDetailPage: routed their inline delete through
  useDeleteEntity, show the delete button for admins, and relabel the confirm
  dialog for admin takedowns.
- Added the delete control to Tour and Event detail pages (previously none).

SECURITY.md: documented the admin moderation trust boundary and extended the
auditor file map. Verified build + no console errors.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-08-18 17:51:34 +07:00

181 lines
5.5 KiB
TypeScript

import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import {
SKATE_CREW_KIND,
parseSkateCrew,
CREW_FOCUS_TAG,
type SkateCrew,
type CrewFocus,
} from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
export function useCrews(limit = 200) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-crews', limit, getQueryKeySuffix()],
select: (crews: SkateCrew[]) => filterAdminHidden(crews, SKATE_CREW_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(10000);
const events = await querySkateContent(
[
{
kinds: [SKATE_CREW_KIND],
'#t': ['skatecrew'],
limit,
},
],
{ signal }
);
const crews: SkateCrew[] = [];
for (const event of events) {
const crew = parseSkateCrew(event);
if (crew) crews.push(crew);
}
return crews.sort((a, b) => b.createdAt - a.createdAt);
},
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
refetchOnMount: true,
});
}
/**
* Query crews that belong to a specific Lab scene (builder / photo / coaching).
* Filters at the relay level via the crew-focus `t` tag, so The Lab pages can
* show a "Crews in this scene" strip without scanning every crew.
*/
export function useCrewsByFocus(focus: Exclude<CrewFocus, 'mixed'>, limit = 30) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const focusTag = CREW_FOCUS_TAG[focus];
const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-crews-focus', focus, limit, getQueryKeySuffix()],
select: (crews: SkateCrew[]) => filterAdminHidden(crews, SKATE_CREW_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(10000);
const events = await querySkateContent(
[
{
kinds: [SKATE_CREW_KIND],
'#t': [focusTag],
limit,
},
],
{ signal }
);
const crews: SkateCrew[] = [];
for (const event of events) {
const crew = parseSkateCrew(event);
// Double-check focus after parsing (guards against loose relay matches).
if (crew && crew.focus === focus) crews.push(crew);
}
return crews.sort((a, b) => b.createdAt - a.createdAt);
},
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
refetchOnMount: true,
});
}
export function useCrew(identifier: string, pubkey?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-crew', identifier, pubkey, getQueryKeySuffix()],
select: (crew: SkateCrew | null) =>
crew && hidden.has({ coord: `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`, id: crew.id })
? null
: crew,
queryFn: async () => {
const signal = AbortSignal.timeout(10000);
const pickMostRecent = (events: { created_at: number }[]) =>
events.length
? events.reduce((latest, current) =>
current.created_at > latest.created_at ? current : latest
)
: undefined;
// 1) Precise addressable lookup: kind + d-tag (+ author when known).
const filter: {
kinds: number[];
'#d': string[];
authors?: string[];
limit: number;
} = {
kinds: [SKATE_CREW_KIND],
'#d': [identifier],
limit: 1,
};
if (pubkey) filter.authors = [pubkey];
let events = await querySkateContent([filter], { signal });
// 2) Fallback: some relays don't index the `d` tag reliably (or choke on
// combined author + tag filters). If nothing came back and we know the
// author, fetch that author's crews and match the identifier locally.
if (events.length === 0 && pubkey) {
const byAuthor = await querySkateContent(
[{ kinds: [SKATE_CREW_KIND], authors: [pubkey], limit: 200 }],
{ signal }
);
events = byAuthor.filter((e) =>
e.tags.some(([name, value]) => name === 'd' && value === identifier)
);
}
const mostRecent = pickMostRecent(events);
if (!mostRecent) return null;
return parseSkateCrew(mostRecent as typeof events[number]);
},
enabled: !!identifier,
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
refetchOnMount: true,
});
}
export interface UserCrewMembership {
crew: SkateCrew;
/** How the user is attached to this crew. */
role: 'founder' | 'member';
}
/**
* The crews a given pubkey belongs to — as founder or listed member.
*
* Powers the "Crews" section on a user's public profile. Founder crews sort
* first, then member crews, newest first within each group.
*/
export function useUserCrews(pubkey?: string) {
const { data: crews, isLoading, error } = useCrews(200);
const memberships = useMemo<UserCrewMembership[]>(() => {
if (!pubkey || !crews) return [];
return crews
.filter((c) => c.pubkey === pubkey || c.members.includes(pubkey))
.map((crew) => ({
crew,
role: crew.pubkey === pubkey ? ('founder' as const) : ('member' as const),
}))
.sort((a, b) => {
if (a.role !== b.role) return a.role === 'founder' ? -1 : 1;
return b.crew.createdAt - a.crew.createdAt;
});
}, [pubkey, crews]);
return { memberships, isLoading, error };
}