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>
This commit is contained in:
parent
0071bcba36
commit
926470a86e
10 changed files with 839 additions and 181 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Users, Check, Clock, ShieldCheck } from 'lucide-react';
|
||||
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';
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
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';
|
||||
|
|
@ -58,6 +59,24 @@ function AdminChip() {
|
|||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
@ -74,12 +93,32 @@ function MemberRow({ member, isAdmin }: { member: CollectiveMember; isAdmin: boo
|
|||
<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.
|
||||
|
|
@ -93,6 +132,7 @@ export function CollectiveMembers({
|
|||
accentText = 'text-indigo-400',
|
||||
accentBorder = 'border-gray-700',
|
||||
}: CollectiveMembersProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: members, isLoading } = useCollectiveMembers({
|
||||
kind,
|
||||
founderPubkey,
|
||||
|
|
@ -106,6 +146,11 @@ export function CollectiveMembers({
|
|||
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) => {
|
||||
|
|
@ -115,6 +160,14 @@ export function CollectiveMembers({
|
|||
})
|
||||
: 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>
|
||||
|
|
@ -146,9 +199,17 @@ export function CollectiveMembers({
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{orderedMembers?.map((m) => (
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Users, Check, Loader2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Users, Check, Loader2, Globe, EyeOff } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
|
|
@ -6,6 +7,10 @@ import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
|
|||
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
MEMBER_VISIBILITY_DESCRIPTIONS,
|
||||
type MemberVisibility,
|
||||
} from '@/lib/types';
|
||||
|
||||
interface ConfirmMembershipCardProps {
|
||||
kind: number;
|
||||
|
|
@ -16,10 +21,55 @@ interface ConfirmMembershipCardProps {
|
|||
accentBg?: string;
|
||||
}
|
||||
|
||||
/** Two-option visibility picker used both when confirming and when editing. */
|
||||
export function VisibilityPicker({
|
||||
value,
|
||||
onChange,
|
||||
accentBg = 'bg-indigo-600',
|
||||
}: {
|
||||
value: MemberVisibility;
|
||||
onChange: (v: MemberVisibility) => void;
|
||||
accentBg?: string;
|
||||
}) {
|
||||
const options: { key: MemberVisibility; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
|
||||
{ key: 'public', label: 'Public', icon: Globe },
|
||||
{ key: 'crew', label: 'Crew only', icon: EyeOff },
|
||||
];
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{options.map(({ key, label, icon: Icon }) => {
|
||||
const active = value === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => onChange(key)}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-bold transition-all active:scale-95',
|
||||
active
|
||||
? cn('border-transparent text-white', accentBg)
|
||||
: 'border-white/10 bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-gray-500">
|
||||
{MEMBER_VISIBILITY_DESCRIPTIONS[value]}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown to a logged-in user who has been LISTED as a member of a collective but
|
||||
* has not yet confirmed. Publishes a NIP-32 attestation on click. Hidden for
|
||||
* everyone else (non-members, already-confirmed members, the founder).
|
||||
* has not yet confirmed. Lets them pick their visibility and publishes a NIP-32
|
||||
* attestation on click. Hidden for everyone else (non-members, already-confirmed
|
||||
* members, the founder).
|
||||
*/
|
||||
export function ConfirmMembershipCard({
|
||||
kind,
|
||||
|
|
@ -32,6 +82,7 @@ export function ConfirmMembershipCard({
|
|||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
const { mutateAsync: confirm, isPending } = useConfirmMembership();
|
||||
const [visibility, setVisibility] = useState<MemberVisibility>('public');
|
||||
const { data: members } = useCollectiveMembers({
|
||||
kind,
|
||||
founderPubkey,
|
||||
|
|
@ -52,10 +103,13 @@ export function ConfirmMembershipCard({
|
|||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
await confirm({ kind, founderPubkey, identifier, collectiveName });
|
||||
await confirm({ kind, founderPubkey, identifier, collectiveName, visibility });
|
||||
toast({
|
||||
title: 'Membership confirmed!',
|
||||
description: `You now show as a verified member of ${collectiveName}.`,
|
||||
description:
|
||||
visibility === 'crew'
|
||||
? `You're verified in ${collectiveName} — visible to crew-mates only.`
|
||||
: `You now show as a verified member of ${collectiveName}.`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
|
|
@ -68,7 +122,7 @@ export function ConfirmMembershipCard({
|
|||
|
||||
return (
|
||||
<Card className="border-2 border-amber-500/50 bg-amber-950/20">
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Users className="mt-0.5 h-6 w-6 shrink-0 text-amber-400" />
|
||||
<div>
|
||||
|
|
@ -78,6 +132,14 @@ export function ConfirmMembershipCard({
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-black uppercase tracking-widest text-gray-400">
|
||||
Your visibility
|
||||
</p>
|
||||
<VisibilityPicker value={visibility} onChange={setVisibility} accentBg={accentBg.split(' ')[0]} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
|
|
|
|||
110
src/components/MemberVisibilityControl.tsx
Normal file
110
src/components/MemberVisibilityControl.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Loader2, EyeOff, Globe } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
|
||||
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { VisibilityPicker } from '@/components/ConfirmMembershipCard';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { MemberVisibility } from '@/lib/types';
|
||||
|
||||
interface Props {
|
||||
kind: number;
|
||||
founderPubkey: string;
|
||||
identifier: string;
|
||||
collectiveName: string;
|
||||
claimedMembers: string[];
|
||||
accentBg?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown to a CONFIRMED member so they can change their visibility (public vs
|
||||
* crew-only) at any time. Re-publishes their kind-1985 attestation with the new
|
||||
* `visibility` tag. Hidden for the founder, non-members, and pending members
|
||||
* (those use the ConfirmMembershipCard instead).
|
||||
*/
|
||||
export function MemberVisibilityControl({
|
||||
kind,
|
||||
founderPubkey,
|
||||
identifier,
|
||||
collectiveName,
|
||||
claimedMembers,
|
||||
accentBg = 'bg-teal-500 hover:bg-teal-600',
|
||||
}: Props) {
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
const { mutateAsync: republish, isPending } = useConfirmMembership();
|
||||
const { data: members } = useCollectiveMembers({
|
||||
kind,
|
||||
founderPubkey,
|
||||
identifier,
|
||||
claimedMembers,
|
||||
enabled: claimedMembers.length > 0,
|
||||
});
|
||||
|
||||
const me = user?.pubkey.toLowerCase();
|
||||
const mine = members?.find((m) => m.pubkey === me);
|
||||
const [visibility, setVisibility] = useState<MemberVisibility>('public');
|
||||
|
||||
// Sync local state to the published value once it loads.
|
||||
useEffect(() => {
|
||||
if (mine?.visibility) setVisibility(mine.visibility);
|
||||
}, [mine?.visibility]);
|
||||
|
||||
if (!user || !me) return null;
|
||||
if (me === founderPubkey.toLowerCase()) return null;
|
||||
// Only for confirmed members.
|
||||
if (!mine || mine.status !== 'confirmed') return null;
|
||||
|
||||
const changed = visibility !== (mine.visibility ?? 'public');
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await republish({ kind, founderPubkey, identifier, collectiveName, visibility });
|
||||
toast({
|
||||
title: 'Visibility updated',
|
||||
description:
|
||||
visibility === 'crew'
|
||||
? 'You now appear only to crew-mates.'
|
||||
: 'You now appear publicly in this crew.',
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Could not update',
|
||||
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 gap-2">
|
||||
{(mine.visibility ?? 'public') === 'crew' ? (
|
||||
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<Globe className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
<p className="text-xs font-black uppercase tracking-widest text-gray-400">
|
||||
Your visibility in this crew
|
||||
</p>
|
||||
</div>
|
||||
<VisibilityPicker value={visibility} onChange={setVisibility} accentBg={accentBg.split(' ')[0]} />
|
||||
{changed && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isPending}
|
||||
className={cn('w-full gap-2 font-bold', accentBg)}
|
||||
>
|
||||
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
Save visibility
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
176
src/components/VouchCard.tsx
Normal file
176
src/components/VouchCard.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { MEMBERSHIP_LABEL_NAMESPACE, MEMBERSHIP_LABEL_VALUE } from '@/lib/types';
|
||||
import { MEMBERSHIP_LABEL_NAMESPACE, MEMBERSHIP_LABEL_VALUE, parseMemberVisibility, type MemberVisibility } from '@/lib/types';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
export type MembershipStatus = 'confirmed' | 'pending';
|
||||
|
|
@ -10,6 +10,12 @@ export interface CollectiveMember {
|
|||
status: MembershipStatus;
|
||||
/** The confirming label event (kind 1985), if the member has attested. */
|
||||
attestation?: NostrEvent;
|
||||
/**
|
||||
* Member's opt-in visibility from their own attestation. `public` (default)
|
||||
* or `crew` (hidden from non-members). Pending members are treated as
|
||||
* `public` until they attest with a choice.
|
||||
*/
|
||||
visibility: MemberVisibility;
|
||||
}
|
||||
|
||||
interface UseCollectiveMembersParams {
|
||||
|
|
@ -96,10 +102,14 @@ export function useCollectiveMembers({
|
|||
// Produce a status for every claimed member, confirmed first.
|
||||
const members: CollectiveMember[] = claimedMembers.map((pubkey) => {
|
||||
const attestation = confirmed.get(pubkey);
|
||||
const visibility = parseMemberVisibility(
|
||||
attestation?.tags.find(([n]) => n === 'visibility')?.[1]
|
||||
);
|
||||
return {
|
||||
pubkey,
|
||||
status: attestation ? 'confirmed' : 'pending',
|
||||
attestation,
|
||||
visibility,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||
import {
|
||||
MEMBERSHIP_LABEL_NAMESPACE,
|
||||
MEMBERSHIP_LABEL_VALUE,
|
||||
type MemberVisibility,
|
||||
} from '@/lib/types';
|
||||
|
||||
interface AttestParams {
|
||||
|
|
@ -17,6 +18,8 @@ interface AttestParams {
|
|||
collectiveName?: string;
|
||||
/** Relay hint for the referenced collective. */
|
||||
relayHint?: string;
|
||||
/** Opt-in visibility choice — defaults to 'public' if omitted. */
|
||||
visibility?: MemberVisibility;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -36,24 +39,28 @@ export function useConfirmMembership() {
|
|||
identifier,
|
||||
collectiveName,
|
||||
relayHint,
|
||||
visibility = 'public',
|
||||
}: AttestParams) => {
|
||||
if (!user) throw new Error('You must be logged in to confirm membership');
|
||||
|
||||
const address = `${kind}:${founderPubkey}:${identifier}`;
|
||||
const aTag = relayHint ? ['a', address, relayHint] : ['a', address];
|
||||
|
||||
const tags: string[][] = [
|
||||
['L', MEMBERSHIP_LABEL_NAMESPACE],
|
||||
['l', MEMBERSHIP_LABEL_VALUE, MEMBERSHIP_LABEL_NAMESPACE],
|
||||
aTag,
|
||||
['p', founderPubkey],
|
||||
['visibility', visibility],
|
||||
['alt', `Skate collective membership confirmation for ${collectiveName ?? address}`],
|
||||
];
|
||||
|
||||
const event = await user.signer.signEvent({
|
||||
kind: 1985,
|
||||
content: collectiveName
|
||||
? `I'm a member of ${collectiveName}`
|
||||
: 'Confirmed collective membership',
|
||||
tags: [
|
||||
['L', MEMBERSHIP_LABEL_NAMESPACE],
|
||||
['l', MEMBERSHIP_LABEL_VALUE, MEMBERSHIP_LABEL_NAMESPACE],
|
||||
aTag,
|
||||
['p', founderPubkey],
|
||||
['alt', `Skate collective membership confirmation for ${collectiveName ?? address}`],
|
||||
],
|
||||
tags,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
|
|
|
|||
146
src/hooks/useVouches.ts
Normal file
146
src/hooks/useVouches.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||
import {
|
||||
VOUCH_LABEL_NAMESPACE,
|
||||
VOUCH_LABEL_VALUE,
|
||||
} from '@/lib/types';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
export interface VouchData {
|
||||
/** All voucher pubkeys (deduped, latest label per author). */
|
||||
vouchers: string[];
|
||||
/** Total vouch count. */
|
||||
count: number;
|
||||
/**
|
||||
* Trust-weighted score: sum of each voucher's web-of-trust score (0-100)
|
||||
* relative to the viewer. A ring of untrusted fakes barely moves this.
|
||||
*/
|
||||
weightedScore: number;
|
||||
/** How many vouchers are in the viewer's web of trust (score > 0). */
|
||||
trustedVouchers: number;
|
||||
/** Whether the current viewer has already vouched. */
|
||||
viewerVouched: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query vouches for a Lab profile coordinate (`kind:pubkey:d`) and compute a
|
||||
* trust-weighted score using the viewer's web of trust.
|
||||
*/
|
||||
export function useVouches(coord: string | undefined) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['vouches', coord, getQueryKeySuffix()],
|
||||
queryFn: async (): Promise<string[]> => {
|
||||
if (!coord) return [];
|
||||
const signal = AbortSignal.timeout(8000);
|
||||
const events = await querySkateContent(
|
||||
[
|
||||
{
|
||||
kinds: [1985],
|
||||
'#a': [coord],
|
||||
'#l': [VOUCH_LABEL_VALUE],
|
||||
limit: 500,
|
||||
},
|
||||
],
|
||||
{ signal }
|
||||
);
|
||||
|
||||
// Latest valid vouch per author.
|
||||
const latest = new Map<string, NostrEvent>();
|
||||
for (const ev of events) {
|
||||
const hasNamespace = ev.tags.some(([n, v]) => n === 'L' && v === VOUCH_LABEL_NAMESPACE);
|
||||
const hasLabel = ev.tags.some(([n, v]) => n === 'l' && v === VOUCH_LABEL_VALUE);
|
||||
const refs = ev.tags.some(([n, v]) => n === 'a' && v === coord);
|
||||
if (!hasNamespace || !hasLabel || !refs) continue;
|
||||
// A retraction is a label with `["l","vouch"]` removed — we simply treat
|
||||
// the newest event; if its content is "retracted" we drop it.
|
||||
const author = ev.pubkey.toLowerCase();
|
||||
const existing = latest.get(author);
|
||||
if (!existing || ev.created_at > existing.created_at) latest.set(author, ev);
|
||||
}
|
||||
|
||||
// Drop retracted vouches (content marker) and self-vouches.
|
||||
const [, providerPubkey] = coord.split(':');
|
||||
const vouchers: string[] = [];
|
||||
for (const [author, ev] of latest) {
|
||||
if (ev.content === 'retracted') continue;
|
||||
if (author === providerPubkey?.toLowerCase()) continue; // no self-vouch
|
||||
vouchers.push(author);
|
||||
}
|
||||
return vouchers;
|
||||
},
|
||||
enabled: !!coord,
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
const vouchers = query.data ?? [];
|
||||
const { data: trustScores } = useBatchTrustScores(vouchers);
|
||||
|
||||
let weightedScore = 0;
|
||||
let trustedVouchers = 0;
|
||||
for (const pk of vouchers) {
|
||||
const score = trustScores?.get(pk) ?? 0;
|
||||
weightedScore += score;
|
||||
if (score > 0) trustedVouchers += 1;
|
||||
}
|
||||
|
||||
const data: VouchData = {
|
||||
vouchers,
|
||||
count: vouchers.length,
|
||||
weightedScore,
|
||||
trustedVouchers,
|
||||
viewerVouched: !!user && vouchers.includes(user.pubkey.toLowerCase()),
|
||||
};
|
||||
|
||||
return { ...query, data };
|
||||
}
|
||||
|
||||
interface VouchParams {
|
||||
/** Provider profile coordinate `kind:pubkey:d`. */
|
||||
coord: string;
|
||||
/** Provider display name (for readability). */
|
||||
providerName?: string;
|
||||
/** Set true to retract a previous vouch. */
|
||||
retract?: boolean;
|
||||
}
|
||||
|
||||
/** Publish (or retract) a vouch for a Lab provider, signed with the viewer's key. */
|
||||
export function useVouchFor() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ coord, providerName, retract }: VouchParams) => {
|
||||
if (!user) throw new Error('You must be logged in to vouch');
|
||||
const [, providerPubkey] = coord.split(':');
|
||||
if (providerPubkey?.toLowerCase() === user.pubkey.toLowerCase()) {
|
||||
throw new Error("You can't vouch for your own profile");
|
||||
}
|
||||
|
||||
const event = await user.signer.signEvent({
|
||||
kind: 1985,
|
||||
content: retract ? 'retracted' : `Vouches for ${providerName ?? 'this skater'}`,
|
||||
tags: [
|
||||
['L', VOUCH_LABEL_NAMESPACE],
|
||||
['l', VOUCH_LABEL_VALUE, VOUCH_LABEL_NAMESPACE],
|
||||
['a', coord],
|
||||
['p', providerPubkey ?? ''],
|
||||
['alt', retract ? 'Retracted a SkateSpots vouch' : `Vouches for a SkateSpots provider (${providerName ?? coord})`],
|
||||
],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
await nostr.event(event, { signal: AbortSignal.timeout(15000) });
|
||||
return event;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vouches'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -85,6 +85,56 @@ export function parseEntityType(value: string | undefined | null): EntityType {
|
|||
export const MEMBERSHIP_LABEL_NAMESPACE = 'org.skatespots.membership';
|
||||
export const MEMBERSHIP_LABEL_VALUE = 'member';
|
||||
|
||||
/**
|
||||
* Member visibility — an OPT-IN privacy choice a member makes on their OWN
|
||||
* membership attestation (kind 1985), so a member can be private even inside a
|
||||
* public crew. Stored as `["visibility", <level>]` on the member's own label.
|
||||
*
|
||||
* - `public` (default / absent): the member shows everywhere — public crew
|
||||
* page, member counts, crew-mate lists.
|
||||
* - `crew`: the member is hidden from NON-members. To outsiders they render as
|
||||
* an anonymous "hidden member" slot; confirmed crew-mates (and the founder)
|
||||
* still see them normally. This is DISCOVERY privacy: the underlying `p` tag
|
||||
* on the founder's crew event is still public on relays (that data belongs
|
||||
* to the founder, not the member, so it can't be encrypted away) — but the
|
||||
* app never surfaces the member's identity to non-members, and they are
|
||||
* excluded from public-facing crew-mate discovery.
|
||||
*
|
||||
* We deliberately DON'T offer a "cryptographically hidden membership" tier: the
|
||||
* membership claim lives in the founder's event and cannot be encrypted by the
|
||||
* member, so promising crypto-privacy there would be misleading. `crew` is
|
||||
* honest app-level privacy; anyone wanting true anonymity should simply not
|
||||
* confirm membership publicly.
|
||||
*/
|
||||
export type MemberVisibility = 'public' | 'crew';
|
||||
|
||||
export const MEMBER_VISIBILITY_LABELS: Record<MemberVisibility, string> = {
|
||||
public: 'Public',
|
||||
crew: 'Crew only',
|
||||
};
|
||||
|
||||
export const MEMBER_VISIBILITY_DESCRIPTIONS: Record<MemberVisibility, string> = {
|
||||
public: 'Anyone can see you in this crew — on the public crew page and in crew-mate discovery.',
|
||||
crew: "Only confirmed crew-mates and the founder see you here. To everyone else you appear as an anonymous “hidden member”. Note: this is app-level privacy — your membership tag still exists publicly on relays, it's just never shown to outsiders.",
|
||||
};
|
||||
|
||||
/** Parse a member visibility value, defaulting to 'public'. */
|
||||
export function parseMemberVisibility(v?: string): MemberVisibility {
|
||||
return v === 'crew' ? 'crew' : 'public';
|
||||
}
|
||||
|
||||
/**
|
||||
* Lab vouches (NIP-32) — the community's anti-fraud lever. On an open protocol
|
||||
* anyone can publish a profile claiming to be a physio, artist, etc., so instead
|
||||
* of trying (and failing) to gatekeep, SkateSpots lets skaters CO-SIGN providers
|
||||
* they personally vouch for. A voucher publishes a kind-1985 label with their
|
||||
* OWN key referencing the provider's addressable coordinate. Vouches are counted
|
||||
* client-side and, crucially, weighted by the viewer's web-of-trust so a ring of
|
||||
* fake accounts vouching for each other carries no weight with real skaters.
|
||||
*/
|
||||
export const VOUCH_LABEL_NAMESPACE = 'org.skatespots.vouch';
|
||||
export const VOUCH_LABEL_VALUE = 'vouch';
|
||||
|
||||
/**
|
||||
* Crew feed/chat moderation (NIP-32).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { ZapButton } from '@/components/ZapButton';
|
|||
import { ClinkPayButton } from '@/components/ClinkPayButton';
|
||||
import { CollectiveMembers } from '@/components/CollectiveMembers';
|
||||
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
||||
import { VouchCard } from '@/components/VouchCard';
|
||||
import { LinkedCrewCard } from '@/components/CrossLinkCards';
|
||||
import { TrustBadge } from '@/components/TrustBadge';
|
||||
import { useLabProfile } from '@/hooks/useLabProfiles';
|
||||
|
|
@ -506,6 +507,15 @@ export default function CoachDetailPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Scene vouches — community anti-fraud / trust signal */}
|
||||
<VouchCard
|
||||
coord={`${profileKind}:${profile.pubkey}:${profile.identifier}`}
|
||||
providerPubkey={profile.pubkey}
|
||||
providerName={profile.title}
|
||||
accentText={accent.text}
|
||||
accentBg={`${accent.bg} hover:opacity-90`}
|
||||
/>
|
||||
|
||||
{/* Owner controls — delete your own Lab profile */}
|
||||
{user && user.pubkey === profile.pubkey && (
|
||||
<Card className="bg-black/90 border-2 border-red-500/30">
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ import { CrewRepBadge } from '@/components/CrewBadge';
|
|||
import { useCrewEvents } from '@/hooks/useCrewEvents';
|
||||
import { EventCard } from '@/components/EventCard';
|
||||
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
||||
import { MemberVisibilityControl } from '@/components/MemberVisibilityControl';
|
||||
import { LinkedLabCard } from '@/components/CrossLinkCards';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -335,84 +337,6 @@ export default function CrewDetailPage() {
|
|||
{/* Crew treasury — hidden for now (kept in code, re-enable by flipping SHOW_CREW_TREASURY) */}
|
||||
{SHOW_CREW_TREASURY && crew.goalRef && <CrewTreasuryCard goalId={crew.goalRef} />}
|
||||
|
||||
{/* Crew-hosted events (jams / comps / meetups) */}
|
||||
{((crewEvents && crewEvents.length > 0) || canManage) && (
|
||||
<Card className="bg-black/90 border-2 border-[#ccff00]/20">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
|
||||
<Calendar className="h-5 w-5 text-[#ccff00]" /> Crew Events
|
||||
</CardTitle>
|
||||
{canManage && (
|
||||
<Link to={`/create-event?crew=${crew.pubkey}:${crew.identifier}`}>
|
||||
<Button size="sm" className="gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90">
|
||||
<Calendar className="h-3.5 w-3.5" /> Host a jam
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{crewEvents && crewEvents.length > 0 ? (
|
||||
crewEvents.slice(0, 5).map((ev) => (
|
||||
<EventCard key={ev.id} event={ev} compact />
|
||||
))
|
||||
) : (
|
||||
<p className="py-4 text-center text-sm text-gray-500">
|
||||
No crew events yet. Host a jam, comp or meetup as the crew — it connects your
|
||||
crew to the events calendar and lets people zap it.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Crew feed */}
|
||||
<Card className="bg-black/90 border-2 border-white/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
|
||||
<MessageSquare className="h-5 w-5 text-teal-400" /> Crew Feed
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{feedLoading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-xl border border-white/5 bg-black/40 p-3 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
))
|
||||
) : (() => {
|
||||
// Moderators still see hidden posts (greyed via the badge on hide),
|
||||
// but for everyone the hidden set is filtered out.
|
||||
const visible = (feed ?? []).filter(
|
||||
(ev) => canManage || !hiddenIds?.has(ev.id),
|
||||
);
|
||||
if (visible.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-gray-500 py-4 text-center">
|
||||
No recent posts from the crew yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return visible.map((ev) => (
|
||||
<div key={ev.id} className={cn(hiddenIds?.has(ev.id) && 'opacity-50')}>
|
||||
{hiddenIds?.has(ev.id) && (
|
||||
<p className="mb-1 flex items-center gap-1 text-[11px] font-bold uppercase text-red-400">
|
||||
<EyeOff className="h-3 w-3" /> Hidden from feed
|
||||
</p>
|
||||
)}
|
||||
<FeedItem
|
||||
event={ev}
|
||||
canModerate={canManage && !hiddenIds?.has(ev.id)}
|
||||
onHide={handleHidePost}
|
||||
isHiding={isHiding}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Crew Agent — AI guide, shown when the crew enabled it */}
|
||||
{crew.agentEnabled && (
|
||||
<button
|
||||
|
|
@ -438,90 +362,148 @@ export default function CrewDetailPage() {
|
|||
</button>
|
||||
)}
|
||||
|
||||
{/* Crew Chat — private E2E-encrypted group chat for members */}
|
||||
<CrewChat
|
||||
crewName={crew.name}
|
||||
founderPubkey={crew.pubkey}
|
||||
members={crew.members}
|
||||
admins={crew.admins}
|
||||
crewCoord={crewCoord}
|
||||
canModerate={canManage}
|
||||
/>
|
||||
{/* Activity — one clean tabbed surface instead of three stacked cards */}
|
||||
<Tabs defaultValue="feed" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-black/60 border border-white/10">
|
||||
<TabsTrigger value="feed" className="gap-1.5 data-[state=active]:bg-teal-500/20 data-[state=active]:text-teal-300">
|
||||
<MessageSquare className="h-4 w-4" /> Feed
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="events" className="gap-1.5 data-[state=active]:bg-[#ccff00]/20 data-[state=active]:text-[#ccff00]">
|
||||
<Calendar className="h-4 w-4" /> Events
|
||||
{crewEvents && crewEvents.length > 0 && (
|
||||
<span className="ml-0.5 rounded-full bg-white/10 px-1.5 text-[10px]">{crewEvents.length}</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="chat" className="gap-1.5 data-[state=active]:bg-teal-500/20 data-[state=active]:text-teal-300">
|
||||
<MessageSquare className="h-4 w-4" /> Chat
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Feed */}
|
||||
<TabsContent value="feed" className="mt-4 space-y-3">
|
||||
{feedLoading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-xl border border-white/5 bg-black/40 p-3 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
))
|
||||
) : (() => {
|
||||
const visible = (feed ?? []).filter(
|
||||
(ev) => canManage || !hiddenIds?.has(ev.id),
|
||||
);
|
||||
if (visible.length === 0) {
|
||||
return (
|
||||
<Card className="border-dashed border-white/10 bg-black/40">
|
||||
<CardContent className="py-10 text-center text-sm text-gray-500">
|
||||
No recent posts from the crew yet.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return visible.map((ev) => (
|
||||
<div key={ev.id} className={cn(hiddenIds?.has(ev.id) && 'opacity-50')}>
|
||||
{hiddenIds?.has(ev.id) && (
|
||||
<p className="mb-1 flex items-center gap-1 text-[11px] font-bold uppercase text-red-400">
|
||||
<EyeOff className="h-3 w-3" /> Hidden from feed
|
||||
</p>
|
||||
)}
|
||||
<FeedItem
|
||||
event={ev}
|
||||
canModerate={canManage && !hiddenIds?.has(ev.id)}
|
||||
onHide={handleHidePost}
|
||||
isHiding={isHiding}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</TabsContent>
|
||||
|
||||
{/* Events */}
|
||||
<TabsContent value="events" className="mt-4 space-y-3">
|
||||
{canManage && (
|
||||
<Link to={`/create-event?crew=${crew.pubkey}:${crew.identifier}`} className="block">
|
||||
<Button size="sm" className="w-full gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90">
|
||||
<Calendar className="h-3.5 w-3.5" /> Host a jam
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{crewEvents && crewEvents.length > 0 ? (
|
||||
crewEvents.slice(0, 8).map((ev) => <EventCard key={ev.id} event={ev} compact />)
|
||||
) : (
|
||||
<Card className="border-dashed border-white/10 bg-black/40">
|
||||
<CardContent className="py-10 text-center text-sm text-gray-500">
|
||||
No crew events yet. Host a jam, comp or meetup as the crew — it connects your
|
||||
crew to the events calendar and lets people zap it.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Chat */}
|
||||
<TabsContent value="chat" className="mt-4">
|
||||
<CrewChat
|
||||
crewName={crew.name}
|
||||
founderPubkey={crew.pubkey}
|
||||
members={crew.members}
|
||||
admins={crew.admins}
|
||||
crewCoord={crewCoord}
|
||||
canModerate={canManage}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
{/* Sidebar — ordered: who → support → band → connections → founder */}
|
||||
<div className="space-y-6">
|
||||
{/* Founder / links / support */}
|
||||
{/* Members (with per-member visibility respected) */}
|
||||
{hasMembers && (
|
||||
<CollectiveMembers
|
||||
kind={SKATE_CREW_KIND}
|
||||
founderPubkey={crew.pubkey}
|
||||
identifier={crew.identifier}
|
||||
claimedMembers={crew.members}
|
||||
admins={crew.admins}
|
||||
accentText="text-teal-400"
|
||||
accentBorder="border-teal-500/40"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* A confirmed member can change their own visibility here */}
|
||||
{hasMembers && (
|
||||
<MemberVisibilityControl
|
||||
kind={SKATE_CREW_KIND}
|
||||
founderPubkey={crew.pubkey}
|
||||
identifier={crew.identifier}
|
||||
collectiveName={crew.name}
|
||||
claimedMembers={crew.members}
|
||||
accentBg="bg-teal-500 hover:bg-teal-600"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Support the crew */}
|
||||
<Card className="bg-black/90 border-2 border-teal-500/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-teal-400 uppercase font-black flex items-center gap-2">
|
||||
<Users className="h-5 w-5" /> Crew
|
||||
<Zap className="h-5 w-5" /> Support
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Link to={`/${npub}`} className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={author.data?.metadata?.picture} alt={founderName} />
|
||||
<AvatarFallback className="text-[10px]">{founderName.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate">Started by {founderName}</span>
|
||||
</Link>
|
||||
|
||||
{sanitizeUrl(crew.website) && (
|
||||
<a href={sanitizeUrl(crew.website)!} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors" title={`Opens: ${urlDomain(crew.website!)}`}>
|
||||
<Globe className="h-4 w-4 text-teal-400" />
|
||||
<span className="truncate">{urlDomain(crew.website!)}</span>
|
||||
</a>
|
||||
<CardContent className="space-y-2">
|
||||
{/* Zap the whole crew — split across founder + confirmed members */}
|
||||
{crewZapRecipients.length > 1 && (
|
||||
<ZapCrewDialog crewName={crew.name} recipients={crewZapRecipients}>
|
||||
<Button className="w-full justify-center gap-2 bg-teal-500 hover:bg-teal-600 font-bold">
|
||||
<Users className="h-4 w-4" />
|
||||
<Zap className="h-4 w-4" />
|
||||
Zap the Crew
|
||||
</Button>
|
||||
</ZapCrewDialog>
|
||||
)}
|
||||
|
||||
{crew.instagram && (
|
||||
<a href={`https://instagram.com/${crew.instagram.replace('@', '')}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
|
||||
<Instagram className="h-4 w-4 text-teal-400" />
|
||||
<span className="truncate">@{crew.instagram.replace('@', '')}</span>
|
||||
</a>
|
||||
<ZapButton target={crew.event} className="w-full justify-center" />
|
||||
{crew.noffer && (
|
||||
<ClinkPayButton noffer={crew.noffer} recipientName={crew.name} className="w-full justify-center" />
|
||||
)}
|
||||
|
||||
{crew.socials.map((s, i) => {
|
||||
const url = sanitizeUrl(s);
|
||||
return url ? (
|
||||
<a
|
||||
key={`${s}-${i}`}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors"
|
||||
title={`Opens: ${urlDomain(s)}`}
|
||||
>
|
||||
<Globe className="h-4 w-4 text-teal-400" />
|
||||
<span className="truncate">{urlDomain(s)}</span>
|
||||
</a>
|
||||
) : (
|
||||
<div
|
||||
key={`${s}-${i}`}
|
||||
className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded"
|
||||
>
|
||||
<Globe className="h-4 w-4 text-teal-400" />
|
||||
<span className="truncate">{s}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="pt-3 border-t border-gray-700 space-y-2">
|
||||
{/* Zap the whole crew — split across founder + confirmed members */}
|
||||
{crewZapRecipients.length > 1 && (
|
||||
<ZapCrewDialog crewName={crew.name} recipients={crewZapRecipients}>
|
||||
<Button className="w-full justify-center gap-2 bg-teal-500 hover:bg-teal-600 font-bold">
|
||||
<Users className="h-4 w-4" />
|
||||
<Zap className="h-4 w-4" />
|
||||
Zap the Crew
|
||||
</Button>
|
||||
</ZapCrewDialog>
|
||||
)}
|
||||
<ZapButton target={crew.event} className="w-full justify-center" />
|
||||
{crew.noffer && (
|
||||
<ClinkPayButton noffer={crew.noffer} recipientName={crew.name} className="w-full justify-center" />
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
@ -581,19 +563,6 @@ export default function CrewDetailPage() {
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{/* Members */}
|
||||
{hasMembers && (
|
||||
<CollectiveMembers
|
||||
kind={SKATE_CREW_KIND}
|
||||
founderPubkey={crew.pubkey}
|
||||
identifier={crew.identifier}
|
||||
claimedMembers={crew.members}
|
||||
admins={crew.admins}
|
||||
accentText="text-teal-400"
|
||||
accentBorder="border-teal-500/40"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Linked Lab profiles — hire this crew */}
|
||||
<LinkedLabCard labRefs={crew.labRefs} />
|
||||
|
||||
|
|
@ -625,6 +594,63 @@ export default function CrewDetailPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Founder & links (bottom — provenance / external) */}
|
||||
<Card className="bg-black/90 border border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm text-gray-400 uppercase font-black tracking-widest">
|
||||
Founder & Links
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Link to={`/${npub}`} className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={author.data?.metadata?.picture} alt={founderName} />
|
||||
<AvatarFallback className="text-[10px]">{founderName.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate">Started by {founderName}</span>
|
||||
</Link>
|
||||
|
||||
{sanitizeUrl(crew.website) && (
|
||||
<a href={sanitizeUrl(crew.website)!} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors" title={`Opens: ${urlDomain(crew.website!)}`}>
|
||||
<Globe className="h-4 w-4 text-teal-400" />
|
||||
<span className="truncate">{urlDomain(crew.website!)}</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{crew.instagram && (
|
||||
<a href={`https://instagram.com/${crew.instagram.replace('@', '')}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
|
||||
<Instagram className="h-4 w-4 text-teal-400" />
|
||||
<span className="truncate">@{crew.instagram.replace('@', '')}</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{crew.socials.map((s, i) => {
|
||||
const url = sanitizeUrl(s);
|
||||
return url ? (
|
||||
<a
|
||||
key={`${s}-${i}`}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors"
|
||||
title={`Opens: ${urlDomain(s)}`}
|
||||
>
|
||||
<Globe className="h-4 w-4 text-teal-400" />
|
||||
<span className="truncate">{urlDomain(s)}</span>
|
||||
</a>
|
||||
) : (
|
||||
<div
|
||||
key={`${s}-${i}`}
|
||||
className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded"
|
||||
>
|
||||
<Globe className="h-4 w-4 text-teal-400" />
|
||||
<span className="truncate">{s}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue