SkateSpots/src/hooks/useEventRsvps.ts
SkateSpots 7cafcc0dfa feat(card,review): Event pass/wristband template + Claims tab in /review
- /card gains a fourth type: Event. Renders an event (kind 31923) you organise
  or RSVP'd going/interested to (useMyRsvpCoords, kind 31925) as a passport/
  wristband: title, date/time, venue, cover, who it admits, going count.
  Derived from the event's own signed event — its QR verifies that event;
  /verify/:naddr renders kind 31923 too. React + canvas renderers; i18n.
- /review gains a dedicated Claims tab (pending private-spot ownership claims),
  replacing the banner that sat above the tabs.
2026-10-02 11:13:40 +07:00

239 lines
8.5 KiB
TypeScript

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<SkateEvent, 'pubkey' | 'identifier'>): string {
return `${CALENDAR_EVENT_KIND}:${event.pubkey}:${event.identifier}`;
}
/**
* Coordinates of calendar events the user RSVP'd (going or interested) — powers
* the "event pass / wristband" card picker. Declined RSVPs are excluded.
*/
export function useMyRsvpCoords(pubkey?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery<string[]>({
queryKey: ['my-rsvp-coords', pubkey, getQueryKeySuffix()],
enabled: !!pubkey,
staleTime: 60_000,
queryFn: async () => {
if (!pubkey) return [];
const signal = AbortSignal.timeout(8000);
const events = await querySkateContent(
[{ kinds: [CALENDAR_RSVP_KIND], authors: [pubkey], limit: 200 }],
{ signal },
);
const coords = new Set<string>();
for (const ev of events) {
const status = ev.tags.find(([n]) => n === 'status')?.[1] ?? 'going';
if (status === 'declined') continue;
const a = ev.tags.find(([n]) => n === 'a')?.[1];
if (a) coords.add(a);
}
return [...coords];
},
});
}
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<string, EventRsvp>();
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<string, { goingCount: number; tentativeCount: number }> {
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<string, EventRsvp>();
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<string, { goingCount: number; tentativeCount: number }>();
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] });
// Refresh the grid/calendar "going" counts too (batch query keyed by the
// full coordinate set), so numbers update right after RSVPing rather than
// waiting out the 30s staleTime.
queryClient.invalidateQueries({ queryKey: ['batch-event-rsvps'] });
},
});
return { ...mutation, isPending: isPending || mutation.isPending };
}