From f37d0184d3602a3557f42d1c5438de955d09cb03 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 3 Sep 2026 15:49:49 +0700 Subject: [PATCH] Collective join-requests: one-tap approve from the bell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the collective join-request into a genuinely seamless flow instead of a dead-end deep-link: - New useApproveCollectiveJoinRequest: owner appends the requester to their own Lab-profile roster (re-signed, preserving all tags) and clears the request in one action — mirrors the crew founder's "Add to crew". - CollectiveJoinRequestCard now resolves the collective and offers a single "Add to collective" tap, with graceful states for loading, already-a-member, and unresolvable-profile (never traps the owner). - Removed the old ?manage=members deep-link (pointed at a read-only card). - Requester-side copy now sets accurate expectations ("added in one tap"). Trust model unchanged: only the owner signs their profile event. Co-authored-by: shakespeare.diy --- src/components/CollectiveJoinRequestCard.tsx | 157 ++++++++++++++----- src/components/RequestToJoinCollective.tsx | 2 +- src/hooks/useCollectiveJoinRequests.ts | 55 +++++++ 3 files changed, 175 insertions(+), 39 deletions(-) diff --git a/src/components/CollectiveJoinRequestCard.tsx b/src/components/CollectiveJoinRequestCard.tsx index a156466..5869372 100644 --- a/src/components/CollectiveJoinRequestCard.tsx +++ b/src/components/CollectiveJoinRequestCard.tsx @@ -4,11 +4,13 @@ import { nip19 } from 'nostr-tools'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { useAuthor } from '@/hooks/useAuthor'; +import { useLabProfile } from '@/hooks/useLabProfiles'; +import { useApproveCollectiveJoinRequest } from '@/hooks/useCollectiveJoinRequests'; import { useDismissCrewNotification } from '@/hooks/useCrewDismissals'; import { useToast } from '@/hooks/useToast'; import { genUserName } from '@/lib/genUserName'; import { parseAddrCoord, type CollectiveJoinRequest } from '@/lib/types'; -import { Users, X, Loader2, Hand, Settings2, Check } from 'lucide-react'; +import { UserPlus, X, Loader2, Hand, Check } from 'lucide-react'; import { cn } from '@/lib/utils'; function npubSafe(pubkey: string): string { @@ -22,10 +24,11 @@ function npubSafe(pubkey: string): string { /** * An incoming "request to join this collective" for a Lab-collective owner. * - * Trust model mirrors crews: only the owner can edit their own Lab-profile - * event, so the card can't add the member for them. Instead it links straight - * to the collective's editor (where the roster lives) and lets the owner - * dismiss the request once handled. No forged membership is ever possible. + * One-tap flow: the owner sees who wants in and hits **Add to collective** — + * that appends the requester to the owner's own Lab-profile roster and clears + * the request. Only the owner can edit their own event, so no forged membership + * is possible. While the profile loads (or if it can't be resolved) the card + * degrades gracefully to a link + Dismiss so the owner is never stuck. */ export function CollectiveJoinRequestCard({ request, @@ -40,17 +43,46 @@ export function CollectiveJoinRequestCard({ const requesterName = requester.data?.metadata?.name ?? genUserName(request.requester); const requesterPic = requester.data?.metadata?.picture; const { toast } = useToast(); - const { mutateAsync: dismiss, isPending: dismissing } = useDismissCrewNotification(); - const [done, setDone] = useState(false); const coord = parseAddrCoord(request.collectiveCoord); - // All Lab crafts render + edit through the /coach detail route. const collectiveHref = coord ? `/coach/${coord.pubkey}/${coord.identifier}` : '/lab'; + // Resolve the collective so we can add the member in one click. + const { data: profile, isLoading: profileLoading } = useLabProfile( + coord?.identifier ?? '', + coord?.pubkey, + ); + + const { mutateAsync: approve, isPending: approving } = useApproveCollectiveJoinRequest(); + const { mutateAsync: dismiss, isPending: dismissing } = useDismissCrewNotification(); + const [done, setDone] = useState<'added' | 'dismissed' | null>(null); + + const alreadyMember = + !!profile && profile.members.some((m) => m.toLowerCase() === request.requester.toLowerCase()); + + const handleAdd = async () => { + if (!profile) return; + try { + await approve({ request, profile }); + setDone('added'); + toast({ + title: `Added ${requesterName} to ${request.collectiveName} 🛹`, + description: "They'll confirm from their side to appear as a verified member.", + }); + onResolved?.(); + } catch (err) { + toast({ + title: 'Could not add member', + description: err instanceof Error ? err.message : 'Please try again.', + variant: 'destructive', + }); + } + }; + const handleDismiss = async () => { try { await dismiss(request.id); - setDone(true); + setDone('dismissed'); onResolved?.(); } catch { toast({ title: 'Could not dismiss', variant: 'destructive' }); @@ -60,12 +92,24 @@ export function CollectiveJoinRequestCard({ if (done) { return (
- - Dismissed request from {requesterName} + {done === 'added' ? ( + <> + + Added {requesterName} to{' '} + {request.collectiveName} + + ) : ( + <> + + Dismissed request from {requesterName} + + )}
); } + const busy = approving || dismissing; + return (
)} -
- - -
- -

- - Add them from your collective’s member list, then dismiss this. -

+ {alreadyMember ? ( +

+ Already on the roster + +

+ ) : !profileLoading && !profile ? ( + /* Profile couldn't be resolved — never trap the owner. Offer a link + to open the collective plus a way to clear the notification. */ +
+ + +
+ ) : ( +
+ + +
+ )}
diff --git a/src/components/RequestToJoinCollective.tsx b/src/components/RequestToJoinCollective.tsx index 6040ae9..612a070 100644 --- a/src/components/RequestToJoinCollective.tsx +++ b/src/components/RequestToJoinCollective.tsx @@ -76,7 +76,7 @@ export function RequestToJoinCollective({ <>

Ask the crew behind {profile.title} to add - you to the roster. They confirm from their side. + you to the roster. They’ll get a notification and can add you in one tap.