Step 3 — member visibility (best-of-both, honest):
- MemberVisibility ('public' | 'crew') stored on the member's OWN kind-1985
attestation via a `visibility` tag, so a public crew can contain a private
member. Confirmed crew-mates + founder see them; outsiders see anonymised
"hidden member" slots. Framed honestly in-UI (app-level discovery privacy).
- VisibilityPicker in ConfirmMembershipCard (choose on join) +
MemberVisibilityControl for confirmed members to change it anytime.
- useCollectiveMembers parses visibility; CollectiveMembers hides crew-only
members from non-members.
Step 4 — anti-fraud vouch layer:
- useVouches/useVouchFor: NIP-32 vouch labels, trust-WEIGHTED via web-of-trust
so fake-account vouch rings carry no weight. VouchCard with new/vouched/
scene-verified tiers, wired into every Lab profile detail page.
Step 4 — CrewDetail UX redesign:
- Activity consolidated into clean Feed/Events/Chat tabs (no more giant scroll)
- Sidebar reordered: Members → Support → Band → Connections → Founder
- Member visibility control surfaced; tighter hero
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
176 lines
6.2 KiB
TypeScript
176 lines
6.2 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 { 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 { 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 ? 'Vouch removed' : 'Vouched!',
|
|
description: data.viewerVouched
|
|
? `You no longer vouch for ${providerName}.`
|
|
: `You vouched for ${providerName}. The scene trusts its own.`,
|
|
});
|
|
} catch (error) {
|
|
toast({
|
|
title: 'Could not vouch',
|
|
description: error instanceof Error ? error.message : 'Please try again.',
|
|
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)} /> Scene Vouches
|
|
</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" /> Scene-verified
|
|
</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" /> 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">
|
|
New · unvouched
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<p className="text-xs text-gray-500">Checking the scene…</p>
|
|
) : data.count === 0 ? (
|
|
<p className="text-xs leading-relaxed text-gray-500">
|
|
No vouches yet. Vouches from skaters in your circles are the best signal that
|
|
someone's the real deal — not a random who wandered in.
|
|
</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} vouch{data.count !== 1 ? 'es' : ''}
|
|
</span>
|
|
</div>
|
|
{data.trustedVouchers > 0 && (
|
|
<p className="text-xs text-green-500/80">
|
|
🛡 {data.trustedVouchers} from skaters in your web of trust
|
|
</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 ? 'Remove vouch' : `Vouch for ${providerName.split(' ')[0]}`}
|
|
</Button>
|
|
)}
|
|
{isOwner && (
|
|
<p className="text-center text-[11px] text-gray-600">
|
|
Skaters who know your work can vouch for you here.
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|