SkateSpots/src/components/CollectiveJoinRequestCard.tsx
shakespeare.diy f37d0184d3 Collective join-requests: one-tap approve from the bell
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 <assistant@shakespeare.diy>
2026-09-03 15:49:49 +07:00

215 lines
7.9 KiB
TypeScript

import { useState } from 'react';
import { Link } from 'react-router-dom';
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 { UserPlus, X, Loader2, Hand, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
function npubSafe(pubkey: string): string {
try {
return nip19.npubEncode(pubkey);
} catch {
return pubkey;
}
}
/**
* An incoming "request to join this collective" for a Lab-collective owner.
*
* 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,
onResolved,
compact,
}: {
request: CollectiveJoinRequest;
onResolved?: () => void;
compact?: boolean;
}) {
const requester = useAuthor(request.requester);
const requesterName = requester.data?.metadata?.name ?? genUserName(request.requester);
const requesterPic = requester.data?.metadata?.picture;
const { toast } = useToast();
const coord = parseAddrCoord(request.collectiveCoord);
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('dismissed');
onResolved?.();
} catch {
toast({ title: 'Could not dismiss', variant: 'destructive' });
}
};
if (done) {
return (
<div className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2.5 text-sm text-gray-400">
{done === 'added' ? (
<>
<Check className="h-4 w-4 text-sky-400" />
Added <span className="font-bold text-white">{requesterName}</span> to{' '}
{request.collectiveName}
</>
) : (
<>
<X className="h-4 w-4 text-gray-500" />
Dismissed request from {requesterName}
</>
)}
</div>
);
}
const busy = approving || dismissing;
return (
<div
className={cn(
'rounded-xl border border-sky-500/25 bg-gradient-to-br from-sky-500/[0.08] to-transparent p-3',
compact && 'p-2.5',
)}
>
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-sky-500/15">
<Hand className="h-4 w-4 text-sky-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug text-gray-200">
<Link
to={`/${npubSafe(request.requester)}`}
className="inline-flex items-center gap-1 font-bold text-white hover:text-sky-300"
>
<Avatar className="h-4 w-4">
<AvatarImage src={requesterPic} alt={requesterName} />
<AvatarFallback className="text-[8px]">{requesterName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
{requesterName}
</Link>{' '}
wants to join{' '}
<Link to={collectiveHref} className="font-black text-sky-300 hover:underline">
{request.collectiveName}
</Link>
</p>
{request.note && (
<p className="mt-1 rounded-lg bg-black/30 px-2.5 py-1.5 text-xs italic text-gray-400 break-words [overflow-wrap:anywhere]">
“{request.note}”
</p>
)}
{alreadyMember ? (
<p className="mt-2 flex items-center gap-1.5 text-xs font-bold text-sky-400/90">
<Check className="h-3.5 w-3.5" /> Already on the roster
<button
type="button"
onClick={handleDismiss}
disabled={busy}
className="ml-auto text-gray-500 underline-offset-2 hover:text-white hover:underline"
>
Clear
</button>
</p>
) : !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. */
<div className="mt-2.5 flex items-center gap-2">
<Button
asChild
size="sm"
className="h-8 flex-1 gap-1.5 bg-sky-500 font-bold text-black hover:bg-sky-600"
>
<Link to={collectiveHref}>
<UserPlus className="h-3.5 w-3.5" />
Open collective
</Link>
</Button>
<Button
size="sm"
variant="outline"
onClick={handleDismiss}
disabled={busy}
className="h-8 gap-1.5 border-white/15 text-gray-400 hover:bg-white/10 hover:text-white"
>
{dismissing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
Dismiss
</Button>
</div>
) : (
<div className="mt-2.5 flex items-center gap-2">
<Button
size="sm"
onClick={handleAdd}
disabled={busy || profileLoading || !profile}
className="h-8 flex-1 gap-1.5 bg-sky-500 font-bold text-black hover:bg-sky-600"
>
{approving || profileLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<UserPlus className="h-3.5 w-3.5" />
)}
{profileLoading ? 'Loading…' : 'Add to collective'}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleDismiss}
disabled={busy}
className="h-8 gap-1.5 border-white/15 text-gray-400 hover:bg-white/10 hover:text-white"
>
{dismissing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
Dismiss
</Button>
</div>
)}
</div>
</div>
</div>
);
}