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:
parent
6ea96171ad
commit
e8c1fbc71a
8 changed files with 930 additions and 64 deletions
151
src/components/CrewInviteCard.tsx
Normal file
151
src/components/CrewInviteCard.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
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 { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -9,45 +10,91 @@ import {
|
|||
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 {
|
||||
crewName: string;
|
||||
founderPubkey: string;
|
||||
identifier: string;
|
||||
/** Whether the viewer is the founder (gets the extra "add member" edit link). */
|
||||
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).
|
||||
*
|
||||
* On Nostr, only the founder can write the crew's member (`p`) list, so an admin
|
||||
* can't silently add someone. What every moderator CAN do is invite: share a
|
||||
* link/message that brings the invitee to the crew page, where — once the
|
||||
* founder lists them — they confirm membership with their own key.
|
||||
*
|
||||
* Founders additionally get a direct "add member" shortcut to the edit form.
|
||||
* 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({
|
||||
crewName,
|
||||
founderPubkey,
|
||||
identifier,
|
||||
isFounder,
|
||||
triggerClassName,
|
||||
}: CrewInviteDialogProps) {
|
||||
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);
|
||||
|
||||
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 =
|
||||
`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.`;
|
||||
|
||||
const copy = async (text: string, which: 'link' | 'msg') => {
|
||||
|
|
@ -69,7 +116,7 @@ export function CrewInviteDialog({
|
|||
const nativeShare = async () => {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ title: `Join ${crewName}`, text: inviteMessage, url: crewUrl });
|
||||
await navigator.share({ title: `Join ${crew.name}`, text: inviteMessage, url: crewUrl });
|
||||
} catch {
|
||||
/* 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 (
|
||||
<Dialog>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -90,18 +164,91 @@ export function CrewInviteDialog({
|
|||
<UserPlus className="h-3.5 w-3.5" /> Invite
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<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 {crewName}
|
||||
<UserPlus className="h-5 w-5 text-teal-400" /> Invite to {crew.name}
|
||||
</DialogTitle>
|
||||
<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>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Invite link */}
|
||||
<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">
|
||||
|
|
@ -112,7 +259,6 @@ export function CrewInviteDialog({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invite message */}
|
||||
<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" />
|
||||
|
|
@ -127,20 +273,20 @@ export function CrewInviteDialog({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Founder-only: add member directly via edit */}
|
||||
{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/${founderPubkey}/${identifier}`}>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
|||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { NewsletterOverlay } from '@/components/NewsletterOverlay';
|
||||
import { NotificationBell } from '@/components/NotificationBell';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
|
|
@ -582,6 +583,8 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
<span className="text-sm font-bold">Status</span>
|
||||
</Button>
|
||||
)}
|
||||
{/* Notifications (crew invites & membership requests) */}
|
||||
{user && <NotificationBell />}
|
||||
{/* Offline Indicator */}
|
||||
<OfflineIndicator />
|
||||
{/* Language Toggle */}
|
||||
|
|
@ -624,6 +627,8 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
</Link>
|
||||
|
||||
<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 ? (
|
||||
<MobileHeaderUserMenu
|
||||
|
|
|
|||
209
src/components/NotificationBell.tsx
Normal file
209
src/components/NotificationBell.tsx
Normal 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
232
src/hooks/useCrewInvites.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
68
src/hooks/useCrewNotifications.ts
Normal file
68
src/hooks/useCrewNotifications.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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 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 CREW_INVITE_KIND = 36229; // Addressable crew invitation — founder/admin invites a specific npub to a crew. SkateSpots-native.
|
||||
|
||||
/**
|
||||
* Lab provider entity type.
|
||||
|
|
@ -257,6 +258,62 @@ export function parseLabRefs(event: NostrEvent): string[] {
|
|||
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
|
||||
* static payment code (see https://github.com/shocknet/clink, kind 21001).
|
||||
|
|
|
|||
|
|
@ -281,9 +281,7 @@ export default function CrewDetailPage() {
|
|||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
{canManage && (
|
||||
<CrewInviteDialog
|
||||
crewName={crew.name}
|
||||
founderPubkey={crew.pubkey}
|
||||
identifier={crew.identifier}
|
||||
crew={crew}
|
||||
isFounder={isFounder}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue