SkateSpots/src/components/CollectiveMembers.tsx
shakespeare.diy 926470a86e Crews: opt-in member visibility + Lab vouch/web-of-trust anti-fraud + CrewDetail redesign
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>
2026-08-10 21:01:57 +07:00

218 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Users, Check, Clock, ShieldCheck, EyeOff } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
import { useCollectiveMembers, type CollectiveMember } from '@/hooks/useCollectiveMembers';
import { cn } from '@/lib/utils';
interface CollectiveMembersProps {
kind: number;
founderPubkey: string;
identifier: string;
claimedMembers: string[];
/** Delegated admin pubkeys (subset of members). */
admins?: string[];
accentText?: string;
accentBorder?: string;
}
function StatusChip({ status }: { status: CollectiveMember['status'] }) {
if (status === 'confirmed') {
return (
<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-500">
<Check className="h-3 w-3" /> Confirmed
</span>
);
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-xs font-bold text-amber-500">
<Clock className="h-3 w-3" /> Pending
</span>
</TooltipTrigger>
<TooltipContent>
Listed by the collective — awaiting this member’s confirmation.
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function AdminChip() {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-teal-500/15 px-2 py-0.5 text-xs font-bold text-teal-300">
<ShieldCheck className="h-3 w-3" /> Admin
</span>
);
}
function CrewOnlyChip() {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs font-bold text-gray-300">
<EyeOff className="h-3 w-3" /> Crew only
</span>
</TooltipTrigger>
<TooltipContent>
This member chose to be visible only to crew-mates. You can see them
because you're in the crew.
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function MemberRow({ member, isAdmin }: { member: CollectiveMember; isAdmin: boolean }) {
const author = useAuthor(member.pubkey);
const metadata = author.data?.metadata;
const name = metadata?.name ?? genUserName(member.pubkey);
const npub = nip19.npubEncode(member.pubkey);
return (
<Link
to={`/${npub}`}
className="flex items-center gap-3 rounded-lg bg-black/40 p-2 transition-colors hover:bg-black/60"
>
<Avatar className="h-9 w-9">
<AvatarImage src={metadata?.picture} alt={name} />
<AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-gray-200">{name}</span>
{member.visibility === 'crew' && <CrewOnlyChip />}
{isAdmin && <AdminChip />}
<StatusChip status={member.status} />
</Link>
);
}
/** Anonymous placeholder shown to NON-members for a `crew`-only member. */
function HiddenMemberRow() {
return (
<div className="flex items-center gap-3 rounded-lg bg-black/40 p-2">
<Avatar className="h-9 w-9">
<AvatarFallback className="bg-white/5 text-gray-600">
<EyeOff className="h-4 w-4" />
</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-gray-500 italic">
Hidden member
</span>
<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">
<EyeOff className="h-3 w-3" /> Crew only
</span>
</div>
);
}
/**
* Renders a collective's members with confirmed / pending trust chips.
* Returns null for individuals or collectives with no listed members.
*/
export function CollectiveMembers({
kind,
founderPubkey,
identifier,
claimedMembers,
admins = [],
accentText = 'text-indigo-400',
accentBorder = 'border-gray-700',
}: CollectiveMembersProps) {
const { user } = useCurrentUser();
const { data: members, isLoading } = useCollectiveMembers({
kind,
founderPubkey,
identifier,
claimedMembers,
enabled: claimedMembers.length > 0,
});
if (claimedMembers.length === 0) return null;
const adminSet = new Set(admins);
const confirmedCount = members?.filter((m) => m.status === 'confirmed').length ?? 0;
// The viewer counts as a crew-mate (can see crew-only members) if they are
// the founder or a claimed member of this crew.
const viewerIsCrewmate =
!!user && (user.pubkey === founderPubkey || claimedMembers.includes(user.pubkey));
// Admins first, then the hook's existing confirmed-first ordering.
const orderedMembers = members
? [...members].sort((a, b) => {
const aa = adminSet.has(a.pubkey) ? 1 : 0;
const ba = adminSet.has(b.pubkey) ? 1 : 0;
return ba - aa;
})
: members;
// Split into what the viewer may see vs. anonymised "hidden member" slots.
// A crew-only member is always visible to themselves.
const isHiddenFromViewer = (m: CollectiveMember) =>
m.visibility === 'crew' && !viewerIsCrewmate && m.pubkey !== user?.pubkey;
const visibleMembers = orderedMembers?.filter((m) => !isHiddenFromViewer(m)) ?? [];
const hiddenCount = orderedMembers?.filter(isHiddenFromViewer).length ?? 0;
return (
<Card className={cn('bg-black/90 border-2', accentBorder)}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<Users className={cn('h-5 w-5', accentText)} />
Members · {claimedMembers.length}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="flex items-start gap-1.5 text-xs text-gray-400">
<ShieldCheck className={cn('mt-0.5 h-3.5 w-3.5 shrink-0', accentText)} />
<span>
{confirmedCount > 0
? `${confirmedCount} of ${claimedMembers.length} verified — each confirmed member signed with their own Nostr key.`
: 'Membership is verified per member. Each member confirms with their own Nostr key.'}
{admins.length > 0 && ` ${admins.length} admin${admins.length !== 1 ? 's' : ''} help run the crew.`}
</span>
</p>
{isLoading ? (
<div className="space-y-2">
{claimedMembers.slice(0, 3).map((_, i) => (
<div key={i} className="flex items-center gap-3 rounded-lg bg-black/40 p-2">
<Skeleton className="h-9 w-9 rounded-full" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-5 w-20 rounded-full" />
</div>
))}
</div>
) : (
<div className="space-y-2">
{visibleMembers.map((m) => (
<MemberRow key={m.pubkey} member={m} isAdmin={adminSet.has(m.pubkey)} />
))}
{/* Collapse all crew-only members into anonymous slots for outsiders */}
{hiddenCount > 0 && (
<>
{Array.from({ length: hiddenCount }).map((_, i) => (
<HiddenMemberRow key={`hidden-${i}`} />
))}
</>
)}
</div>
)}
</CardContent>
</Card>
);
}