SkateSpots/src/components/ReportsInbox.tsx

225 lines
8.3 KiB
TypeScript

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 { useTranslation } from '@/contexts/language';
import { genUserName } from '@/lib/genUserName';
/** "3 h ago" — uses the shared `routeUi.age*` keys so it localizes. */
function ago(unixSeconds: number, t: (key: string, params?: Record<string, string | number>) => string): string {
const mins = Math.round((Date.now() / 1000 - unixSeconds) / 60);
if (mins < 1) return t('routeUi.ageJustNow');
if (mins < 60) return t('routeUi.ageMinAgo', { count: mins });
const hours = Math.round(mins / 60);
if (hours < 24) return t('routeUi.ageHourAgo', { count: hours });
return t('routeUi.ageDayAgo', { count: Math.round(hours / 24) });
}
function Reporter({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const name = author.data?.metadata?.name ?? author.data?.metadata?.display_name ?? genUserName(pubkey);
return <span className="text-gray-400">{name}</span>;
}
function ReportRow({ report }: { report: RecentReport }) {
const { t } = useTranslation();
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<void>, title: string) => {
try {
await fn();
toast({ title });
} catch (e) {
toast({
title: t('toastsUi.couldNotComplete'),
description: e instanceof Error ? e.message : t('toastsUi.tryAgainLower'),
variant: 'destructive',
});
}
};
return (
<Card className="border-2 border-white/10 bg-white/[0.03]">
<CardContent className="space-y-3 p-4">
<div className="flex flex-wrap items-center gap-2">
<Badge className="border border-red-500/50 bg-red-500/20 text-[10px] font-black uppercase tracking-wide text-red-300">
<Flag className="mr-1 h-3 w-3" />
{report.reason}
</Badge>
{hidden && (
<Badge className="border border-red-500/40 bg-red-500/10 text-[10px] font-bold uppercase tracking-wide text-red-300">
<EyeOff className="mr-1 h-3 w-3" />
{t('toastsUi.hiddenToast')}
</Badge>
)}
{verified && (
<Badge className="border border-emerald-500/40 bg-emerald-500/10 text-[10px] font-bold uppercase tracking-wide text-emerald-300">
<BadgeCheck className="mr-1 h-3 w-3" />
{t('toastsUi.confirmed')}
</Badge>
)}
<span className="text-[11px] text-gray-500">
{t('miscUi.reportedBy')} <Reporter pubkey={report.reporter} /> · {ago(report.createdAt, t)}
</span>
</div>
{/* What was reported */}
<div className="rounded-xl border border-white/8 bg-black/30 p-3">
{target ? (
<>
<p className="text-sm font-bold text-white">
{target.title || t('legacyMiscUi.reportsUntitled')}
{meta?.label && (
<span className="ml-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">
{meta.label}
</span>
)}
</p>
{report.content && (
<p className="mt-1 text-xs leading-relaxed text-gray-400">{report.content}</p>
)}
</>
) : (
<p className="flex items-center gap-2 text-xs text-gray-500">
<ShieldQuestion className="h-3.5 w-3.5" />
{t('legacyMiscUi.reportsResolveFailed')}
{report.content && <span className="text-gray-400">“{report.content}”</span>}
</p>
)}
</div>
<div className="flex flex-wrap gap-2">
{target && !hidden && !verified && (
<Button
size="sm"
variant="outline"
className="gap-1.5 border-red-500/40 text-red-400 hover:bg-red-500/10"
disabled={busy}
onClick={() =>
void act(
() =>
hideContent({
coord,
id: target.id,
reason: `reported (${report.reason}) by ${report.reporter}`,
}),
t('toastsUi.hiddenToast'),
)
}
>
{hiding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <EyeOff className="h-3.5 w-3.5" />}
{t('common.hide')}
</Button>
)}
{target && !hidden && !verified && (
<Button
size="sm"
className="gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-400"
disabled={busy}
onClick={() =>
void act(
() => confirmContent({ coord, id: target.id, reason: 'report reviewed — no action' }),
t('toastsUi.confirmed'),
)
}
>
{confirming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <BadgeCheck className="h-3.5 w-3.5" />}
{t('miscUi.dismissReport')}
</Button>
)}
{target && (hidden || verified) && (
<Button
size="sm"
variant="outline"
className="gap-1.5 border-white/20 text-gray-200 hover:bg-white/10"
disabled={busy}
onClick={() =>
void act(
() => clearVerdict({ coord, id: target.id, reason: 'verdict retracted' }),
t('toastsUi.undone'),
)
}
>
{clearing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Undo2 className="h-3.5 w-3.5" />}
{t('common.undo')}
</Button>
)}
{target && meta?.path && target.identifier && (
<Button asChild size="sm" variant="ghost" className="gap-1.5 text-gray-400">
<Link to={`${meta.path}/${target.pubkey}/${target.identifier}`}>
<ExternalLink className="h-3.5 w-3.5" />
{t('legacyMiscUi.reportsLookAtIt')}
</Link>
</Button>
)}
</div>
</CardContent>
</Card>
);
}
/**
* The report inbox tab. Independent of the heuristic queue: anything anyone
* reported shows up here, resolvable or not.
*/
export function ReportsInbox() {
const { t } = useTranslation();
const { data: reports, isLoading } = useRecentReports(200);
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 (!reports || reports.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">
{t('legacyMiscUi.reportsNoReports')}
</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-3">
{reports.map((r) => (
<ReportRow key={r.id} report={r} />
))}
</div>
);
}