SkateSpots/src/components/TrustFlaggedNotice.tsx
SkateSpots e66888128f feat(trust): a real mute (NIP-51 kind 10000) that hides content
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.
2026-10-09 18:23:04 +07:00

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>
);
}