From 926470a86ee2f79bea42d0affb1a05c43b171bfb Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Mon, 10 Aug 2026 21:01:57 +0700 Subject: [PATCH] Crews: opt-in member visibility + Lab vouch/web-of-trust anti-fraud + CrewDetail redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/CollectiveMembers.tsx | 65 +++- src/components/ConfirmMembershipCard.tsx | 74 ++++- src/components/MemberVisibilityControl.tsx | 110 +++++++ src/components/VouchCard.tsx | 176 ++++++++++ src/hooks/useCollectiveMembers.ts | 12 +- src/hooks/useMembershipAttestation.ts | 21 +- src/hooks/useVouches.ts | 146 +++++++++ src/lib/types.ts | 50 +++ src/pages/CoachDetailPage.tsx | 10 + src/pages/CrewDetailPage.tsx | 356 +++++++++++---------- 10 files changed, 839 insertions(+), 181 deletions(-) create mode 100644 src/components/MemberVisibilityControl.tsx create mode 100644 src/components/VouchCard.tsx create mode 100644 src/hooks/useVouches.ts diff --git a/src/components/CollectiveMembers.tsx b/src/components/CollectiveMembers.tsx index 91ce8ed..412edf8 100644 --- a/src/components/CollectiveMembers.tsx +++ b/src/components/CollectiveMembers.tsx @@ -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 ( + + + + + Crew only + + + + This member chose to be visible only to crew-mates. You can see them + because you're in the crew. + + + + ); +} + 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 {name.slice(0, 2).toUpperCase()} {name} + {member.visibility === 'crew' && } {isAdmin && } ); } +/** Anonymous placeholder shown to NON-members for a `crew`-only member. */ +function HiddenMemberRow() { + return ( +
+ + + + + + + Hidden member + + + Crew only + +
+ ); +} + /** * 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 ( @@ -146,9 +199,17 @@ export function CollectiveMembers({ ) : (
- {orderedMembers?.map((m) => ( + {visibleMembers.map((m) => ( ))} + {/* Collapse all crew-only members into anonymous slots for outsiders */} + {hiddenCount > 0 && ( + <> + {Array.from({ length: hiddenCount }).map((_, i) => ( + + ))} + + )}
)} diff --git a/src/components/ConfirmMembershipCard.tsx b/src/components/ConfirmMembershipCard.tsx index f8a4a04..65ddb09 100644 --- a/src/components/ConfirmMembershipCard.tsx +++ b/src/components/ConfirmMembershipCard.tsx @@ -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 ( +
+
+ {options.map(({ key, label, icon: Icon }) => { + const active = value === key; + return ( + + ); + })} +
+

+ {MEMBER_VISIBILITY_DESCRIPTIONS[value]} +

+
+ ); +} + /** * 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('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 ( - +
@@ -78,6 +132,14 @@ export function ConfirmMembershipCard({

+ +
+

+ Your visibility +

+ +
+ + )} +
+
+ ); +} diff --git a/src/components/VouchCard.tsx b/src/components/VouchCard.tsx new file mode 100644 index 0000000..e4f46fd --- /dev/null +++ b/src/components/VouchCard.tsx @@ -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 ( + + + + + + + {name.slice(0, 2).toUpperCase()} + + + + {name} + + + ); +} + +/** + * 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 ( + + +
+

+ Scene Vouches +

+ {tier === 'verified' && ( + + Scene-verified + + )} + {tier === 'vouched' && ( + + Vouched + + )} + {tier === 'new' && ( + + New · unvouched + + )} +
+ + {isLoading ? ( +

Checking the scene…

+ ) : data.count === 0 ? ( +

+ 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. +

+ ) : ( +
+
+
+ {data.vouchers.slice(0, 6).map((pk) => ( + + ))} +
+ + {data.count} vouch{data.count !== 1 ? 'es' : ''} + +
+ {data.trustedVouchers > 0 && ( +

+ 🛡 {data.trustedVouchers} from skaters in your web of trust +

+ )} +
+ )} + + {user && !isOwner && ( + + )} + {isOwner && ( +

+ Skaters who know your work can vouch for you here. +

+ )} +
+
+ ); +} diff --git a/src/hooks/useCollectiveMembers.ts b/src/hooks/useCollectiveMembers.ts index 21096b4..8078d5c 100644 --- a/src/hooks/useCollectiveMembers.ts +++ b/src/hooks/useCollectiveMembers.ts @@ -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, }; }); diff --git a/src/hooks/useMembershipAttestation.ts b/src/hooks/useMembershipAttestation.ts index 48c1f5c..150a19a 100644 --- a/src/hooks/useMembershipAttestation.ts +++ b/src/hooks/useMembershipAttestation.ts @@ -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), }); diff --git a/src/hooks/useVouches.ts b/src/hooks/useVouches.ts new file mode 100644 index 0000000..8f17004 --- /dev/null +++ b/src/hooks/useVouches.ts @@ -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 => { + 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(); + 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'] }); + }, + }); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 6ec3f5e..e8528b2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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", ]` 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 = { + public: 'Public', + crew: 'Crew only', +}; + +export const MEMBER_VISIBILITY_DESCRIPTIONS: Record = { + 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). * diff --git a/src/pages/CoachDetailPage.tsx b/src/pages/CoachDetailPage.tsx index aa60ade..2dec737 100644 --- a/src/pages/CoachDetailPage.tsx +++ b/src/pages/CoachDetailPage.tsx @@ -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() {
+ {/* Scene vouches — community anti-fraud / trust signal */} + + {/* Owner controls — delete your own Lab profile */} {user && user.pubkey === profile.pubkey && ( diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx index 2f2d25c..7518fc6 100644 --- a/src/pages/CrewDetailPage.tsx +++ b/src/pages/CrewDetailPage.tsx @@ -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 && } - {/* Crew-hosted events (jams / comps / meetups) */} - {((crewEvents && crewEvents.length > 0) || canManage) && ( - - - - Crew Events - - {canManage && ( - - - - )} - - - {crewEvents && crewEvents.length > 0 ? ( - crewEvents.slice(0, 5).map((ev) => ( - - )) - ) : ( -

- 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. -

- )} -
-
- )} - - {/* Crew feed */} - - - - Crew Feed - - - - {feedLoading ? ( - Array.from({ length: 3 }).map((_, i) => ( -
- - - -
- )) - ) : (() => { - // 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 ( -

- No recent posts from the crew yet. -

- ); - } - return visible.map((ev) => ( -
- {hiddenIds?.has(ev.id) && ( -

- Hidden from feed -

- )} - -
- )); - })()} -
-
- {/* Crew Agent — AI guide, shown when the crew enabled it */} {crew.agentEnabled && ( + + )} + {crewEvents && crewEvents.length > 0 ? ( + crewEvents.slice(0, 8).map((ev) => ) + ) : ( + + + 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. + + + )} + + + {/* Chat */} + + + + - {/* Sidebar */} + {/* Sidebar — ordered: who → support → band → connections → founder */}
- {/* Founder / links / support */} + {/* Members (with per-member visibility respected) */} + {hasMembers && ( + + )} + + {/* A confirmed member can change their own visibility here */} + {hasMembers && ( + + )} + + {/* Support the crew */} - Crew + Support - - - - - {founderName.slice(0, 2).toUpperCase()} - - Started by {founderName} - - - {sanitizeUrl(crew.website) && ( - - - {urlDomain(crew.website!)} - + + {/* Zap the whole crew — split across founder + confirmed members */} + {crewZapRecipients.length > 1 && ( + + + )} - - {crew.instagram && ( - - - @{crew.instagram.replace('@', '')} - + + {crew.noffer && ( + )} - - {crew.socials.map((s, i) => { - const url = sanitizeUrl(s); - return url ? ( - - - {urlDomain(s)} - - ) : ( -
- - {s} -
- ); - })} - -
- {/* Zap the whole crew — split across founder + confirmed members */} - {crewZapRecipients.length > 1 && ( - - - - )} - - {crew.noffer && ( - - )} -
@@ -581,19 +563,6 @@ export default function CrewDetailPage() { )} - {/* Members */} - {hasMembers && ( - - )} - {/* Linked Lab profiles — hire this crew */} @@ -625,6 +594,63 @@ export default function CrewDetailPage() { )} + + {/* Founder & links (bottom — provenance / external) */} + + + + Founder & Links + + + + + + + {founderName.slice(0, 2).toUpperCase()} + + Started by {founderName} + + + {sanitizeUrl(crew.website) && ( + + + {urlDomain(crew.website!)} + + )} + + {crew.instagram && ( + + + @{crew.instagram.replace('@', '')} + + )} + + {crew.socials.map((s, i) => { + const url = sanitizeUrl(s); + return url ? ( + + + {urlDomain(s)} + + ) : ( +
+ + {s} +
+ ); + })} +
+