diff --git a/NIP.md b/NIP.md index 1dda1fe..4cd026b 100644 --- a/NIP.md +++ b/NIP.md @@ -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:::"], + ["a", "35006::"], + ["crew_name", "Barcelona Night Crew"], + ["p", ""], + ["p", ""], + ["alt", "Crew join request: Barcelona Night Crew on SkateSpots"] + ] +} +``` + +| Tag | Description | +|-----|-------------| +| `d` | Deterministic id `:` — 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": [""] }`. | + +**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 diff --git a/src/components/CrewForm.tsx b/src/components/CrewForm.tsx index 3128b95..844435a 100644 --- a/src/components/CrewForm.tsx +++ b/src/components/CrewForm.tsx @@ -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 */}
-
{/* Encrypted chat coordinator (Cordn / MLS) */} diff --git a/src/components/CrewRequestToJoin.tsx b/src/components/CrewRequestToJoin.tsx index 0d8dc36..8b23de5 100644 --- a/src/components/CrewRequestToJoin.tsx +++ b/src/components/CrewRequestToJoin.tsx @@ -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 - Join {crewName} + Join {crew.name} - - Sends a private, encrypted message to the crew's founder & admins. + + Notifies the crew's founder & admins so they can add you.