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>
This commit is contained in:
shakespeare.diy 2026-09-02 22:59:34 +07:00
parent 6ea96171ad
commit e8c1fbc71a
8 changed files with 930 additions and 64 deletions

View file

@ -0,0 +1,151 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { useAuthor } from '@/hooks/useAuthor';
import { useAcceptCrewInvite, useDeclineCrewInvite } from '@/hooks/useCrewInvites';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { parseAddrCoord, type CrewInvite } from '@/lib/types';
import { Users, Check, X, Loader2, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* A single crew-invite notification card with Accept / Decline actions.
* Accepting publishes the invitee's membership attestation (making them a
* confirmed member); declining dismisses the invite locally.
*/
export function CrewInviteCard({
invite,
onResolved,
compact,
}: {
invite: CrewInvite;
onResolved?: () => void;
compact?: boolean;
}) {
const inviter = useAuthor(invite.inviter);
const inviterName = inviter.data?.metadata?.name ?? genUserName(invite.inviter);
const inviterPic = inviter.data?.metadata?.picture;
const { toast } = useToast();
const { mutateAsync: accept, isPending: accepting } = useAcceptCrewInvite();
const { mutateAsync: decline, isPending: declining } = useDeclineCrewInvite();
const [done, setDone] = useState<'accepted' | 'declined' | null>(null);
const coord = parseAddrCoord(invite.crewCoord);
const crewHref = coord ? `/crew/${coord.pubkey}/${coord.identifier}` : '/crews';
const handleAccept = async () => {
try {
await accept({ invite });
setDone('accepted');
toast({
title: `Welcome to ${invite.crewName}! 🛹`,
description: 'Your membership is confirmed.',
});
onResolved?.();
} catch (err) {
toast({
title: 'Could not accept',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
};
const handleDecline = async () => {
try {
await decline({ invite });
setDone('declined');
onResolved?.();
} catch {
toast({ title: 'Could not decline', 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 === 'accepted' ? (
<>
<Check className="h-4 w-4 text-teal-400" />
You joined <span className="font-bold text-white">{invite.crewName}</span>
</>
) : (
<>
<X className="h-4 w-4 text-gray-500" />
Declined invite to {invite.crewName}
</>
)}
</div>
);
}
const busy = accepting || declining;
return (
<div className={cn('rounded-xl border border-teal-500/25 bg-gradient-to-br from-teal-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-teal-500/15">
<Users className="h-4.5 w-4.5 text-teal-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug text-gray-200">
<Link to={`/${inviterNpubSafe(invite.inviter)}`} className="inline-flex items-center gap-1 font-bold text-white hover:text-teal-300">
<Avatar className="h-4 w-4">
<AvatarImage src={inviterPic} alt={inviterName} />
<AvatarFallback className="text-[8px]">{inviterName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
{inviterName}
</Link>{' '}
invited you to join{' '}
<Link to={crewHref} className="font-black text-teal-300 hover:underline">
{invite.crewName}
</Link>
{invite.role === 'admin' && (
<span className="ml-1 inline-flex items-center gap-0.5 rounded-full bg-teal-500/20 px-1.5 py-0.5 text-[9px] font-black uppercase text-teal-300">
<ShieldCheck className="h-2.5 w-2.5" /> Admin
</span>
)}
</p>
{invite.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]">
“{invite.note}”
</p>
)}
<div className="mt-2.5 flex items-center gap-2">
<Button
size="sm"
onClick={handleAccept}
disabled={busy}
className="h-8 flex-1 gap-1.5 bg-teal-500 font-bold text-black hover:bg-teal-600"
>
{accepting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
Accept
</Button>
<Button
size="sm"
variant="outline"
onClick={handleDecline}
disabled={busy}
className="h-8 gap-1.5 border-white/15 text-gray-400 hover:bg-white/10 hover:text-white"
>
{declining ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
Decline
</Button>
</div>
</div>
</div>
</div>
);
}
// Keep a tiny local encoder to avoid importing nip19 into every card render path.
import { nip19 } from 'nostr-tools';
function inviterNpubSafe(pubkey: string): string {
try {
return nip19.npubEncode(pubkey);
} catch {
return pubkey;
}
}

View file

@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { UserPlus, Copy, Check, Share2, Edit } from 'lucide-react'; import { UserPlus, Copy, Check, Share2, Edit, Send, Loader2, ShieldCheck } from 'lucide-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -9,45 +10,91 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; 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 { 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 { cn } from '@/lib/utils';
import type { SkateCrew } from '@/lib/types';
interface CrewInviteDialogProps { interface CrewInviteDialogProps {
crewName: string; crew: SkateCrew;
founderPubkey: string; /** Whether the viewer is the founder (gets the extra "add member" edit link + admin toggle). */
identifier: string;
/** Whether the viewer is the founder (gets the extra "add member" edit link). */
isFounder: boolean; isFounder: boolean;
triggerClassName?: string; 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). * Invite flow for crew moderators (founder + admins).
* *
* On Nostr, only the founder can write the crew's member (`p`) list, so an admin * Two ways to invite:
* can't silently add someone. What every moderator CAN do is invite: share a * 1. **Send invite** — publishes a structured invite (kind 36229) addressed to a
* link/message that brings the invitee to the crew page, where — once the * specific npub. That person gets a notification with Accept / Decline.
* founder lists them — they confirm membership with their own key. * When the FOUNDER sends it, the invitee is pre-listed so accepting instantly
* * confirms membership. Admins can invite too (founder auto-confirms on accept).
* Founders additionally get a direct "add member" shortcut to the edit form. * 2. **Share link** — copy/share a link for people not yet on Nostr.
*/ */
export function CrewInviteDialog({ export function CrewInviteDialog({ crew, isFounder, triggerClassName }: CrewInviteDialogProps) {
crewName,
founderPubkey,
identifier,
isFounder,
triggerClassName,
}: CrewInviteDialogProps) {
const { toast } = useToast(); const { toast } = useToast();
const [open, setOpen] = useState(false);
const [copiedLink, setCopiedLink] = useState(false); const [copiedLink, setCopiedLink] = useState(false);
const [copiedMsg, setCopiedMsg] = useState(false); const [copiedMsg, setCopiedMsg] = useState(false);
const crewUrl = `${window.location.origin}/crew/${founderPubkey}/${identifier}`; // 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 = const inviteMessage =
`Yo — join the ${crewName} crew on SkateSpots 🛹\n${crewUrl}\n\n` + `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.`; `Log in with your Nostr key, then confirm your membership on the crew page.`;
const copy = async (text: string, which: 'link' | 'msg') => { const copy = async (text: string, which: 'link' | 'msg') => {
@ -69,7 +116,7 @@ export function CrewInviteDialog({
const nativeShare = async () => { const nativeShare = async () => {
if (navigator.share) { if (navigator.share) {
try { try {
await navigator.share({ title: `Join ${crewName}`, text: inviteMessage, url: crewUrl }); await navigator.share({ title: `Join ${crew.name}`, text: inviteMessage, url: crewUrl });
} catch { } catch {
/* cancelled */ /* cancelled */
} }
@ -78,8 +125,35 @@ export function CrewInviteDialog({
} }
}; };
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 ( return (
<Dialog> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <Button
type="button" type="button"
@ -90,57 +164,129 @@ export function CrewInviteDialog({
<UserPlus className="h-3.5 w-3.5" /> Invite <UserPlus className="h-3.5 w-3.5" /> Invite
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5 text-teal-400" /> Invite to {crewName} <UserPlus className="h-5 w-5 text-teal-400" /> Invite to {crew.name}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Share this with someone you want in the crew. They'll land on the crew page to confirm. Send an invite to someone's Nostr key, or share a link.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <Tabs defaultValue="send" className="w-full">
{/* Invite link */} <TabsList className="grid w-full grid-cols-2">
<div className="space-y-1.5"> <TabsTrigger value="send" className="gap-1.5">
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">Invite link</label> <Send className="h-3.5 w-3.5" /> Send invite
<div className="flex gap-2"> </TabsTrigger>
<Input readOnly value={crewUrl} className="font-mono text-xs" onFocus={(e) => e.target.select()} /> <TabsTrigger value="share" className="gap-1.5">
<Button type="button" variant="outline" size="icon" className="shrink-0" onClick={() => copy(crewUrl, 'link')}> <Share2 className="h-3.5 w-3.5" /> Share link
{copiedLink ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />} </TabsTrigger>
</Button> </TabsList>
</div>
</div>
{/* Invite message */} {/* ── Send a structured invite to an npub ── */}
<div className="space-y-1.5"> <TabsContent value="send" className="space-y-4 pt-2">
<label className="text-xs font-bold uppercase tracking-wide text-gray-500">Message</label> <div className="space-y-1.5">
<Textarea readOnly value={inviteMessage} rows={4} className="text-sm" /> <label className="text-xs font-bold uppercase tracking-wide text-gray-500">
<div className="flex gap-2"> Skater's npub
<Button type="button" variant="outline" className="flex-1 gap-1.5" onClick={() => copy(inviteMessage, 'msg')}> </label>
{copiedMsg ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />} <Input
Copy message value={inviteeInput}
</Button> onChange={(e) => setInviteeInput(e.target.value)}
<Button type="button" className="flex-1 gap-1.5 bg-teal-500 hover:bg-teal-600 text-black" onClick={nativeShare}> placeholder="npub1… or nprofile1…"
<Share2 className="h-4 w-4" /> Share className={cn('font-mono text-xs', invalidKey && 'border-red-500/50')}
</Button> autoComplete="off"
spellCheck={false}
/>
{invalidKey && (
<p className="text-xs text-red-400">That doesn't look like a valid npub.</p>
)}
</div> </div>
</div>
{/* Founder-only: add member directly via edit */} {resolvedPubkey && <InviteePreview pubkey={resolvedPubkey} />}
{isFounder && (
<div className="rounded-lg border border-teal-500/30 bg-teal-500/5 p-3"> <div className="space-y-1.5">
<p className="mb-2 text-xs text-gray-400"> <label className="text-xs font-bold uppercase tracking-wide text-gray-500">
As founder you can also add a member's key directly, then they confirm. Note <span className="font-normal text-gray-600">(optional)</span>
</p> </label>
<Link to={`/edit-crew/${founderPubkey}/${identifier}`}> <Textarea
<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"> value={note}
<Edit className="h-3.5 w-3.5" /> Add member in crew settings 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> </Button>
</Link> </div>
</div> </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> </DialogContent>
</Dialog> </Dialog>
); );

View file

@ -60,6 +60,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { NewsletterOverlay } from '@/components/NewsletterOverlay'; import { NewsletterOverlay } from '@/components/NewsletterOverlay';
import { NotificationBell } from '@/components/NotificationBell';
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
@ -582,6 +583,8 @@ export function Layout({ children, noPadding }: LayoutProps) {
<span className="text-sm font-bold">Status</span> <span className="text-sm font-bold">Status</span>
</Button> </Button>
)} )}
{/* Notifications (crew invites & membership requests) */}
{user && <NotificationBell />}
{/* Offline Indicator */} {/* Offline Indicator */}
<OfflineIndicator /> <OfflineIndicator />
{/* Language Toggle */} {/* Language Toggle */}
@ -624,6 +627,8 @@ export function Layout({ children, noPadding }: LayoutProps) {
</Link> </Link>
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-1 flex-shrink-0">
{/* Notifications (crew invites & membership requests) */}
{user && <NotificationBell />}
{/* User menu when logged in / Login button when not */} {/* User menu when logged in / Login button when not */}
{user ? ( {user ? (
<MobileHeaderUserMenu <MobileHeaderUserMenu

View file

@ -0,0 +1,209 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Bell, Users, Check, Loader2, EyeOff, Globe } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { CrewInviteCard } from '@/components/CrewInviteCard';
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
import { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { SKATE_CREW_KIND, type MemberVisibility, type SkateCrew } from '@/lib/types';
import { cn } from '@/lib/utils';
/**
* A pending-confirm crew: this user has been LISTED in a crew's member list but
* hasn't published their own attestation yet. Rendered like an invite so they
* can confirm (with a visibility choice) right from the bell.
*/
function PendingConfirmCard({ crew, onResolved }: { crew: SkateCrew; onResolved?: () => void }) {
const isPending = useIsPendingMember(crew);
const founder = useAuthor(crew.pubkey);
const founderName = founder.data?.metadata?.name ?? genUserName(crew.pubkey);
const founderPic = founder.data?.metadata?.picture;
const { toast } = useToast();
const { mutateAsync: confirm, isPending: confirming } = useConfirmMembership();
const [visibility, setVisibility] = useState<MemberVisibility>('public');
const [done, setDone] = useState(false);
// Only actionable while the member is genuinely pending confirmation.
if (!isPending || done) return null;
const handleConfirm = async () => {
try {
await confirm({
kind: SKATE_CREW_KIND,
founderPubkey: crew.pubkey,
identifier: crew.identifier,
collectiveName: crew.name,
visibility,
});
setDone(true);
toast({ title: `You're in ${crew.name}! 🛹`, description: 'Membership confirmed.' });
onResolved?.();
} catch (err) {
toast({
title: 'Could not confirm',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
};
return (
<div className="rounded-xl border border-teal-500/25 bg-gradient-to-br from-teal-500/[0.08] to-transparent p-3">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-teal-500/15">
<Users className="h-4 w-4 text-teal-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug text-gray-200">
<span className="inline-flex items-center gap-1 font-bold text-white">
<Avatar className="h-4 w-4">
<AvatarImage src={founderPic} alt={founderName} />
<AvatarFallback className="text-[8px]">{founderName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
{founderName}
</span>{' '}
added you to{' '}
<Link to={`/crew/${crew.pubkey}/${crew.identifier}`} className="font-black text-teal-300 hover:underline">
{crew.name}
</Link>
. Confirm to appear as a member.
</p>
{/* Visibility toggle */}
<div className="mt-2 grid grid-cols-2 gap-1.5">
{([
{ key: 'public', label: 'Public', icon: Globe },
{ key: 'crew', label: 'Crew only', icon: EyeOff },
] as const).map(({ key, label, icon: Icon }) => {
const active = visibility === key;
return (
<button
key={key}
type="button"
onClick={() => setVisibility(key)}
className={cn(
'flex items-center justify-center gap-1 rounded-lg border px-2 py-1.5 text-xs font-bold transition-all active:scale-95',
active
? 'border-transparent bg-teal-500 text-black'
: 'border-white/10 bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white',
)}
>
<Icon className="h-3 w-3" />
{label}
</button>
);
})}
</div>
<Button
size="sm"
onClick={handleConfirm}
disabled={confirming}
className="mt-2.5 h-8 w-full gap-1.5 bg-teal-500 font-bold text-black hover:bg-teal-600"
>
{confirming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
Confirm membership
</Button>
</div>
</div>
</div>
);
}
/**
* Header notification bell. Surfaces actionable crew notifications:
* - structured crew invites (accept / decline)
* - "pending confirmation" crews (someone listed you — confirm to join)
*
* Shows an unread count badge. Only renders when logged in.
*/
export function NotificationBell({ className }: { className?: string }) {
const [open, setOpen] = useState(false);
const { user, invites, listedCrews, isLoading } = useCrewNotifications();
// The bell only makes sense when logged in.
const total = useMemo(() => invites.length + listedCrews.length, [invites, listedCrews]);
if (!user) return null;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn('relative text-gray-400 hover:text-teal-400 hover:bg-teal-500/10', className)}
title="Notifications"
aria-label={`Notifications${total > 0 ? ` (${total} new)` : ''}`}
>
<Bell className="h-5 w-5" />
{total > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex min-w-[18px] items-center justify-center rounded-full bg-teal-500 px-1 text-[10px] font-black leading-[18px] text-black">
{total > 9 ? '9+' : total}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[360px] max-w-[calc(100vw-1.5rem)] p-0" sideOffset={8}>
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
<h3 className="flex items-center gap-2 text-sm font-black uppercase tracking-wide text-white">
<Bell className="h-4 w-4 text-teal-400" /> Notifications
</h3>
{total > 0 && (
<span className="rounded-full bg-teal-500/20 px-2 py-0.5 text-[10px] font-black uppercase text-teal-300">
{total} new
</span>
)}
</div>
<ScrollArea className="max-h-[70vh]">
<div className="space-y-2.5 p-3">
{isLoading && total === 0 && (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
</div>
)}
{!isLoading && total === 0 && (
<div className="flex flex-col items-center gap-2 py-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white/5">
<Bell className="h-5 w-5 text-gray-600" />
</div>
<p className="text-sm font-bold text-gray-400">You're all caught up</p>
<p className="max-w-[220px] text-xs text-gray-600">
Crew invites and membership requests will show up here.
</p>
</div>
)}
{invites.map((invite) => (
<CrewInviteCard key={invite.id} invite={invite} compact />
))}
{listedCrews.map((crew) => (
<PendingConfirmCard key={`${crew.pubkey}:${crew.identifier}`} crew={crew} />
))}
</div>
</ScrollArea>
<div className="border-t border-white/10 p-2">
<Button
asChild
variant="ghost"
size="sm"
className="w-full text-xs font-bold text-gray-400 hover:text-teal-400"
onClick={() => setOpen(false)}
>
<Link to="/crews">Browse all crews</Link>
</Button>
</div>
</PopoverContent>
</Popover>
);
}

232
src/hooks/useCrewInvites.ts Normal file
View file

@ -0,0 +1,232 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
import {
CREW_INVITE_KIND,
SKATE_CREW_KIND,
crewInviteD,
parseCrewInvite,
parseAddrCoord,
type CrewInvite,
type SkateCrew,
type MemberVisibility,
} from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify';
// ─── Local dismissals ─────────────────────────────────────────────────────────
// The invitee can't delete an invite they didn't author, so accepted/declined
// invites are cleared from *their* notifications via a local dismissal set.
// (The public invite event still exists on relays; it just stops nagging them.)
const DISMISS_KEY = 'skatespots:dismissed-crew-invites';
function readDismissed(): Set<string> {
try {
const raw = localStorage.getItem(DISMISS_KEY);
return new Set(raw ? (JSON.parse(raw) as string[]) : []);
} catch {
return new Set();
}
}
function dismissInvite(inviteId: string) {
try {
const set = readDismissed();
set.add(inviteId);
localStorage.setItem(DISMISS_KEY, JSON.stringify([...set]));
} catch {
// ignore storage errors
}
}
// ─── Sending an invite ──────────────────────────────────────────────────────
interface SendInviteParams {
crew: SkateCrew;
/** Invitee pubkey (hex). */
invitee: string;
role?: 'member' | 'admin';
note?: string;
/** Whether the current user is the crew founder (can also add the p-tag). */
isFounder: boolean;
}
/**
* Send a structured crew invite to a specific pubkey.
*
* The invite is a public, addressable event (kind 36229) that `p`-tags the
* invitee so they can query "invites addressed to me". It carries nothing
* sensitive — real membership stays unforgeable (founder `p` tag + invitee
* attestation).
*
* When the sender is the FOUNDER, we optimistically add the invitee to the
* crew's member (`p`) list at invite time, so the moment they accept (attest)
* they show as a confirmed member with no extra founder step. When an ADMIN
* invites, they can't edit the founder's event, so the invite alone is sent and
* the founder confirms the add on accept.
*/
export function useSendCrewInvite() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ crew, invitee, role = 'member', note = '', isFounder }: SendInviteParams) => {
if (!user) throw new Error('You must be logged in to invite.');
const crewCoord = `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`;
const inviteePk = invitee.toLowerCase();
if (inviteePk === user.pubkey.toLowerCase()) {
throw new Error("You can't invite yourself.");
}
// 1. Publish the invite event.
const inviteEvent = await user.signer.signEvent({
kind: CREW_INVITE_KIND,
content: note.slice(0, 500),
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', crewInviteD(crewCoord, inviteePk)],
['p', inviteePk],
['a', crewCoord],
['crew_name', crew.name],
['role', role],
['alt', `Crew invite: join ${crew.name} on SkateSpots`],
],
});
await nostr.event(inviteEvent, { signal: AbortSignal.timeout(15000) });
// 2. Founder optimistically pre-lists the invitee so accept = instant confirm.
if (isFounder && user.pubkey.toLowerCase() === crew.pubkey.toLowerCase()) {
const already = crew.members.some((m) => m.toLowerCase() === inviteePk);
if (!already) {
const updated = republishCrewWithMember(crew.event, inviteePk, role);
const signed = await user.signer.signEvent(updated);
await nostr.event(signed, { signal: AbortSignal.timeout(15000) });
}
}
return inviteEvent;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['skate-crews'] });
queryClient.invalidateQueries({ queryKey: ['skate-crew'] });
queryClient.invalidateQueries({ queryKey: ['crew-invites'] });
},
});
}
/**
* Rebuild a crew event's tags with an added member `p` tag, preserving all
* other tags. Returns an unsigned event template ready to sign.
*/
function republishCrewWithMember(
crewEvent: NostrEvent,
memberPubkey: string,
role: 'member' | 'admin',
): { kind: number; content: string; created_at: number; tags: string[][] } {
const tags = crewEvent.tags.filter(
([n, v]) => !(n === 'p' && v?.toLowerCase() === memberPubkey.toLowerCase()),
);
if (role === 'admin') tags.push(['p', memberPubkey, '', 'admin']);
else tags.push(['p', memberPubkey]);
return {
kind: crewEvent.kind,
content: crewEvent.content,
created_at: Math.floor(Date.now() / 1000),
tags,
};
}
// ─── Reading invites addressed to me ─────────────────────────────────────────
/** Live crew invites where the given pubkey is the invitee (excludes locally dismissed). */
export function useMyCrewInvites(pubkey: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['crew-invites', 'incoming', pubkey, getQueryKeySuffix()],
enabled: !!pubkey,
staleTime: 30_000,
refetchOnMount: 'always',
queryFn: async (): Promise<CrewInvite[]> => {
if (!pubkey) return [];
const signal = AbortSignal.timeout(10000);
const events = await querySkateContent(
[{ kinds: [CREW_INVITE_KIND], '#p': [pubkey], limit: 100 }],
{ signal },
);
const dismissed = readDismissed();
const seen = new Map<string, CrewInvite>();
for (const ev of events) {
const inv = parseCrewInvite(ev);
if (!inv || inv.invitee.toLowerCase() !== pubkey.toLowerCase()) continue;
if (dismissed.has(inv.id)) continue;
// Dedup by crew coord (addressable — newest wins).
const prev = seen.get(inv.crewCoord);
if (!prev || inv.createdAt > prev.createdAt) seen.set(inv.crewCoord, inv);
}
return [...seen.values()].sort((a, b) => b.createdAt - a.createdAt);
},
});
}
// ─── Accept / decline ─────────────────────────────────────────────────────────
/**
* Accept a crew invite: publish the invitee's own NIP-32 membership attestation
* (kind 1985). Once the founder has listed them (founders pre-list on invite),
* they show as a confirmed member. The invite is dismissed locally.
*/
export function useAcceptCrewInvite() {
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const { mutateAsync: confirm } = useConfirmMembership();
return useMutation({
mutationFn: async ({
invite,
visibility = 'public',
}: {
invite: CrewInvite;
visibility?: MemberVisibility;
}) => {
if (!user) throw new Error('You must be logged in.');
const coord = parseAddrCoord(invite.crewCoord);
if (!coord) throw new Error('Invalid crew reference.');
await confirm({
kind: coord.kind,
founderPubkey: coord.pubkey,
identifier: coord.identifier,
collectiveName: invite.crewName,
visibility,
});
dismissInvite(invite.id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-invites'] });
queryClient.invalidateQueries({ queryKey: ['collective-members'] });
queryClient.invalidateQueries({ queryKey: ['skate-crews'] });
},
});
}
/** Decline a crew invite — dismiss it locally so it clears from notifications. */
export function useDeclineCrewInvite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ invite }: { invite: CrewInvite }) => {
dismissInvite(invite.id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-invites'] });
},
});
}

View file

@ -0,0 +1,68 @@
import { useMemo } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useMyCrewInvites } from '@/hooks/useCrewInvites';
import { useCrews } from '@/hooks/useCrews';
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
import { SKATE_CREW_KIND, type CrewInvite, type SkateCrew } from '@/lib/types';
export interface CrewNotification {
/** Stable id for the notification (invite id or crew coord). */
id: string;
type: 'invite' | 'pending-confirm';
crew?: SkateCrew;
invite?: CrewInvite;
createdAt: number;
}
/**
* The logged-in user's actionable crew notifications:
*
* - `invite`: a structured crew invite addressed to them (kind 36229).
* - `pending-confirm`: a crew that has LISTED them as a member but which they
* haven't confirmed yet (they were added directly, e.g.
* via the founder editor before invites existed).
*
* Both resolve to the same "join this crew" action, so the notification center
* can render them uniformly.
*/
export function useCrewNotifications() {
const { user } = useCurrentUser();
const { data: invites, isLoading: invitesLoading } = useMyCrewInvites(user?.pubkey);
const { data: crews, isLoading: crewsLoading } = useCrews(200);
// Crews that list me as a member (candidates for "pending confirmation").
const listedCrews = useMemo(() => {
if (!user || !crews) return [];
return crews.filter(
(c) =>
c.pubkey.toLowerCase() !== user.pubkey.toLowerCase() &&
c.members.some((m) => m.toLowerCase() === user.pubkey.toLowerCase()),
);
}, [user, crews]);
return {
user,
invites: invites ?? [],
listedCrews,
isLoading: invitesLoading || crewsLoading,
};
}
/**
* Resolve whether the logged-in user has CONFIRMED membership in a listed crew.
* Used per-crew to decide if a "pending confirm" notification is still relevant.
*/
export function useIsPendingMember(crew: SkateCrew | undefined) {
const { user } = useCurrentUser();
const { data: members } = useCollectiveMembers({
kind: SKATE_CREW_KIND,
founderPubkey: crew?.pubkey ?? '',
identifier: crew?.identifier ?? '',
claimedMembers: crew?.members ?? [],
enabled: !!crew && (crew.members.length ?? 0) > 0,
});
if (!user || !crew) return false;
const me = members?.find((m) => m.pubkey.toLowerCase() === user.pubkey.toLowerCase());
return me?.status === 'pending';
}

View file

@ -64,6 +64,7 @@ export const SKATE_TOUR_KIND = 30823; // Local skate tours - guides offer to sho
export const SPOT_CHAT_KIND = 24899; // Ephemeral geohash-anchored local chat ("Spot Chat"). SkateSpots-native. export const SPOT_CHAT_KIND = 24899; // Ephemeral geohash-anchored local chat ("Spot Chat"). SkateSpots-native.
export const SASQUATCH_PRO_KIND = 32671; // Addressable "Sasquatch Pro" membership receipt (self-signed, references the zap payment). SkateSpots-native. export const SASQUATCH_PRO_KIND = 32671; // Addressable "Sasquatch Pro" membership receipt (self-signed, references the zap payment). SkateSpots-native.
export const SPOT_STATUS_KIND = 2259; // Regular event - community "spot status report" (active/bust/skate-stopped/gone/closed). SkateSpots-native. export const SPOT_STATUS_KIND = 2259; // Regular event - community "spot status report" (active/bust/skate-stopped/gone/closed). SkateSpots-native.
export const CREW_INVITE_KIND = 36229; // Addressable crew invitation — founder/admin invites a specific npub to a crew. SkateSpots-native.
/** /**
* Lab provider entity type. * Lab provider entity type.
@ -257,6 +258,62 @@ export function parseLabRefs(event: NostrEvent): string[] {
return refs; return refs;
} }
// ─── Crew invitations (kind 36229) ─────────────────────────────────────────
//
// A structured, addressable invite from a crew's founder/admin to a specific
// person's pubkey. Addressable so re-inviting the same person replaces the old
// invite (one live invite per crew+invitee). The invite itself is public and
// carries nothing sensitive ("come join") — actual membership stays unforgeable
// via the founder's `p` tag + the invitee's own NIP-32 attestation.
//
// kind: 36229
// content: optional personal note from the inviter
// tags:
// ["d", "<crewCoord>:<inviteePubkey>"] // unique per crew+invitee
// ["p", "<inviteePubkey>"] // recipient — queryable by them
// ["a", "<crewCoord>"] // the crew (35006:pubkey:d)
// ["crew_name", "<name>"] // denormalized for notification UI
// ["role", "member" | "admin"] // proposed role (default member)
// ["alt", "Crew invite: …"]
export interface CrewInvite {
id: string;
inviter: string; // pubkey of founder/admin who sent it
invitee: string; // pubkey being invited
crewCoord: string; // 35006:pubkey:d
crewName: string;
role: 'member' | 'admin';
note: string; // optional personal message
createdAt: number;
event: NostrEvent;
}
export function parseCrewInvite(event: NostrEvent): CrewInvite | null {
if (event.kind !== CREW_INVITE_KIND) return null;
const invitee = event.tags.find(([n]) => n === 'p')?.[1];
const crewCoord = event.tags.find(([n]) => n === 'a')?.[1];
if (!invitee || !crewCoord) return null;
const c = parseAddrCoord(crewCoord);
if (!c || c.kind !== SKATE_CREW_KIND) return null;
const role = event.tags.find(([n]) => n === 'role')?.[1] === 'admin' ? 'admin' : 'member';
return {
id: event.id,
inviter: event.pubkey,
invitee,
crewCoord,
crewName: event.tags.find(([n]) => n === 'crew_name')?.[1] ?? 'a crew',
role,
note: event.content.slice(0, 500),
createdAt: event.created_at,
event,
};
}
/** Build the deterministic d-tag identifier for a crew invite. */
export function crewInviteD(crewCoord: string, inviteePubkey: string): string {
return `${crewCoord}:${inviteePubkey}`;
}
/** /**
* Validate a CLINK Offer code (noffer1...) — a Nostr-native, self-custodial * Validate a CLINK Offer code (noffer1...) — a Nostr-native, self-custodial
* static payment code (see https://github.com/shocknet/clink, kind 21001). * static payment code (see https://github.com/shocknet/clink, kind 21001).

View file

@ -281,9 +281,7 @@ export default function CrewDetailPage() {
<div className="mt-4 flex flex-wrap items-center gap-2"> <div className="mt-4 flex flex-wrap items-center gap-2">
{canManage && ( {canManage && (
<CrewInviteDialog <CrewInviteDialog
crewName={crew.name} crew={crew}
founderPubkey={crew.pubkey}
identifier={crew.identifier}
isFounder={isFounder} isFounder={isFounder}
/> />
)} )}