import { useQuery } from '@tanstack/react-query'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { ADMIN_PUBKEYS } from '@/lib/admins'; import { MODERATION_LABEL_NAMESPACE, MODERATION_LABEL_HIDDEN, MODERATION_LABEL_VERIFIED, MODERATION_LABEL_CLEARED, } from '@/lib/types'; import { getLocalVerdicts, type LocalVerdict } from '@/lib/localModeration'; import { getLocalDeletions } from '@/lib/localDeletions'; import { useMuteList } from '@/hooks/useMuteList'; /** * 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; /** * Admin-VOUCHED targets (the positive label). Used to promote a spot out of * the review queue and to show a "Verified" badge. */ verifiedCoords: Set; verifiedIds: Set; isVerified: (opts: { coord?: string; id?: string }) => boolean; /** Authors the viewer muted (NIP-51 kind 10000 + optimistic). */ mutedPubkeys: Set; } const EMPTY_PUBKEYS = new Set(); const EMPTY: AdminHiddenSet = { coords: new Set(), ids: new Set(), has: () => false, verifiedCoords: new Set(), verifiedIds: new Set(), isVerified: () => false, mutedPubkeys: EMPTY_PUBKEYS, }; /** * 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[] { const deleted = getLocalDeletions(); if (hidden.coords.size === 0 && hidden.ids.size === 0 && deleted.size === 0 && hidden.mutedPubkeys.size === 0) return items; return items.filter((it) => { // Muted authors are dropped everywhere this filter is used (NIP-51 mute). if (hidden.mutedPubkeys.has(it.pubkey)) return false; const coord = `${kind}:${it.pubkey}:${it.identifier}`; if (hidden.has({ coord, id: it.id })) return false; // Locally-deleted (NIP-09 pending): drop immediately so a deleted item can // never flash back from the persistence/offline cache on refresh. if (deleted.has(coord) || deleted.has(`e:${it.id}`)) return false; return true; }); } /** * 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[] { const deleted = getLocalDeletions(); if (hidden.coords.size === 0 && hidden.ids.size === 0 && deleted.size === 0 && hidden.mutedPubkeys.size === 0) return items; return items.filter((it) => { if (hidden.mutedPubkeys.has(it.pubkey)) return false; const coord = `${it.event.kind}:${it.pubkey}:${it.identifier}`; if (hidden.has({ coord, id: it.id })) return false; if (deleted.has(coord) || deleted.has(`e:${it.id}`)) return false; return true; }); } 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, // One request for ALL admin verdicts: hidden, verified and cleared. // `cleared` (undo) MUST be included or a hidden item can never be // un-hidden on another device — it is the newest-wins retraction. '#l': [MODERATION_LABEL_HIDDEN, MODERATION_LABEL_VERIFIED, MODERATION_LABEL_CLEARED], limit: 1000, }, ], { signal }, ); const adminSet = new Set(ADMIN_PUBKEYS.map((p) => p.toLowerCase())); // NEWEST VERDICT PER TARGET WINS. Kind 1985 is a regular event, so there // can be several labels for one target; the latest one is the current // truth. That is what makes a verdict reversible: an undo publishes // `cleared`, which supersedes an earlier `hidden`/`verified`. const verdicts = new Map(); 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, ); if (!hasNamespace) continue; const labelOf = (want: string) => ev.tags.some(([n, v]) => n === 'l' && v === want); const verdict: LocalVerdict | null = labelOf(MODERATION_LABEL_HIDDEN) ? 'hidden' : labelOf(MODERATION_LABEL_VERIFIED) ? 'verified' : labelOf(MODERATION_LABEL_CLEARED) ? 'cleared' : null; if (!verdict) continue; for (const [n, v] of ev.tags) { const key = n === 'a' ? v : n === 'e' ? `e:${v}` : null; if (!key) continue; const current = verdicts.get(key); if (!current || ev.created_at > current.at) { verdicts.set(key, { verdict, at: ev.created_at }); } } } // Overlay this device's OPTIMISTIC verdicts (just-made ones win until the // relay catches up, and an undo written locally replaces the old verdict). for (const [key, entry] of getLocalVerdicts()) { const current = verdicts.get(key); if (!current || entry.at >= current.at) verdicts.set(key, entry); } const coords = new Set(); const ids = new Set(); const verifiedCoords = new Set(); const verifiedIds = new Set(); for (const [key, { verdict }] of verdicts) { const isId = key.startsWith('e:'); const value = isId ? key.slice(2) : key; if (verdict === 'hidden') (isId ? ids : coords).add(value); else if (verdict === 'verified') (isId ? verifiedIds : verifiedCoords).add(value); // `cleared` adds nothing — the target is simply neutral again. } return { coords, ids, has: ({ coord, id }) => (!!coord && coords.has(coord)) || (!!id && ids.has(id)), verifiedCoords, verifiedIds, isVerified: ({ coord, id }) => (!!coord && verifiedCoords.has(coord)) || (!!id && verifiedIds.has(id)), mutedPubkeys: EMPTY_PUBKEYS, }; }, staleTime: 60 * 1000, gcTime: 10 * 60 * 1000, refetchOnMount: true, }); const mutes = useMuteList(); const mutedPubkeys = mutes.data ?? EMPTY_PUBKEYS; // 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 coords = new Set(); const ids = new Set(); const verifiedCoords = new Set(); const verifiedIds = new Set(); for (const [key, entry] of getLocalVerdicts()) { const isId = key.startsWith('e:'); const value = isId ? key.slice(2) : key; if (entry.verdict === 'hidden') (isId ? ids : coords).add(value); else if (entry.verdict === 'verified') (isId ? verifiedIds : verifiedCoords).add(value); } if (coords.size === 0 && ids.size === 0 && verifiedCoords.size === 0 && verifiedIds.size === 0) { return { ...EMPTY, mutedPubkeys }; } return { coords, ids, has: ({ coord, id }) => (!!coord && coords.has(coord)) || (!!id && ids.has(id)), verifiedCoords, verifiedIds, isVerified: ({ coord, id }) => (!!coord && verifiedCoords.has(coord)) || (!!id && verifiedIds.has(id)), mutedPubkeys, }; } return { ...query.data, mutedPubkeys }; }