- Upgrade CrewInviteDialog with a "Send invite" tab: invite a specific npub with live invitee preview, optional note, and founder-only "invite as admin" toggle. Preserves the shareable-link flow on a second tab. - Add NotificationBell to desktop + mobile headers: unread badge, popover with incoming crew invites (accept/decline) and pending-confirmation crews (confirm with public/crew-only visibility). - Wire useSendCrewInvite / useMyCrewInvites / accept / decline hooks and the useCrewNotifications aggregator into the UI. - Membership stays unforgeable (founder p-tag + invitee NIP-32 attestation). Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
293 lines
12 KiB
TypeScript
293 lines
12 KiB
TypeScript
import { useState } from 'react';
|
|
import { UserPlus, Copy, Check, Share2, Edit, Send, Loader2, ShieldCheck } from 'lucide-react';
|
|
import { Link } from 'react-router-dom';
|
|
import { nip19 } from 'nostr-tools';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from '@/components/ui/dialog';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { useSendCrewInvite } from '@/hooks/useCrewInvites';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { cn } from '@/lib/utils';
|
|
import type { SkateCrew } from '@/lib/types';
|
|
|
|
interface CrewInviteDialogProps {
|
|
crew: SkateCrew;
|
|
/** Whether the viewer is the founder (gets the extra "add member" edit link + admin toggle). */
|
|
isFounder: boolean;
|
|
triggerClassName?: string;
|
|
}
|
|
|
|
/** Try to resolve a raw string (npub / nprofile / hex) to a hex pubkey. */
|
|
function resolvePubkey(input: string): string | null {
|
|
const trimmed = input.trim();
|
|
if (!trimmed) return null;
|
|
// Bare hex
|
|
if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase();
|
|
try {
|
|
const decoded = nip19.decode(trimmed);
|
|
if (decoded.type === 'npub') return decoded.data.toLowerCase();
|
|
if (decoded.type === 'nprofile') return decoded.data.pubkey.toLowerCase();
|
|
} catch {
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Small live preview of who a pubkey resolves to (avatar + name). */
|
|
function InviteePreview({ pubkey }: { pubkey: string }) {
|
|
const author = useAuthor(pubkey);
|
|
const name = author.data?.metadata?.name ?? genUserName(pubkey);
|
|
const pic = author.data?.metadata?.picture;
|
|
return (
|
|
<div className="flex items-center gap-2 rounded-lg border border-teal-500/25 bg-teal-500/5 px-3 py-2">
|
|
<Avatar className="h-8 w-8">
|
|
<AvatarImage src={pic} alt={name} />
|
|
<AvatarFallback className="text-[10px]">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-bold text-white">{name}</p>
|
|
<p className="truncate font-mono text-[10px] text-gray-500">{nip19.npubEncode(pubkey)}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Invite flow for crew moderators (founder + admins).
|
|
*
|
|
* Two ways to invite:
|
|
* 1. **Send invite** — publishes a structured invite (kind 36229) addressed to a
|
|
* specific npub. That person gets a notification with Accept / Decline.
|
|
* When the FOUNDER sends it, the invitee is pre-listed so accepting instantly
|
|
* confirms membership. Admins can invite too (founder auto-confirms on accept).
|
|
* 2. **Share link** — copy/share a link for people not yet on Nostr.
|
|
*/
|
|
export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInviteDialogProps) {
|
|
const { toast } = useToast();
|
|
const [open, setOpen] = useState(false);
|
|
const [copiedLink, setCopiedLink] = useState(false);
|
|
const [copiedMsg, setCopiedMsg] = useState(false);
|
|
|
|
// Send-invite state
|
|
const [inviteeInput, setInviteeInput] = useState('');
|
|
const [note, setNote] = useState('');
|
|
const [asAdmin, setAsAdmin] = useState(false);
|
|
const { mutateAsync: sendInvite, isPending: sending } = useSendCrewInvite();
|
|
|
|
const resolvedPubkey = resolvePubkey(inviteeInput);
|
|
const inputLooksLikeKey = inviteeInput.trim().length > 0;
|
|
const invalidKey = inputLooksLikeKey && !resolvedPubkey;
|
|
|
|
const crewUrl = `${window.location.origin}/crew/${crew.pubkey}/${crew.identifier}`;
|
|
const inviteMessage =
|
|
`Yo — join the ${crew.name} crew on SkateSpots 🛹\n${crewUrl}\n\n` +
|
|
`Log in with your Nostr key, then confirm your membership on the crew page.`;
|
|
|
|
const copy = async (text: string, which: 'link' | 'msg') => {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
if (which === 'link') {
|
|
setCopiedLink(true);
|
|
setTimeout(() => setCopiedLink(false), 2000);
|
|
} else {
|
|
setCopiedMsg(true);
|
|
setTimeout(() => setCopiedMsg(false), 2000);
|
|
}
|
|
toast({ title: 'Copied!' });
|
|
} catch {
|
|
toast({ title: 'Copy failed', variant: 'destructive' });
|
|
}
|
|
};
|
|
|
|
const nativeShare = async () => {
|
|
if (navigator.share) {
|
|
try {
|
|
await navigator.share({ title: `Join ${crew.name}`, text: inviteMessage, url: crewUrl });
|
|
} catch {
|
|
/* cancelled */
|
|
}
|
|
} else {
|
|
copy(inviteMessage, 'msg');
|
|
}
|
|
};
|
|
|
|
const handleSend = async () => {
|
|
if (!resolvedPubkey) return;
|
|
try {
|
|
await sendInvite({
|
|
crew,
|
|
invitee: resolvedPubkey,
|
|
role: asAdmin ? 'admin' : 'member',
|
|
note,
|
|
isFounder,
|
|
});
|
|
toast({
|
|
title: 'Invite sent! 🛹',
|
|
description: "They'll get a notification to accept or decline.",
|
|
});
|
|
setInviteeInput('');
|
|
setNote('');
|
|
setAsAdmin(false);
|
|
setOpen(false);
|
|
} catch (err) {
|
|
toast({
|
|
title: 'Could not send invite',
|
|
description: err instanceof Error ? err.message : 'Please try again.',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className={cn('gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10', triggerClassName)}
|
|
>
|
|
<UserPlus className="h-3.5 w-3.5" /> Invite
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<UserPlus className="h-5 w-5 text-teal-400" /> Invite to {crew.name}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Send an invite to someone's Nostr key, or share a link.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<Tabs defaultValue="send" className="w-full">
|
|
<TabsList className="grid w-full grid-cols-2">
|
|
<TabsTrigger value="send" className="gap-1.5">
|
|
<Send className="h-3.5 w-3.5" /> Send invite
|
|
</TabsTrigger>
|
|
<TabsTrigger value="share" className="gap-1.5">
|
|
<Share2 className="h-3.5 w-3.5" /> Share link
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* ── Send a structured invite to an npub ── */}
|
|
<TabsContent value="send" className="space-y-4 pt-2">
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">
|
|
Skater's npub
|
|
</label>
|
|
<Input
|
|
value={inviteeInput}
|
|
onChange={(e) => setInviteeInput(e.target.value)}
|
|
placeholder="npub1… or nprofile1…"
|
|
className={cn('font-mono text-xs', invalidKey && 'border-red-500/50')}
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
/>
|
|
{invalidKey && (
|
|
<p className="text-xs text-red-400">That doesn't look like a valid npub.</p>
|
|
)}
|
|
</div>
|
|
|
|
{resolvedPubkey && <InviteePreview pubkey={resolvedPubkey} />}
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">
|
|
Note <span className="font-normal text-gray-600">(optional)</span>
|
|
</label>
|
|
<Textarea
|
|
value={note}
|
|
onChange={(e) => setNote(e.target.value.slice(0, 500))}
|
|
placeholder="Come skate with us…"
|
|
rows={2}
|
|
className="text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{isFounder && (
|
|
<div className="flex items-center justify-between rounded-lg border border-teal-500/25 bg-teal-500/5 px-3 py-2.5">
|
|
<div className="flex items-center gap-2">
|
|
<ShieldCheck className="h-4 w-4 text-teal-400" />
|
|
<div>
|
|
<Label htmlFor="invite-admin" className="text-sm font-bold text-white">
|
|
Invite as admin
|
|
</Label>
|
|
<p className="text-[11px] text-gray-500">Can manage the crew & invite others.</p>
|
|
</div>
|
|
</div>
|
|
<Switch id="invite-admin" checked={asAdmin} onCheckedChange={setAsAdmin} />
|
|
</div>
|
|
)}
|
|
|
|
<Button
|
|
type="button"
|
|
onClick={handleSend}
|
|
disabled={!resolvedPubkey || sending}
|
|
className="w-full gap-1.5 bg-teal-500 font-bold text-black hover:bg-teal-600"
|
|
>
|
|
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
|
Send invite
|
|
</Button>
|
|
<p className="text-center text-[11px] text-gray-500">
|
|
They'll get a notification to accept or decline. Membership only counts once they accept.
|
|
</p>
|
|
</TabsContent>
|
|
|
|
{/* ── Share a link (for people not on Nostr yet) ── */}
|
|
<TabsContent value="share" className="space-y-4 pt-2">
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">Invite link</label>
|
|
<div className="flex gap-2">
|
|
<Input readOnly value={crewUrl} className="font-mono text-xs" onFocus={(e) => e.target.select()} />
|
|
<Button type="button" variant="outline" size="icon" className="shrink-0" onClick={() => copy(crewUrl, 'link')}>
|
|
{copiedLink ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">Message</label>
|
|
<Textarea readOnly value={inviteMessage} rows={4} className="text-sm" />
|
|
<div className="flex gap-2">
|
|
<Button type="button" variant="outline" className="flex-1 gap-1.5" onClick={() => copy(inviteMessage, 'msg')}>
|
|
{copiedMsg ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
|
|
Copy message
|
|
</Button>
|
|
<Button type="button" className="flex-1 gap-1.5 bg-teal-500 hover:bg-teal-600 text-black" onClick={nativeShare}>
|
|
<Share2 className="h-4 w-4" /> Share
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{isFounder && (
|
|
<div className="rounded-lg border border-teal-500/30 bg-teal-500/5 p-3">
|
|
<p className="mb-2 text-xs text-gray-400">
|
|
As founder you can also add a member's key directly, then they confirm.
|
|
</p>
|
|
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
|
|
<Button type="button" variant="outline" size="sm" className="w-full gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
|
|
<Edit className="h-3.5 w-3.5" /> Add member in crew settings
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|