SkateSpots/src/pages/AdminReviewPage.tsx
SkateSpots 3051858de7 contents(admin): tidy the review queue + add gallery screenshots
- Review queue: past verdicts (e.g. the 13 hidden labels from an Aug/Sep cleanup
  sweep) are correct but noisy — they buried the items that still need a
  decision. They are now COLLAPSED behind a 'Show actioned (N)' toggle, with the
  verdicts untouched (still hidden) and 'Undo all N' still available. Verified:
  the Spots tab renders 0 hidden cards by default with the toggle + Undo all.
- public/images/screenshots/{map,spot,marketplace}.png (1280x800, quantised) for
  the app-listing gallery.
2026-09-28 11:08:31 +07:00

710 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import {
ShieldAlert,
Flag,
EyeOff,
ExternalLink,
Loader2,
CheckCircle2,
BadgeCheck,
Users,
ShieldQuestion,
Undo2,
} from 'lucide-react';
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';
import { useSpotReports } from '@/hooks/useSpotReports';
import { useAdminHide, useAdminVerify, useAdminClear } from '@/hooks/useAdminHide';
import { useAdminModeration } from '@/hooks/useAdminModeration';
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import { isAdminPubkey } from '@/lib/admins';
import { contentFlags } from '@/lib/spotQuality';
import {
SKATE_SPOT_KIND,
SKATE_SHOP_KIND,
CALENDAR_EVENT_KIND,
SKATE_TOUR_KIND,
MARKETPLACE_ITEM_KIND,
SKATE_CREW_KIND,
SKATE_COACH_KIND,
} from '@/lib/types';
import NotFound from '@/pages/NotFound';
/**
* Admin review queue.
*
* Covers EVERY content kind we can hide (spots, shops, events, tours, gear) —
* the hide/verify labels were always kind-agnostic (`a`/`e` tags), so the queue
* just needed to stop being spot-only.
*
* Nothing here deletes: **Hide** publishes a public, reversible NIP-32 label,
* **Confirm** publishes the opposite (`verified`) so a review can IMPROVE an
* item's standing, and **Undo** retracts the verdict back to neutral. Human
* reports (NIP-56) always rank above the automated heuristics. Flags are
* hypotheses — a human decides.
*/
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. */
interface QueueRow {
id: string;
pubkey: string;
identifier: string;
kind: number;
title: string;
description: string;
image?: string;
imageCount: number;
hasLocation: boolean;
href: string;
/** Spot-only signals (others have no status/photo data). */
communityStatusReports?: number;
communityPhotos?: number;
}
// ─── Shared rendering: trust + reports + verdicts + actions ──────────────────
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();
const { clearVerdict, isPending: clearing } = useAdminClear();
const moderation = useAdminModeration();
const { reportNotASpot } = useNotASpotReport();
const { toast } = useToast();
const [bulkBusy, setBulkBusy] = useState(false);
// Old verdicts (e.g. a cleanup sweep from weeks ago) are correct but noisy —
// they must not bury the items that still need a decision.
const [showActioned, setShowActioned] = useState(false);
const mappers = useMemo(() => [...new Set(rows.map((r) => r.pubkey))], [rows]);
const { data: trustScores } = useBatchTrustScores(mappers);
const { getCounts } = useSpotReports(rows);
const prepared = useMemo(
() =>
rows
.map((row) => {
const coord = `${row.kind}:${row.pubkey}:${row.identifier}`;
const hidden = moderation.has({ coord, id: row.id });
const verified = moderation.isVerified({ coord, id: row.id });
const flags = contentFlags({
title: row.title,
description: row.description,
imageCount: row.imageCount,
hasLocation: row.hasLocation,
});
const trust = trustScores?.get(row.pubkey);
if (typeof trust === 'number' && trust <= 0) {
flags.push('mapper not in your web of trust');
}
if (row.communityStatusReports !== undefined || row.communityPhotos !== undefined) {
const signals = (row.communityStatusReports ?? 0) + (row.communityPhotos ?? 0);
if (signals === 0 && flags.length > 0) flags.push('no community signals');
}
const reports = getCounts(row.id);
if (reports.total > 0) {
flags.unshift(
reports.notASpot > 0 && row.kind === SKATE_SPOT_KIND
? 'reported: not a skate spot'
: `reported ×${reports.total}`,
);
}
return { row, coord, hidden, verified, actioned: hidden || verified, trust, reports, flags };
})
.filter((r) => r.actioned || r.flags.length > 0)
.sort((a, b) => {
if (a.actioned !== b.actioned) return a.actioned ? 1 : -1;
if (a.reports.notASpot !== b.reports.notASpot) return b.reports.notASpot - a.reports.notASpot;
if (a.reports.total !== b.reports.total) return b.reports.total - a.reports.total;
return b.flags.length - a.flags.length;
}),
[rows, moderation, trustScores, getCounts],
);
const act = async (
fn: () => Promise<void>,
ok: { title: string; description: string },
fail: string,
) => {
try {
await fn();
toast(ok);
} catch (e) {
toast({
title: fail,
description: e instanceof Error ? e.message : 'Try again.',
variant: 'destructive',
});
}
};
if (isLoading) {
return (
<div className="space-y-3">
<Skeleton className="h-24 w-full rounded-2xl" />
<Skeleton className="h-24 w-full rounded-2xl" />
</div>
);
}
if (prepared.length === 0) {
return (
<Card className="border-dashed border-white/10">
<CardContent className="space-y-3 py-12 text-center">
<CheckCircle2 className="mx-auto h-8 w-8 text-emerald-500" />
<p className="text-sm text-gray-400">
Nothing to review — nothing of this kind trips the junk signals.
</p>
</CardContent>
</Card>
);
}
const actioned = prepared.filter((r) => r.actioned);
const open = prepared.filter((r) => !r.actioned);
const visible = showActioned ? prepared : open;
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 (
<div className="space-y-3">
{actioned.length > 0 && (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-2xl border border-white/10 bg-white/[0.02] px-4 py-3">
<p className="text-xs text-gray-400">
{actioned.length} already actioned in this tab (hidden or confirmed).
</p>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="ghost"
className="gap-1.5 text-gray-400 hover:text-white"
onClick={() => setShowActioned((v) => !v)}
>
<EyeOff className="h-3.5 w-3.5" />
{showActioned ? 'Hide actioned' : `Show actioned (${actioned.length})`}
</Button>
{actioned.length > 1 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline" className="gap-1.5 border-white/20" disabled={bulkBusy}>
{bulkBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Undo2 className="h-3.5 w-3.5" />}
Undo all {actioned.length}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Undo every verdict in this tab?</AlertDialogTitle>
<AlertDialogDescription>
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.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => void undoAll()}>
Undo {actioned.length}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
)}
{visible.length === 0 && (
<Card className="border-dashed border-white/10">
<CardContent className="space-y-3 py-12 text-center">
<CheckCircle2 className="mx-auto h-8 w-8 text-emerald-500" />
<p className="text-sm text-gray-400">
Nothing waiting on you. {actioned.length} earlier verdict
{actioned.length === 1 ? '' : 's'} {actioned.length === 1 ? 'is' : 'are'} hidden above.
</p>
</CardContent>
</Card>
)}
{visible.map(({ row, coord, hidden, verified, trust, reports, flags }) => (
<Card
key={row.id}
className={
hidden
? 'border-2 border-red-500/25 bg-red-500/[0.04]'
: verified
? 'border-2 border-emerald-500/25 bg-emerald-500/[0.04]'
: 'border-2 border-white/10 bg-white/[0.03]'
}
>
<CardContent className="space-y-3 p-4">
<div className="flex items-start gap-3">
{row.image ? (
<img src={row.image} alt="" className="h-14 w-14 shrink-0 rounded-lg object-cover" />
) : (
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-lg bg-white/10 text-xl">
🛹
</div>
)}
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 truncate font-bold text-white">
{hidden && (
<Badge className="border border-red-500/50 bg-red-500/20 text-[10px] font-black uppercase tracking-wide text-red-300">
<EyeOff className="mr-1 h-3 w-3" />
Hidden
</Badge>
)}
{verified && (
<Badge className="border border-emerald-500/50 bg-emerald-500/20 text-[10px] font-black uppercase tracking-wide text-emerald-300">
<BadgeCheck className="mr-1 h-3 w-3" />
Confirmed
</Badge>
)}
<span className="truncate">{row.title || '(untitled)'}</span>
</p>
<p className="truncate text-xs text-gray-500">
{row.description?.trim() || 'no description'}
</p>
<div className="mt-1.5 flex flex-wrap gap-1">
{!hidden &&
!verified &&
flags.map((f) => (
<Badge
key={f}
className={
f.startsWith('reported')
? 'border-red-500/50 bg-red-500/20 text-[10px] font-black uppercase tracking-wide text-red-300'
: 'border border-amber-500/40 bg-amber-500/15 text-[10px] font-bold uppercase tracking-wide text-amber-300'
}
>
{f}
</Badge>
))}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-gray-500">
<span className="flex items-center gap-1">
{typeof trust === 'number' && trust > 0 ? (
<BadgeCheck className="h-3 w-3 text-emerald-500" />
) : (
<ShieldQuestion className="h-3 w-3 text-gray-600" />
)}
mapper trust {typeof trust === 'number' ? trust : '—'}
</span>
{row.communityStatusReports !== undefined && (
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{row.communityStatusReports + (row.communityPhotos ?? 0) > 0
? [
row.communityStatusReports > 0
? `${row.communityStatusReports} status report${row.communityStatusReports === 1 ? '' : 's'}`
: null,
(row.communityPhotos ?? 0) > 0
? `${row.communityPhotos} community photo${row.communityPhotos === 1 ? '' : 's'}`
: null,
]
.filter(Boolean)
.join(' · ')
: 'no community signals'}
</span>
)}
{reports.total > 0 && (
<span className="flex items-center gap-1 text-red-400">
<Flag className="h-3 w-3" />
{reports.total} report{reports.total === 1 ? '' : 's'}
</span>
)}
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
{hidden || verified ? (
<Button
size="sm"
variant="outline"
className="gap-1.5 border-white/20 text-gray-200 hover:bg-white/10"
disabled={clearing}
onClick={() =>
void act(
() => clearVerdict({ coord, id: row.id, reason: 'verdict retracted' }),
{ title: 'Undone', description: 'Back to neutral — not hidden, not vouched.' },
'Could not undo',
)
}
>
{clearing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Undo2 className="h-3.5 w-3.5" />}
Undo
</Button>
) : (
<>
<Button
size="sm"
className="gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-400"
disabled={confirming}
onClick={() =>
void act(
() => confirmContent({ coord, id: row.id, reason: 'looks legit after review' }),
{ title: 'Confirmed', description: 'Vouched for — it drops off this queue.' },
'Could not confirm',
)
}
>
{confirming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <BadgeCheck className="h-3.5 w-3.5" />}
Confirm
</Button>
{confirmHide ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="outline"
className="gap-1.5 border-red-500/40 text-red-400 hover:bg-red-500/10"
disabled={hiding}
>
{hiding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <EyeOff className="h-3.5 w-3.5" />}
Hide
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Hide this for everyone?</AlertDialogTitle>
<AlertDialogDescription>
{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.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
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
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<Button
size="sm"
variant="outline"
className="gap-1.5 border-red-500/40 text-red-400 hover:bg-red-500/10"
disabled={hiding}
onClick={() =>
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',
)
}
>
{hiding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <EyeOff className="h-3.5 w-3.5" />}
Hide
</Button>
)}
{row.kind === SKATE_SPOT_KIND && (
<Button
size="sm"
variant="outline"
className="gap-1.5 border-amber-500/40 text-amber-400 hover:bg-amber-500/10"
onClick={() =>
void reportNotASpot({ eventId: row.id, pubkey: row.pubkey, title: row.title })
}
>
<Flag className="h-3.5 w-3.5" />
Not a skate spot
</Button>
)}
</>
)}
<Button asChild size="sm" variant="ghost" className="gap-1.5 text-gray-400">
<Link to={row.href}>
<ExternalLink className="h-3.5 w-3.5" />
Look at it
</Link>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
);
}
// ─── Per-kind loaders (one hook each, so only the open tab queries) ──────────
function SpotsQueue() {
// includeHidden: a hidden item must stay listed, or its verdict can't be undone.
const { data, isLoading } = useSkateSpots(300, { includeHidden: true });
const spots = useMemo(() => data ?? [], [data]);
// Spot-only signals.
const statusInput = useMemo(
() => spots.map((s) => ({ pubkey: s.pubkey, identifier: s.identifier })),
[spots],
);
const { getStatus } = useBatchSpotStatus(statusInput);
const { getCommunityPhotoCount } = useBatchSpotPhotos(statusInput);
const rows: QueueRow[] = spots.map((s) => ({
id: s.id,
pubkey: s.pubkey,
identifier: s.identifier,
kind: SKATE_SPOT_KIND,
title: s.title,
description: s.description,
image: s.images?.[0],
imageCount: s.images?.length ?? 0,
hasLocation: !!s.geohash,
href: `/spot/${s.pubkey}/${s.identifier}`,
communityStatusReports: getStatus(s.pubkey, s.identifier)?.reporterCount ?? 0,
communityPhotos: getCommunityPhotoCount(s.pubkey, s.identifier),
}));
return <QueueList rows={rows} isLoading={isLoading} />;
}
function ShopsQueue() {
const { data, isLoading } = useSkateShops(300, { includeHidden: true });
const rows: QueueRow[] = (data ?? []).map((s) => ({
id: s.id,
pubkey: s.pubkey,
identifier: s.identifier,
kind: SKATE_SHOP_KIND,
title: s.name,
description: s.description,
image: s.images?.[0],
imageCount: s.images?.length ?? 0,
hasLocation: !!s.geohash,
href: `/shop/${s.pubkey}/${s.identifier}`,
}));
return <QueueList rows={rows} isLoading={isLoading} />;
}
function EventsQueue() {
const { data, isLoading } = useSkateEvents(200, { includeHidden: true });
const rows: QueueRow[] = (data ?? []).map((e) => ({
id: e.id,
pubkey: e.pubkey,
identifier: e.identifier,
kind: CALENDAR_EVENT_KIND,
title: e.title,
description: e.description,
image: e.image,
imageCount: e.image ? 1 : 0,
hasLocation: !!e.geohash,
href: `/event/${e.pubkey}/${e.identifier}`,
}));
return <QueueList rows={rows} isLoading={isLoading} />;
}
function ToursQueue() {
const { data, isLoading } = useTours(200, { includeHidden: true });
const rows: QueueRow[] = (data ?? []).map((t) => ({
id: t.id,
pubkey: t.pubkey,
identifier: t.identifier,
kind: SKATE_TOUR_KIND,
title: t.title,
description: t.description,
image: t.image,
imageCount: t.image ? 1 : 0,
hasLocation: !!t.geohash,
href: `/tour/${t.pubkey}/${t.identifier}`,
}));
return <QueueList rows={rows} isLoading={isLoading} />;
}
function GearQueue() {
const { data, isLoading } = useMarketplaceItems(200, { includeHidden: true });
const rows: QueueRow[] = (data ?? []).map((i) => ({
id: i.id,
pubkey: i.pubkey,
identifier: i.identifier,
kind: MARKETPLACE_ITEM_KIND,
title: i.title,
description: i.description,
image: i.images?.[0],
imageCount: i.images?.length ?? 0,
hasLocation: !!i.geohash,
href: `/listing/${i.pubkey}/${i.identifier}`,
}));
return <QueueList rows={rows} isLoading={isLoading} />;
}
function CrewsQueue() {
const { data, isLoading } = useCrews(300, { includeHidden: true });
const rows: QueueRow[] = (data ?? []).map((c) => ({
id: c.id,
pubkey: c.pubkey,
identifier: c.identifier,
kind: SKATE_CREW_KIND,
title: c.name,
description: c.bio,
image: c.image,
imageCount: c.image ? 1 : 0,
hasLocation: !!c.geohash,
href: `/crew/${c.pubkey}/${c.identifier}`,
}));
return (
<QueueList
rows={rows}
isLoading={isLoading}
confirmHide
confirmHideNote="Hiding a crew removes the whole community from browse for everyone, not just one pin."
/>
);
}
function LabQueue() {
const { data, isLoading } = useLabProfiles(300, { includeHidden: true });
const rows: QueueRow[] = (data ?? []).map((p) => ({
id: p.id,
pubkey: p.pubkey,
identifier: p.identifier,
kind: p.event?.kind ?? SKATE_COACH_KIND,
title: p.title,
description: p.bio,
image: p.image,
imageCount: p.image ? 1 : 0,
hasLocation: !!p.geohash,
href: `/coach/${p.pubkey}/${p.identifier}`,
}));
return (
<QueueList
rows={rows}
isLoading={isLoading}
confirmHide
confirmHideNote="Hiding a Lab profile takes a person's listing down for everyone, not just one pin."
/>
);
}
export default function AdminReviewPage() {
const { user } = useCurrentUser();
const [kind, setKind] = useState<QueueKind>('reports');
useSeoMeta({ title: 'Review queue — SkateSpots' });
// Admins only. Everyone else sees a 404 rather than a hint that this exists.
if (!isAdminPubkey(user?.pubkey)) return <NotFound />;
return (
<div className="container mx-auto max-w-3xl px-4 py-8 pb-24 md:pb-14">
<PageHeader
icon={ShieldAlert}
title="Review queue"
accent="#ff6600"
subtitle="Spots, shops, events, tours and gear that trip the junk signals. Hide and Confirm are soft, reversible labels; reports rank first. Nothing is deleted."
/>
<div className="mb-4 flex flex-wrap gap-1.5">
{TABS.map((t) => (
<button
key={t.id}
type="button"
onClick={() => setKind(t.id)}
className={
kind === t.id
? 'rounded-full border border-[#ff6600]/60 bg-[#ff6600]/15 px-3 py-1.5 text-[11px] font-black uppercase tracking-wide text-[#ff6600]'
: 'rounded-full border border-white/12 px-3 py-1.5 text-[11px] font-bold uppercase tracking-wide text-gray-400 hover:text-white'
}
aria-pressed={kind === t.id}
>
{t.label}
</button>
))}
</div>
{kind === 'reports' && <ReportsInbox />}
{kind === 'spots' && <SpotsQueue />}
{kind === 'shops' && <ShopsQueue />}
{kind === 'events' && <EventsQueue />}
{kind === 'tours' && <ToursQueue />}
{kind === 'gear' && <GearQueue />}
{kind === 'crews' && <CrewsQueue />}
{kind === 'lab' && <LabQueue />}
<p className="mt-6 text-[11px] leading-relaxed text-gray-600">
<strong className="text-gray-500">Hide</strong> publishes{' '}
<code>org.skatespots.moderation / hidden</code>;{' '}
<strong className="text-gray-500">Confirm</strong> publishes <code>… / verified</code>;{' '}
<strong className="text-gray-500">Undo</strong> publishes <code>… / cleared</code> (back to
neutral). All three are public, reversible labels — nothing is ever deleted from relays.
</p>
</div>
);
}