e.preventDefault()}>
{/* More options menu */}
diff --git a/src/components/RsvpButtons.tsx b/src/components/RsvpButtons.tsx
new file mode 100644
index 0000000..1bfaa5c
--- /dev/null
+++ b/src/components/RsvpButtons.tsx
@@ -0,0 +1,138 @@
+import { Check, HelpCircle, X, Loader2 } from 'lucide-react';
+import { LoginArea } from '@/components/auth/LoginArea';
+import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { useToast } from '@/hooks/useToast';
+import { useEventRsvps, useRsvp } from '@/hooks/useEventRsvps';
+import { eventCoord } from '@/hooks/useEventRsvps';
+import { cn } from '@/lib/utils';
+import type { RsvpStatus, SkateEvent } from '@/lib/types';
+
+const OPTIONS: {
+ status: RsvpStatus;
+ label: string;
+ shortLabel: string;
+ icon: typeof Check;
+ activeClass: string;
+}[] = [
+ {
+ status: 'accepted',
+ label: "I'm Going",
+ shortLabel: 'Going',
+ icon: Check,
+ activeClass: 'bg-green-500 border-green-500 text-black',
+ },
+ {
+ status: 'tentative',
+ label: 'Interested',
+ shortLabel: 'Maybe',
+ icon: HelpCircle,
+ activeClass: 'bg-[#ccff00] border-[#ccff00] text-black',
+ },
+ {
+ status: 'declined',
+ label: "Can't make it",
+ shortLabel: "Can't",
+ icon: X,
+ activeClass: 'bg-red-500/80 border-red-500 text-white',
+ },
+];
+
+/**
+ * NIP-52 RSVP control. A segmented Going / Interested / Can't-make-it toggle
+ * that publishes (or replaces) the user's RSVP. Tapping the active option again
+ * is a no-op. Logged-out users see a login prompt instead.
+ */
+export function RsvpButtons({ event, className }: { event: SkateEvent; className?: string }) {
+ const { user } = useCurrentUser();
+ const { toast } = useToast();
+ const coord = eventCoord(event);
+ const { myRsvp, myStatus, goingCount, tentativeCount } = useEventRsvps(coord);
+ const { mutateAsync: rsvp, isPending } = useRsvp();
+
+ const isHost = user?.pubkey === event.pubkey;
+
+ if (!user) {
+ return (
+
+
+ Are you going?{' '}
+ Log in to RSVP.
+
+
+
+ );
+ }
+
+ const handleClick = async (status: RsvpStatus) => {
+ if (status === myStatus || isPending) return;
+ try {
+ await rsvp({
+ event,
+ status,
+ existingIdentifier: myRsvp?.identifier,
+ });
+ const msg =
+ status === 'accepted'
+ ? "You're going! ๐น"
+ : status === 'tentative'
+ ? 'Marked as interested.'
+ : 'Marked as not going.';
+ toast({ title: msg });
+ } catch (err) {
+ toast({
+ title: 'Could not RSVP',
+ description: err instanceof Error ? err.message : 'Please try again.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ return (
+
+ {isHost && (
+
+ You're hosting this โ but you can still set your status.
+
+ )}
+
+ {OPTIONS.map(({ status, label, shortLabel, icon: Icon, activeClass }) => {
+ const active = myStatus === status;
+ return (
+
+ );
+ })}
+
+ {(goingCount > 0 || tentativeCount > 0) && (
+
+ {goingCount} going
+ {tentativeCount > 0 && (
+ <>
+ {' ยท '}
+ {tentativeCount} interested
+ >
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/hooks/useActivityNotifications.ts b/src/hooks/useActivityNotifications.ts
index f4eb9ed..e671b2c 100644
--- a/src/hooks/useActivityNotifications.ts
+++ b/src/hooks/useActivityNotifications.ts
@@ -16,7 +16,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
* load minimal (a single filter) per the project's query-efficiency rules.
*/
-export type ActivityType = 'reaction' | 'repost' | 'zap' | 'mention' | 'reply';
+export type ActivityType = 'reaction' | 'repost' | 'zap' | 'mention' | 'reply' | 'rsvp';
export interface ActivityNotification {
id: string;
@@ -32,6 +32,8 @@ export interface ActivityNotification {
targetEventId?: string;
/** Sats amount for zaps, parsed from the bolt11/description when available. */
amountSats?: number;
+ /** For RSVPs: accepted / tentative / declined. */
+ rsvpStatus?: 'accepted' | 'tentative' | 'declined';
}
const REACTION_KIND = 7;
@@ -39,6 +41,7 @@ const REPOST_KINDS = [6, 16];
const ZAP_RECEIPT_KIND = 9735;
const TEXT_NOTE_KIND = 1;
const COMMENT_KIND = 1111;
+const RSVP_KIND = 31925;
/** Parse the sats amount out of a zap receipt (9735) if we can. */
function parseZapAmount(event: NostrEvent): number | undefined {
@@ -102,9 +105,10 @@ export function useActivityNotifications() {
ZAP_RECEIPT_KIND,
TEXT_NOTE_KIND,
COMMENT_KIND,
+ RSVP_KIND,
],
'#p': [pubkey],
- limit: 100,
+ limit: 120,
},
],
{ signal },
@@ -171,6 +175,20 @@ export function useActivityNotifications() {
content: event.content,
targetEventId,
});
+ } else if (event.kind === RSVP_KIND) {
+ // Someone RSVP'd to an event I'm hosting (RSVP p-tags the host).
+ const status = event.tags.find(([t]) => t === 'status')?.[1];
+ if (status !== 'accepted' && status !== 'tentative' && status !== 'declined') continue;
+ out.push({
+ id: event.id,
+ type: 'rsvp',
+ pubkey: event.pubkey,
+ createdAt: event.created_at,
+ event,
+ content: event.content,
+ targetEventId,
+ rsvpStatus: status,
+ });
}
}
@@ -199,6 +217,12 @@ export function activityVerb(n: ActivityNotification): string {
return 'mentioned you';
case 'reply':
return 'replied to you';
+ case 'rsvp':
+ return n.rsvpStatus === 'accepted'
+ ? "is going to your event"
+ : n.rsvpStatus === 'tentative'
+ ? 'is interested in your event'
+ : "can't make your event";
default:
return 'interacted with you';
}
diff --git a/src/hooks/useEventRsvps.ts b/src/hooks/useEventRsvps.ts
new file mode 100644
index 0000000..cb8ca1c
--- /dev/null
+++ b/src/hooks/useEventRsvps.ts
@@ -0,0 +1,155 @@
+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
): 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();
+ 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 };
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 83c05a4..7484ceb 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -46,6 +46,7 @@ function clampStr(s: string | undefined, max: number): string | undefined {
export const SKATE_SPOT_KIND = 30818; // Addressable event - editable per pubkey+kind+d-tag
export const SKATE_SHOP_KIND = 30017; // NIP-15 Stall (marketplace)
export const CALENDAR_EVENT_KIND = 31923;
+export const CALENDAR_RSVP_KIND = 31925; // NIP-52 Calendar Event RSVP (accepted/tentative/declined)
export const SKATE_SKATER_KIND = 35000; // Skater profile โ the app-native "I skate here" identity
export const SKATE_COACH_KIND = 35001;
export const SKATE_FILMER_KIND = 35002; // Skate filmer (video) profiles
@@ -843,6 +844,53 @@ export function parseSkateEvent(event: NostrEvent): SkateEvent | null {
};
}
+// โโโ NIP-52 Calendar Event RSVP (kind 31925) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+export type RsvpStatus = 'accepted' | 'tentative' | 'declined';
+
+export interface EventRsvp {
+ id: string;
+ /** The pubkey of the person RSVP'ing. */
+ pubkey: string;
+ /** The RSVP's own `d` identifier. */
+ identifier: string;
+ /** Coordinate of the calendar event being responded to ("31923:pubkey:d"). */
+ eventCoord: string;
+ status: RsvpStatus;
+ /** Optional free-form note. */
+ note?: string;
+ createdAt: number;
+ event: NostrEvent;
+}
+
+/**
+ * Parse a NIP-52 RSVP (kind 31925). Requires a valid `a` coordinate to a
+ * calendar event and a recognized `status`.
+ */
+export function parseEventRsvp(event: NostrEvent): EventRsvp | null {
+ if (event.kind !== CALENDAR_RSVP_KIND) return null;
+
+ const d = event.tags.find(([name]) => name === 'd')?.[1];
+ const eventCoord = event.tags.find(([name]) => name === 'a')?.[1];
+ const status = event.tags.find(([name]) => name === 'status')?.[1] as RsvpStatus | undefined;
+
+ if (!d || !eventCoord || !status) return null;
+ if (!['accepted', 'tentative', 'declined'].includes(status)) return null;
+ // Must reference a calendar event coordinate.
+ if (!eventCoord.startsWith(`${CALENDAR_EVENT_KIND}:`)) return null;
+
+ return {
+ id: event.id,
+ pubkey: event.pubkey,
+ identifier: d,
+ eventCoord,
+ status,
+ note: event.content?.slice(0, MAX_DESCRIPTION_LEN) || undefined,
+ createdAt: event.created_at,
+ event,
+ };
+}
+
// Main spot categories (mutually exclusive - user picks ONE)
export const SPOT_CATEGORIES = {
street: { label: 'Street', emoji: '๐ฃ๏ธ', description: 'Random found spots, urban terrain' },
diff --git a/src/pages/EventDetailPage.tsx b/src/pages/EventDetailPage.tsx
index 595841d..7003c86 100644
--- a/src/pages/EventDetailPage.tsx
+++ b/src/pages/EventDetailPage.tsx
@@ -11,9 +11,12 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { ZapButton } from '@/components/ZapButton';
+import { RsvpButtons } from '@/components/RsvpButtons';
+import { AttendeeStack } from '@/components/AttendeeStack';
import { CommentsSection } from '@/components/comments/CommentsSection';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { useSkateEvent } from '@/hooks/useSkateEvents';
+import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { EVENT_TYPE_LABELS, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND } from '@/lib/types';
@@ -101,6 +104,9 @@ export default function EventDetailPage() {
return { lat: latitude, lng: longitude };
}, [event?.geohash]);
+ // NIP-52 RSVPs for this event (counts + attendee avatars).
+ const rsvpSummary = useEventRsvps(event ? eventCoord(event) : undefined);
+
const isUpcoming = event ? event.start > Date.now() / 1000 : false;
const isPast = event ? event.start < Date.now() / 1000 : false;
@@ -361,6 +367,40 @@ export default function EventDetailPage() {
+ {/* RSVP / Attendance */}
+
+
+
+
+ {isPast ? 'Who came' : 'Who\u2019s going'}
+
+
+
+ {!isPast && }
+
+ {rsvpSummary.goingCount > 0 ? (
+
+
+
+ Going ยท {rsvpSummary.goingCount}
+
+ {rsvpSummary.tentativeCount > 0 && (
+
+ {rsvpSummary.tentativeCount} interested
+
+ )}
+
+
+
+ ) : (
+ !rsvpSummary.isLoading &&
+ isPast && (
+ No RSVPs were recorded.
+ )
+ )}
+
+
+
{/* Map (if has location) */}
{coordinates && (
diff --git a/src/pages/EventsPage.tsx b/src/pages/EventsPage.tsx
index 4cc732f..bca04b3 100644
--- a/src/pages/EventsPage.tsx
+++ b/src/pages/EventsPage.tsx
@@ -64,6 +64,50 @@ const ACCENT = {
type MainView = 'events' | 'tours';
+// โโโ Date window filter (skaters plan by weekend) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+type DateWindow = 'all' | 'today' | 'weekend' | 'week' | 'month';
+
+const DATE_WINDOW_LABELS: Record, string> = {
+ today: 'Today',
+ weekend: 'This Weekend',
+ week: 'This Week',
+ month: 'This Month',
+};
+
+/**
+ * Returns [startSec, endSec) for a date window, in local time. `weekend` covers
+ * the upcoming Sat+Sun (or the current weekend if it's already Sat/Sun).
+ */
+function dateWindowRange(window: Exclude): [number, number] {
+ const now = new Date();
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ const sec = (d: Date) => Math.floor(d.getTime() / 1000);
+ const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
+
+ switch (window) {
+ case 'today':
+ return [sec(startOfToday), sec(addDays(startOfToday, 1))];
+ case 'weekend': {
+ // day: 0=Sun โฆ 6=Sat. Find the coming Saturday (or today if Sat/Sun).
+ const day = startOfToday.getDay();
+ let satOffset: number;
+ if (day === 6) satOffset = 0; // Saturday
+ else if (day === 0) satOffset = -1; // Sunday โ weekend started yesterday
+ else satOffset = 6 - day; // upcoming Saturday
+ const sat = addDays(startOfToday, satOffset);
+ const end = addDays(sat, 2); // Sat 00:00 โ Mon 00:00
+ // Never start the window in the past.
+ const start = sec(sat) < sec(startOfToday) ? sec(startOfToday) : sec(sat);
+ return [start, sec(end)];
+ }
+ case 'week':
+ return [sec(startOfToday), sec(addDays(startOfToday, 7))];
+ case 'month':
+ return [sec(startOfToday), sec(addDays(startOfToday, 30))];
+ }
+}
+
// โโโ FAB โ create event / tour โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function CreateFAB({ mainView }: { mainView: MainView }) {
@@ -735,6 +779,7 @@ export default function EventsPage() {
const [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming');
const [trustFilter, setTrustFilter] = useState(smartDefault);
const [sortBy, setSortBy] = useState('recent');
+ const [dateWindow, setDateWindow] = useState('all');
// โโ Tour filters โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const [tourSearchQuery, setTourSearchQuery] = useState('');
@@ -811,6 +856,12 @@ export default function EventsPage() {
let upcoming = filtered.filter((e) => e.start >= now);
let past = filtered.filter((e) => e.start < now);
+ // Date window applies to upcoming events only.
+ if (dateWindow !== 'all') {
+ const [ws, we] = dateWindowRange(dateWindow);
+ upcoming = upcoming.filter((e) => e.start >= ws && e.start < we);
+ }
+
const sortFn = (a: typeof events[0], b: typeof events[0]) => {
if (sortBy === 'trust') {
const diff = (getTrustScore(b.pubkey) || 0) - (getTrustScore(a.pubkey) || 0);
@@ -829,7 +880,7 @@ export default function EventsPage() {
past.sort((a, b) => sortBy === 'recent' ? b.start - a.start : sortFn(a, b));
return { upcoming, past };
- }, [events, searchQuery, locationQuery, selectedTypes, trustFilter, trustScores, sortBy, now, allComments]);
+ }, [events, searchQuery, locationQuery, selectedTypes, trustFilter, trustScores, sortBy, dateWindow, now, allComments]);
// โโ Filter tours โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const filteredTours = useMemo(() => {
@@ -885,7 +936,8 @@ export default function EventsPage() {
const activeEventFilterCount =
selectedTypes.length +
(trustFilter !== 'all' ? 1 : 0) +
- (sortBy !== 'recent' ? 1 : 0);
+ (sortBy !== 'recent' ? 1 : 0) +
+ (dateWindow !== 'all' ? 1 : 0);
const activeTourFilterCount =
(tourCategory !== 'all' ? 1 : 0) +
@@ -901,6 +953,7 @@ export default function EventsPage() {
setSelectedTypes([]);
setTrustFilter('all');
setSortBy('recent');
+ setDateWindow('all');
};
const handleClearTourFilters = () => {
@@ -933,7 +986,7 @@ export default function EventsPage() {
const handleSearchChange = mainView === 'events' ? setSearchQuery : setTourSearchQuery;
const handleLocationChange = mainView === 'events' ? setLocationQuery : setTourLocationQuery;
- const hasEventFilters = !!(searchQuery || locationQuery || selectedTypes.length > 0 || trustFilter !== 'all' || sortBy !== 'recent');
+ const hasEventFilters = !!(searchQuery || locationQuery || selectedTypes.length > 0 || trustFilter !== 'all' || sortBy !== 'recent' || dateWindow !== 'all');
const hasTourFilters = !!(tourSearchQuery || tourLocationQuery || tourCategory !== 'all' || tourSkillLevel !== 'all' || tourTrustFilter !== 'all' || tourSortBy !== 'recent');
return (
@@ -989,6 +1042,31 @@ export default function EventsPage() {
totalCount={totalCount}
/>
+ {/* โโ Quick date-window chips (events ยท upcoming) โโ */}
+ {mainView === 'events' && activeTab === 'upcoming' && (
+
+ {(['all', 'today', 'weekend', 'week', 'month'] as DateWindow[]).map((w) => {
+ const active = dateWindow === w;
+ const label = w === 'all' ? 'Anytime' : DATE_WINDOW_LABELS[w];
+ return (
+
+ );
+ })}
+
+ )}
+
{/* โโ Results โโ */}