Add sponsor picker + notifiable crew join-requests

Part A — Sponsor picker:
- CrewSponsorPicker searches the shop directory and emits a 30017 coordinate
  so founders select a sponsoring shop instead of pasting a raw coordinate.
- Wired into CrewForm, replacing the raw coordinate input.

Part B — Notifiable join-requests (kind 31969):
- New addressable join-request event p-tagged to founder + admins, making the
  request→approve direction symmetric with invites (surfaced in the bell, not
  buried in a DM inbox).
- useCrewInvites: useSendJoinRequest, useIncomingJoinRequests,
  useApproveJoinRequest (founder adds member), useDismissJoinRequest (local).
- CrewRequestToJoin now publishes the structured event.
- JoinRequestCard (Add to crew / Dismiss) wired into NotificationBell and
  counted in the unread badge.
- Documented kind 31969 in NIP.md alongside invites.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 00:23:55 +07:00
parent 50d8d9e614
commit a2c34800ce
10 changed files with 615 additions and 64 deletions

47
NIP.md
View file

@ -2245,6 +2245,53 @@ unforgeable (founder `p` tag + invitee attestation).
Founders also retain a shareable **link/message** invite for people not yet on
Nostr, and a shortcut to add a member key directly in crew settings.
#### Join requests (kind `31969`) — the symmetric, notifiable inverse of invites
An invite flows moderator → skater. A **join request** flows the other way:
a skater asks a crew to let them in. To keep the two directions symmetric —
i.e. so a request lands in the leadership's **notification bell** with a
one-click action, rather than getting lost in a DM inbox — a request is a
public, addressable event (kind `31969`) that `p`-tags the founder **and** every
admin. It carries nothing sensitive: just an optional note.
```json
{
"kind": 31969,
"content": "Local, skate the plaza most nights — would love to roll with you.",
"tags": [
["d", "35006:<founder>:<crewDtag>:<requesterPubkey>"],
["a", "35006:<founder>:<crewDtag>"],
["crew_name", "Barcelona Night Crew"],
["p", "<founderPubkey>"],
["p", "<adminPubkey>"],
["alt", "Crew join request: Barcelona Night Crew on SkateSpots"]
]
}
```
| Tag | Description |
|-----|-------------|
| `d` | Deterministic id `<crewCoord>:<requesterPubkey>` — re-requesting **replaces** the prior request (addressable), so a crew never accrues duplicate asks from the same person. |
| `a` | The crew coordinate (`35006:pubkey:d`) being requested. |
| `crew_name` | Readable crew name for rendering the notification without a second fetch. |
| `p` | One entry per **founder + admin**. Queryable at the relay level so each moderator finds requests addressed to them: `{ kinds: [31969], "#p": ["<me>"] }`. |
**Flow:**
1. **Request.** A logged-out-of-the-crew skater publishes the request. The event
is authored by the requester (`pubkey`), so the "who wants in" claim is
cryptographically bound to them — no spoofing.
2. **Notify.** Each moderator's client queries `#p` requests, filters to crews
they actually lead/help run, and surfaces a card with **Add to crew**
(founder only) / **Dismiss**.
3. **Approve.** Only the **founder** can add the requester's `p` tag to the crew
event (they author it); the requester then confirms with their own NIP-32
attestation, exactly as with an invite. Admins can surface and dismiss but
cannot add.
4. **Dismiss.** Because a moderator can't delete an event they didn't author,
handled/ignored requests are cleared from *their* bell via a local dismissal
set.
#### Private Crew Chat (Cordn / MLS) — the `coordinator` tag
A crew's **public** identity (roster, spots, events, treasury, sponsor) lives on

View file

@ -17,6 +17,7 @@ import { genUserName } from '@/lib/genUserName';
import { SpotPicker } from '@/components/SpotPicker';
import { LabLinkField } from '@/components/LabLinkField';
import { isCrewVideoUrl, parseCrewVideo } from '@/lib/videoEmbed';
import { CrewSponsorPicker } from '@/components/crew/CrewSponsorPicker';
import { NofferField } from '@/components/NofferField';
import {
SKATE_CREW_KIND,
@ -885,21 +886,14 @@ export function CrewForm({ existing }: CrewFormProps) {
{/* Sponsor shop */}
<div className="space-y-2 rounded-lg border border-amber-500/30 bg-amber-500/[0.04] p-4">
<Label htmlFor="crew-sponsor" className="flex items-center gap-2 text-amber-400">
<Label className="flex items-center gap-2 text-amber-400">
<Store className="h-4 w-4" /> Sponsored by <span className="font-normal text-gray-500">· optional</span>
</Label>
<p className="text-xs text-gray-500">
If a local shop backs your crew, paste their shop address here to show a
“Sponsored by” banner linking to them. Use the shop's coordinate:
<code className="text-amber-300"> 30017:pubkey:d</code>.
If a local shop backs your crew, pick them here to show a
“Sponsored by” banner linking to their shop.
</p>
<Input
id="crew-sponsor"
placeholder="30017:pubkey:shop-id"
value={sponsorRef}
onChange={(e) => setSponsorRef(e.target.value)}
className="bg-gray-800 border-gray-700 font-mono text-xs"
/>
<CrewSponsorPicker value={sponsorRef} onChange={setSponsorRef} />
</div>
{/* Encrypted chat coordinator (Cordn / MLS) */}

View file

@ -11,67 +11,44 @@ import {
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrLogin } from '@nostrify/react/login';
import { useSendDirectMessage } from '@/hooks/useDirectMessages';
import { useSendJoinRequest } from '@/hooks/useCrewInvites';
import { useToast } from '@/hooks/useToast';
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
import { UserPlus, Loader2, Lock } from 'lucide-react';
import { UserPlus, Loader2, Bell } from 'lucide-react';
import type { SkateCrew } from '@/lib/types';
interface CrewRequestToJoinProps {
crewName: string;
founderPubkey: string;
/** Delegated admins — they also receive the request so growth isn't founder-only. */
admins?: string[];
crew: SkateCrew;
}
/**
* Lets a skater request to join a crew via an encrypted NIP-17 DM to the
* founder and admins. This removes the founder-push-only bottleneck: anyone can
* knock, and any admin can then add them from the crew editor.
*
* Privacy: the request is a gift-wrapped DM (kinds 14/13/1059) — only the
* recipients can read it. No public "join" event leaks the social graph.
* Lets a skater request to join a crew by publishing a structured, notifiable
* join-request (kind 31969) addressed to the founder + admins. Their clients
* surface it in the notification bell with a one-click "Add to crew" (founder),
* so requests aren't lost in a DM inbox. Nothing sensitive is published — just
* an optional note.
*/
export function CrewRequestToJoin({ crewName, founderPubkey, admins = [] }: CrewRequestToJoinProps) {
export function CrewRequestToJoin({ crew }: CrewRequestToJoinProps) {
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
const { mutateAsync: sendMessage, isPending } = useSendDirectMessage();
const { mutateAsync: sendJoinRequest, isPending } = useSendJoinRequest();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [note, setNote] = useState('');
if (!user) return null;
const recipients = Array.from(new Set([founderPubkey, ...admins])).filter(
const recipients = Array.from(new Set([crew.pubkey, ...crew.admins])).filter(
(pk) => pk !== user.pubkey,
);
// Don't show for people already in the leadership.
if (recipients.length === 0) return null;
const canDM = checkNip44(user, logins);
const handleSend = async () => {
if (!canDM) {
toast({
title: 'Encryption unavailable',
description: 'Your signer does not support NIP-44 encryption. Please upgrade your extension.',
variant: 'destructive',
});
return;
}
try {
const body =
`🛹 Request to join ${crewName}` +
(note.trim() ? `\n\n${note.trim()}` : '\n\nWould love to roll with the crew.');
await sendMessage({
recipients,
content: body,
subject: `${crewName} — join request`,
});
await sendJoinRequest({ crew, note });
toast({
title: 'Request sent!',
description: `The ${crewName} crew leadership got your encrypted message.`,
title: 'Request sent! 🛹',
description: `The ${crew.name} leadership will see it in their notifications.`,
});
setOpen(false);
setNote('');
@ -98,11 +75,11 @@ export function CrewRequestToJoin({ crewName, founderPubkey, admins = [] }: Crew
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5 text-teal-400" /> Join {crewName}
<UserPlus className="h-5 w-5 text-teal-400" /> Join {crew.name}
</DialogTitle>
<DialogDescription className="flex items-center gap-1.5">
<Lock className="h-3 w-3 text-teal-400" />
Sends a private, encrypted message to the crew's founder &amp; admins.
<Bell className="h-3 w-3 text-teal-400" />
Notifies the crew's founder &amp; admins so they can add you.
</DialogDescription>
</DialogHeader>
<Textarea

View file

@ -0,0 +1,168 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useApproveJoinRequest, useDismissJoinRequest } from '@/hooks/useCrewInvites';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { parseAddrCoord, type CrewJoinRequest, type SkateCrew } from '@/lib/types';
import { UserPlus, Check, X, Loader2, Hand } from 'lucide-react';
import { cn } from '@/lib/utils';
function npubSafe(pubkey: string): string {
try {
return nip19.npubEncode(pubkey);
} catch {
return pubkey;
}
}
/**
* A single incoming join-request card, shown to a crew's founder/admins. The
* FOUNDER gets a one-click "Add to crew" (publishes the updated crew member
* list); admins can only dismiss (they can't edit the founder's event). Either
* way the request can be cleared locally so it stops nagging.
*/
export function JoinRequestCard({
request,
crew,
onResolved,
compact,
}: {
request: CrewJoinRequest;
crew: SkateCrew;
onResolved?: () => void;
compact?: boolean;
}) {
const { user } = useCurrentUser();
const requester = useAuthor(request.requester);
const requesterName = requester.data?.metadata?.name ?? genUserName(request.requester);
const requesterPic = requester.data?.metadata?.picture;
const { toast } = useToast();
const { mutateAsync: approve, isPending: approving } = useApproveJoinRequest();
const { mutateAsync: dismiss, isPending: dismissing } = useDismissJoinRequest();
const [done, setDone] = useState<'approved' | 'dismissed' | null>(null);
const coord = parseAddrCoord(request.crewCoord);
const crewHref = coord ? `/crew/${coord.pubkey}/${coord.identifier}` : '/crews';
const isFounder = !!user && user.pubkey.toLowerCase() === crew.pubkey.toLowerCase();
const handleApprove = async () => {
try {
await approve({ request, crew });
setDone('approved');
toast({
title: `Added ${requesterName} to ${crew.name} 🛹`,
description: "They'll confirm from their side to appear as a member.",
});
onResolved?.();
} catch (err) {
toast({
title: 'Could not add member',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
};
const handleDismiss = async () => {
try {
await dismiss({ request });
setDone('dismissed');
onResolved?.();
} catch {
toast({ title: 'Could not dismiss', 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 === 'approved' ? (
<>
<Check className="h-4 w-4 text-emerald-400" />
Added <span className="font-bold text-white">{requesterName}</span> to {crew.name}
</>
) : (
<>
<X className="h-4 w-4 text-gray-500" />
Dismissed request from {requesterName}
</>
)}
</div>
);
}
const busy = approving || dismissing;
return (
<div
className={cn(
'rounded-xl border border-emerald-500/25 bg-gradient-to-br from-emerald-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-emerald-500/15">
<Hand className="h-4 w-4 text-emerald-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug text-gray-200">
<Link
to={`/${npubSafe(request.requester)}`}
className="inline-flex items-center gap-1 font-bold text-white hover:text-emerald-300"
>
<Avatar className="h-4 w-4">
<AvatarImage src={requesterPic} alt={requesterName} />
<AvatarFallback className="text-[8px]">{requesterName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
{requesterName}
</Link>{' '}
wants to join{' '}
<Link to={crewHref} className="font-black text-emerald-300 hover:underline">
{crew.name}
</Link>
</p>
{request.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]">
“{request.note}”
</p>
)}
<div className="mt-2.5 flex items-center gap-2">
{isFounder ? (
<Button
size="sm"
onClick={handleApprove}
disabled={busy}
className="h-8 flex-1 gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-600"
>
{approving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <UserPlus className="h-3.5 w-3.5" />}
Add to crew
</Button>
) : (
<span className="flex-1 text-xs text-gray-500">
Only the founder can add members.
</span>
)}
<Button
size="sm"
variant="outline"
onClick={handleDismiss}
disabled={busy}
className={cn(
'h-8 gap-1.5 border-white/15 text-gray-400 hover:bg-white/10 hover:text-white',
!isFounder && 'flex-1',
)}
>
{dismissing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
Dismiss
</Button>
</div>
</div>
</div>
</div>
);
}

View file

@ -6,6 +6,7 @@ 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 { JoinRequestCard } from '@/components/JoinRequestCard';
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
import { useAuthor } from '@/hooks/useAuthor';
@ -125,10 +126,13 @@ function PendingConfirmCard({ crew, onResolved }: { crew: SkateCrew; onResolved?
*/
export function NotificationBell({ className }: { className?: string }) {
const [open, setOpen] = useState(false);
const { user, invites, listedCrews, isLoading } = useCrewNotifications();
const { user, invites, listedCrews, joinRequests, isLoading } = useCrewNotifications();
// The bell only makes sense when logged in.
const total = useMemo(() => invites.length + listedCrews.length, [invites, listedCrews]);
const total = useMemo(
() => invites.length + listedCrews.length + joinRequests.length,
[invites, listedCrews, joinRequests],
);
if (!user) return null;
@ -182,6 +186,10 @@ export function NotificationBell({ className }: { className?: string }) {
</div>
)}
{joinRequests.map(({ request, crew }) => (
<JoinRequestCard key={request.id} request={request} crew={crew} compact />
))}
{invites.map((invite) => (
<CrewInviteCard key={invite.id} invite={invite} compact />
))}

View file

@ -0,0 +1,142 @@
import { useMemo, useState } from 'react';
import { Store, Search, X, Check, Loader2, MapPin } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useSkateShops } from '@/hooks/useSkateShops';
import { SKATE_SHOP_KIND, parseAddrCoord, type SkateShop } from '@/lib/types';
import { cn } from '@/lib/utils';
/**
* Pick a sponsoring skate shop by searching the shop directory — no raw
* coordinates. Emits the selected shop's coordinate (`30017:pubkey:d`) via
* `onChange`, or `''` when cleared.
*/
export function CrewSponsorPicker({
value,
onChange,
}: {
value: string;
onChange: (coord: string) => void;
}) {
const { data: shops, isLoading } = useSkateShops(200);
const [query, setQuery] = useState('');
// Resolve the currently-selected coordinate back to a shop for the preview.
const selected = useMemo(() => {
const coord = parseAddrCoord(value);
if (!coord) return null;
return shops?.find((s) => s.pubkey === coord.pubkey && s.identifier === coord.identifier) ?? null;
}, [value, shops]);
const results = useMemo(() => {
if (!shops) return [];
const q = query.trim().toLowerCase();
const list = q
? shops.filter(
(s) =>
s.name.toLowerCase().includes(q) ||
s.location?.toLowerCase().includes(q),
)
: shops;
return list.slice(0, 8);
}, [shops, query]);
const coordOf = (s: SkateShop) => `${SKATE_SHOP_KIND}:${s.pubkey}:${s.identifier}`;
// A shop is selected but not yet resolvable (still loading, or off-relay):
// keep the coordinate but show a neutral chip so the choice isn't lost.
const hasValueButUnresolved = !!parseAddrCoord(value) && !selected;
if (value) {
return (
<div className="flex items-center gap-3 rounded-xl border border-amber-500/30 bg-amber-500/[0.06] p-3">
{selected ? (
<>
<Avatar className="h-10 w-10 rounded-lg border border-amber-500/40">
<AvatarImage src={selected.images?.[0]} alt={selected.name} className="object-cover" />
<AvatarFallback className="rounded-lg bg-amber-500/15 text-amber-300">
<Store className="h-4 w-4" />
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-1 text-[10px] font-black uppercase tracking-widest text-amber-400/80">
<Check className="h-3 w-3" /> Sponsor selected
</p>
<p className="truncate text-sm font-black text-white">{selected.name}</p>
{selected.location && (
<p className="truncate text-xs text-gray-400">{selected.location}</p>
)}
</div>
</>
) : (
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm text-gray-400">
{isLoading ? <Loader2 className="h-4 w-4 animate-spin text-amber-400" /> : <Store className="h-4 w-4 text-amber-400" />}
<span className="truncate">
{hasValueButUnresolved ? 'Sponsor set (shop loading…)' : 'Sponsor set'}
</span>
</div>
)}
<button
type="button"
onClick={() => onChange('')}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-white/10 text-gray-400 transition-colors hover:border-red-500/40 hover:text-red-400"
aria-label="Clear sponsor"
>
<X className="h-4 w-4" />
</button>
</div>
);
}
return (
<div className="space-y-2">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search skate shops by name or city…"
className="bg-gray-800 border-gray-700 pl-9 text-sm"
/>
</div>
{isLoading ? (
<div className="flex items-center gap-2 px-1 py-3 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" /> Loading shops…
</div>
) : results.length === 0 ? (
<p className="px-1 py-3 text-sm text-gray-500">
{query ? 'No shops match — try a different search.' : 'No shops found yet.'}
</p>
) : (
<div className="max-h-56 space-y-1.5 overflow-y-auto pr-1">
{results.map((s) => (
<button
key={coordOf(s)}
type="button"
onClick={() => onChange(coordOf(s))}
className={cn(
'flex w-full items-center gap-3 rounded-xl border border-gray-700 bg-gray-800/60 p-2 text-left transition-all hover:border-amber-500/40 hover:bg-amber-500/[0.06] active:scale-[0.99]',
)}
>
<Avatar className="h-9 w-9 rounded-lg">
<AvatarImage src={s.images?.[0]} alt={s.name} className="object-cover" />
<AvatarFallback className="rounded-lg bg-amber-500/15 text-amber-300">
<Store className="h-4 w-4" />
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-white">{s.name}</p>
{s.location && (
<p className="flex items-center gap-1 truncate text-xs text-gray-500">
<MapPin className="h-3 w-3 shrink-0" /> {s.location}
</p>
)}
</div>
</button>
))}
</div>
)}
</div>
);
}

View file

@ -5,11 +5,15 @@ import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
import {
CREW_INVITE_KIND,
CREW_JOIN_REQUEST_KIND,
SKATE_CREW_KIND,
crewInviteD,
crewJoinRequestD,
parseCrewInvite,
parseCrewJoinRequest,
parseAddrCoord,
type CrewInvite,
type CrewJoinRequest,
type SkateCrew,
type MemberVisibility,
} from '@/lib/types';
@ -230,3 +234,153 @@ export function useDeclineCrewInvite() {
},
});
}
// ─── Join requests (notifiable) ───────────────────────────────────────────────
// A skater asks to join a crew. We publish a structured, addressable event
// (kind 31969) p-tagged to the founder + admins so it shows in THEIR notification
// bell with a one-click "Add to crew" (founder) — parallel to invites, so the
// two directions are symmetric. Handled requests are cleared via a local
// dismissal set on the moderator's device.
const JOIN_DISMISS_KEY = 'skatespots:dismissed-join-requests';
function readJoinDismissed(): Set<string> {
try {
const raw = localStorage.getItem(JOIN_DISMISS_KEY);
return new Set(raw ? (JSON.parse(raw) as string[]) : []);
} catch {
return new Set();
}
}
function dismissJoinRequest(id: string) {
try {
const set = readJoinDismissed();
set.add(id);
localStorage.setItem(JOIN_DISMISS_KEY, JSON.stringify([...set]));
} catch {
// ignore
}
}
/** Publish a structured join-request to a crew's founder + admins. */
export function useSendJoinRequest() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
crew,
note = '',
}: {
crew: SkateCrew;
note?: string;
}) => {
if (!user) throw new Error('You must be logged in to request to join.');
const crewCoord = `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`;
const recipients = Array.from(new Set([crew.pubkey, ...crew.admins])).filter(
(pk) => pk.toLowerCase() !== user.pubkey.toLowerCase(),
);
const event = await user.signer.signEvent({
kind: CREW_JOIN_REQUEST_KIND,
content: note.slice(0, 500),
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', crewJoinRequestD(crewCoord, user.pubkey.toLowerCase())],
['a', crewCoord],
['crew_name', crew.name],
...recipients.map((pk) => ['p', pk]),
['alt', `Crew join request: ${crew.name} on SkateSpots`],
],
});
await nostr.event(event, { signal: AbortSignal.timeout(15000) });
return event;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-join-requests'] });
},
});
}
/** Incoming join-requests addressed to the given moderator (founder/admin). */
export function useIncomingJoinRequests(pubkey: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['crew-join-requests', 'incoming', pubkey, getQueryKeySuffix()],
enabled: !!pubkey,
staleTime: 30_000,
refetchOnMount: 'always',
queryFn: async (): Promise<CrewJoinRequest[]> => {
if (!pubkey) return [];
const signal = AbortSignal.timeout(10000);
const events = await querySkateContent(
[{ kinds: [CREW_JOIN_REQUEST_KIND], '#p': [pubkey], limit: 100 }],
{ signal },
);
const dismissed = readJoinDismissed();
const seen = new Map<string, CrewJoinRequest>();
for (const ev of events) {
const req = parseCrewJoinRequest(ev);
if (!req) continue;
if (!req.recipients.includes(pubkey.toLowerCase())) continue;
if (dismissed.has(req.id)) continue;
// Dedup by (crew + requester) — newest wins.
const key = `${req.crewCoord}:${req.requester}`;
const prev = seen.get(key);
if (!prev || req.createdAt > prev.createdAt) seen.set(key, req);
}
return [...seen.values()].sort((a, b) => b.createdAt - a.createdAt);
},
});
}
/**
* Approve a join-request: the FOUNDER adds the requester to the crew's member
* (`p`) list. The requester then confirms with their own key (standard
* attestation). Only the founder can do this (they author the crew event); an
* admin dismisses/handles out-of-band.
*/
export function useApproveJoinRequest() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ request, crew }: { request: CrewJoinRequest; crew: SkateCrew }) => {
if (!user) throw new Error('You must be logged in.');
if (user.pubkey.toLowerCase() !== crew.pubkey.toLowerCase()) {
throw new Error('Only the crew founder can add members.');
}
const alreadyMember = crew.members.some(
(m) => m.toLowerCase() === request.requester.toLowerCase(),
);
if (!alreadyMember) {
const updated = republishCrewWithMember(crew.event, request.requester, 'member');
const signed = await user.signer.signEvent(updated);
await nostr.event(signed, { signal: AbortSignal.timeout(15000) });
}
dismissJoinRequest(request.id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-join-requests'] });
queryClient.invalidateQueries({ queryKey: ['skate-crews'] });
queryClient.invalidateQueries({ queryKey: ['skate-crew'] });
},
});
}
/** Dismiss a join-request locally (moderator handled/ignored it). */
export function useDismissJoinRequest() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ request }: { request: CrewJoinRequest }) => {
dismissJoinRequest(request.id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['crew-join-requests'] });
},
});
}

View file

@ -1,9 +1,9 @@
import { useMemo } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useMyCrewInvites } from '@/hooks/useCrewInvites';
import { useMyCrewInvites, useIncomingJoinRequests } from '@/hooks/useCrewInvites';
import { useCrews } from '@/hooks/useCrews';
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
import { SKATE_CREW_KIND, type CrewInvite, type SkateCrew } from '@/lib/types';
import { SKATE_CREW_KIND, type CrewInvite, type CrewJoinRequest, type SkateCrew } from '@/lib/types';
export interface CrewNotification {
/** Stable id for the notification (invite id or crew coord). */
@ -28,6 +28,7 @@ export interface CrewNotification {
export function useCrewNotifications() {
const { user } = useCurrentUser();
const { data: invites, isLoading: invitesLoading } = useMyCrewInvites(user?.pubkey);
const { data: joinRequests, isLoading: joinLoading } = useIncomingJoinRequests(user?.pubkey);
const { data: crews, isLoading: crewsLoading } = useCrews(200);
// Crews that list me as a member (candidates for "pending confirmation").
@ -40,11 +41,31 @@ export function useCrewNotifications() {
);
}, [user, crews]);
// Resolve each incoming join-request to its crew (for approval + display).
const requestsWithCrew = useMemo(() => {
if (!user || !joinRequests?.length || !crews) return [];
return joinRequests
.map((req) => {
const crew = crews.find(
(c) => `${SKATE_CREW_KIND}:${c.pubkey}:${c.identifier}` === req.crewCoord,
);
return crew ? { request: req, crew } : null;
})
.filter((x): x is { request: CrewJoinRequest; crew: SkateCrew } => !!x)
// Only show requests for crews I actually lead or help run.
.filter(
({ crew }) =>
crew.pubkey.toLowerCase() === user.pubkey.toLowerCase() ||
crew.admins.some((a) => a.toLowerCase() === user.pubkey.toLowerCase()),
);
}, [user, joinRequests, crews]);
return {
user,
invites: invites ?? [],
listedCrews,
isLoading: invitesLoading || crewsLoading,
joinRequests: requestsWithCrew,
isLoading: invitesLoading || joinLoading || crewsLoading,
};
}

View file

@ -66,6 +66,7 @@ export const SPOT_CHAT_KIND = 24899; // Ephemeral geohash-anchored local chat ("
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.
export const CREW_JOIN_REQUEST_KIND = 31969; // Addressable crew join-request — a skater asks to join a crew (notifiable). SkateSpots-native.
/**
* Lab provider entity type.
@ -315,6 +316,51 @@ export function crewInviteD(crewCoord: string, inviteePubkey: string): string {
return `${crewCoord}:${inviteePubkey}`;
}
/**
* A structured crew join-request (kind 31969) — a skater asks to join a crew.
* Public and addressable (keyed by crew + requester, so re-requesting replaces),
* `p`-tagged to the founder + admins so their clients can surface it as a
* notification with a one-click "Add to crew". Carries nothing sensitive — just
* an optional note.
*/
export interface CrewJoinRequest {
id: string;
requester: string; // pubkey asking to join
crewCoord: string; // 35006:pubkey:d
crewName: string;
note: string; // optional personal message
/** Founder + admin pubkeys this request is addressed to (from `p` tags). */
recipients: string[];
createdAt: number;
event: NostrEvent;
}
export function parseCrewJoinRequest(event: NostrEvent): CrewJoinRequest | null {
if (event.kind !== CREW_JOIN_REQUEST_KIND) return null;
const crewCoord = event.tags.find(([n]) => n === 'a')?.[1];
if (!crewCoord) return null;
const c = parseAddrCoord(crewCoord);
if (!c || c.kind !== SKATE_CREW_KIND) return null;
const recipients = event.tags
.filter(([n, v]) => n === 'p' && /^[0-9a-f]{64}$/i.test(v ?? ''))
.map(([, v]) => v.toLowerCase());
return {
id: event.id,
requester: event.pubkey,
crewCoord,
crewName: event.tags.find(([n]) => n === 'crew_name')?.[1] ?? 'a crew',
note: event.content.slice(0, 500),
recipients,
createdAt: event.created_at,
event,
};
}
/** Deterministic d-tag for a join-request: crew coord + requester. */
export function crewJoinRequestD(crewCoord: string, requesterPubkey: string): string {
return `${crewCoord}:${requesterPubkey}`;
}
/**
* Validate a CLINK Offer code (noffer1...) — a Nostr-native, self-custodial
* static payment code (see https://github.com/shocknet/clink, kind 21001).

View file

@ -298,13 +298,7 @@ export default function CrewDetailPage() {
<Users className="h-3 w-3" /> Member
</Badge>
)}
{role === 'none' && (
<CrewRequestToJoin
crewName={crew.name}
founderPubkey={crew.pubkey}
admins={crew.admins}
/>
)}
{role === 'none' && <CrewRequestToJoin crew={crew} />}
</>
) : null
}