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{' '}