import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useDismissCrewNotification } from '@/hooks/useCrewDismissals'; import { COLLECTIVE_JOIN_REQUEST_KIND, collectiveJoinRequestD, parseCollectiveJoinRequest, type CollectiveJoinRequest, } from '@/lib/types'; import type { LabProfile } from '@/hooks/useLabProfiles'; /** * Lab-collective join-requests — mirror of the crew join-request flow. * * Someone asks to join a Lab collective (studio / build team). The request is * addressable (keyed by collective + requester, so re-requesting replaces) and * `p`-tagged to the collective owner so it surfaces in their notification bell. * The owner adds the member through their normal Lab-profile editor — only they * can edit their own event, so there's no forged-membership risk. */ /** Publish (or replace) a join-request to a Lab collective's owner. */ export function useSendCollectiveJoinRequest() { const { nostr } = useNostr(); const { user } = useCurrentUser(); const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ profile, note = '' }: { profile: LabProfile; note?: string }) => { if (!user) throw new Error('You must be logged in to request to join.'); const coord = `${profile.event.kind}:${profile.pubkey}:${profile.identifier}`; const event = await user.signer.signEvent({ kind: COLLECTIVE_JOIN_REQUEST_KIND, content: note.slice(0, 500), created_at: Math.floor(Date.now() / 1000), tags: [ ['d', collectiveJoinRequestD(coord, user.pubkey.toLowerCase())], ['a', coord], ['collective_name', profile.title], ['p', profile.pubkey], ['alt', `Collective join request: ${profile.title} on SkateSpots`], ], }); await nostr.event(event, { signal: AbortSignal.timeout(15000) }); return event; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['collective-join-requests'] }); }, }); } /** Incoming collective join-requests addressed to the given owner. */ export function useIncomingCollectiveJoinRequests(pubkey: string | undefined) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); return useQuery({ queryKey: ['collective-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: [COLLECTIVE_JOIN_REQUEST_KIND], '#p': [pubkey], limit: 100 }], { signal }, ); const seen = new Map(); for (const ev of events) { const req = parseCollectiveJoinRequest(ev); if (!req) continue; if (!req.recipients.includes(pubkey.toLowerCase())) continue; // Dedup by (collective + requester) — newest wins. const key = `${req.collectiveCoord}:${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); }, }); } /** * One-click approval: the collective OWNER adds the requester to their own Lab * profile (`p` tag) and dismisses the request. Only the owner authors the * profile event, so this is the sole trusted path — mirrors the crew founder's * "Add to crew". Re-signing preserves every other tag on the profile. */ export function useApproveCollectiveJoinRequest() { const { nostr } = useNostr(); const { user } = useCurrentUser(); const queryClient = useQueryClient(); const { mutateAsync: dismiss } = useDismissCrewNotification(); return useMutation({ mutationFn: async ({ request, profile, }: { request: CollectiveJoinRequest; profile: LabProfile; }) => { if (!user) throw new Error('You must be logged in.'); if (user.pubkey.toLowerCase() !== profile.pubkey.toLowerCase()) { throw new Error('Only the collective owner can add members.'); } // Re-fetch the freshest profile before rewriting its roster, so approving // multiple requests back to back can't rebuild from a stale snapshot and // drop an already-added member (the whole addressable event is rewritten // on every add). Fall back to the passed-in snapshot on a relay hiccup. let baseEvent = profile.event; try { const fresh = await nostr.query( [{ kinds: [profile.event.kind], authors: [profile.pubkey], '#d': [profile.identifier], limit: 1, }], { signal: AbortSignal.timeout(8000) }, ); let latest = baseEvent; for (const ev of fresh) { if (ev.created_at > latest.created_at) latest = ev; } baseEvent = latest; } catch { // keep snapshot } const already = baseEvent.tags.some( ([n, v]) => n === 'p' && v?.toLowerCase() === request.requester.toLowerCase(), ); if (!already) { // Clone the profile event, drop any stale p for this pubkey, append it. const tags = baseEvent.tags.filter( ([n, v]) => !(n === 'p' && v?.toLowerCase() === request.requester.toLowerCase()), ); tags.push(['p', request.requester]); const signed = await user.signer.signEvent({ kind: baseEvent.kind, content: baseEvent.content, created_at: Math.floor(Date.now() / 1000), tags, }); await nostr.event(signed, { signal: AbortSignal.timeout(15000) }); } // Clear it from the owner's bell (they can't delete the requester's event). await dismiss(request.id); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['collective-join-requests'] }); queryClient.invalidateQueries({ queryKey: ['lab-profile'] }); queryClient.invalidateQueries({ queryKey: ['lab-profiles'] }); queryClient.invalidateQueries({ queryKey: ['collective-members'] }); }, }); } /** * Whether the logged-in user already has a pending join-request to a given * collective coordinate (so the button can show "Requested"). */ export function useMyCollectiveJoinRequest(collectiveCoord: string | undefined) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { user } = useCurrentUser(); return useQuery({ queryKey: ['collective-join-requests', 'mine', collectiveCoord, user?.pubkey, getQueryKeySuffix()], enabled: !!collectiveCoord && !!user?.pubkey, staleTime: 30_000, queryFn: async (): Promise => { if (!collectiveCoord || !user?.pubkey) return false; const signal = AbortSignal.timeout(8000); const d = collectiveJoinRequestD(collectiveCoord, user.pubkey.toLowerCase()); const events = await querySkateContent( [{ kinds: [COLLECTIVE_JOIN_REQUEST_KIND], authors: [user.pubkey], '#d': [d], limit: 1 }], { signal }, ); return events.length > 0; }, }); }