225 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|