- 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>
155 lines
5.3 KiB
TypeScript
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 };
|
|
}
|