SkateSpots/src/hooks/useEventRsvps.ts
shakespeare.diy 7439b980bb Events Phase 1: NIP-52 RSVPs, attendee stacks, date filter, host notifications
- Adopt NIP-52 RSVP (kind 31925): parseEventRsvp + RsvpStatus types
- useEventRsvps (counts by status, attendee pubkeys, my status) + useRsvp
  mutation that replaces the user's prior RSVP via a stable d-tag
- RsvpButtons (Going / Interested / Can't make it) + AttendeeStack avatar row
- EventDetailPage: "Who's going" card with RSVP control + attendee avatars
- EventCard: going count on both compact and full cards
- EventsPage: quick date-window chips (Today / Weekend / Week / Month)
- Activity bell: host RSVP notifications (new 'rsvp' activity type)
- Document RSVP schema in NIP.md

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

155 lines
5.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 (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]);
}
/**
* 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 };
}