import { useMemo } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { CALENDAR_EVENT_KIND, CALENDAR_RSVP_KIND, parseEventRsvp, type EventRsvp, type RsvpStatus, type SkateEvent, } from '@/lib/types'; /** * NIP-52 RSVP layer for skate calendar events (kind 31925). * ───────────────────────────────────────────────────────────────────────────── * Given a calendar event coordinate ("31923:pubkey:d") we fetch every RSVP that * references it, keep the newest per person, and expose: * • counts by status (going / interested / declined) * • the ordered lists of pubkeys (for avatar stacks) * • the logged-in user's own current status * * Publishing a new RSVP reuses the person's existing `d` identifier so it * REPLACES their prior RSVP (addressable), letting them freely switch between * Going / Interested / Can't make it. */ export interface EventRsvpSummary { going: string[]; tentative: string[]; declined: string[]; goingCount: number; tentativeCount: number; /** All RSVPs, newest per person. */ rsvps: EventRsvp[]; /** The logged-in user's current RSVP, if any. */ myRsvp: EventRsvp | null; myStatus: RsvpStatus | null; isLoading: boolean; } /** Build the addressable coordinate for a calendar event. */ export function eventCoord(event: Pick): string { return `${CALENDAR_EVENT_KIND}:${event.pubkey}:${event.identifier}`; } export function useEventRsvps(coord: string | undefined): EventRsvpSummary { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { user } = useCurrentUser(); const query = useQuery({ queryKey: ['event-rsvps', coord, getQueryKeySuffix()], enabled: !!coord, staleTime: 30_000, queryFn: async (c) => { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); const events = await querySkateContent( [{ kinds: [CALENDAR_RSVP_KIND], '#a': [coord!], limit: 500 }], { signal }, ); // Keep the newest RSVP per pubkey (an addressable replace can arrive as // multiple copies across relays / revisions). const byPubkey = new Map(); for (const ev of events) { const rsvp = parseEventRsvp(ev); if (!rsvp || rsvp.eventCoord !== coord) continue; const existing = byPubkey.get(rsvp.pubkey); if (!existing || rsvp.createdAt > existing.createdAt) { byPubkey.set(rsvp.pubkey, rsvp); } } return Array.from(byPubkey.values()); }, }); return useMemo(() => { const rsvps = query.data ?? []; const going: string[] = []; const tentative: string[] = []; const declined: string[] = []; // Newest first so avatar stacks show the most recent RSVPs. const sorted = [...rsvps].sort((a, b) => b.createdAt - a.createdAt); for (const r of sorted) { if (r.status === 'accepted') going.push(r.pubkey); else if (r.status === 'tentative') tentative.push(r.pubkey); else if (r.status === 'declined') declined.push(r.pubkey); } const myRsvp = user ? rsvps.find((r) => r.pubkey === user.pubkey) ?? null : null; return { going, tentative, declined, goingCount: going.length, tentativeCount: tentative.length, rsvps: sorted, myRsvp, myStatus: myRsvp?.status ?? null, isLoading: query.isLoading, }; }, [query.data, query.isLoading, user]); } /** * Publish (or replace) the logged-in user's RSVP to a calendar event. * Reuses an existing RSVP `d` so switching status replaces the prior one. */ export function useRsvp() { const { mutateAsync: publish, isPending } = useNostrPublish(); const { user } = useCurrentUser(); const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: async (opts: { event: SkateEvent; status: RsvpStatus; /** Reuse the existing RSVP identifier when the user already RSVP'd. */ existingIdentifier?: string; note?: string; }) => { if (!user) throw new Error('You must be logged in to RSVP.'); const coord = eventCoord(opts.event); const d = opts.existingIdentifier || crypto.randomUUID(); const tags: string[][] = [ ['d', d], ['a', coord, '', ''], ['e', opts.event.id], ['status', opts.status], // Let the host easily query RSVPs addressed to them. ['p', opts.event.pubkey], // Free/busy hint (omit for declined per NIP-52). ...(opts.status !== 'declined' ? [['fb', 'busy']] : []), ['alt', `RSVP (${opts.status}) to a skate event`], ]; return publish({ kind: CALENDAR_RSVP_KIND, content: opts.note ?? '', tags, }); }, onSuccess: (_data, variables) => { const coord = eventCoord(variables.event); queryClient.invalidateQueries({ queryKey: ['event-rsvps', coord] }); }, }); return { ...mutation, isPending: isPending || mutation.isPending }; }