From 314c8620f11c4f9c7b3eaea57f7130799a73d455 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 3 Sep 2026 15:41:03 +0700 Subject: [PATCH] =?UTF-8?q?Lab=20collective=20join-requests=20=E2=86=92=20?= =?UTF-8?q?notification=20bell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skaters can now request to join a Lab collective (studio/build team), mirroring the crew join-request flow: - New addressable kind 36387 (collective join-request), documented in NIP.md - useCollectiveJoinRequests: send / incoming / "already requested" hooks - Owner-only notification surfaced in the bell (All + Crews tabs) via CollectiveJoinRequestCard, reusing the synced dismissal set - "Request to join this collective" button on collective Lab profiles (RequestToJoinCollective), hidden for the owner and existing members Trust model preserved: only the owner can edit their own profile roster, so the card links them to the member editor — no forged membership possible. Co-authored-by: shakespeare.diy --- NIP.md | 44 +++++- src/components/CollectiveJoinRequestCard.tsx | 134 +++++++++++++++++++ src/components/NotificationBell.tsx | 16 ++- src/components/RequestToJoinCollective.tsx | 126 +++++++++++++++++ src/hooks/useCollectiveJoinRequests.ts | 109 +++++++++++++++ src/hooks/useCrewNotifications.ts | 16 ++- src/lib/types.ts | 53 ++++++++ src/pages/CoachDetailPage.tsx | 11 ++ 8 files changed, 502 insertions(+), 7 deletions(-) create mode 100644 src/components/CollectiveJoinRequestCard.tsx create mode 100644 src/components/RequestToJoinCollective.tsx create mode 100644 src/hooks/useCollectiveJoinRequests.ts diff --git a/NIP.md b/NIP.md index 8fb6c72..f629a20 100644 --- a/NIP.md +++ b/NIP.md @@ -2395,6 +2395,47 @@ join-request, `a`-tags the crew, and `p`-tags both the requester (role Clients **must** keep only endorsements whose author is a listed admin of the endorsed crew, and dedupe to one endorsement per admin per request. +#### Collective join requests (kind `36387`) — join a Lab collective + +Lab collectives (studios, build teams, filmer houses — any Lab profile with +`entity = collective`) accept the same "ask to join" flow as crews, but keyed to +the collective's own coordinate. A skater publishes a public, addressable event +(kind `36387`) that `p`-tags the collective's owner so it surfaces in their +notification bell. + +```json +{ + "kind": 36387, + "content": "Been filming the local plaza scene for years — would love to roll with you.", + "tags": [ + ["d", "35003:::"], + ["a", "35003::"], + ["collective_name", "Barcelona Filmers"], + ["p", ""], + ["alt", "Collective join request: Barcelona Filmers on SkateSpots"] + ] +} +``` + +| Tag | Description | +|-----|-------------| +| `d` | Collective coordinate + requester pubkey, so re-requesting **replaces** the prior request rather than piling up. | +| `a` | The Lab collective coordinate (`::`) where `` is any Lab craft kind (35001–35009). Clients validate the referenced kind is a Lab craft kind. | +| `collective_name` | Cached title for rendering the notification without a second fetch. | +| `p` | The collective **owner** — queryable so the owner finds requests addressed to them: `{ kinds: [36387], "#p": [""] }`. | +| `alt` | NIP-31 human-readable summary. | + +**Flow (mirrors crews):** + +1. **Request.** The skater authors the request, binding "who wants in" to their key. +2. **Notify.** The owner's client queries `#p` and surfaces a card linking to the + collective's member editor plus a **Dismiss** action. +3. **Add.** Only the owner authors the collective profile, so only they add the + requester's `p` tag; the requester then confirms with their own NIP-32 + membership attestation, exactly as with any collective member. +4. **Dismiss.** Handled/ignored requests are cleared from the owner's bell via + the same synced dismissal set below (keyed by event id). + #### Dismissed notifications (kind `30078`) — private, cross-device Neither an invitee nor a moderator authored the invite / join-request they're @@ -2402,7 +2443,8 @@ acting on, so they can't delete it — they can only **stop it nagging them**. That "dismissed" set is kept as a single private, addressable **NIP-78** application-data event (kind `30078`, `d = skatespots:crew-notif-dismissals`) whose `content` is a **NIP-44 payload encrypted to the user's own key**. One -list covers both invites and join-requests (both keyed by event id). +list covers invites, crew join-requests, and collective join-requests (all keyed +by event id). ```json { diff --git a/src/components/CollectiveJoinRequestCard.tsx b/src/components/CollectiveJoinRequestCard.tsx new file mode 100644 index 0000000..a156466 --- /dev/null +++ b/src/components/CollectiveJoinRequestCard.tsx @@ -0,0 +1,134 @@ +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 { useAuthor } from '@/hooks/useAuthor'; +import { useDismissCrewNotification } from '@/hooks/useCrewDismissals'; +import { useToast } from '@/hooks/useToast'; +import { genUserName } from '@/lib/genUserName'; +import { parseAddrCoord, type CollectiveJoinRequest } from '@/lib/types'; +import { Users, X, Loader2, Hand, Settings2, Check } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +function npubSafe(pubkey: string): string { + try { + return nip19.npubEncode(pubkey); + } catch { + return pubkey; + } +} + +/** + * An incoming "request to join this collective" for a Lab-collective owner. + * + * Trust model mirrors crews: only the owner can edit their own Lab-profile + * event, so the card can't add the member for them. Instead it links straight + * to the collective's editor (where the roster lives) and lets the owner + * dismiss the request once handled. No forged membership is ever possible. + */ +export function CollectiveJoinRequestCard({ + request, + onResolved, + compact, +}: { + request: CollectiveJoinRequest; + onResolved?: () => void; + compact?: boolean; +}) { + 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: dismiss, isPending: dismissing } = useDismissCrewNotification(); + const [done, setDone] = useState(false); + + const coord = parseAddrCoord(request.collectiveCoord); + // All Lab crafts render + edit through the /coach detail route. + const collectiveHref = coord ? `/coach/${coord.pubkey}/${coord.identifier}` : '/lab'; + + const handleDismiss = async () => { + try { + await dismiss(request.id); + setDone(true); + onResolved?.(); + } catch { + toast({ title: 'Could not dismiss', variant: 'destructive' }); + } + }; + + if (done) { + return ( +
+ + Dismissed request from {requesterName} +
+ ); + } + + return ( +
+
+
+ +
+
+

+ + + + {requesterName.slice(0, 2).toUpperCase()} + + {requesterName} + {' '} + wants to join{' '} + + {request.collectiveName} + +

+ {request.note && ( +

+ “{request.note}” +

+ )} + +
+ + +
+ +

+ + Add them from your collective’s member list, then dismiss this. +

+
+
+
+ ); +} diff --git a/src/components/NotificationBell.tsx b/src/components/NotificationBell.tsx index aec691b..97a2eef 100644 --- a/src/components/NotificationBell.tsx +++ b/src/components/NotificationBell.tsx @@ -8,6 +8,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { CrewInviteCard } from '@/components/CrewInviteCard'; import { JoinRequestCard } from '@/components/JoinRequestCard'; +import { CollectiveJoinRequestCard } from '@/components/CollectiveJoinRequestCard'; import { ActivityNotificationCard } from '@/components/ActivityNotificationCard'; import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications'; import { useActivityNotifications } from '@/hooks/useActivityNotifications'; @@ -145,13 +146,14 @@ function EmptyState({ label }: { label: string }) { */ export function NotificationBell({ className }: { className?: string }) { const [open, setOpen] = useState(false); - const { user, invites, listedCrews, joinRequests, isLoading: crewLoading } = useCrewNotifications(); + const { user, invites, listedCrews, joinRequests, collectiveRequests, isLoading: crewLoading } = + useCrewNotifications(); const { notifications: activity, isLoading: activityLoading } = useActivityNotifications(); const { lastRead, markAllRead } = useNotificationsRead(); const crewTotal = useMemo( - () => invites.length + listedCrews.length + joinRequests.length, - [invites, listedCrews, joinRequests], + () => invites.length + listedCrews.length + joinRequests.length + collectiveRequests.length, + [invites, listedCrews, joinRequests, collectiveRequests], ); // Unread social items = activity newer than the last time the bell was opened. @@ -243,6 +245,9 @@ export function NotificationBell({ className }: { className?: string }) { {joinRequests.map(({ request, crew, endorsements }) => ( ))} + {collectiveRequests.map((request) => ( + + ))} {invites.map((invite) => ( ))} @@ -290,12 +295,15 @@ export function NotificationBell({ className }: { className?: string }) { )} {!crewLoading && crewTotal === 0 && ( - + )}
{joinRequests.map(({ request, crew, endorsements }) => ( ))} + {collectiveRequests.map((request) => ( + + ))} {invites.map((invite) => ( ))} diff --git a/src/components/RequestToJoinCollective.tsx b/src/components/RequestToJoinCollective.tsx new file mode 100644 index 0000000..6040ae9 --- /dev/null +++ b/src/components/RequestToJoinCollective.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useToast } from '@/hooks/useToast'; +import { + useSendCollectiveJoinRequest, + useMyCollectiveJoinRequest, +} from '@/hooks/useCollectiveJoinRequests'; +import type { LabProfile } from '@/hooks/useLabProfiles'; +import { Users, Hand, Loader2, Check, X } from 'lucide-react'; + +/** + * "Request to join this collective" — shown on a Lab collective's profile to + * logged-in skaters who aren't the owner and aren't already listed. Mirrors the + * crew join-request: it notifies the owner (who adds the member through their + * own editor). Owners never see it on their own profile. + */ +export function RequestToJoinCollective({ + profile, + accentText = 'text-teal-400', + accentBorder = 'border-teal-500', +}: { + profile: LabProfile; + accentText?: string; + accentBorder?: string; +}) { + const { user } = useCurrentUser(); + const { toast } = useToast(); + const [open, setOpen] = useState(false); + const [note, setNote] = useState(''); + + const coord = `${profile.event.kind}:${profile.pubkey}:${profile.identifier}`; + const { mutateAsync: send, isPending: sending } = useSendCollectiveJoinRequest(); + const { data: alreadyRequested, isLoading: checking } = useMyCollectiveJoinRequest(coord); + + // Gate: must be logged in, not the owner, not already a listed member. + if (!user) return null; + const meLc = user.pubkey.toLowerCase(); + if (meLc === profile.pubkey.toLowerCase()) return null; + if (profile.members.some((m) => m.toLowerCase() === meLc)) return null; + + const handleSend = async () => { + try { + await send({ profile, note }); + setOpen(false); + setNote(''); + toast({ + title: 'Request sent 🤝', + description: `${profile.title} will see it in their notifications and can add you.`, + }); + } catch (err) { + toast({ + title: 'Could not send request', + description: err instanceof Error ? err.message : 'Please try again.', + variant: 'destructive', + }); + } + }; + + return ( + + +
+ +

Join this collective

+
+ + {alreadyRequested ? ( +
+ + Request sent — waiting on {profile.title}. +
+ ) : !open ? ( + <> +

+ Ask the crew behind {profile.title} to add + you to the roster. They confirm from their side. +

+ + + ) : ( +
+