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 (
+ 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'
+ )}
+ >
+
+ {label}
+
+ );
+ })}
+
+
+ {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
+
+
+
+
0,
+ });
+
+ const me = user?.pubkey.toLowerCase();
+ const mine = members?.find((m) => m.pubkey === me);
+ const [visibility, setVisibility] = useState('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 (
+
+
+
+ {(mine.visibility ?? 'public') === 'crew' ? (
+
+ ) : (
+
+ )}
+
+ Your visibility in this crew
+
+
+
+ {changed && (
+
+ {isPending ? : null}
+ Save 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 && (
+
+ {isPending ? (
+
+ ) : (
+
+ )}
+ {data.viewerVouched ? 'Remove vouch' : `Vouch for ${providerName.split(' ')[0]}`}
+
+ )}
+ {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 && (
-
-
- Host a jam
-
-
- )}
-
-
- {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 && (
)}
- {/* Crew Chat β private E2E-encrypted group chat for members */}
-
+ {/* Activity β one clean tabbed surface instead of three stacked cards */}
+
+
+
+ Feed
+
+
+ Events
+ {crewEvents && crewEvents.length > 0 && (
+ {crewEvents.length}
+ )}
+
+
+ Chat
+
+
+
+ {/* Feed */}
+
+ {feedLoading ? (
+ Array.from({ length: 3 }).map((_, i) => (
+
+
+
+
+
+ ))
+ ) : (() => {
+ 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
+
+ )}
+
+
+ ));
+ })()}
+
+
+ {/* Events */}
+
+ {canManage && (
+
+
+ Host a jam
+
+
+ )}
+ {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 && (
+
+
+
+
+ Zap the Crew
+
+
)}
-
- {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 && (
-
-
-
-
- Zap the Crew
-
-
- )}
-
- {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}
+
+ );
+ })}
+
+