SkateSpots/src/components/MemberVisibilityControl.tsx
shakespeare.diy 926470a86e Crews: opt-in member visibility + Lab vouch/web-of-trust anti-fraud + CrewDetail redesign
Step 3 — member visibility (best-of-both, honest):
- MemberVisibility ('public' | 'crew') stored on the member's OWN kind-1985
  attestation via a `visibility` tag, so a public crew can contain a private
  member. Confirmed crew-mates + founder see them; outsiders see anonymised
  "hidden member" slots. Framed honestly in-UI (app-level discovery privacy).
- VisibilityPicker in ConfirmMembershipCard (choose on join) +
  MemberVisibilityControl for confirmed members to change it anytime.
- useCollectiveMembers parses visibility; CollectiveMembers hides crew-only
  members from non-members.

Step 4 — anti-fraud vouch layer:
- useVouches/useVouchFor: NIP-32 vouch labels, trust-WEIGHTED via web-of-trust
  so fake-account vouch rings carry no weight. VouchCard with new/vouched/
  scene-verified tiers, wired into every Lab profile detail page.

Step 4 — CrewDetail UX redesign:
- Activity consolidated into clean Feed/Events/Chat tabs (no more giant scroll)
- Sidebar reordered: Members → Support → Band → Connections → Founder
- Member visibility control surfaced; tighter hero

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-08-10 21:01:57 +07:00

110 lines
3.6 KiB
TypeScript

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>
);
}