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 { 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'; import type { NostrEvent } from '@nostrify/nostrify'; // ─── Dismissals ─────────────────────────────────────────────────────────────── // The invitee/moderator can't delete an event they didn't author, so handled // invites & join-requests are cleared from *their* notifications via a // dismissal set. That set is a private, cross-device-synced NIP-78 list — // see `useCrewDismissals`. The public event still exists on relays; it just // stops nagging them, everywhere they log in. // ─── 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()) { // Re-fetch the freshest roster so a rapid second invite/approval can't // rebuild from a stale snapshot and drop a just-added member. const latest = await fetchLatestCrewEvent(nostr, crew); const already = latest.tags.some( ([n, v]) => n === 'p' && v?.toLowerCase() === inviteePk, ); if (!already) { const updated = republishCrewWithMember(latest, 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, }; } /** * Fetch the FRESHEST crew event for a coordinate right before a roster write. * * The crew roster is a single addressable event that we rewrite wholesale on * every member add. Basing that rewrite on a cached `crew.event` snapshot is * unsafe: approving two join-requests (or pre-listing two invitees) back to * back would rebuild the second write from the pre-first-write snapshot and * silently DROP the member added by the first — the same stale-snapshot clobber * we guard against in spots/events/shops. So we always re-fetch + merge first. * * Falls back to the provided snapshot if the relay returns nothing (offline / * hiccup) so an add still goes through rather than throwing. */ async function fetchLatestCrewEvent( nostr: ReturnType['nostr'], crew: SkateCrew, ): Promise { try { const events = await nostr.query( [{ kinds: [SKATE_CREW_KIND], authors: [crew.pubkey], '#d': [crew.identifier], limit: 1, }], { signal: AbortSignal.timeout(8000) }, ); // Newest wins (relays may hold multiple revisions). let latest: NostrEvent | undefined; for (const ev of events) { if (!latest || ev.created_at > latest.created_at) latest = ev; } // Only trust a fresher-or-equal event; never regress to an older revision. if (latest && latest.created_at >= crew.event.created_at) return latest; } catch { // fall through to snapshot } return crew.event; } // ─── Reading invites addressed to me ───────────────────────────────────────── /** * Live crew invites where the given pubkey is the invitee. Dismissal filtering * is applied downstream in `useCrewNotifications` (via the synced dismissal * set), so this returns every live invite addressed to the user. */ 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 => { if (!pubkey) return []; const signal = AbortSignal.timeout(10000); const events = await querySkateContent( [{ kinds: [CREW_INVITE_KIND], '#p': [pubkey], limit: 100 }], { signal }, ); const seen = new Map(); for (const ev of events) { const inv = parseCrewInvite(ev); if (!inv || inv.invitee.toLowerCase() !== pubkey.toLowerCase()) 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(); const { mutateAsync: dismiss } = useDismissCrewNotification(); 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.'); // The ONE required action: publish the membership attestation. This is the // single signature the UI waits on. await confirm({ kind: coord.kind, founderPubkey: coord.pubkey, identifier: coord.identifier, collectiveName: invite.crewName, visibility, }); // Dismiss is optimistic/local-first and best-effort for cross-device sync; // fire it WITHOUT blocking accept so a second signer round-trip can never // leave the Accept button stuck on "loading". void dismiss(invite.id).catch(() => { /* local dismissal already applied; sync is best-effort */ }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['crew-invites'] }); queryClient.invalidateQueries({ queryKey: ['collective-members'] }); queryClient.invalidateQueries({ queryKey: ['skate-crews'] }); }, }); } /** Decline a crew invite — dismiss it so it clears from notifications (synced). */ export function useDeclineCrewInvite() { const queryClient = useQueryClient(); const { mutateAsync: dismiss } = useDismissCrewNotification(); return useMutation({ mutationFn: async ({ invite }: { invite: CrewInvite }) => { await dismiss(invite.id); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['crew-invites'] }); }, }); } // ─── 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 the shared, // cross-device-synced dismissal set (`useCrewDismissals`); filtering happens // downstream in `useCrewNotifications`. /** 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 => { if (!pubkey) return []; const signal = AbortSignal.timeout(10000); const events = await querySkateContent( [{ kinds: [CREW_JOIN_REQUEST_KIND], '#p': [pubkey], limit: 100 }], { signal }, ); const seen = new Map(); for (const ev of events) { const req = parseCrewJoinRequest(ev); if (!req) continue; if (!req.recipients.includes(pubkey.toLowerCase())) 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(); const { mutateAsync: dismiss } = useDismissCrewNotification(); 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.'); } // Re-fetch the freshest roster before rewriting it, so approving several // requests in quick succession can't clobber earlier additions (each // approval rewrites the whole addressable roster event). const latest = await fetchLatestCrewEvent(nostr, crew); const alreadyMember = latest.tags.some( ([n, v]) => n === 'p' && v?.toLowerCase() === request.requester.toLowerCase(), ); if (!alreadyMember) { const updated = republishCrewWithMember(latest, request.requester, 'member'); const signed = await user.signer.signEvent(updated); await nostr.event(signed, { signal: AbortSignal.timeout(15000) }); } await dismiss(request.id); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['crew-join-requests'] }); queryClient.invalidateQueries({ queryKey: ['skate-crews'] }); queryClient.invalidateQueries({ queryKey: ['skate-crew'] }); }, }); } /** Dismiss a join-request (moderator handled/ignored it) — synced across devices. */ export function useDismissJoinRequest() { const queryClient = useQueryClient(); const { mutateAsync: dismiss } = useDismissCrewNotification(); return useMutation({ mutationFn: async ({ request }: { request: CrewJoinRequest }) => { await dismiss(request.id); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['crew-join-requests'] }); }, }); } // ─── 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; }, }); }