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>
This commit is contained in:
parent
453adc4604
commit
7439b980bb
10 changed files with 605 additions and 8 deletions
26
NIP.md
26
NIP.md
|
|
@ -342,6 +342,32 @@ Skateboarding and music culture are deeply connected. These event types support
|
|||
|
||||
Events can have multiple type tags. For example, a video premiere with a live band would have both `premiere` and `concert` tags.
|
||||
|
||||
### Event RSVPs (Attendance)
|
||||
|
||||
Attendance uses the standard **NIP-52 RSVP** (kind `31925`). A skater responds to a calendar event with `accepted` (going), `tentative` (interested), or `declined` (can't make it). RSVPs are addressable, so publishing a new one with the same `d` replaces the prior status — letting a skater freely switch.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 31925,
|
||||
"content": "<optional note>",
|
||||
"tags": [
|
||||
["d", "<unique-id>"],
|
||||
["a", "31923:<event-author-pubkey>:<event-d>", "", ""],
|
||||
["e", "<event-id>"],
|
||||
["status", "accepted"],
|
||||
["p", "<event-author-pubkey>"],
|
||||
["fb", "busy"],
|
||||
["alt", "RSVP (accepted) to a skate event"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- The `a` tag references the calendar event coordinate being responded to (required).
|
||||
- The `p` tag targets the host so hosts can query RSVPs addressed to them (and get notified).
|
||||
- `fb` (`free`/`busy`) is omitted for `declined` per NIP-52.
|
||||
|
||||
Clients query an event's RSVPs with `{ kinds: [31925], "#a": ["31923:<host>:<d>"] }` and keep the newest per pubkey. Hosts see incoming RSVPs in their notification center via `{ kinds: [31925], "#p": ["<host-pubkey>"] }`.
|
||||
|
||||
## Social Interaction
|
||||
|
||||
Comments and reactions to skate spots and events use standard Nostr kinds:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Heart, Repeat2, Zap, AtSign, MessageCircle } from 'lucide-react';
|
||||
import { Heart, Repeat2, Zap, AtSign, MessageCircle, CalendarCheck } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
|
|
@ -21,6 +21,7 @@ const TYPE_META: Record<
|
|||
zap: { icon: Zap, color: 'text-yellow-400', ring: 'bg-yellow-500/15' },
|
||||
mention: { icon: AtSign, color: 'text-sky-400', ring: 'bg-sky-500/15' },
|
||||
reply: { icon: MessageCircle, color: 'text-teal-400', ring: 'bg-teal-500/15' },
|
||||
rsvp: { icon: CalendarCheck, color: 'text-emerald-400', ring: 'bg-emerald-500/15' },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
71
src/components/AttendeeStack.tsx
Normal file
71
src/components/AttendeeStack.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function StackAvatar({ pubkey, index }: { pubkey: string; index: number }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const name = author.data?.metadata?.name ?? genUserName(pubkey);
|
||||
const pic = author.data?.metadata?.picture;
|
||||
|
||||
const npub = (() => {
|
||||
try {
|
||||
return nip19.npubEncode(pubkey);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const avatar = (
|
||||
<Avatar
|
||||
className="h-7 w-7 border-2 border-background ring-0"
|
||||
style={{ zIndex: 20 - index }}
|
||||
title={name}
|
||||
>
|
||||
<AvatarImage src={pic} alt={name} />
|
||||
<AvatarFallback className="text-[9px] font-bold">
|
||||
{name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
|
||||
if (!npub) return avatar;
|
||||
return (
|
||||
<Link to={`/${npub}`} className="transition-transform hover:-translate-y-0.5">
|
||||
{avatar}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A horizontal, overlapping stack of attendee avatars with a "+N" overflow.
|
||||
* Purely presentational — pass a list of pubkeys.
|
||||
*/
|
||||
export function AttendeeStack({
|
||||
pubkeys,
|
||||
max = 5,
|
||||
className,
|
||||
}: {
|
||||
pubkeys: string[];
|
||||
max?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
if (pubkeys.length === 0) return null;
|
||||
const shown = pubkeys.slice(0, max);
|
||||
const overflow = pubkeys.length - shown.length;
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center', className)}>
|
||||
<div className="flex -space-x-2">
|
||||
{shown.map((pk, i) => (
|
||||
<StackAvatar key={pk} pubkey={pk} index={i} />
|
||||
))}
|
||||
</div>
|
||||
{overflow > 0 && (
|
||||
<span className="ml-2 text-xs font-bold text-muted-foreground">+{overflow}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,10 +11,11 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Calendar, Clock, MapPin, MoreVertical, Flag } from 'lucide-react';
|
||||
import { Calendar, Clock, MapPin, MoreVertical, Flag, Users } from 'lucide-react';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
||||
import { ZapButton } from '@/components/ZapButton';
|
||||
import { TrustBadge } from '@/components/TrustBadge';
|
||||
import { OfficialBadge } from '@/components/OfficialBadge';
|
||||
|
|
@ -73,6 +74,7 @@ export function EventCard({ event, compact = false }: EventCardProps) {
|
|||
const relativeTime = getRelativeTime(event.start);
|
||||
const isUpcoming = event.start > Date.now() / 1000;
|
||||
const isOwner = user?.pubkey === event.pubkey;
|
||||
const { goingCount } = useEventRsvps(eventCoord(event));
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
|
|
@ -119,6 +121,12 @@ export function EventCard({ event, compact = false }: EventCardProps) {
|
|||
<span className="truncate">{event.location}</span>
|
||||
</span>
|
||||
)}
|
||||
{goingCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-green-400 font-bold">
|
||||
<Users className="h-3 w-3" />
|
||||
{goingCount}
|
||||
</span>
|
||||
)}
|
||||
<TrustBadge pubkey={event.pubkey} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -228,7 +236,15 @@ export function EventCard({ event, compact = false }: EventCardProps) {
|
|||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end mt-4 pt-4 border-t border-[#ccff00]/20">
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[#ccff00]/20">
|
||||
{goingCount > 0 ? (
|
||||
<span className="flex items-center gap-1.5 text-sm font-bold text-green-400">
|
||||
<Users className="h-4 w-4" />
|
||||
{goingCount} going
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.preventDefault()}>
|
||||
<ZapButton target={event.event} className="text-gray-400 hover:text-[#ccff00]" />
|
||||
{/* More options menu */}
|
||||
|
|
|
|||
138
src/components/RsvpButtons.tsx
Normal file
138
src/components/RsvpButtons.tsx
Normal file
|
|
@ -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 (
|
||||
<div className={cn('rounded-xl border border-white/10 bg-white/[0.03] p-4 text-center', className)}>
|
||||
<p className="mb-3 text-sm font-bold text-gray-300">
|
||||
Are you going?{' '}
|
||||
<span className="text-muted-foreground font-normal">Log in to RSVP.</span>
|
||||
</p>
|
||||
<LoginArea className="w-full justify-center" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{isHost && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You're hosting this — but you can still set your status.
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{OPTIONS.map(({ status, label, shortLabel, icon: Icon, activeClass }) => {
|
||||
const active = myStatus === status;
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => handleClick(status)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-1 rounded-xl border-2 py-2.5 text-xs font-black uppercase tracking-wide transition-all active:scale-95 disabled:opacity-60',
|
||||
active
|
||||
? activeClass
|
||||
: 'border-white/10 bg-white/5 text-gray-400 hover:border-white/20 hover:text-white',
|
||||
)}
|
||||
>
|
||||
{isPending && active ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Icon className="h-4 w-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
<span className="sm:hidden">{shortLabel}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{(goingCount > 0 || tentativeCount > 0) && (
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
<span className="font-bold text-green-400">{goingCount}</span> going
|
||||
{tentativeCount > 0 && (
|
||||
<>
|
||||
{' · '}
|
||||
<span className="font-bold text-[#ccff00]">{tentativeCount}</span> interested
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
155
src/hooks/useEventRsvps.ts
Normal file
155
src/hooks/useEventRsvps.ts
Normal file
|
|
@ -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<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 };
|
||||
}
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* RSVP / Attendance */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
{isPast ? 'Who came' : 'Who\u2019s going'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!isPast && <RsvpButtons event={event} />}
|
||||
|
||||
{rsvpSummary.goingCount > 0 ? (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-black uppercase tracking-widest text-muted-foreground">
|
||||
Going · {rsvpSummary.goingCount}
|
||||
</span>
|
||||
{rsvpSummary.tentativeCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{rsvpSummary.tentativeCount} interested
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<AttendeeStack pubkeys={rsvpSummary.going} max={8} />
|
||||
</div>
|
||||
) : (
|
||||
!rsvpSummary.isLoading &&
|
||||
isPast && (
|
||||
<p className="text-sm text-muted-foreground">No RSVPs were recorded.</p>
|
||||
)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Map (if has location) */}
|
||||
{coordinates && (
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -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<Exclude<DateWindow, 'all'>, 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<DateWindow, 'all'>): [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<TrustFilterLevel>(smartDefault);
|
||||
const [sortBy, setSortBy] = useState<SortOption>('recent');
|
||||
const [dateWindow, setDateWindow] = useState<DateWindow>('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' && (
|
||||
<div className="mt-4 flex items-center gap-2 overflow-x-auto pb-1 scrollbar-none">
|
||||
{(['all', 'today', 'weekend', 'week', 'month'] as DateWindow[]).map((w) => {
|
||||
const active = dateWindow === w;
|
||||
const label = w === 'all' ? 'Anytime' : DATE_WINDOW_LABELS[w];
|
||||
return (
|
||||
<button
|
||||
key={w}
|
||||
type="button"
|
||||
onClick={() => setDateWindow(w)}
|
||||
className={cn(
|
||||
'flex-shrink-0 rounded-full border px-3 py-1.5 text-xs font-bold uppercase tracking-wide transition-all active:scale-95',
|
||||
active
|
||||
? 'border-[#ccff00] bg-[#ccff00] text-black'
|
||||
: 'border-white/10 bg-white/5 text-gray-400 hover:border-white/20 hover:text-white',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Results ── */}
|
||||
<div className="mt-6">
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue