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 (
+
+
+
+
+ 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}”}
+
+ 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}
+
+
+
+
+