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 (calendar event kind 31923, RSVP 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]); } /** * Batch RSVP counts for a whole list of events in ONE relay query. * * Rendering a grid of EventCards used to fire one RSVP query PER card (an N+1 * storm on a 100-event page). This queries every event coordinate at once with * a single `#a` filter, dedupes to the newest RSVP per person per event, and * returns a Map of coordinate → { goingCount, tentativeCount }. */ export function useBatchEventRsvps( coords: string[], ): Map { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); // Stable key regardless of order so we don't refetch on re-render churn. const sortedKey = useMemo(() => [...coords].sort().join(','), [coords]); const query = useQuery({ queryKey: ['batch-event-rsvps', sortedKey, getQueryKeySuffix()], enabled: coords.length > 0, staleTime: 30_000, queryFn: async (c) => { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); const events = await querySkateContent( [{ kinds: [CALENDAR_RSVP_KIND], '#a': coords, limit: 2000 }], { signal }, ); // newest RSVP per (coord, pubkey) const latest = new Map(); for (const ev of events) { const rsvp = parseEventRsvp(ev); if (!rsvp || !coords.includes(rsvp.eventCoord)) continue; const key = `${rsvp.eventCoord}|${rsvp.pubkey}`; const existing = latest.get(key); if (!existing || rsvp.createdAt > existing.createdAt) latest.set(key, rsvp); } const counts = new Map(); for (const rsvp of latest.values()) { const entry = counts.get(rsvp.eventCoord) ?? { goingCount: 0, tentativeCount: 0 }; if (rsvp.status === 'accepted') entry.goingCount += 1; else if (rsvp.status === 'tentative') entry.tentativeCount += 1; counts.set(rsvp.eventCoord, entry); } return counts; }, }); return query.data ?? new Map(); } /** * 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 }; }