Host tools: attendee list w/ trust, event updates, edit-alerts, Hosting tab

- AttendeeList: tabbed Going/Interested with faces + names + a "who you trust"
  signal (reuses batch trust scores); replaces the plain avatar stack
- Event Updates (kind 7103): usePostEventUpdate + useEventUpdates; host-only
  composer with update/reschedule/relocation/cancelled categories that p-tags
  every RSVPer so they get notified; public feed on the event page
- EditEventPage: auto-post a reschedule/relocation update to notify attendees
  when the host changes time or location
- Activity bell: new 'event-update' notification type for RSVPers
- EventsPage: "Hosting" tab (logged-in) with a HostingDashboard listing the
  user's upcoming/past events, live RSVP counts, and quick edit/update actions
- Document Event Update kind 7103 in NIP.md

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 11:25:57 +07:00
parent 7439b980bb
commit 78f4a6d09a
11 changed files with 819 additions and 31 deletions

26
NIP.md
View file

@ -368,6 +368,32 @@ Attendance uses the standard **NIP-52 RSVP** (kind `31925`). A skater responds t
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>"] }`. 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>"] }`.
### Event Updates (Host broadcasts)
Hosts broadcast changes ("rain — moved to Sunday", "meet at the back ledge") with a **SkateSpots-native Event Update** (kind `7103`, regular event) pinned to a calendar event. Every current RSVPer is `p`-tagged so they get a notification; anyone viewing the event also sees the update thread.
```json
{
"kind": 7103,
"content": "Rain — moved to Sunday 2pm. Meet at the back ledge.",
"tags": [
["a", "31923:<event-author-pubkey>:<event-d>", "", ""],
["e", "<event-id>"],
["update-kind", "reschedule"],
["t", "skateboarding"],
["p", "<rsvper-pubkey>"],
["p", "<rsvper-pubkey>"],
["alt", "Update to a skate event"]
]
}
```
- `update-kind` is one of `update` (default), `reschedule`, `relocation`, or `cancelled` — used for iconography/emphasis.
- Only the event's own author should post updates (clients enforce this).
- When a host edits an event's **time or location**, the client auto-publishes a `reschedule`/`relocation` update to notify RSVPers.
Clients query an event's updates with `{ kinds: [7103], "#a": ["31923:<host>:<d>"] }`.
## Social Interaction ## Social Interaction
Comments and reactions to skate spots and events use standard Nostr kinds: Comments and reactions to skate spots and events use standard Nostr kinds:

View file

@ -1,6 +1,6 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { Heart, Repeat2, Zap, AtSign, MessageCircle, CalendarCheck } from 'lucide-react'; import { Heart, Repeat2, Zap, AtSign, MessageCircle, CalendarCheck, Megaphone } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
@ -22,6 +22,7 @@ const TYPE_META: Record<
mention: { icon: AtSign, color: 'text-sky-400', ring: 'bg-sky-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' }, reply: { icon: MessageCircle, color: 'text-teal-400', ring: 'bg-teal-500/15' },
rsvp: { icon: CalendarCheck, color: 'text-emerald-400', ring: 'bg-emerald-500/15' }, rsvp: { icon: CalendarCheck, color: 'text-emerald-400', ring: 'bg-emerald-500/15' },
'event-update': { icon: Megaphone, color: 'text-[#ccff00]', ring: 'bg-[#ccff00]/15' },
}; };
/** /**
@ -58,7 +59,10 @@ export function ActivityNotificationCard({
}); });
const snippet = const snippet =
(notification.type === 'reply' || notification.type === 'mention') && notification.content (notification.type === 'reply' ||
notification.type === 'mention' ||
notification.type === 'event-update') &&
notification.content
? notification.content.slice(0, 120) ? notification.content.slice(0, 120)
: null; : null;

View file

@ -0,0 +1,150 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { ShieldCheck, Users } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useAuthor } from '@/hooks/useAuthor';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
const TRUSTED_THRESHOLD = 60;
function AttendeeRow({ pubkey, trusted }: { pubkey: string; trusted: boolean }) {
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 inner = (
<div className="flex items-center gap-3 rounded-lg px-2 py-1.5 transition-colors hover:bg-white/5">
<Avatar className="h-8 w-8">
<AvatarImage src={pic} alt={name} />
<AvatarFallback className="text-[10px] font-bold">
{name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-gray-200">{name}</span>
{trusted && (
<span
className="flex items-center gap-1 rounded-full bg-[#ccff00]/15 px-2 py-0.5 text-[10px] font-black uppercase tracking-wide text-[#ccff00]"
title="Someone you follow or a trusted mapper"
>
<ShieldCheck className="h-3 w-3" />
Trusted
</span>
)}
</div>
);
if (!npub) return inner;
return <Link to={`/${npub}`}>{inner}</Link>;
}
/**
* Full attendee list for a host: tabbed Going / Interested, each row showing a
* face + name, plus a "trust signal" summarizing how many of the Going crowd
* are people the viewer follows or trusted mappers (skate events live or die on
* whether real locals show up).
*/
export function AttendeeList({
going,
tentative,
className,
}: {
going: string[];
tentative: string[];
className?: string;
}) {
const [tab, setTab] = useState<'going' | 'tentative'>('going');
const allPubkeys = useMemo(() => [...new Set([...going, ...tentative])], [going, tentative]);
const { data: trustScores } = useBatchTrustScores(allPubkeys);
const trustedGoing = useMemo(
() => going.filter((pk) => (trustScores?.get(pk) ?? 0) >= TRUSTED_THRESHOLD).length,
[going, trustScores],
);
if (going.length === 0 && tentative.length === 0) {
return (
<p className={cn('text-sm text-muted-foreground', className)}>
No RSVPs yet — be the first to say you're going.
</p>
);
}
const list = tab === 'going' ? going : tentative;
return (
<div className={cn('space-y-3', className)}>
{/* Trust signal */}
{going.length > 0 && (
<div className="flex items-center gap-2 rounded-lg border border-[#ccff00]/20 bg-[#ccff00]/5 px-3 py-2">
<Users className="h-4 w-4 flex-shrink-0 text-[#ccff00]" />
<p className="text-xs text-gray-300">
<span className="font-black text-white">{going.length}</span> going
{trustedGoing > 0 && (
<>
{' · '}
<span className="font-black text-[#ccff00]">{trustedGoing}</span> you trust
</>
)}
</p>
</div>
)}
{/* Tabs */}
<div className="flex gap-1.5">
<button
type="button"
onClick={() => setTab('going')}
className={cn(
'flex-1 rounded-lg py-1.5 text-xs font-black uppercase tracking-wide transition-all',
tab === 'going'
? 'bg-green-500 text-black'
: 'bg-white/5 text-gray-400 hover:text-white',
)}
>
Going ({going.length})
</button>
<button
type="button"
onClick={() => setTab('tentative')}
className={cn(
'flex-1 rounded-lg py-1.5 text-xs font-black uppercase tracking-wide transition-all',
tab === 'tentative'
? 'bg-[#ccff00] text-black'
: 'bg-white/5 text-gray-400 hover:text-white',
)}
>
Interested ({tentative.length})
</button>
</div>
{/* List */}
{list.length > 0 ? (
<div className="max-h-72 space-y-0.5 overflow-y-auto pr-1">
{list.map((pk) => (
<AttendeeRow
key={pk}
pubkey={pk}
trusted={(trustScores?.get(pk) ?? 0) >= TRUSTED_THRESHOLD}
/>
))}
</div>
) : (
<p className="py-4 text-center text-sm text-muted-foreground">
{tab === 'going' ? 'Nobody has confirmed yet.' : 'Nobody marked interested yet.'}
</p>
)}
</div>
);
}

View file

@ -0,0 +1,201 @@
import { useState } from 'react';
import { formatDistanceToNow } from 'date-fns';
import { Megaphone, Loader2, Send, CalendarClock, MapPin, XCircle, Info } from 'lucide-react';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast';
import { useEventUpdates, usePostEventUpdate } from '@/hooks/useEventUpdates';
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import type { EventUpdate, EventUpdateKind, SkateEvent } from '@/lib/types';
const UPDATE_KIND_META: Record<
EventUpdateKind,
{ icon: typeof Info; label: string; color: string; ring: string }
> = {
update: { icon: Info, label: 'Update', color: 'text-sky-400', ring: 'bg-sky-500/15' },
reschedule: { icon: CalendarClock, label: 'New time', color: 'text-amber-400', ring: 'bg-amber-500/15' },
relocation: { icon: MapPin, label: 'New spot', color: 'text-[#ff0040]', ring: 'bg-[#ff0040]/15' },
cancelled: { icon: XCircle, label: 'Cancelled', color: 'text-red-400', ring: 'bg-red-500/15' },
};
function UpdateItem({ update }: { update: EventUpdate }) {
const author = useAuthor(update.pubkey);
const name = author.data?.metadata?.name ?? genUserName(update.pubkey);
const pic = author.data?.metadata?.picture;
const meta = UPDATE_KIND_META[update.updateKind];
const Icon = meta.icon;
return (
<div className="flex gap-3">
<div className="relative flex-shrink-0">
<Avatar className="h-8 w-8">
<AvatarImage src={pic} alt={name} />
<AvatarFallback className="text-[10px] font-bold">
{name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span
className={cn(
'absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full ring-2 ring-background',
meta.ring,
)}
>
<Icon className={cn('h-2.5 w-2.5', meta.color)} />
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-white">{name}</span>
{update.updateKind !== 'update' && (
<span
className={cn(
'rounded-full px-1.5 py-0.5 text-[9px] font-black uppercase tracking-wide',
meta.ring,
meta.color,
)}
>
{meta.label}
</span>
)}
<span className="text-[11px] text-gray-600">
{formatDistanceToNow(new Date(update.createdAt * 1000), { addSuffix: true })}
</span>
</div>
<p className="mt-0.5 whitespace-pre-wrap break-words text-sm text-gray-300">
{update.message}
</p>
</div>
</div>
);
}
/**
* Host update composer (only for the event author) + a public feed of updates.
* Posting p-tags every RSVPer so they get a notification.
*/
export function EventUpdates({ event }: { event: SkateEvent }) {
const { user } = useCurrentUser();
const { toast } = useToast();
const coord = eventCoord(event);
const { data: updates, isLoading } = useEventUpdates(coord);
const { going, tentative } = useEventRsvps(coord);
const { mutateAsync: post, isPending } = usePostEventUpdate();
const [message, setMessage] = useState('');
const [kind, setKind] = useState<EventUpdateKind>('update');
const isHost = user?.pubkey === event.pubkey;
const handlePost = async () => {
const text = message.trim();
if (!text) return;
try {
await post({
event,
message: text,
updateKind: kind,
notifyPubkeys: [...going, ...tentative],
});
setMessage('');
setKind('update');
const reach = new Set([...going, ...tentative]).size;
toast({
title: 'Update posted',
description: reach > 0 ? `${reach} ${reach === 1 ? 'person' : 'people'} notified.` : 'Everyone will see it on the event.',
});
} catch (err) {
toast({
title: 'Could not post update',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
}
};
const hasUpdates = (updates?.length ?? 0) > 0;
return (
<div className="space-y-4">
{/* Composer — host only */}
{isHost && (
<div className="rounded-xl border border-[#ccff00]/25 bg-[#ccff00]/[0.04] p-3">
<div className="mb-2 flex items-center gap-2 text-sm font-black uppercase tracking-wide text-[#ccff00]">
<Megaphone className="h-4 w-4" />
Post an update
</div>
<div className="mb-2 flex flex-wrap gap-1.5">
{(Object.keys(UPDATE_KIND_META) as EventUpdateKind[]).map((k) => {
const m = UPDATE_KIND_META[k];
const Icon = m.icon;
const active = kind === k;
return (
<button
key={k}
type="button"
onClick={() => setKind(k)}
className={cn(
'flex items-center gap-1 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-all active:scale-95',
active
? cn('border-transparent', m.ring, m.color)
: 'border-white/10 bg-white/5 text-gray-500 hover:text-white',
)}
>
<Icon className="h-3 w-3" />
{m.label}
</button>
);
})}
</div>
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Rain — moved to Sunday 2pm. Meet at the back ledge…"
rows={3}
maxLength={500}
className="resize-none border-white/10 bg-black/40 text-sm"
/>
<div className="mt-2 flex items-center justify-between">
<span className="text-[11px] text-gray-500">
{going.length + tentative.length > 0
? `Notifies ${new Set([...going, ...tentative]).size} RSVP${new Set([...going, ...tentative]).size === 1 ? '' : "'s"}`
: 'Visible to everyone on this event'}
</span>
<Button
size="sm"
onClick={handlePost}
disabled={isPending || !message.trim()}
className="gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
{isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
Post
</Button>
</div>
</div>
)}
{/* Feed */}
{isLoading && !hasUpdates ? (
<div className="flex items-center gap-2 py-4 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" /> Loading updates…
</div>
) : hasUpdates ? (
<div className="space-y-4">
{updates!.map((u) => (
<UpdateItem key={u.id} update={u} />
))}
</div>
) : (
!isHost && (
<p className="text-sm text-muted-foreground">
No updates from the host yet. RSVP and you'll be notified if anything changes.
</p>
)
)}
</div>
);
}

View file

@ -0,0 +1,171 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Calendar, Users, Pencil, Megaphone, Plus, CalendarClock } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateEvents } from '@/hooks/useSkateEvents';
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
import { cn } from '@/lib/utils';
import type { SkateEvent } from '@/lib/types';
function formatWhen(ts: number): string {
return new Date(ts * 1000).toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
/** A single hosted-event row with live RSVP counts + quick actions. */
function HostedEventRow({ event, isPast }: { event: SkateEvent; isPast: boolean }) {
const { goingCount, tentativeCount } = useEventRsvps(eventCoord(event));
const to = `/event/${event.pubkey}/${event.identifier}`;
return (
<Card className={cn('border-white/10 transition-colors hover:border-[#ccff00]/40', isPast && 'opacity-70')}>
<CardContent className="flex items-center gap-4 p-4">
{/* Date chip */}
<div className="flex-shrink-0 w-14 text-center">
<div className="rounded-lg border-2 border-[#ccff00]/40 bg-[#ccff00]/10 p-1.5">
<div className="text-[10px] font-black uppercase text-[#ccff00]">
{new Date(event.start * 1000).toLocaleDateString(undefined, { month: 'short' })}
</div>
<div className="text-xl font-black text-white leading-none">
{new Date(event.start * 1000).getDate()}
</div>
</div>
</div>
{/* Info */}
<Link to={to} className="min-w-0 flex-1">
<h3 className="truncate font-bold uppercase tracking-wide text-white hover:text-[#ccff00]">
{event.title}
</h3>
<p className="flex items-center gap-1 text-xs text-gray-500">
<CalendarClock className="h-3 w-3" />
{formatWhen(event.start)}
</p>
<div className="mt-1 flex items-center gap-3 text-xs">
<span className="flex items-center gap-1 font-bold text-green-400">
<Users className="h-3 w-3" />
{goingCount} going
</span>
{tentativeCount > 0 && (
<span className="text-[#ccff00]">{tentativeCount} interested</span>
)}
</div>
</Link>
{/* Quick actions */}
<div className="flex flex-shrink-0 items-center gap-1.5">
<Link to={to} title="Post update">
<Button size="icon" variant="ghost" className="h-8 w-8 text-gray-400 hover:text-[#ccff00]">
<Megaphone className="h-4 w-4" />
</Button>
</Link>
<Link to={`/edit-event/${event.pubkey}/${event.identifier}`} title="Edit">
<Button size="icon" variant="ghost" className="h-8 w-8 text-gray-400 hover:text-white">
<Pencil className="h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
);
}
/**
* "Hosting" dashboard — the events the logged-in user created, split into
* upcoming and past, each with live RSVP counts and quick edit/update actions.
* The host's home base.
*/
export function HostingDashboard() {
const { user } = useCurrentUser();
const { data: events, isLoading } = useSkateEvents(200);
const now = Math.floor(Date.now() / 1000);
const mine = useMemo(() => {
if (!user || !events) return { upcoming: [], past: [] };
const owned = events.filter((e) => e.pubkey === user.pubkey);
return {
upcoming: owned.filter((e) => e.start >= now).sort((a, b) => a.start - b.start),
past: owned.filter((e) => e.start < now).sort((a, b) => b.start - a.start),
};
}, [user, events, now]);
if (!user) {
return (
<Card className="border-dashed">
<CardContent className="py-12 text-center">
<Calendar className="mx-auto mb-3 h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Log in to see the events you're hosting.</p>
</CardContent>
</Card>
);
}
if (isLoading) {
return (
<div className="space-y-3">
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-20 w-full rounded-xl" />
))}
</div>
);
}
const total = mine.upcoming.length + mine.past.length;
if (total === 0) {
return (
<Card className="border-dashed">
<CardContent className="py-12 text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-[#ccff00]/10">
<Calendar className="h-7 w-7 text-[#ccff00]" />
</div>
<h3 className="mb-1 font-black uppercase tracking-wide text-white">You're not hosting anything yet</h3>
<p className="mx-auto mb-5 max-w-xs text-sm text-muted-foreground">
Run a jam, comp or session. Create an event and skaters can RSVP, get updates, and roll through.
</p>
<Link to="/create-event">
<Button className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90">
<Plus className="h-4 w-4" />
Create an event
</Button>
</Link>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{mine.upcoming.length > 0 && (
<div className="space-y-2.5">
<h2 className="text-xs font-black uppercase tracking-widest text-[#ccff00]">
Upcoming · {mine.upcoming.length}
</h2>
{mine.upcoming.map((e) => (
<HostedEventRow key={e.id} event={e} isPast={false} />
))}
</div>
)}
{mine.past.length > 0 && (
<div className="space-y-2.5">
<h2 className="text-xs font-black uppercase tracking-widest text-gray-500">
Past · {mine.past.length}
</h2>
{mine.past.map((e) => (
<HostedEventRow key={e.id} event={e} isPast />
))}
</div>
)}
</div>
);
}

View file

@ -16,7 +16,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
* load minimal (a single filter) per the project's query-efficiency rules. * load minimal (a single filter) per the project's query-efficiency rules.
*/ */
export type ActivityType = 'reaction' | 'repost' | 'zap' | 'mention' | 'reply' | 'rsvp'; export type ActivityType = 'reaction' | 'repost' | 'zap' | 'mention' | 'reply' | 'rsvp' | 'event-update';
export interface ActivityNotification { export interface ActivityNotification {
id: string; id: string;
@ -42,6 +42,7 @@ const ZAP_RECEIPT_KIND = 9735;
const TEXT_NOTE_KIND = 1; const TEXT_NOTE_KIND = 1;
const COMMENT_KIND = 1111; const COMMENT_KIND = 1111;
const RSVP_KIND = 31925; const RSVP_KIND = 31925;
const EVENT_UPDATE_KIND = 7103;
/** Parse the sats amount out of a zap receipt (9735) if we can. */ /** Parse the sats amount out of a zap receipt (9735) if we can. */
function parseZapAmount(event: NostrEvent): number | undefined { function parseZapAmount(event: NostrEvent): number | undefined {
@ -106,6 +107,7 @@ export function useActivityNotifications() {
TEXT_NOTE_KIND, TEXT_NOTE_KIND,
COMMENT_KIND, COMMENT_KIND,
RSVP_KIND, RSVP_KIND,
EVENT_UPDATE_KIND,
], ],
'#p': [pubkey], '#p': [pubkey],
limit: 120, limit: 120,
@ -189,6 +191,17 @@ export function useActivityNotifications() {
targetEventId, targetEventId,
rsvpStatus: status, rsvpStatus: status,
}); });
} else if (event.kind === EVENT_UPDATE_KIND) {
// A host posted an update to an event I RSVP'd to (I'm p-tagged).
out.push({
id: event.id,
type: 'event-update',
pubkey: event.pubkey,
createdAt: event.created_at,
event,
content: event.content,
targetEventId,
});
} }
} }
@ -223,6 +236,8 @@ export function activityVerb(n: ActivityNotification): string {
: n.rsvpStatus === 'tentative' : n.rsvpStatus === 'tentative'
? 'is interested in your event' ? 'is interested in your event'
: "can't make your event"; : "can't make your event";
case 'event-update':
return 'posted an event update';
default: default:
return 'interacted with you'; return 'interacted with you';
} }

View file

@ -0,0 +1,97 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import {
EVENT_UPDATE_KIND,
parseEventUpdate,
type EventUpdate,
type EventUpdateKind,
type SkateEvent,
} from '@/lib/types';
import { eventCoord } from '@/hooks/useEventRsvps';
/**
* Host "Event Updates" (kind 7103) — a public broadcast pinned to a calendar
* event. Hosts use it to say "rain, moved to Sunday" / "meet at the back ledge".
* Everyone who RSVP'd is p-tagged so they get a notification; anyone browsing
* the event page also sees the update thread.
*/
export function useEventUpdates(coord: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['event-updates', coord, getQueryKeySuffix()],
enabled: !!coord,
staleTime: 30_000,
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
const events = await querySkateContent(
[{ kinds: [EVENT_UPDATE_KIND], '#a': [coord!], limit: 100 }],
{ signal },
);
const updates: EventUpdate[] = [];
for (const ev of events) {
const u = parseEventUpdate(ev);
if (u && u.eventCoord === coord) updates.push(u);
}
// Newest first.
return updates.sort((a, b) => b.createdAt - a.createdAt);
},
});
}
/**
* Publish a host update. Only the event's own author should call this (the UI
* gates it). All `notifyPubkeys` (RSVPers) are p-tagged so they get pinged.
*/
export function usePostEventUpdate() {
const { mutateAsync: publish, isPending } = useNostrPublish();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (opts: {
event: SkateEvent;
message: string;
updateKind?: EventUpdateKind;
/** RSVPer pubkeys to notify (deduped, minus the host). */
notifyPubkeys?: string[];
}) => {
if (!user) throw new Error('You must be logged in to post an update.');
if (user.pubkey !== opts.event.pubkey) {
throw new Error('Only the event host can post updates.');
}
const coord = eventCoord(opts.event);
const kind = opts.updateKind ?? 'update';
const notify = Array.from(
new Set((opts.notifyPubkeys ?? []).filter((pk) => pk && pk !== user.pubkey)),
);
const tags: string[][] = [
['a', coord, '', ''],
['e', opts.event.id],
['update-kind', kind],
['t', 'skateboarding'],
['alt', `Update to a skate event: ${opts.event.title}`],
...notify.map((pk) => ['p', pk]),
];
return publish({
kind: EVENT_UPDATE_KIND,
content: opts.message.trim(),
tags,
});
},
onSuccess: (_data, variables) => {
const coord = eventCoord(variables.event);
queryClient.invalidateQueries({ queryKey: ['event-updates', coord] });
},
});
return { ...mutation, isPending: isPending || mutation.isPending };
}

View file

@ -47,6 +47,7 @@ export const SKATE_SPOT_KIND = 30818; // Addressable event - editable per pubkey
export const SKATE_SHOP_KIND = 30017; // NIP-15 Stall (marketplace) export const SKATE_SHOP_KIND = 30017; // NIP-15 Stall (marketplace)
export const CALENDAR_EVENT_KIND = 31923; export const CALENDAR_EVENT_KIND = 31923;
export const CALENDAR_RSVP_KIND = 31925; // NIP-52 Calendar Event RSVP (accepted/tentative/declined) export const CALENDAR_RSVP_KIND = 31925; // NIP-52 Calendar Event RSVP (accepted/tentative/declined)
export const EVENT_UPDATE_KIND = 7103; // Regular event - host broadcast/update pinned to a calendar event. SkateSpots-native.
export const SKATE_SKATER_KIND = 35000; // Skater profile — the app-native "I skate here" identity export const SKATE_SKATER_KIND = 35000; // Skater profile — the app-native "I skate here" identity
export const SKATE_COACH_KIND = 35001; export const SKATE_COACH_KIND = 35001;
export const SKATE_FILMER_KIND = 35002; // Skate filmer (video) profiles export const SKATE_FILMER_KIND = 35002; // Skate filmer (video) profiles
@ -891,6 +892,55 @@ export function parseEventRsvp(event: NostrEvent): EventRsvp | null {
}; };
} }
// ─── Event Update (kind 7103) — host broadcast pinned to a calendar event ────
export type EventUpdateKind = 'update' | 'reschedule' | 'relocation' | 'cancelled';
export interface EventUpdate {
id: string;
pubkey: string;
/** Coordinate of the calendar event ("31923:pubkey:d"). */
eventCoord: string;
/** The calendar event's raw id, if referenced. */
eventId?: string;
message: string;
/** Category of update, for iconography / emphasis. */
updateKind: EventUpdateKind;
createdAt: number;
event: NostrEvent;
}
/**
* Parse a host Event Update (kind 7103). Requires an `a` coordinate to a
* calendar event and non-empty content.
*/
export function parseEventUpdate(event: NostrEvent): EventUpdate | null {
if (event.kind !== EVENT_UPDATE_KIND) return null;
const eventCoord = event.tags.find(([name]) => name === 'a')?.[1];
if (!eventCoord || !eventCoord.startsWith(`${CALENDAR_EVENT_KIND}:`)) return null;
const message = event.content?.trim();
if (!message) return null;
const rawKind = event.tags.find(([name]) => name === 'update-kind')?.[1];
const updateKind: EventUpdateKind =
rawKind === 'reschedule' || rawKind === 'relocation' || rawKind === 'cancelled'
? rawKind
: 'update';
return {
id: event.id,
pubkey: event.pubkey,
eventCoord,
eventId: event.tags.find(([name]) => name === 'e')?.[1],
message: message.slice(0, MAX_DESCRIPTION_LEN),
updateKind,
createdAt: event.created_at,
event,
};
}
// Main spot categories (mutually exclusive - user picks ONE) // Main spot categories (mutually exclusive - user picks ONE)
export const SPOT_CATEGORIES = { export const SPOT_CATEGORIES = {
street: { label: 'Street', emoji: '🛣️', description: 'Random found spots, urban terrain' }, street: { label: 'Street', emoji: '🛣️', description: 'Random found spots, urban terrain' },

View file

@ -15,8 +15,10 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUploadFile } from '@/hooks/useUploadFile'; import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useSkateEvent } from '@/hooks/useSkateEvents'; import { useSkateEvent } from '@/hooks/useSkateEvents';
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
import { usePostEventUpdate } from '@/hooks/useEventUpdates';
import { LoginArea } from '@/components/auth/LoginArea'; import { LoginArea } from '@/components/auth/LoginArea';
import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS } from '@/lib/types'; import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, type EventUpdateKind } from '@/lib/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
Calendar, Calendar,
@ -78,6 +80,10 @@ export default function EditEventPage() {
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast(); const { toast } = useToast();
// RSVP audience + host-update poster, so edits can auto-notify attendees.
const rsvpSummary = useEventRsvps(event ? eventCoord(event) : undefined);
const { mutateAsync: postUpdate } = usePostEventUpdate();
// Form state // Form state
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
@ -283,15 +289,49 @@ export default function EditEventPage() {
tags.push(['image', image]); tags.push(['image', image]);
} }
// Detect the changes attendees care about (time / location) BEFORE we
// overwrite the event, so we can auto-notify RSVPers.
const newLocation = location.trim();
const timeChanged = startTimestamp !== event.start || endTimestamp !== event.end;
const locationChanged = newLocation !== (event.location || '');
await createEvent({ await createEvent({
kind: CALENDAR_EVENT_KIND, kind: CALENDAR_EVENT_KIND,
content: description.trim(), content: description.trim(),
tags, tags,
}); });
// Auto-alert attendees when the when/where changed.
const audience = [...rsvpSummary.going, ...rsvpSummary.tentative];
if ((timeChanged || locationChanged) && audience.length > 0) {
const updateKind: EventUpdateKind = timeChanged ? 'reschedule' : 'relocation';
const parts: string[] = [];
if (timeChanged) {
parts.push(
`New time: ${startDateTime.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })} at ${startDateTime.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}.`,
);
}
if (locationChanged) {
parts.push(newLocation ? `New spot: ${newLocation}.` : 'Location updated.');
}
try {
await postUpdate({
event: { ...event, start: startTimestamp, end: endTimestamp, location: newLocation },
message: `${parts.join(' ')} — see the event for details.`,
updateKind,
notifyPubkeys: audience,
});
} catch {
// Non-fatal: the edit still succeeded even if the alert didn't post.
}
}
const notified = (timeChanged || locationChanged) && audience.length > 0;
toast({ toast({
title: 'Event updated!', title: 'Event updated!',
description: 'Your changes have been published to the network.', description: notified
? `Changes published — ${new Set(audience).size} attendee${new Set(audience).size === 1 ? '' : 's'} notified.`
: 'Your changes have been published to the network.',
}); });
navigate(`/event/${user?.pubkey}/${event.identifier}`); navigate(`/event/${user?.pubkey}/${event.identifier}`);

View file

@ -12,7 +12,8 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ZapButton } from '@/components/ZapButton'; import { ZapButton } from '@/components/ZapButton';
import { RsvpButtons } from '@/components/RsvpButtons'; import { RsvpButtons } from '@/components/RsvpButtons';
import { AttendeeStack } from '@/components/AttendeeStack'; import { AttendeeList } from '@/components/AttendeeList';
import { EventUpdates } from '@/components/EventUpdates';
import { CommentsSection } from '@/components/comments/CommentsSection'; import { CommentsSection } from '@/components/comments/CommentsSection';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog'; import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { useSkateEvent } from '@/hooks/useSkateEvents'; import { useSkateEvent } from '@/hooks/useSkateEvents';
@ -32,6 +33,7 @@ import {
CalendarPlus, CalendarPlus,
Pencil, Pencil,
Music, Music,
Megaphone,
} from 'lucide-react'; } from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
@ -333,6 +335,19 @@ export default function EventDetailPage() {
</Card> </Card>
)} )}
{/* Host Updates */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Megaphone className="h-5 w-5 text-[#ccff00]" />
Updates from the host
</CardTitle>
</CardHeader>
<CardContent>
<EventUpdates event={event} />
</CardContent>
</Card>
{/* Comments Section */} {/* Comments Section */}
{event.event && <CommentsSection root={event.event} />} {event.event && <CommentsSection root={event.event} />}
</div> </div>
@ -378,20 +393,8 @@ export default function EventDetailPage() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{!isPast && <RsvpButtons event={event} />} {!isPast && <RsvpButtons event={event} />}
{rsvpSummary.goingCount > 0 ? ( {(rsvpSummary.goingCount > 0 || rsvpSummary.tentativeCount > 0) ? (
<div> <AttendeeList going={rsvpSummary.going} tentative={rsvpSummary.tentative} />
<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 && !rsvpSummary.isLoading &&
isPast && ( isPast && (

View file

@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
import { Layout } from '@/components/Layout'; import { Layout } from '@/components/Layout';
import { EventCard, EventCardSkeleton } from '@/components/EventCard'; import { EventCard, EventCardSkeleton } from '@/components/EventCard';
import { TourCard, TourCardSkeleton } from '@/components/TourCard'; import { TourCard, TourCardSkeleton } from '@/components/TourCard';
import { HostingDashboard } from '@/components/HostingDashboard';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter'; import { type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter';
@ -36,6 +37,7 @@ import {
Users, Users,
CheckCircle2, CheckCircle2,
ChevronDown, ChevronDown,
Megaphone,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -60,9 +62,18 @@ const ACCENT = {
badge: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30', badge: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30',
stripe: 'bg-cyan-500', stripe: 'bg-cyan-500',
}, },
hosting: {
active: 'bg-orange-500 text-white border-orange-500',
chip: 'bg-orange-500 text-white border-orange-500',
chipHover: 'hover:border-orange-500/60 hover:text-orange-400',
border: 'border-orange-500/40',
icon: 'text-orange-400',
badge: 'bg-orange-500/15 text-orange-400 border-orange-500/30',
stripe: 'bg-orange-500',
},
} as const; } as const;
type MainView = 'events' | 'tours'; type MainView = 'events' | 'tours' | 'hosting';
// ─── Date window filter (skaters plan by weekend) ───────────────────────────── // ─── Date window filter (skaters plan by weekend) ─────────────────────────────
@ -172,6 +183,9 @@ interface ToolbarProps {
mainView: MainView; mainView: MainView;
onViewChange: (v: MainView) => void; onViewChange: (v: MainView) => void;
counts: { events: number; tours: number }; counts: { events: number; tours: number };
/** Show the "Hosting" tab (logged-in users only). */
showHosting: boolean;
hostingCount: number;
// Events // Events
searchQuery: string; searchQuery: string;
onSearchChange: (q: string) => void; onSearchChange: (q: string) => void;
@ -198,6 +212,8 @@ function EventsToolbar({
mainView, mainView,
onViewChange, onViewChange,
counts, counts,
showHosting,
hostingCount,
searchQuery, searchQuery,
onSearchChange, onSearchChange,
locationQuery, locationQuery,
@ -221,14 +237,14 @@ function EventsToolbar({
return ( return (
<div className="sticky top-14 md:top-16 z-30 bg-black/95 backdrop-blur border-b border-white/5 -mx-4 px-4 py-3 space-y-2.5"> <div className="sticky top-14 md:top-16 z-30 bg-black/95 backdrop-blur border-b border-white/5 -mx-4 px-4 py-3 space-y-2.5">
{/* Main view tabs: Events / Tours */} {/* Main view tabs: Events / Tours / Hosting */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{(['events', 'tours'] as MainView[]).map((view) => { {(['events', 'tours', ...(showHosting ? ['hosting'] as const : [])] as MainView[]).map((view) => {
const isActive = mainView === view; const isActive = mainView === view;
const a = ACCENT[view]; const a = ACCENT[view];
const Icon = view === 'events' ? Calendar : Route; const Icon = view === 'events' ? Calendar : view === 'tours' ? Route : Megaphone;
const label = view === 'events' ? 'Events' : 'Tours'; const label = view === 'events' ? 'Events' : view === 'tours' ? 'Tours' : 'Hosting';
const count = counts[view]; const count = view === 'hosting' ? hostingCount : counts[view as 'events' | 'tours'];
return ( return (
<button <button
key={view} key={view}
@ -291,7 +307,8 @@ function EventsToolbar({
</div> </div>
)} )}
{/* Search + Location + Filter row */} {/* Search + Location + Filter row (not shown in Hosting) */}
{mainView !== 'hosting' && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Search */} {/* Search */}
<div className="relative flex-1"> <div className="relative flex-1">
@ -346,9 +363,10 @@ function EventsToolbar({
)} )}
</button> </button>
</div> </div>
)}
{/* Active filter chips — only shown when something is selected */} {/* Active filter chips — only shown when something is selected */}
{hasActiveChips && ( {mainView !== 'hosting' && hasActiveChips && (
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
{selectedTypes.map((type) => ( {selectedTypes.map((type) => (
<button <button
@ -385,7 +403,7 @@ function EventsToolbar({
)} )}
{/* Results count */} {/* Results count */}
{!isLoading && ( {mainView !== 'hosting' && !isLoading && (
<p className="text-[11px] text-gray-500 font-semibold leading-none"> <p className="text-[11px] text-gray-500 font-semibold leading-none">
{totalCount === 0 ? 'No results' : `${totalCount} result${totalCount !== 1 ? 's' : ''}`} {totalCount === 0 ? 'No results' : `${totalCount} result${totalCount !== 1 ? 's' : ''}`}
</p> </p>
@ -819,6 +837,12 @@ export default function EventsPage() {
tours: tours?.length ?? 0, tours: tours?.length ?? 0,
}), [events, tours]); }), [events, tours]);
// Events the logged-in user is hosting (for the Hosting tab badge).
const hostingCount = useMemo(
() => (user && events ? events.filter((e) => e.pubkey === user.pubkey).length : 0),
[user, events],
);
// ── Filter events ───────────────────────────────────────────────────────────── // ── Filter events ─────────────────────────────────────────────────────────────
const filteredEvents = useMemo(() => { const filteredEvents = useMemo(() => {
if (!events) return { upcoming: [], past: [] }; if (!events) return { upcoming: [], past: [] };
@ -999,13 +1023,15 @@ export default function EventsPage() {
<h1 className="text-4xl md:text-5xl font-black uppercase leading-none mb-1"> <h1 className="text-4xl md:text-5xl font-black uppercase leading-none mb-1">
<span className="neon-lime">SKATE</span>{' '} <span className="neon-lime">SKATE</span>{' '}
<span className="text-white"> <span className="text-white">
{mainView === 'events' ? 'EVENTS' : 'TOURS'} {mainView === 'events' ? 'EVENTS' : mainView === 'tours' ? 'TOURS' : 'HOSTING'}
</span> </span>
</h1> </h1>
<p className="text-gray-500 text-sm"> <p className="text-gray-500 text-sm">
{mainView === 'events' {mainView === 'events'
? t('events.findEvents') ? t('events.findEvents')
: 'Local guides, road trips & world destruction competitions'} : mainView === 'tours'
? 'Local guides, road trips & world destruction competitions'
: 'Your events — RSVPs, updates & quick edits'}
</p> </p>
</div> </div>
@ -1023,6 +1049,8 @@ export default function EventsPage() {
mainView={mainView} mainView={mainView}
onViewChange={handleViewChange} onViewChange={handleViewChange}
counts={rawCounts} counts={rawCounts}
showHosting={!!user}
hostingCount={hostingCount}
searchQuery={currentSearchQuery} searchQuery={currentSearchQuery}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
locationQuery={currentLocationQuery} locationQuery={currentLocationQuery}
@ -1070,6 +1098,9 @@ export default function EventsPage() {
{/* ── Results ── */} {/* ── Results ── */}
<div className="mt-6"> <div className="mt-6">
{/* Hosting dashboard */}
{mainView === 'hosting' && <HostingDashboard />}
{/* Events */} {/* Events */}
{mainView === 'events' && ( {mainView === 'events' && (
eventsLoading ? ( eventsLoading ? (