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>
218 lines
7.7 KiB
TypeScript
218 lines
7.7 KiB
TypeScript
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>
|
||
);
|
||
}
|