Audited the RSVP / event-update notification flow end to end. The data
layer was already solid and secure — RSVPs p-tag the host, updates
p-tag every RSVPer, and useActivityNotifications guards event-update
spoofing by requiring the author to be the event's host (from the `a`
coordinate) or an admin. The gap was on the rendering side.
Fixes:
- ActivityNotificationCard hardwired every notification to link to the
ACTOR's profile. For an RSVP ("Alice is going to your event") the host
wants the event + attendee list, and for an event update ("moved to
Sunday") the attendee wants to READ the update — not visit a profile.
Both events carry the event's `a` coordinate (31923:pubkey:identifier),
so these two types now route to /event/:pubkey/:identifier. Everything
else still lands on the actor's profile. d-tags containing ':' are
reconstructed correctly.
- useRsvp only invalidated the single-event RSVP query, so the grid /
calendar "going" counts lagged behind by up to the 30s staleTime after
RSVPing. Now also invalidates the batch-event-rsvps query so counts
update promptly.
- Social-tab empty-state copy omitted RSVPs and host updates even though
they land there; made it honest.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
210 lines
7.6 KiB
TypeScript
210 lines
7.6 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] });
|
|
// 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 };
|
|
}
|