SkateSpots/src/hooks/useEventRsvps.ts
shakespeare.diy 135fbaab40 Events discovery polish + real bug/perf fixes across the grid
UX (all participants):
- EventCard now shows a live status pill (In 2 days / HAPPENING NOW / ENDED) on
  both the full and compact variants — the at-a-glance signal was previously
  detail-page only
- EventCard shows a "Presented by <sponsor>" ribbon so paid sponsor placements
  get real visibility in the discovery feed (not just after a click)
- Added a bottom legibility gradient so overlays stay readable on any cover

FIX (crash risk): EventsPage getTrustScore did `trustScores?.map.get` — since
useBatchTrustScores returns a plain Map (no `.map`), this threw TypeError once
scores loaded. Now reads the Map directly.

PERF (N+1 query storm): rendering the grid fired one RSVP query PER card
(~100 queries on a full page). Added useBatchEventRsvps — a single #a-filtered
query for all event coordinates — and thread goingCount into EventCard as a prop.
Cards without the prop (detail page, hosting dashboard) keep their own query.

Removed now-dead getRelativeTime/relativeTime/isUpcoming (superseded by the
EventCountdown pill).

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 14:47:02 +07:00

206 lines
7.3 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}`;
}
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] });
},
});
return { ...mutation, isPending: isPending || mutation.isPending };
}