SkateSpots/src/components/TrustBadge.tsx
SkateSpots fead9ca781 feat(trust): spec-correct NIP-85 consumer + Verified/Unknown/Flagged badge
First slice of TRUST_SCORE.md.

- lib/nip85.ts: parse kind-10040 provider designations, resolve a provider
  with a house fallback, parse kind-30382 assertions (rank/followers/
  reporters/muters/hops; absent hops means no path, absence is not
  distrust), and author-check the provider.
- hooks/useTrustAssertions.ts: batch read from the resolved provider,
  signature-verified via onlyVerified + the author check.
- lib/trustLadder.ts: three buckets and thresholds (flagged = trusted
  reporters >= 2; verified rank >= 2).
- hooks/useTrustBucket.ts: prefers a NIP-85 assertion (verify + flag),
  falls back to the local WoT ladder (verify only).
- TrustBadge.tsx: now renders Verified / Unknown / Flagged with glyph,
  colour and tooltip; Unknown hidden by default, Flagged always shown.
  Reaches the map/tour/event cards, marketplace card, listing detail and
  coach detail through the shared component.

Open follow-ups (in TRUST_SCORE.md): batch the bucket at list level, a
'who reported' breakdown, a provider-choice setting, alerts, confidence
scoring.
2026-10-09 17:07:34 +07:00

98 lines
3.1 KiB
TypeScript

import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { CheckCircle2, HelpCircle, Flag } from 'lucide-react';
import { useTrustBucket } from '@/hooks/useTrustBucket';
import { bucketDescKey, bucketLabelKey, type TrustBucket } from '@/lib/trustLadder';
import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language';
interface TrustBadgeProps {
pubkey: string | undefined;
className?: string;
showLabel?: boolean;
size?: 'sm' | 'md' | 'lg';
/**
* Also render the neutral "Unknown" bucket. Off by default so a card row is
* not flooded with a badge on every author; surfaces with room can turn it on.
*/
showUnknown?: boolean;
}
const BUCKET_STYLE: Record<
TrustBucket,
{ className: string; Icon: typeof CheckCircle2 }
> = {
verified: {
className: 'border-green-500/50 text-green-400 bg-green-500/10',
Icon: CheckCircle2,
},
unknown: {
className: 'border-gray-500/50 text-gray-400',
Icon: HelpCircle,
},
flagged: {
className: 'border-red-500/60 text-red-400 bg-red-500/10',
Icon: Flag,
},
};
/**
* TrustBadge shows one of three buckets for an author:
* verified (someone in your network vouches), unknown (nothing said yet), or
* flagged (trusted people reported them). Prefers a NIP-85 assertion, falls
* back to the local web-of-trust score. Flagged always shows.
*/
export function TrustBadge({
pubkey,
className,
showLabel = true,
size = 'sm',
showUnknown = false,
}: TrustBadgeProps) {
const { t } = useTranslation();
const { bucket, assertion, isLoading } = useTrustBucket(pubkey);
if (isLoading) return null;
if (bucket === 'unknown' && !showUnknown) return null;
const { className: bucketClass, Icon } = BUCKET_STYLE[bucket];
const sizeClasses = {
sm: 'text-[9px] px-1.5 py-0 h-4 gap-0.5',
md: 'text-xs px-2 py-0.5 h-5 gap-1',
lg: 'text-sm px-3 py-1 h-6 gap-1.5',
};
const iconSizes = {
sm: 'h-2.5 w-2.5',
md: 'h-3 w-3',
lg: 'h-4 w-4',
};
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={cn(sizeClasses[size], bucketClass, className, 'cursor-help select-none')}
>
<Icon className={iconSizes[size]} />
{showLabel && <span className="font-bold uppercase">{t(bucketLabelKey(bucket))}</span>}
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<div className="space-y-1">
<p className="font-bold">{t(bucketLabelKey(bucket))}</p>
<p className="text-xs text-muted-foreground">{t(bucketDescKey(bucket))}</p>
{bucket === 'flagged' && assertion?.reporters !== undefined && assertion.reporters > 0 && (
<p className="text-xs text-muted-foreground">
{t('walletUi.trust.reportedByTrusted', { count: assertion.reporters })}
</p>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}