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>
206 lines
7.3 KiB
TypeScript
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 };
|
|
}
|