From 3c0a91a37986d5988b9ede93820b3e87542858d3 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 28 Sep 2026 10:20:54 +0700 Subject: [PATCH] feat(moderation): report inbox, bulk undo, crews + Lab in the queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Report INBOX: /review now defaults to a Reports tab. useRecentReports fetches recent NIP-56 (1984) and resolves the referenced event by id (one extra ids: query), so ANY report is actionable — content that isn't flagged, comments, profiles, anything. reportTargetMeta maps kind -> label + route for the link. Actions: Hide / Dismiss report (verify) / Undo / Look at it. - Bulk undo: when a tab holds >1 verdict, a toolbar offers 'Undo all N' with an AlertDialog that warns each undo is its own label publish (one Amber approval each). Built for the 13 old hidden labels from Sep 3. - Crews + Lab tabs: added includeHidden to useCrews/useLabProfiles. Hiding these removes a community / a person's listing, so their Hide goes through a confirmation (confirmHide/confirmHideNote on QueueList). Verified in the browser: 8 tabs render; the inbox shows and resolves a real report (target + reason); the Spots tab shows the bulk-undo toolbar. tsc/eslint clean, 101 tests, smoke PASS. --- AGENTS.md | 14 ++ release-notes.txt | 6 +- src/components/ReportsInbox.tsx | 223 ++++++++++++++++++++++++++++++++ src/hooks/useCrews.ts | 7 +- src/hooks/useLabProfiles.ts | 7 +- src/hooks/useRecentReports.ts | 120 +++++++++++++++++ src/pages/AdminReviewPage.tsx | 201 +++++++++++++++++++++++++--- 7 files changed, 554 insertions(+), 24 deletions(-) create mode 100644 src/components/ReportsInbox.tsx create mode 100644 src/hooks/useRecentReports.ts diff --git a/AGENTS.md b/AGENTS.md index d5fce07..6f7df6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -461,6 +461,20 @@ session with **no other memory of this project**. So: covers a genuinely empty map (`allLocations.length === 0`) with Add-spot + Near-me CTAs. ⚠️ Don't key it off `mapSpots`: that is derived FROM `filteredSpots`, so it is 0 whenever filters hide everything. +- **Report inbox + bulk undo + crews/Lab moderation (2026-09-28):** finishing the + moderation gaps. `/review` now defaults to a **Reports** tab: `useRecentReports` + fetches recent NIP-56 (1984) reports and **resolves the referenced event by id** + (one extra `ids:` query) so ANY report is actionable — including content that + isn't flagged and comments/profiles that never appear in the heuristic queue. + `reportTargetMeta(kind)` maps a kind to its label + route for the "Look at it" + link. The queue also has **crews** and **lab** tabs (added `includeHidden` to + `useCrews`/`useLabProfiles`); because hiding those removes a community or a + person's listing rather than one pin, their Hide button goes through an + `AlertDialog` confirmation (`confirmHide`/`confirmHideNote` on `QueueList`). + When a tab holds more than one verdict, a toolbar offers **Undo all N** (also + AlertDialog-gated, and it warns that each undo is a separate label publish — + one Amber approval each). Verified in the browser: 8 tabs render, the inbox + shows and resolves a real report, and the Spots tab shows the bulk-undo toolbar. - **Mints directory**: `/mints` page lists Cashu mints from the community **MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* → diff --git a/release-notes.txt b/release-notes.txt index a5bed1d..fff0911 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -25,8 +25,10 @@ v1.5.4 now? Publish anyway — the spot just stays marked Unverified until one lands. - **Empty map? It now says so and offers to fix it**: "No spots mapped yet — be the first to map your park", with a one-tap Add spot. -- The admin review queue now covers **shops, events, tours and gear** too, not - just spots. +- The admin review queue now covers **shops, events, tours, gear, crews and Lab + profiles** too, not just spots — and has a **report inbox** (every report, with + the reported item resolved, not just whatever's already flagged) plus **bulk + undo** for clearing old verdicts. 🛍 App Shop — a new section (empty on purpose, with free printables) - Free to print, no sats needed: a 55 × 86 mm "DO YOU EVEN SKATE BRO?" sticker diff --git a/src/components/ReportsInbox.tsx b/src/components/ReportsInbox.tsx new file mode 100644 index 0000000..eca2309 --- /dev/null +++ b/src/components/ReportsInbox.tsx @@ -0,0 +1,223 @@ +import { Link } from 'react-router-dom'; +import { + Flag, + EyeOff, + BadgeCheck, + Undo2, + Loader2, + ExternalLink, + CheckCircle2, + ShieldQuestion, +} from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useRecentReports, reportTargetMeta, type RecentReport } from '@/hooks/useRecentReports'; +import { useAdminHide, useAdminVerify, useAdminClear } from '@/hooks/useAdminHide'; +import { useAdminModeration } from '@/hooks/useAdminModeration'; +import { useToast } from '@/hooks/useToast'; +import { genUserName } from '@/lib/genUserName'; + +/** "3 h ago" — small enough to keep local. */ +function ago(unixSeconds: number): string { + const mins = Math.round((Date.now() / 1000 - unixSeconds) / 60); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins} min ago`; + const hours = Math.round(mins / 60); + if (hours < 24) return `${hours} h ago`; + return `${Math.round(hours / 24)} d ago`; +} + +function Reporter({ pubkey }: { pubkey: string }) { + const author = useAuthor(pubkey); + const name = author.data?.metadata?.name ?? author.data?.metadata?.display_name ?? genUserName(pubkey); + return {name}; +} + +function ReportRow({ report }: { report: RecentReport }) { + const { hideContent, isPending: hiding } = useAdminHide(); + const { confirmContent, isPending: confirming } = useAdminVerify(); + const { clearVerdict, isPending: clearing } = useAdminClear(); + const moderation = useAdminModeration(); + const { toast } = useToast(); + + const { target } = report; + const meta = target ? reportTargetMeta(target.kind) : null; + const coord = target?.coord; + const hidden = !!target && moderation.has({ coord, id: target.id }); + const verified = !!target && moderation.isVerified({ coord, id: target.id }); + const busy = hiding || confirming || clearing; + + const act = async (fn: () => Promise, title: string) => { + try { + await fn(); + toast({ title }); + } catch (e) { + toast({ + title: 'Could not complete that', + description: e instanceof Error ? e.message : 'Try again.', + variant: 'destructive', + }); + } + }; + + return ( + + +
+ + + {report.reason} + + {hidden && ( + + + Hidden + + )} + {verified && ( + + + Confirmed + + )} + + reported by · {ago(report.createdAt)} + +
+ + {/* What was reported */} +
+ {target ? ( + <> +

+ {target.title || '(untitled)'} + {meta?.label && ( + + {meta.label} + + )} +

+ {report.content && ( +

{report.content}

+ )} + + ) : ( +

+ + Couldn't resolve the reported event (it may have been deleted, or the report targets a + profile rather than a post). + {report.content && “{report.content}”} +

+ )} +
+ +
+ {target && !hidden && !verified && ( + + )} + {target && !hidden && !verified && ( + + )} + {target && (hidden || verified) && ( + + )} + {target && meta?.path && target.identifier && ( + + )} +
+
+
+ ); +} + +/** + * The report inbox tab. Independent of the heuristic queue: anything anyone + * reported shows up here, resolvable or not. + */ +export function ReportsInbox() { + const { data: reports, isLoading } = useRecentReports(200); + + if (isLoading) { + return ( +
+ + +
+ ); + } + + if (!reports || reports.length === 0) { + return ( + + + +

+ No reports yet. Either everything is fine, or nobody's found the button. +

+
+
+ ); + } + + return ( +
+ {reports.map((r) => ( + + ))} +
+ ); +} diff --git a/src/hooks/useCrews.ts b/src/hooks/useCrews.ts index 241009c..5589c62 100644 --- a/src/hooks/useCrews.ts +++ b/src/hooks/useCrews.ts @@ -10,13 +10,16 @@ import { import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration'; -export function useCrews(limit = 200) { +export function useCrews(limit = 200, options: { includeHidden?: boolean } = {}) { + const { includeHidden = false } = options; const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const hidden = useAdminModeration(); return useQuery({ queryKey: ['skate-crews', limit, getQueryKeySuffix()], - select: (crews: SkateCrew[]) => filterAdminHidden(crews, SKATE_CREW_KIND, hidden), + // `includeHidden`: admin tooling only (review queue + undo). + select: (crews: SkateCrew[]) => + includeHidden ? crews : filterAdminHidden(crews, SKATE_CREW_KIND, hidden), queryFn: async () => { const signal = AbortSignal.timeout(10000); diff --git a/src/hooks/useLabProfiles.ts b/src/hooks/useLabProfiles.ts index b905062..44e1437 100644 --- a/src/hooks/useLabProfiles.ts +++ b/src/hooks/useLabProfiles.ts @@ -99,13 +99,16 @@ export function parseLabProfile(event: NostrEvent): LabProfile | null { } } -export function useLabProfiles(limit = 100) { +export function useLabProfiles(limit = 100, options: { includeHidden?: boolean } = {}) { + const { includeHidden = false } = options; const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const hidden = useAdminModeration(); return useQuery({ queryKey: ['lab-profiles', limit, getQueryKeySuffix()], - select: (profiles: LabProfile[]) => filterAdminHiddenMixed(profiles, hidden), + // `includeHidden`: admin tooling only (review queue + undo). + select: (profiles: LabProfile[]) => + includeHidden ? profiles : filterAdminHiddenMixed(profiles, hidden), queryFn: async () => { const signal = AbortSignal.timeout(15000); diff --git a/src/hooks/useRecentReports.ts b/src/hooks/useRecentReports.ts new file mode 100644 index 0000000..285225b --- /dev/null +++ b/src/hooks/useRecentReports.ts @@ -0,0 +1,120 @@ +import { useQuery } from '@tanstack/react-query'; +import { useSkateRelays } from '@/hooks/useSkateRelays'; +import type { NostrEvent } from '@nostrify/nostrify'; + +/** NIP-56 report kind. */ +export const REPORT_KIND = 1984; + +export interface ReportTarget { + id: string; + kind: number; + pubkey: string; + identifier?: string; + /** `kind:pubkey:d` when the target is addressable (what the labels use). */ + coord?: string; + title?: string; +} + +export interface RecentReport { + id: string; + reporter: string; + createdAt: number; + /** NIP-56 reason from the `e` tag: spam | illegal | impersonation | other … */ + reason: string; + content: string; + /** The reported event, when we could resolve it. */ + target?: ReportTarget; +} + +/** + * The report inbox: recent NIP-56 reports, with their targets RESOLVED. + * + * WHY: reports were only ever visible for items that already happened to be in + * the review queue (and only as a count). A report about a comment, a profile, a + * crew or anything not currently flagged had nowhere to land. This reads recent + * reports and resolves the referenced event so a human can act on any of them — + * no heuristics involved. + */ +export function useRecentReports(limit = 200) { + const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + + return useQuery({ + queryKey: ['recent-reports', limit, getQueryKeySuffix()], + staleTime: 60_000, + queryFn: async (c): Promise => { + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(9000)]); + + const reports = await querySkateContent([{ kinds: [REPORT_KIND], limit }], { signal }); + if (reports.length === 0) return []; + + // Resolve the reported events: one extra query for every referenced id. + const ids = [ + ...new Set( + reports + .flatMap((r) => r.tags.filter(([n]) => n === 'e').map(([, v]) => v)) + .filter(Boolean), + ), + ].slice(0, 200); + + const targets = new Map(); + if (ids.length > 0) { + try { + const events = await querySkateContent([{ ids, limit: ids.length }], { signal }); + for (const ev of events) targets.set(ev.id, ev); + } catch { + // Resolution is best-effort — an unresolvable report is still shown. + } + } + + return reports + .map((r): RecentReport => { + const eTag = r.tags.find(([n]) => n === 'e'); + const targetEvent = eTag?.[1] ? targets.get(eTag[1]) : undefined; + const d = targetEvent?.tags.find(([n]) => n === 'd')?.[1]; + const title = targetEvent?.tags.find(([n]) => n === 'title' || n === 'name')?.[1]; + return { + id: r.id, + reporter: r.pubkey, + createdAt: r.created_at, + reason: eTag?.[2] || 'other', + content: r.content, + target: targetEvent + ? { + id: targetEvent.id, + kind: targetEvent.kind, + pubkey: targetEvent.pubkey, + identifier: d, + coord: d ? `${targetEvent.kind}:${targetEvent.pubkey}:${d}` : undefined, + title, + } + : undefined, + }; + }) + .sort((a, b) => b.createdAt - a.createdAt); + }, + }); +} + +/** Human label + route for a reported kind. */ +export function reportTargetMeta(kind: number): { label: string; path?: string } { + switch (kind) { + case 30818: + return { label: 'spot', path: '/spot' }; + case 30017: + return { label: 'shop', path: '/shop' }; + case 31923: + case 31925: + return { label: 'event', path: '/event' }; + case 30823: + return { label: 'tour', path: '/tour' }; + case 30402: + return { label: 'listing', path: '/listing' }; + case 35006: + return { label: 'crew', path: '/crew' }; + case 35000: + return { label: 'skater profile' }; + default: + if (kind >= 35001 && kind <= 35014) return { label: 'Lab profile' }; + return { label: `kind ${kind}` }; + } +} diff --git a/src/pages/AdminReviewPage.tsx b/src/pages/AdminReviewPage.tsx index fa30ede..d373bfb 100644 --- a/src/pages/AdminReviewPage.tsx +++ b/src/pages/AdminReviewPage.tsx @@ -17,12 +17,26 @@ import { PageHeader } from '@/components/PageHeader'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { ReportsInbox } from '@/components/ReportsInbox'; import { Skeleton } from '@/components/ui/skeleton'; import { useSkateSpots } from '@/hooks/useSkateSpots'; import { useSkateShops } from '@/hooks/useSkateShops'; import { useSkateEvents } from '@/hooks/useSkateEvents'; import { useTours } from '@/hooks/useTours'; import { useMarketplaceItems } from '@/hooks/useMarketplace'; +import { useCrews } from '@/hooks/useCrews'; +import { useLabProfiles } from '@/hooks/useLabProfiles'; import { useBatchTrustScores } from '@/hooks/useBatchTrustScores'; import { useBatchSpotStatus } from '@/hooks/useSpotStatus'; import { useBatchSpotPhotos } from '@/hooks/useSpotContributions'; @@ -40,6 +54,8 @@ import { CALENDAR_EVENT_KIND, SKATE_TOUR_KIND, MARKETPLACE_ITEM_KIND, + SKATE_CREW_KIND, + SKATE_COACH_KIND, } from '@/lib/types'; import NotFound from '@/pages/NotFound'; @@ -57,14 +73,17 @@ import NotFound from '@/pages/NotFound'; * hypotheses — a human decides. */ -type QueueKind = 'spots' | 'shops' | 'events' | 'tours' | 'gear'; +type QueueKind = 'reports' | 'spots' | 'shops' | 'events' | 'tours' | 'gear' | 'crews' | 'lab'; const TABS: Array<{ id: QueueKind; label: string }> = [ + { id: 'reports', label: 'Reports' }, { id: 'spots', label: 'Spots' }, { id: 'shops', label: 'Shops' }, { id: 'events', label: 'Events' }, { id: 'tours', label: 'Tours' }, { id: 'gear', label: 'Gear' }, + { id: 'crews', label: 'Crews' }, + { id: 'lab', label: 'Lab' }, ]; /** One reviewable item, flattened from whatever kind it came from. */ @@ -89,9 +108,14 @@ interface QueueRow { function QueueList({ rows, isLoading, + /** Hiding these removes a community/identity, not just a pin — confirm first. */ + confirmHide = false, + confirmHideNote, }: { rows: QueueRow[]; isLoading: boolean; + confirmHide?: boolean; + confirmHideNote?: string; }) { const { hideContent, isPending: hiding } = useAdminHide(); const { confirmContent, isPending: confirming } = useAdminVerify(); @@ -100,6 +124,7 @@ function QueueList({ const { reportNotASpot } = useNotASpotReport(); const { toast } = useToast(); + const [bulkBusy, setBulkBusy] = useState(false); const mappers = useMemo(() => [...new Set(rows.map((r) => r.pubkey))], [rows]); const { data: trustScores } = useBatchTrustScores(mappers); const { getCounts } = useSpotReports(rows); @@ -188,8 +213,58 @@ function QueueList({ ); } + const actioned = prepared.filter((r) => r.actioned); + + const undoAll = async () => { + setBulkBusy(true); + let done = 0; + for (const r of actioned) { + try { + await clearVerdict({ coord: r.coord, id: r.row.id, reason: 'bulk undo' }); + done += 1; + } catch { + // Keep going: one failure shouldn't abandon the rest. + } + } + setBulkBusy(false); + toast({ + title: `Undid ${done} verdict${done === 1 ? '' : 's'}`, + description: 'Each one is a separate label publish — on Amber that is one approval each.', + }); + }; + return (
+ {actioned.length > 1 && ( +
+

+ {actioned.length} item{actioned.length === 1 ? '' : 's'} hidden or confirmed in this tab. +

+ + + + + + + Undo every verdict in this tab? + + This sets each item back to neutral — not hidden, not vouched. It publishes one + public label per item, so with Amber you'll get {actioned.length} approval screens. + + + + Cancel + void undoAll()}> + Undo {actioned.length} + + + + +
+ )} {prepared.map(({ row, coord, hidden, verified, trust, reports, flags }) => ( : } Confirm - + {confirmHide ? ( + + + + + + + Hide this for everyone? + + {confirmHideNote ?? + 'This removes the item from browse and search for every SkateSpots user. It is a public, reversible label — you can Undo it from this queue.'} + + + + Cancel + + void act( + () => hideContent({ coord, id: row.id, reason: `auto-flagged: ${flags.join(', ')}` }), + { title: 'Hidden', description: 'Removed from the app via a public label. Reversible.' }, + 'Could not hide', + ) + } + > + Hide it + + + + + ) : ( + + )} {row.kind === SKATE_SPOT_KIND && (
+ {kind === 'reports' && } {kind === 'spots' && } {kind === 'shops' && } {kind === 'events' && } {kind === 'tours' && } {kind === 'gear' && } + {kind === 'crews' && } + {kind === 'lab' && }

Hide publishes{' '}