SkateSpots/src/components/CrewInviteDialog.tsx
shakespeare.diy e8c1fbc71a Add crew invitation & notification system (Phase 1)
- 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>
2026-09-02 22:59:34 +07:00

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 &amp; 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>
);
}