From f4bca7b73693ed887f03b1395d80de2c147ffa42 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 3 Sep 2026 01:40:04 +0700 Subject: [PATCH] Admins can endorse join-requests; founder ratifies (kind 9087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the admin/founder asymmetry without breaking the trust model: only the founder authors the crew event, so only they add members — but an admin can now publicly *vouch* for a join-request, and the founder ratifies with one click. Protocol (SkateSpots-native kind 9087, regular event): - e-tags the join-request, a-tags the crew, p-tags the requester (role "requester") and the founder (so it hits the founder's bell). - Clients keep only endorsements authored by a real admin of the endorsed crew and dedupe one-per-admin-per-request (no credibility inflation). - New parseCrewEndorsement + CrewEndorsement type; documented in NIP.md. Hooks: - useEndorseJoinRequest (admin publishes an endorsement, admin-gated). - useJoinRequestEndorsements: batch-fetch endorsements for all visible requests in ONE #e query, grouped per request, admin-verified per crew. - useCrewNotifications attaches endorsements to each request entry. UX (JoinRequestCard): - Founder: primary becomes "Approve & add" when vouched, with a vouch strip (stacked admin avatars + "Vouched by Rider +1 more") as social proof. - Admin: the old dead-end "Only the founder can add members" becomes a real "Vouch for them" action, then a "Vouched — waiting on the founder" state, plus a one-line explainer so the path isn't a mystery. - Endorsements enrich the existing request card (no duplicate notification). Build clean, no console errors. Not deployed. Co-authored-by: shakespeare.diy --- NIP.md | 42 ++++++++- src/components/JoinRequestCard.tsx | 132 ++++++++++++++++++++++++---- src/components/NotificationBell.tsx | 10 ++- src/hooks/useCrewInvites.ts | 93 ++++++++++++++++++++ src/hooks/useCrewNotifications.ts | 31 ++++++- src/lib/types.ts | 41 +++++++++ 6 files changed, 323 insertions(+), 26 deletions(-) diff --git a/NIP.md b/NIP.md index 758eba2..6bd1ce2 100644 --- a/NIP.md +++ b/NIP.md @@ -2287,12 +2287,48 @@ admin. It carries nothing sensitive: just an optional note. (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, + attestation, exactly as with an invite. +4. **Endorse (admins).** An admin can't edit the roster, but can publicly + **vouch** for a request (kind `9087`, below). The founder sees the + endorsement(s) on the request card as social proof and ratifies with one + click — closing the admin/founder asymmetry without changing who owns the + crew identity. +5. **Dismiss.** Because a moderator can't delete an event they didn't author, handled/ignored requests are cleared from *their* bell via the synced dismissal set (below). +#### Admin endorsements (kind `9087`) — propose, founder confirms + +Because the founder is the sole author of the crew event, an admin approving a +member directly is impossible by design. Instead an admin publishes a small +**endorsement** — a public "I vouch for this rider" signal that `e`-tags the +join-request, `a`-tags the crew, and `p`-tags both the requester (role +`requester`) and the founder (so it lands in the founder's bell). + +```json +{ + "kind": 9087, + "content": "Solid — skates the plaza with us most nights.", + "tags": [ + ["e", ""], + ["a", "35006::"], + ["p", "", "", "requester"], + ["p", ""], + ["alt", "Crew endorsement: vouching for a new member of Barcelona Night Crew"] + ] +} +``` + +| Tag | Description | +|-----|-------------| +| `e` | The join-request (kind `31969`) event id being endorsed. Queryable so a client fetches all endorsements for a set of requests in one `#e` filter. | +| `a` | The crew coordinate — used to verify the endorser is actually an **admin of that crew** (endorsements from non-admins are ignored, so nobody can inflate a request's credibility). | +| `p` (requester) | The rider being vouched for, marked with the `requester` role. | +| `p` (founder) | Surfaces the endorsement in the founder's notification bell. | + +Clients **must** keep only endorsements whose author is a listed admin of the +endorsed crew, and dedupe to one endorsement per admin per request. + #### Dismissed notifications (kind `30078`) — private, cross-device Neither an invitee nor a moderator authored the invite / join-request they're diff --git a/src/components/JoinRequestCard.tsx b/src/components/JoinRequestCard.tsx index c7e548c..b31a9bb 100644 --- a/src/components/JoinRequestCard.tsx +++ b/src/components/JoinRequestCard.tsx @@ -5,11 +5,15 @@ 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 { + useApproveJoinRequest, + useDismissJoinRequest, + useEndorseJoinRequest, +} 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 { parseAddrCoord, type CrewEndorsement, type CrewJoinRequest, type SkateCrew } from '@/lib/types'; +import { UserPlus, Check, X, Loader2, Hand, ThumbsUp, Clock } from 'lucide-react'; import { cn } from '@/lib/utils'; function npubSafe(pubkey: string): string { @@ -20,20 +24,49 @@ function npubSafe(pubkey: string): string { } } +/** Tiny avatar for an endorsing admin (used in the vouch strip). */ +function EndorserAvatar({ pubkey }: { pubkey: string }) { + const author = useAuthor(pubkey); + const name = author.data?.metadata?.name ?? genUserName(pubkey); + return ( + + + {name.slice(0, 2).toUpperCase()} + + ); +} + +/** Readable "Vouched by Rider, Mia +1" line for the endorsement strip. */ +function EndorserNames({ pubkeys }: { pubkeys: string[] }) { + const first = useAuthor(pubkeys[0]); + const firstName = first.data?.metadata?.name ?? genUserName(pubkeys[0]); + const extra = pubkeys.length - 1; + return ( + + {firstName} + {extra > 0 && +{extra} more} + + ); +} + /** - * 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. + * A single incoming join-request card for a crew's founder / admins. + * + * Trust model: only the founder authors the crew event, so only they can *add* + * a member. Admins therefore **endorse** ("vouch for") a request; the founder + * sees those endorsements as social proof and ratifies with one click. This + * closes the admin/founder asymmetry without breaking who-can-edit-the-roster. */ export function JoinRequestCard({ request, crew, + endorsements = [], onResolved, compact, }: { request: CrewJoinRequest; crew: SkateCrew; + endorsements?: CrewEndorsement[]; onResolved?: () => void; compact?: boolean; }) { @@ -44,11 +77,18 @@ export function JoinRequestCard({ const { toast } = useToast(); const { mutateAsync: approve, isPending: approving } = useApproveJoinRequest(); const { mutateAsync: dismiss, isPending: dismissing } = useDismissJoinRequest(); - const [done, setDone] = useState<'approved' | 'dismissed' | null>(null); + const { mutateAsync: endorse, isPending: endorsing } = useEndorseJoinRequest(); + const [done, setDone] = useState<'approved' | 'dismissed' | 'endorsed' | 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 meLc = user?.pubkey.toLowerCase(); + const isFounder = !!meLc && meLc === crew.pubkey.toLowerCase(); + const isAdmin = !!meLc && crew.admins.some((a) => a.toLowerCase() === meLc); + + const endorserPubkeys = endorsements.map((e) => e.endorser); + const iEndorsed = !!meLc && endorserPubkeys.some((p) => p.toLowerCase() === meLc); + const hasEndorsements = endorserPubkeys.length > 0; const handleApprove = async () => { try { @@ -68,6 +108,24 @@ export function JoinRequestCard({ } }; + const handleEndorse = async () => { + try { + await endorse({ request, crew }); + setDone('endorsed'); + toast({ + title: `You vouched for ${requesterName} 🤝`, + description: `The founder of ${crew.name} will see your endorsement and can add them.`, + }); + onResolved?.(); + } catch (err) { + toast({ + title: 'Could not endorse', + description: err instanceof Error ? err.message : 'Please try again.', + variant: 'destructive', + }); + } + }; + const handleDismiss = async () => { try { await dismiss({ request }); @@ -86,6 +144,11 @@ export function JoinRequestCard({ Added {requesterName} to {crew.name} + ) : done === 'endorsed' ? ( + <> + + You vouched for {requesterName} — founder will confirm + ) : ( <> @@ -96,7 +159,7 @@ export function JoinRequestCard({ ); } - const busy = approving || dismissing; + const busy = approving || dismissing || endorsing; return (
)} + + {/* Vouch strip — admins' endorsements as social proof for the founder. */} + {hasEndorsements && ( +
+
+ {endorserPubkeys.slice(0, 3).map((pk) => ( + + ))} +
+

+ Vouched by +

+
+ )} +
{isFounder ? ( + ) : isAdmin ? ( + iEndorsed ? ( + + Vouched — waiting on the founder + + ) : ( + + ) ) : ( - - Only the founder can add members. - + Crew leadership only. )}
+ + {/* Micro-help so the admin path isn't a mystery. */} + {isAdmin && !iEndorsed && ( +

+ Admins can’t add members directly — vouching tells the founder you back this rider. +

+ )}
diff --git a/src/components/NotificationBell.tsx b/src/components/NotificationBell.tsx index 60fc867..e938c1c 100644 --- a/src/components/NotificationBell.tsx +++ b/src/components/NotificationBell.tsx @@ -186,8 +186,14 @@ export function NotificationBell({ className }: { className?: string }) { )} - {joinRequests.map(({ request, crew }) => ( - + {joinRequests.map(({ request, crew, endorsements }) => ( + ))} {invites.map((invite) => ( diff --git a/src/hooks/useCrewInvites.ts b/src/hooks/useCrewInvites.ts index e36287e..b888cdc 100644 --- a/src/hooks/useCrewInvites.ts +++ b/src/hooks/useCrewInvites.ts @@ -7,14 +7,17 @@ import { useDismissCrewNotification } from '@/hooks/useCrewDismissals'; import { CREW_INVITE_KIND, CREW_JOIN_REQUEST_KIND, + CREW_ENDORSEMENT_KIND, SKATE_CREW_KIND, crewInviteD, crewJoinRequestD, parseCrewInvite, parseCrewJoinRequest, + parseCrewEndorsement, parseAddrCoord, type CrewInvite, type CrewJoinRequest, + type CrewEndorsement, type SkateCrew, type MemberVisibility, } from '@/lib/types'; @@ -350,3 +353,93 @@ export function useDismissJoinRequest() { }, }); } + +// ─── Admin endorsements (admin proposes → founder confirms) ──────────────────── +// Only the founder can edit the roster, so an admin can't add a member. Instead +// an admin publicly *vouches* for a request (kind 9087, p-tagging the founder). +// The founder sees the endorsement(s) on the request card and ratifies with one +// click — closing the admin/founder asymmetry without breaking the trust model. + +/** An admin endorses ("vouches for") a join-request so the founder can ratify. */ +export function useEndorseJoinRequest() { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + request, + crew, + note = '', + }: { + request: CrewJoinRequest; + crew: SkateCrew; + note?: string; + }) => { + if (!user) throw new Error('You must be logged in.'); + const isAdmin = crew.admins.some((a) => a.toLowerCase() === user.pubkey.toLowerCase()); + if (!isAdmin) throw new Error('Only a crew admin can endorse a request.'); + + const event = await user.signer.signEvent({ + kind: CREW_ENDORSEMENT_KIND, + content: note.slice(0, 300), + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['e', request.id], // the request being endorsed + ['a', request.crewCoord], // the crew + ['p', request.requester, '', 'requester'], // who's being vouched for + ['p', crew.pubkey], // surface to the founder's bell + ['alt', `Crew endorsement: vouching for a new member of ${crew.name}`], + ], + }); + await nostr.event(event, { signal: AbortSignal.timeout(15000) }); + return event; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['crew-endorsements'] }); + }, + }); +} + +/** + * Fetch endorsements for a set of join-request ids in ONE query, grouped by + * request id. Only endorsements from actual crew admins are kept (an outsider + * can't inflate a request's credibility). + */ +export function useJoinRequestEndorsements( + requestIds: string[], + adminsByCrewCoord: Map>, +) { + const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const ids = [...requestIds].sort(); + + return useQuery({ + queryKey: ['crew-endorsements', ids, getQueryKeySuffix()], + enabled: ids.length > 0, + staleTime: 30_000, + queryFn: async (): Promise> => { + const signal = AbortSignal.timeout(10000); + const events = await querySkateContent( + [{ kinds: [CREW_ENDORSEMENT_KIND], '#e': ids, limit: 200 }], + { signal }, + ); + + const byRequest = new Map(); + const seen = new Set(); // dedupe one endorser per request + for (const ev of events) { + const end = parseCrewEndorsement(ev); + if (!end || !ids.includes(end.requestId)) continue; + // Endorser must be an admin of the endorsed crew. + const admins = adminsByCrewCoord.get(end.crewCoord); + if (!admins || !admins.has(end.endorser.toLowerCase())) continue; + const key = `${end.requestId}:${end.endorser.toLowerCase()}`; + if (seen.has(key)) continue; + seen.add(key); + const arr = byRequest.get(end.requestId) ?? []; + arr.push(end); + byRequest.set(end.requestId, arr); + } + return byRequest; + }, + }); +} diff --git a/src/hooks/useCrewNotifications.ts b/src/hooks/useCrewNotifications.ts index 959758e..258c2ca 100644 --- a/src/hooks/useCrewNotifications.ts +++ b/src/hooks/useCrewNotifications.ts @@ -1,10 +1,10 @@ import { useMemo } from 'react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useMyCrewInvites, useIncomingJoinRequests } from '@/hooks/useCrewInvites'; +import { useMyCrewInvites, useIncomingJoinRequests, useJoinRequestEndorsements } from '@/hooks/useCrewInvites'; import { useCrewDismissals } from '@/hooks/useCrewDismissals'; import { useCrews } from '@/hooks/useCrews'; import { useCollectiveMembers } from '@/hooks/useCollectiveMembers'; -import { SKATE_CREW_KIND, type CrewInvite, type CrewJoinRequest, type SkateCrew } from '@/lib/types'; +import { SKATE_CREW_KIND, type CrewEndorsement, type CrewInvite, type CrewJoinRequest, type SkateCrew } from '@/lib/types'; export interface CrewNotification { /** Stable id for the notification (invite id or crew coord). */ @@ -73,11 +73,36 @@ export function useCrewNotifications() { ); }, [user, joinRequests, crews, dismissed]); + // Batch-fetch admin endorsements for the visible requests (one query), keyed + // per crew so only real admins of THAT crew can vouch. + const requestIds = useMemo( + () => requestsWithCrew.map(({ request }) => request.id), + [requestsWithCrew], + ); + const adminsByCrewCoord = useMemo(() => { + const map = new Map>(); + for (const { request, crew } of requestsWithCrew) { + map.set(request.crewCoord, new Set(crew.admins.map((a) => a.toLowerCase()))); + } + return map; + }, [requestsWithCrew]); + const { data: endorsements } = useJoinRequestEndorsements(requestIds, adminsByCrewCoord); + + // Attach each request's endorsements for the card to render. + const requestsWithEndorsements = useMemo( + () => + requestsWithCrew.map((entry) => ({ + ...entry, + endorsements: (endorsements?.get(entry.request.id) ?? []) as CrewEndorsement[], + })), + [requestsWithCrew, endorsements], + ); + return { user, invites: visibleInvites, listedCrews, - joinRequests: requestsWithCrew, + joinRequests: requestsWithEndorsements, isLoading: invitesLoading || joinLoading || crewsLoading, }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 1f94fea..83c05a4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -67,6 +67,7 @@ export const SASQUATCH_PRO_KIND = 32671; // Addressable "Sasquatch Pro" membersh 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. +export const CREW_ENDORSEMENT_KIND = 9087; // Regular event — an admin endorses ("vouches for") a join-request; the founder ratifies. SkateSpots-native. /** * Lab provider entity type. @@ -361,6 +362,46 @@ export function crewJoinRequestD(crewCoord: string, requesterPubkey: string): st return `${crewCoord}:${requesterPubkey}`; } +/** + * An admin's endorsement of a join-request (kind 9087). Since only the founder + * can edit the crew roster, an admin can't add a member directly — but they can + * publicly *vouch* for a request. The founder sees these endorsements on the + * request card and ratifies with one click. Public + `p`-tagged to the founder + * so it surfaces in their bell as a stronger signal. + */ +export interface CrewEndorsement { + id: string; + endorser: string; // admin pubkey who vouched + requester: string; // pubkey being vouched for + requestId: string; // the join-request event id being endorsed + crewCoord: string; // 35006:pubkey:d + note: string; // optional "why" + createdAt: number; + event: NostrEvent; +} + +export function parseCrewEndorsement(event: NostrEvent): CrewEndorsement | null { + if (event.kind !== CREW_ENDORSEMENT_KIND) return null; + const requestId = event.tags.find(([n]) => n === 'e')?.[1]; + const crewCoord = event.tags.find(([n]) => n === 'a')?.[1]; + const requester = event.tags.find(([n, , , role]) => n === 'p' && role === 'requester')?.[1] + ?? event.tags.find(([n]) => n === 'p')?.[1]; + if (!requestId || !crewCoord || !requester) return null; + const c = parseAddrCoord(crewCoord); + if (!c || c.kind !== SKATE_CREW_KIND) return null; + if (!/^[0-9a-f]{64}$/i.test(requester)) return null; + return { + id: event.id, + endorser: event.pubkey, + requester: requester.toLowerCase(), + requestId, + crewCoord, + note: event.content.slice(0, 300), + createdAt: event.created_at, + event, + }; +} + /** * Validate a CLINK Offer code (noffer1...) — a Nostr-native, self-custodial * static payment code (see https://github.com/shocknet/clink, kind 21001).