diff --git a/NIP.md b/NIP.md index ec1d91d..42ab3f1 100644 --- a/NIP.md +++ b/NIP.md @@ -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::"] }` and keep the newest per pubkey. Hosts see incoming RSVPs in their notification center via `{ kinds: [31925], "#p": [""] }`. +### 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::", "", ""], + ["e", ""], + ["update-kind", "reschedule"], + ["t", "skateboarding"], + ["p", ""], + ["p", ""], + ["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::"] }`. + ## Social Interaction Comments and reactions to skate spots and events use standard Nostr kinds: diff --git a/src/components/ActivityNotificationCard.tsx b/src/components/ActivityNotificationCard.tsx index cdeca13..be5bdea 100644 --- a/src/components/ActivityNotificationCard.tsx +++ b/src/components/ActivityNotificationCard.tsx @@ -1,6 +1,6 @@ import { Link } from 'react-router-dom'; 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 { useAuthor } from '@/hooks/useAuthor'; 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' }, reply: { icon: MessageCircle, color: 'text-teal-400', ring: 'bg-teal-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 = - (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) : null; diff --git a/src/components/AttendeeList.tsx b/src/components/AttendeeList.tsx new file mode 100644 index 0000000..a7f4787 --- /dev/null +++ b/src/components/AttendeeList.tsx @@ -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 = ( +
+ + + + {name.slice(0, 2).toUpperCase()} + + + {name} + {trusted && ( + + + Trusted + + )} +
+ ); + + if (!npub) return inner; + return {inner}; +} + +/** + * 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 ( +

+ No RSVPs yet — be the first to say you're going. +

+ ); + } + + const list = tab === 'going' ? going : tentative; + + return ( +
+ {/* Trust signal */} + {going.length > 0 && ( +
+ +

+ {going.length} going + {trustedGoing > 0 && ( + <> + {' · '} + {trustedGoing} you trust + + )} +

+
+ )} + + {/* Tabs */} +
+ + +
+ + {/* List */} + {list.length > 0 ? ( +
+ {list.map((pk) => ( + = TRUSTED_THRESHOLD} + /> + ))} +
+ ) : ( +

+ {tab === 'going' ? 'Nobody has confirmed yet.' : 'Nobody marked interested yet.'} +

+ )} +
+ ); +} diff --git a/src/components/EventUpdates.tsx b/src/components/EventUpdates.tsx new file mode 100644 index 0000000..16310c4 --- /dev/null +++ b/src/components/EventUpdates.tsx @@ -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 ( +
+
+ + + + {name.slice(0, 2).toUpperCase()} + + + + + +
+
+
+ {name} + {update.updateKind !== 'update' && ( + + {meta.label} + + )} + + {formatDistanceToNow(new Date(update.createdAt * 1000), { addSuffix: true })} + +
+

+ {update.message} +

+
+
+ ); +} + +/** + * 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('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 ( +
+ {/* Composer — host only */} + {isHost && ( +
+
+ + Post an update +
+
+ {(Object.keys(UPDATE_KIND_META) as EventUpdateKind[]).map((k) => { + const m = UPDATE_KIND_META[k]; + const Icon = m.icon; + const active = kind === k; + return ( + + ); + })} +
+