179 lines
6.4 KiB
TypeScript
179 lines
6.4 KiB
TypeScript
import { ShieldCheck, Loader2, HeartHandshake, Users } from 'lucide-react';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from '@/components/ui/tooltip';
|
|
import { Link } from 'react-router-dom';
|
|
import { nip19 } from 'nostr-tools';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { useVouches, useVouchFor } from '@/hooks/useVouches';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface VouchCardProps {
|
|
/** Provider coordinate `kind:pubkey:d`. */
|
|
coord: string;
|
|
/** Provider pubkey (to hide the button for the owner). */
|
|
providerPubkey: string;
|
|
/** Provider display name. */
|
|
providerName: string;
|
|
accentText?: string;
|
|
accentBg?: string;
|
|
}
|
|
|
|
function VoucherAvatar({ pubkey }: { pubkey: string }) {
|
|
const author = useAuthor(pubkey);
|
|
const meta = author.data?.metadata;
|
|
const name = meta?.name ?? genUserName(pubkey);
|
|
const npub = nip19.npubEncode(pubkey);
|
|
return (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Link to={`/${npub}`}>
|
|
<Avatar className="h-7 w-7 border border-white/10">
|
|
<AvatarImage src={meta?.picture} alt={name} />
|
|
<AvatarFallback className="text-[10px]">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
|
</Avatar>
|
|
</Link>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{name}</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The community anti-fraud primitive. Skaters vouch for real providers; the
|
|
* verdict is trust-weighted so fake profiles with only cold/fake vouches read as
|
|
* "new / unvouched" while genuinely-known people surface as scene-verified.
|
|
*/
|
|
export function VouchCard({
|
|
coord,
|
|
providerPubkey,
|
|
providerName,
|
|
accentText = 'text-teal-400',
|
|
accentBg = 'bg-teal-500 hover:bg-teal-600',
|
|
}: VouchCardProps) {
|
|
const { t } = useTranslation();
|
|
const { user } = useCurrentUser();
|
|
const { data, isLoading } = useVouches(coord);
|
|
const { mutateAsync: vouch, isPending } = useVouchFor();
|
|
const { toast } = useToast();
|
|
|
|
const isOwner = user?.pubkey.toLowerCase() === providerPubkey.toLowerCase();
|
|
|
|
// Trust tier from the trust-weighted score.
|
|
const tier =
|
|
data.trustedVouchers >= 3 || data.weightedScore >= 150
|
|
? 'verified'
|
|
: data.count > 0
|
|
? 'vouched'
|
|
: 'new';
|
|
|
|
const handleVouch = async () => {
|
|
try {
|
|
await vouch({ coord, providerName, retract: data.viewerVouched });
|
|
toast({
|
|
title: data.viewerVouched ? t('socialUi.vouch.removedTitle') : t('socialUi.vouch.vouchedTitle'),
|
|
description: data.viewerVouched
|
|
? t('socialUi.vouch.removedDesc', { name: providerName })
|
|
: t('socialUi.vouch.vouchedDesc', { name: providerName }),
|
|
});
|
|
} catch (error) {
|
|
toast({
|
|
title: t('socialUi.vouch.couldNotVouch'),
|
|
description: error instanceof Error ? error.message : t('common.tryAgain'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card className="border border-white/10 bg-black/60">
|
|
<CardContent className="space-y-3 p-4">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-gray-400">
|
|
<HeartHandshake className={cn('h-4 w-4', accentText)} /> {t('socialUi.vouch.title')}
|
|
</p>
|
|
{tier === 'verified' && (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-green-500/15 px-2 py-0.5 text-xs font-bold text-green-400">
|
|
<ShieldCheck className="h-3 w-3" /> {t('socialUi.vouch.sceneVerified')}
|
|
</span>
|
|
)}
|
|
{tier === 'vouched' && (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-0.5 text-xs font-bold text-amber-400">
|
|
<Users className="h-3 w-3" /> {t('socialUi.vouch.vouched')}
|
|
</span>
|
|
)}
|
|
{tier === 'new' && (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-white/5 px-2 py-0.5 text-xs font-bold text-gray-500">
|
|
{t('socialUi.vouch.newUnvouched')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<p className="text-xs text-gray-500">{t('socialUi.vouch.checking')}</p>
|
|
) : data.count === 0 ? (
|
|
<p className="text-xs leading-relaxed text-gray-500">
|
|
{t('socialUi.vouch.empty')}
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="flex -space-x-2">
|
|
{data.vouchers.slice(0, 6).map((pk) => (
|
|
<VoucherAvatar key={pk} pubkey={pk} />
|
|
))}
|
|
</div>
|
|
<span className="text-sm font-bold text-gray-300">
|
|
{data.count === 1
|
|
? t('socialUi.vouch.countOne', { count: data.count })
|
|
: t('socialUi.vouch.countMany', { count: data.count })}
|
|
</span>
|
|
</div>
|
|
{data.trustedVouchers > 0 && (
|
|
<p className="text-xs text-green-500/80">
|
|
🛡 {t('socialUi.vouch.trusted', { count: data.trustedVouchers })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{user && !isOwner && (
|
|
<Button
|
|
type="button"
|
|
onClick={handleVouch}
|
|
disabled={isPending}
|
|
variant={data.viewerVouched ? 'outline' : 'default'}
|
|
className={cn(
|
|
'w-full gap-2 font-bold',
|
|
data.viewerVouched ? 'border-white/15 text-gray-300' : accentBg
|
|
)}
|
|
>
|
|
{isPending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<HeartHandshake className="h-4 w-4" />
|
|
)}
|
|
{data.viewerVouched ? t('socialUi.vouch.remove') : t('socialUi.vouch.vouchFor', { name: providerName.split(' ')[0] })}
|
|
</Button>
|
|
)}
|
|
{isOwner && (
|
|
<p className="text-center text-[11px] text-gray-400">
|
|
{t('socialUi.vouch.ownerNote')}
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|