Completes the safety loop. useMuteList reads the viewer's kind-10000 mute list plus optimistic local mutes (lib/localMutes.ts); useMuteUser republishes the list with the target added, records it locally and invalidates content queries. useAdminModeration carries a new mutedPubkeys set, and the shared filterAdminHidden/filterAdminHiddenMixed drop muted authors — so every list hook filters them with no per-hook changes (admin tooling with includeHidden still sees them). The flagged notice gains a Mute action.
130 lines
4.6 KiB
TypeScript
130 lines
4.6 KiB
TypeScript
import { useState } from 'react';
|
|
import { AlertTriangle, Flag } from 'lucide-react';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { ReportDialog } from '@/components/ReportDialog';
|
|
import { useTrustBucket } from '@/hooks/useTrustBucket';
|
|
import { useUserReports } from '@/hooks/useUserReports';
|
|
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
|
import { useMuteUser } from '@/hooks/useMuteUser';
|
|
import { FLAGGED_REPORTERS_THRESHOLD } from '@/lib/trustLadder';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface TrustFlaggedNoticeProps {
|
|
pubkey: string;
|
|
targetTitle?: string;
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* A safety notice for a flagged counterparty (seller, coach, organiser). It is
|
|
* informational, not an automatic takedown: it shows that trusted people
|
|
* reported the account, breaks the reporters down by the viewer's own network,
|
|
* and offers Report / Dismiss. Dismissal is session-only — a safety signal
|
|
* reappears on the next visit rather than staying hidden forever.
|
|
*/
|
|
export function TrustFlaggedNotice({ pubkey, targetTitle, className }: TrustFlaggedNoticeProps) {
|
|
const { t } = useTranslation();
|
|
const { bucket, assertion } = useTrustBucket(pubkey);
|
|
const { reporterPubkeys, reporterCount } = useUserReports(pubkey);
|
|
const { data: scores } = useBatchTrustScores(reporterPubkeys);
|
|
const [dismissed, setDismissed] = useState(false);
|
|
const [reportOpen, setReportOpen] = useState(false);
|
|
const muteUser = useMuteUser();
|
|
|
|
const flagged = bucket === 'flagged' || reporterCount >= FLAGGED_REPORTERS_THRESHOLD;
|
|
if (!flagged || dismissed) return null;
|
|
|
|
let follow = 0;
|
|
let network = 0;
|
|
let other = 0;
|
|
for (const pk of reporterPubkeys) {
|
|
const score = scores?.get(pk);
|
|
if (score !== undefined && score >= 90) follow += 1;
|
|
else if (score === 60) network += 1;
|
|
else other += 1;
|
|
}
|
|
|
|
const trustedCount = assertion?.reporters ?? reporterCount;
|
|
|
|
return (
|
|
<div className={cn('rounded-2xl border-2 border-red-500/40 bg-red-500/10 p-4', className)}>
|
|
<div className="flex items-start gap-3">
|
|
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-red-400" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-black uppercase tracking-wide text-red-300">
|
|
{t('walletUi.trust.flaggedNoticeTitle')}
|
|
</p>
|
|
<p className="mt-1 text-xs leading-relaxed text-gray-300">
|
|
{t('walletUi.trust.flaggedNoticeDesc')}
|
|
</p>
|
|
{trustedCount > 0 && (
|
|
<p className="mt-1 text-xs text-gray-400">
|
|
{t('walletUi.trust.reportedByTrusted', { count: trustedCount })}
|
|
</p>
|
|
)}
|
|
|
|
{(follow > 0 || network > 0 || other > 0) && (
|
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
{follow > 0 && (
|
|
<Chip label={t('walletUi.trust.flaggedReporterFollow', { count: follow })} />
|
|
)}
|
|
{network > 0 && (
|
|
<Chip label={t('walletUi.trust.flaggedReporterNetwork', { count: network })} />
|
|
)}
|
|
{other > 0 && (
|
|
<Chip label={t('walletUi.trust.flaggedReporterOther', { count: other })} />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-3 flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-8 min-h-0 gap-1.5 border-red-500/40 text-red-300 hover:bg-red-500/10"
|
|
onClick={() => setReportOpen(true)}
|
|
>
|
|
<Flag className="h-3.5 w-3.5" />
|
|
{t('walletUi.trust.flaggedReport')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-8 min-h-0"
|
|
disabled={muteUser.isPending}
|
|
onClick={() => muteUser.mutate(pubkey, { onSuccess: () => setDismissed(true) })}
|
|
>
|
|
{t('walletUi.trust.mute')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-8 min-h-0 text-gray-400"
|
|
onClick={() => setDismissed(true)}
|
|
>
|
|
{t('walletUi.trust.flaggedDismiss')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<ReportDialog
|
|
open={reportOpen}
|
|
onOpenChange={setReportOpen}
|
|
targetType="user"
|
|
targetPubkey={pubkey}
|
|
targetTitle={targetTitle}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Chip({ label }: { label: string }) {
|
|
return (
|
|
<span className="rounded-full bg-red-500/15 px-2 py-0.5 text-[10px] font-medium text-red-200">
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|