Closes the admin/founder asymmetry without breaking the trust model: only the founder authors the crew event, so only they add members — but an admin can now publicly *vouch* for a join-request, and the founder ratifies with one click. Protocol (SkateSpots-native kind 9087, regular event): - e-tags the join-request, a-tags the crew, p-tags the requester (role "requester") and the founder (so it hits the founder's bell). - Clients keep only endorsements authored by a real admin of the endorsed crew and dedupe one-per-admin-per-request (no credibility inflation). - New parseCrewEndorsement + CrewEndorsement type; documented in NIP.md. Hooks: - useEndorseJoinRequest (admin publishes an endorsement, admin-gated). - useJoinRequestEndorsements: batch-fetch endorsements for all visible requests in ONE #e query, grouped per request, admin-verified per crew. - useCrewNotifications attaches endorsements to each request entry. UX (JoinRequestCard): - Founder: primary becomes "Approve & add" when vouched, with a vouch strip (stacked admin avatars + "Vouched by Rider +1 more") as social proof. - Admin: the old dead-end "Only the founder can add members" becomes a real "Vouch for them" action, then a "Vouched — waiting on the founder" state, plus a one-line explainer so the path isn't a mystery. - Endorsements enrich the existing request card (no duplicate notification). Build clean, no console errors. Not deployed. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
264 lines
9.8 KiB
TypeScript
264 lines
9.8 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 { useCurrentUser } from '@/hooks/useCurrentUser';
|
||
import { useAuthor } from '@/hooks/useAuthor';
|
||
import {
|
||
useApproveJoinRequest,
|
||
useDismissJoinRequest,
|
||
useEndorseJoinRequest,
|
||
} from '@/hooks/useCrewInvites';
|
||
import { useToast } from '@/hooks/useToast';
|
||
import { genUserName } from '@/lib/genUserName';
|
||
import { parseAddrCoord, type CrewEndorsement, type CrewJoinRequest, type SkateCrew } from '@/lib/types';
|
||
import { UserPlus, Check, X, Loader2, Hand, ThumbsUp, Clock } from 'lucide-react';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
function npubSafe(pubkey: string): string {
|
||
try {
|
||
return nip19.npubEncode(pubkey);
|
||
} catch {
|
||
return pubkey;
|
||
}
|
||
}
|
||
|
||
/** Tiny avatar for an endorsing admin (used in the vouch strip). */
|
||
function EndorserAvatar({ pubkey }: { pubkey: string }) {
|
||
const author = useAuthor(pubkey);
|
||
const name = author.data?.metadata?.name ?? genUserName(pubkey);
|
||
return (
|
||
<Avatar className="h-5 w-5 ring-2 ring-gray-900">
|
||
<AvatarImage src={author.data?.metadata?.picture} alt={name} />
|
||
<AvatarFallback className="text-[8px]">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||
</Avatar>
|
||
);
|
||
}
|
||
|
||
/** Readable "Vouched by Rider, Mia +1" line for the endorsement strip. */
|
||
function EndorserNames({ pubkeys }: { pubkeys: string[] }) {
|
||
const first = useAuthor(pubkeys[0]);
|
||
const firstName = first.data?.metadata?.name ?? genUserName(pubkeys[0]);
|
||
const extra = pubkeys.length - 1;
|
||
return (
|
||
<span className="font-bold text-emerald-300">
|
||
{firstName}
|
||
{extra > 0 && <span className="font-normal text-emerald-400/70"> +{extra} more</span>}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* A single incoming join-request card for a crew's founder / admins.
|
||
*
|
||
* Trust model: only the founder authors the crew event, so only they can *add*
|
||
* a member. Admins therefore **endorse** ("vouch for") a request; the founder
|
||
* sees those endorsements as social proof and ratifies with one click. This
|
||
* closes the admin/founder asymmetry without breaking who-can-edit-the-roster.
|
||
*/
|
||
export function JoinRequestCard({
|
||
request,
|
||
crew,
|
||
endorsements = [],
|
||
onResolved,
|
||
compact,
|
||
}: {
|
||
request: CrewJoinRequest;
|
||
crew: SkateCrew;
|
||
endorsements?: CrewEndorsement[];
|
||
onResolved?: () => void;
|
||
compact?: boolean;
|
||
}) {
|
||
const { user } = useCurrentUser();
|
||
const requester = useAuthor(request.requester);
|
||
const requesterName = requester.data?.metadata?.name ?? genUserName(request.requester);
|
||
const requesterPic = requester.data?.metadata?.picture;
|
||
const { toast } = useToast();
|
||
const { mutateAsync: approve, isPending: approving } = useApproveJoinRequest();
|
||
const { mutateAsync: dismiss, isPending: dismissing } = useDismissJoinRequest();
|
||
const { mutateAsync: endorse, isPending: endorsing } = useEndorseJoinRequest();
|
||
const [done, setDone] = useState<'approved' | 'dismissed' | 'endorsed' | null>(null);
|
||
|
||
const coord = parseAddrCoord(request.crewCoord);
|
||
const crewHref = coord ? `/crew/${coord.pubkey}/${coord.identifier}` : '/crews';
|
||
const meLc = user?.pubkey.toLowerCase();
|
||
const isFounder = !!meLc && meLc === crew.pubkey.toLowerCase();
|
||
const isAdmin = !!meLc && crew.admins.some((a) => a.toLowerCase() === meLc);
|
||
|
||
const endorserPubkeys = endorsements.map((e) => e.endorser);
|
||
const iEndorsed = !!meLc && endorserPubkeys.some((p) => p.toLowerCase() === meLc);
|
||
const hasEndorsements = endorserPubkeys.length > 0;
|
||
|
||
const handleApprove = async () => {
|
||
try {
|
||
await approve({ request, crew });
|
||
setDone('approved');
|
||
toast({
|
||
title: `Added ${requesterName} to ${crew.name} 🛹`,
|
||
description: "They'll confirm from their side to appear as a member.",
|
||
});
|
||
onResolved?.();
|
||
} catch (err) {
|
||
toast({
|
||
title: 'Could not add member',
|
||
description: err instanceof Error ? err.message : 'Please try again.',
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleEndorse = async () => {
|
||
try {
|
||
await endorse({ request, crew });
|
||
setDone('endorsed');
|
||
toast({
|
||
title: `You vouched for ${requesterName} 🤝`,
|
||
description: `The founder of ${crew.name} will see your endorsement and can add them.`,
|
||
});
|
||
onResolved?.();
|
||
} catch (err) {
|
||
toast({
|
||
title: 'Could not endorse',
|
||
description: err instanceof Error ? err.message : 'Please try again.',
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleDismiss = async () => {
|
||
try {
|
||
await dismiss({ request });
|
||
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 === 'approved' ? (
|
||
<>
|
||
<Check className="h-4 w-4 text-emerald-400" />
|
||
Added <span className="font-bold text-white">{requesterName}</span> to {crew.name}
|
||
</>
|
||
) : done === 'endorsed' ? (
|
||
<>
|
||
<ThumbsUp className="h-4 w-4 text-emerald-400" />
|
||
You vouched for <span className="font-bold text-white">{requesterName}</span> — founder will confirm
|
||
</>
|
||
) : (
|
||
<>
|
||
<X className="h-4 w-4 text-gray-500" />
|
||
Dismissed request from {requesterName}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const busy = approving || dismissing || endorsing;
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'rounded-xl border border-emerald-500/25 bg-gradient-to-br from-emerald-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-emerald-500/15">
|
||
<Hand className="h-4 w-4 text-emerald-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-emerald-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={crewHref} className="font-black text-emerald-300 hover:underline">
|
||
{crew.name}
|
||
</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>
|
||
)}
|
||
|
||
{/* Vouch strip — admins' endorsements as social proof for the founder. */}
|
||
{hasEndorsements && (
|
||
<div className="mt-2 flex items-center gap-2 rounded-lg border border-emerald-500/20 bg-emerald-500/[0.06] px-2.5 py-1.5">
|
||
<div className="flex -space-x-1.5">
|
||
{endorserPubkeys.slice(0, 3).map((pk) => (
|
||
<EndorserAvatar key={pk} pubkey={pk} />
|
||
))}
|
||
</div>
|
||
<p className="min-w-0 flex-1 truncate text-[11px] text-emerald-200/90">
|
||
Vouched by <EndorserNames pubkeys={endorserPubkeys} />
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-2.5 flex items-center gap-2">
|
||
{isFounder ? (
|
||
<Button
|
||
size="sm"
|
||
onClick={handleApprove}
|
||
disabled={busy}
|
||
className="h-8 flex-1 gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-600"
|
||
>
|
||
{approving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <UserPlus className="h-3.5 w-3.5" />}
|
||
{hasEndorsements ? 'Approve & add' : 'Add to crew'}
|
||
</Button>
|
||
) : isAdmin ? (
|
||
iEndorsed ? (
|
||
<span className="flex flex-1 items-center gap-1.5 text-xs font-bold text-emerald-400/90">
|
||
<Clock className="h-3.5 w-3.5" /> Vouched — waiting on the founder
|
||
</span>
|
||
) : (
|
||
<Button
|
||
size="sm"
|
||
onClick={handleEndorse}
|
||
disabled={busy}
|
||
className="h-8 flex-1 gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-600"
|
||
>
|
||
{endorsing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ThumbsUp className="h-3.5 w-3.5" />}
|
||
Vouch for them
|
||
</Button>
|
||
)
|
||
) : (
|
||
<span className="flex-1 text-xs text-gray-500">Crew leadership only.</span>
|
||
)}
|
||
<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>
|
||
|
||
{/* Micro-help so the admin path isn't a mystery. */}
|
||
{isAdmin && !iEndorsed && (
|
||
<p className="mt-1.5 text-[11px] leading-snug text-gray-500">
|
||
Admins can’t add members directly — vouching tells the founder you back this rider.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|