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>
This commit is contained in:
parent
314c8620f1
commit
f37d0184d3
3 changed files with 175 additions and 39 deletions
|
|
@ -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 (
|
||||
<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">
|
||||
<Check className="h-4 w-4 text-emerald-400" />
|
||||
Dismissed request from {requesterName}
|
||||
{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(
|
||||
|
|
@ -100,33 +144,70 @@ export function CollectiveJoinRequestCard({
|
|||
</p>
|
||||
)}
|
||||
|
||||
<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}?manage=members`}>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
Manage roster
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleDismiss}
|
||||
disabled={dismissing}
|
||||
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>
|
||||
|
||||
<p className="mt-1.5 flex items-center gap-1 text-[11px] leading-snug text-gray-500">
|
||||
<Users className="h-3 w-3" />
|
||||
Add them from your collective’s member list, then dismiss this.
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ export function RequestToJoinCollective({
|
|||
<>
|
||||
<p className="text-sm text-gray-400 mb-3">
|
||||
Ask the crew behind <span className="font-bold text-white">{profile.title}</span> 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.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setOpen(true)}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||
import { useNostr } from '@nostrify/react';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { useDismissCrewNotification } from '@/hooks/useCrewDismissals';
|
||||
import {
|
||||
COLLECTIVE_JOIN_REQUEST_KIND,
|
||||
collectiveJoinRequestD,
|
||||
|
|
@ -83,6 +84,60 @@ export function useIncomingCollectiveJoinRequests(pubkey: string | undefined) {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One-click approval: the collective OWNER adds the requester to their own Lab
|
||||
* profile (`p` tag) and dismisses the request. Only the owner authors the
|
||||
* profile event, so this is the sole trusted path — mirrors the crew founder's
|
||||
* "Add to crew". Re-signing preserves every other tag on the profile.
|
||||
*/
|
||||
export function useApproveCollectiveJoinRequest() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: dismiss } = useDismissCrewNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
request,
|
||||
profile,
|
||||
}: {
|
||||
request: CollectiveJoinRequest;
|
||||
profile: LabProfile;
|
||||
}) => {
|
||||
if (!user) throw new Error('You must be logged in.');
|
||||
if (user.pubkey.toLowerCase() !== profile.pubkey.toLowerCase()) {
|
||||
throw new Error('Only the collective owner can add members.');
|
||||
}
|
||||
|
||||
const already = profile.members.some(
|
||||
(m) => m.toLowerCase() === request.requester.toLowerCase(),
|
||||
);
|
||||
if (!already) {
|
||||
// Clone the profile event, drop any stale p for this pubkey, append it.
|
||||
const tags = profile.event.tags.filter(
|
||||
([n, v]) => !(n === 'p' && v?.toLowerCase() === request.requester.toLowerCase()),
|
||||
);
|
||||
tags.push(['p', request.requester]);
|
||||
const signed = await user.signer.signEvent({
|
||||
kind: profile.event.kind,
|
||||
content: profile.event.content,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
});
|
||||
await nostr.event(signed, { signal: AbortSignal.timeout(15000) });
|
||||
}
|
||||
// Clear it from the owner's bell (they can't delete the requester's event).
|
||||
await dismiss(request.id);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['collective-join-requests'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['lab-profile'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['lab-profiles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['collective-members'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the logged-in user already has a pending join-request to a given
|
||||
* collective coordinate (so the button can show "Requested").
|
||||
|
|
|
|||
Loading…
Reference in a new issue