import { useQuery } from '@tanstack/react-query'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { ADMIN_PUBKEYS } from '@/lib/admins'; import { MODERATION_LABEL_NAMESPACE, MODERATION_LABEL_HIDDEN, } from '@/lib/types'; import { getLocalHidden } from '@/lib/localModeration'; /** * App-wide admin moderation layer. * * SkateSpots admins (see src/lib/admins.ts) can take down ANY listing (spot, * crew, tour, event, shop, Lab profile, …). Because a NIP-09 deletion request * signed by a non-author is not honored by most relays, admin takedowns also * publish a NIP-32 kind:1985 `hidden` label referencing the target. This hook * fetches those admin labels so the client can hide the content everywhere, * reliably, regardless of relay NIP-09 behavior. * * Security: only labels authored by a hardcoded ADMIN pubkey are honored, so a * random user cannot hide someone else's listing. The label authorship is the * trust boundary (see .agents/skills/nostr-security §3). */ export interface AdminHiddenSet { /** Hidden addressable coordinates `kind:pubkey:d`. */ coords: Set; /** Hidden concrete event ids. */ ids: Set; /** True if either set contains anything (cheap guard for callers). */ has: (opts: { coord?: string; id?: string }) => boolean; } const EMPTY: AdminHiddenSet = { coords: new Set(), ids: new Set(), has: () => false, }; /** * Drop admin-hidden entities from a parsed list. Works for any entity that * carries `{ id, pubkey, identifier }` (spots, crews, tours, shops, events, …). * `kind` is the entity's Nostr kind, used to build the `a` coordinate. */ export function filterAdminHidden< T extends { id: string; pubkey: string; identifier: string }, >(items: T[], kind: number, hidden: AdminHiddenSet): T[] { if (hidden.coords.size === 0 && hidden.ids.size === 0) return items; return items.filter( (it) => !hidden.has({ coord: `${kind}:${it.pubkey}:${it.identifier}`, id: it.id }), ); } /** * Like `filterAdminHidden`, but for a mixed-kind list where each item carries * its own source `event` (e.g. Lab profiles, which span 10 different kinds). * The kind is read from `it.event.kind`. */ export function filterAdminHiddenMixed< T extends { id: string; pubkey: string; identifier: string; event: { kind: number }; }, >(items: T[], hidden: AdminHiddenSet): T[] { if (hidden.coords.size === 0 && hidden.ids.size === 0) return items; return items.filter( (it) => !hidden.has({ coord: `${it.event.kind}:${it.pubkey}:${it.identifier}`, id: it.id, }), ); } export function useAdminModeration() { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const query = useQuery({ queryKey: ['admin-moderation', getQueryKeySuffix()], queryFn: async (): Promise => { const signal = AbortSignal.timeout(8000); const events = await querySkateContent( [ { kinds: [1985], authors: ADMIN_PUBKEYS, '#l': [MODERATION_LABEL_HIDDEN], limit: 1000, }, ], { signal }, ); const adminSet = new Set(ADMIN_PUBKEYS.map((p) => p.toLowerCase())); const coords = new Set(); const ids = new Set(); for (const ev of events) { // Defense in depth: re-verify author + namespace + label client-side. if (!adminSet.has(ev.pubkey.toLowerCase())) continue; const hasNamespace = ev.tags.some( ([n, v]) => n === 'L' && v === MODERATION_LABEL_NAMESPACE, ); const hasLabel = ev.tags.some( ([n, v]) => n === 'l' && v === MODERATION_LABEL_HIDDEN, ); if (!hasNamespace || !hasLabel) continue; for (const [n, v] of ev.tags) { if (n === 'a' && v) coords.add(v); if (n === 'e' && v) ids.add(v); } } // Merge in this device's OPTIMISTIC local hides so admin-removed content // disappears instantly, before the background kind:1985 label propagates. const local = getLocalHidden(); for (const c of local.coords) coords.add(c); for (const i of local.ids) ids.add(i); return { coords, ids, has: ({ coord, id }) => (!!coord && coords.has(coord)) || (!!id && ids.has(id)), }; }, staleTime: 60 * 1000, gcTime: 10 * 60 * 1000, refetchOnMount: true, }); // Even if the relay query is still loading/empty, honor local optimistic // hides so the takedown is reflected immediately on this device. if (!query.data) { const local = getLocalHidden(); if (local.coords.size === 0 && local.ids.size === 0) return EMPTY; return { coords: local.coords, ids: local.ids, has: ({ coord, id }) => (!!coord && local.coords.has(coord)) || (!!id && local.ids.has(id)), }; } return query.data; }