Merge branch 'feat/nip-52-calendar-events' into 'main'
Add NIP-52 calendar event support with RSVPs and social activity feed See merge request soapbox-pub/ditto!21
This commit is contained in:
@@ -53,6 +53,8 @@ const hardcodedConfig: AppConfig = {
|
||||
feedIncludeReposts: true,
|
||||
feedIncludeArticles: false,
|
||||
showArticles: false,
|
||||
showEvents: false,
|
||||
feedIncludeEvents: false,
|
||||
showVines: false,
|
||||
showPolls: false,
|
||||
showTreasures: false,
|
||||
|
||||
@@ -27,6 +27,7 @@ import { KindFeedPage } from "./pages/KindFeedPage";
|
||||
import { VideosFeedPage } from "./pages/VideosFeedPage";
|
||||
import { PhotosFeedPage } from "./pages/PhotosFeedPage";
|
||||
import { VinesFeedPage } from "./pages/VinesFeedPage";
|
||||
import { EventsFeedPage } from "./pages/EventsFeedPage";
|
||||
import { WebxdcFeedPage } from "./pages/WebxdcFeedPage";
|
||||
import { TreasuresPage } from "./pages/TreasuresPage";
|
||||
import { ThemesPage } from "./pages/ThemesPage";
|
||||
@@ -75,6 +76,7 @@ export function AppRouter() {
|
||||
<Route path="/settings/advanced" element={<AdvancedSettingsPage />} />
|
||||
<Route path="/settings/magic" element={<MagicSettingsPage />} />
|
||||
<Route path="/settings/network" element={<NetworkSettingsPage />} />
|
||||
<Route path="/events" element={<EventsFeedPage />} />
|
||||
<Route path="/photos" element={<PhotosFeedPage />} />
|
||||
<Route path="/videos" element={<VideosFeedPage />} />
|
||||
{/* /streams redirects to /videos for backward compatibility */}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { CalendarDays, MapPin, Clock, Users } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface CalendarEventContentProps {
|
||||
event: NostrEvent;
|
||||
/** When true, limits the description to 2 lines for compact feed display. */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Extract the first value for a given tag name. */
|
||||
function getTag(tags: string[][], name: string): string | undefined {
|
||||
return tags.find(([n]) => n === name)?.[1];
|
||||
}
|
||||
|
||||
/** Collect all values for a repeated tag name. */
|
||||
function getAllTags(tags: string[][], name: string): string[][] {
|
||||
return tags.filter(([n]) => n === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a location tag value. Some clients encode location as JSON
|
||||
* (e.g. `{"description":"Riga, Latvia","coordinates":{"lat":56.9,"lon":24.1}}`).
|
||||
* Extract a human-readable string when possible, otherwise return the raw value.
|
||||
*/
|
||||
function parseLocation(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.startsWith('{')) return raw;
|
||||
try {
|
||||
const obj = JSON.parse(trimmed);
|
||||
if (typeof obj.description === 'string' && obj.description) return obj.description;
|
||||
if (typeof obj.name === 'string' && obj.name) return obj.name;
|
||||
if (typeof obj.address === 'string' && obj.address) return obj.address;
|
||||
} catch {
|
||||
// not JSON, return as-is
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** Date-only formatter: "Jan 15, 2026" */
|
||||
const dateFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
/** Date+time formatter: "Jan 15, 2026 at 3:00 PM" */
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
/** Time-only formatter: "3:00 PM" */
|
||||
const timeFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
/** Check if two dates fall on the same calendar day. */
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the date/time display for a NIP-52 calendar event.
|
||||
*
|
||||
* Kind 31922 (date-based): "Jan 15, 2026" or "Jan 15 - Jan 17, 2026"
|
||||
* Kind 31923 (time-based): "Jan 15, 2026 at 3:00 PM" or time ranges
|
||||
*/
|
||||
function formatEventDate(event: NostrEvent): string {
|
||||
const start = getTag(event.tags, 'start');
|
||||
if (!start) return '';
|
||||
|
||||
if (event.kind === 31922) {
|
||||
// Date-based: start/end are YYYY-MM-DD strings
|
||||
// Parse as UTC to avoid timezone shifting the date
|
||||
const startDate = new Date(start + 'T00:00:00Z');
|
||||
if (isNaN(startDate.getTime())) return start;
|
||||
|
||||
const end = getTag(event.tags, 'end');
|
||||
if (end) {
|
||||
const endDate = new Date(end + 'T00:00:00Z');
|
||||
if (!isNaN(endDate.getTime()) && endDate > startDate) {
|
||||
// Multi-day range: "Jan 15 - Jan 17, 2026"
|
||||
// NIP-52: end date is exclusive, so display the last inclusive day
|
||||
const lastDay = new Date(endDate.getTime() - 86400000);
|
||||
if (lastDay > startDate) {
|
||||
const startParts = dateFormatter.formatToParts(startDate);
|
||||
const startStr = startParts
|
||||
.filter((p) => p.type !== 'year' && p.type !== 'literal' || p.value === ' ')
|
||||
.map((p) => (p.type === 'literal' && p.value.includes(',') ? '' : p.value))
|
||||
.join('')
|
||||
.trim();
|
||||
return `${startStr} – ${dateFormatter.format(lastDay)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dateFormatter.format(startDate);
|
||||
}
|
||||
|
||||
if (event.kind === 31923) {
|
||||
// Time-based: start/end are Unix timestamps
|
||||
const startTs = parseInt(start, 10);
|
||||
if (isNaN(startTs)) return start;
|
||||
const startDate = new Date(startTs * 1000);
|
||||
|
||||
const end = getTag(event.tags, 'end');
|
||||
if (end) {
|
||||
const endTs = parseInt(end, 10);
|
||||
if (!isNaN(endTs) && endTs > startTs) {
|
||||
const endDate = new Date(endTs * 1000);
|
||||
|
||||
if (isSameDay(startDate, endDate)) {
|
||||
// Same day: "Jan 15, 2026 at 3:00 PM – 5:00 PM"
|
||||
return `${dateTimeFormatter.format(startDate)} – ${timeFormatter.format(endDate)}`;
|
||||
}
|
||||
// Different days: "Jan 15, 2026 at 3:00 PM – Jan 16, 2026 at 5:00 PM"
|
||||
return `${dateTimeFormatter.format(startDate)} – ${dateTimeFormatter.format(endDate)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return dateTimeFormatter.format(startDate);
|
||||
}
|
||||
|
||||
return start;
|
||||
}
|
||||
|
||||
/** Renders NIP-52 calendar event content (kind 31922 and 31923). */
|
||||
export function CalendarEventContent({ event, compact, className }: CalendarEventContentProps) {
|
||||
const title = useMemo(() => getTag(event.tags, 'title'), [event.tags]);
|
||||
const image = useMemo(() => getTag(event.tags, 'image'), [event.tags]);
|
||||
const locationRaw = useMemo(() => getTag(event.tags, 'location'), [event.tags]);
|
||||
const location = useMemo(() => locationRaw ? parseLocation(locationRaw) : undefined, [locationRaw]);
|
||||
const dateDisplay = useMemo(() => formatEventDate(event), [event]);
|
||||
const hashtags = useMemo(() => getAllTags(event.tags, 't').map(([, v]) => v).filter(Boolean), [event.tags]);
|
||||
const participants = useMemo(() => getAllTags(event.tags, 'p'), [event.tags]);
|
||||
const hasContent = event.content.trim().length > 0;
|
||||
const summary = useMemo(() => getTag(event.tags, 'summary'), [event.tags]);
|
||||
|
||||
return (
|
||||
<div className={cn('mt-2 rounded-xl border border-border overflow-hidden', className)}>
|
||||
{/* Cover image or gradient header */}
|
||||
{image ? (
|
||||
<div className="aspect-video rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={image}
|
||||
alt={title ?? 'Calendar event'}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center bg-gradient-to-br from-primary/10 via-primary/5 to-transparent py-8">
|
||||
<CalendarDays className="h-10 w-10 text-primary/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event details */}
|
||||
<div className="space-y-2 p-3">
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<h3 className="text-[15px] font-semibold leading-snug">{title}</h3>
|
||||
)}
|
||||
|
||||
{/* Date/time */}
|
||||
{dateDisplay && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Clock className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{dateDisplay}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
{location && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<MapPin className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Participants count */}
|
||||
{participants.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Users className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
{participants.length} {participants.length === 1 ? 'participant' : 'participants'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary (brief description from tag) */}
|
||||
{summary && !hasContent && (
|
||||
<p className={cn('text-sm text-muted-foreground', compact && 'line-clamp-2')}>
|
||||
{summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Description (event.content) via NoteContent */}
|
||||
{hasContent && (
|
||||
<div className={cn(compact && 'line-clamp-2')}>
|
||||
<NoteContent event={event} className="text-sm" hideEmbedImages={!!image} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hashtags */}
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{hashtags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[11px] px-2 py-0">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarDays,
|
||||
MapPin,
|
||||
Clock,
|
||||
Users,
|
||||
Check,
|
||||
X as XIcon,
|
||||
HelpCircle,
|
||||
Share2,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { RSVPAvatars } from '@/components/RSVPAvatars';
|
||||
import { ZapDialog } from '@/components/ZapDialog';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useEventRSVPs } from '@/hooks/useEventRSVPs';
|
||||
import { useMyRSVP } from '@/hooks/useMyRSVP';
|
||||
import { usePublishRSVP } from '@/hooks/usePublishRSVP';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function getTag(tags: string[][], name: string): string | undefined {
|
||||
return tags.find(([n]) => n === name)?.[1];
|
||||
}
|
||||
|
||||
function getAllTags(tags: string[][], name: string): string[][] {
|
||||
return tags.filter(([n]) => n === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a location tag value. Some clients encode location as JSON
|
||||
* (e.g. `{"description":"Riga, Latvia","coordinates":{"lat":56.9,"lon":24.1}}`).
|
||||
* Extract a human-readable string when possible, otherwise return the raw value.
|
||||
*/
|
||||
function parseLocation(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.startsWith('{')) return raw;
|
||||
try {
|
||||
const obj = JSON.parse(trimmed);
|
||||
if (typeof obj.description === 'string' && obj.description) return obj.description;
|
||||
if (typeof obj.name === 'string' && obj.name) return obj.name;
|
||||
if (typeof obj.address === 'string' && obj.address) return obj.address;
|
||||
} catch {
|
||||
// not JSON, return as-is
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function getEventCoord(event: NostrEvent): string {
|
||||
const d = getTag(event.tags, 'd') ?? '';
|
||||
return `${event.kind}:${event.pubkey}:${d}`;
|
||||
}
|
||||
|
||||
function formatDetailDate(event: NostrEvent): string {
|
||||
const startRaw = getTag(event.tags, 'start');
|
||||
const endRaw = getTag(event.tags, 'end');
|
||||
if (!startRaw) return 'Date not specified';
|
||||
|
||||
if (event.kind === 31922) {
|
||||
const fmt = (d: string) => {
|
||||
const [y, m, day] = d.split('-').map(Number);
|
||||
return new Date(y, m - 1, day).toLocaleDateString('en-US', {
|
||||
month: 'long', day: 'numeric', year: 'numeric',
|
||||
});
|
||||
};
|
||||
if (endRaw && endRaw !== startRaw) return `${fmt(startRaw)} - ${fmt(endRaw)}`;
|
||||
return fmt(startRaw);
|
||||
}
|
||||
|
||||
// kind 31923 — unix timestamps
|
||||
const startTs = parseInt(startRaw, 10) * 1000;
|
||||
const endTs = endRaw ? parseInt(endRaw, 10) * 1000 : undefined;
|
||||
const startTzid = getTag(event.tags, 'start_tzid');
|
||||
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
month: 'long', day: 'numeric', year: 'numeric',
|
||||
hour: 'numeric', minute: '2-digit',
|
||||
...(startTzid ? { timeZone: startTzid } : {}),
|
||||
};
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat('en-US', opts);
|
||||
const startStr = dateFmt.format(new Date(startTs));
|
||||
|
||||
if (endTs) {
|
||||
const sameDay = new Date(startTs).toDateString() === new Date(endTs).toDateString();
|
||||
if (sameDay) {
|
||||
const timeFmt = new Intl.DateTimeFormat('en-US', {
|
||||
hour: 'numeric', minute: '2-digit',
|
||||
...(startTzid ? { timeZone: startTzid } : {}),
|
||||
});
|
||||
return `${startStr} - ${timeFmt.format(new Date(endTs))}`;
|
||||
}
|
||||
return `${startStr} - ${dateFmt.format(new Date(endTs))}`;
|
||||
}
|
||||
return startStr;
|
||||
}
|
||||
|
||||
const ROLE_ORDER = ['host', 'speaker', 'moderator', 'participant'];
|
||||
function roleSort(a: string, b: string): number {
|
||||
const ai = ROLE_ORDER.indexOf(a.toLowerCase());
|
||||
const bi = ROLE_ORDER.indexOf(b.toLowerCase());
|
||||
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
function PersonRow({ pubkey, label, size = 'md' }: { pubkey: string; label?: string; size?: 'sm' | 'md' }) {
|
||||
const { data } = useAuthor(pubkey);
|
||||
const metadata: NostrMetadata | undefined = data?.metadata;
|
||||
const name = metadata?.display_name || metadata?.name || genUserName(pubkey);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
const avatarCls = size === 'sm' ? 'size-8' : 'size-10';
|
||||
const fallbackCls = size === 'sm' ? 'text-xs' : '';
|
||||
|
||||
return (
|
||||
<Link to={profileUrl} className="flex items-center gap-3 group">
|
||||
<Avatar className={cn(avatarCls, 'ring-2 ring-background')}>
|
||||
<AvatarImage src={metadata?.picture} />
|
||||
<AvatarFallback className={cn('bg-muted text-muted-foreground', fallbackCls)}>
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn('font-medium truncate group-hover:underline', size === 'sm' && 'text-sm')}>{name}</p>
|
||||
{!label && size === 'md' && <p className="text-xs text-muted-foreground">Organizer</p>}
|
||||
</div>
|
||||
{label && (
|
||||
<Badge variant="secondary" className="ml-auto capitalize text-xs shrink-0">{label}</Badge>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
|
||||
const title = getTag(event.tags, 'title') ?? 'Untitled Event';
|
||||
const image = getTag(event.tags, 'image');
|
||||
const locationRaw = getTag(event.tags, 'location');
|
||||
const location = locationRaw ? parseLocation(locationRaw) : undefined;
|
||||
const summary = getTag(event.tags, 'summary');
|
||||
const hashtags = getAllTags(event.tags, 't').map(([, v]) => v).filter(Boolean);
|
||||
const links = getAllTags(event.tags, 'r').map(([, v]) => v).filter(Boolean);
|
||||
|
||||
const eventCoord = useMemo(() => getEventCoord(event), [event]);
|
||||
const dateStr = useMemo(() => formatDetailDate(event), [event]);
|
||||
|
||||
// Participants grouped by role
|
||||
const participantsByRole = useMemo(() => {
|
||||
const pTags = getAllTags(event.tags, 'p');
|
||||
const groups = new Map<string, string[]>();
|
||||
for (const tag of pTags) {
|
||||
const pubkey = tag[1];
|
||||
const role = tag[3] || 'Participant';
|
||||
if (!pubkey) continue;
|
||||
const list = groups.get(role) ?? [];
|
||||
list.push(pubkey);
|
||||
groups.set(role, list);
|
||||
}
|
||||
return Array.from(groups.entries()).sort(([a], [b]) => roleSort(a, b));
|
||||
}, [event.tags]);
|
||||
|
||||
// RSVP state
|
||||
const rsvps = useEventRSVPs(eventCoord);
|
||||
const myRsvp = useMyRSVP(eventCoord);
|
||||
const publishRSVP = usePublishRSVP();
|
||||
|
||||
const [selectedStatus, setSelectedStatus] = useState<'accepted' | 'declined' | 'tentative' | null>(null);
|
||||
const [rsvpNote, setRsvpNote] = useState('');
|
||||
|
||||
const activeStatus = selectedStatus ?? myRsvp.status;
|
||||
const hasChanged = selectedStatus !== null && selectedStatus !== myRsvp.status;
|
||||
|
||||
const handleRSVP = useCallback(async () => {
|
||||
if (!activeStatus) return;
|
||||
try {
|
||||
await publishRSVP.mutateAsync({
|
||||
eventCoord,
|
||||
eventAuthorPubkey: event.pubkey,
|
||||
status: activeStatus,
|
||||
note: rsvpNote || undefined,
|
||||
});
|
||||
setSelectedStatus(null);
|
||||
setRsvpNote('');
|
||||
toast({ title: 'RSVP updated' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to update RSVP', variant: 'destructive' });
|
||||
}
|
||||
}, [activeStatus, eventCoord, event.pubkey, rsvpNote, publishRSVP, toast]);
|
||||
|
||||
const handleShare = useCallback(async () => {
|
||||
const d = getTag(event.tags, 'd') ?? '';
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: event.kind,
|
||||
pubkey: event.pubkey,
|
||||
identifier: d,
|
||||
});
|
||||
const url = `${window.location.origin}/${naddr}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast({ title: 'Link copied to clipboard' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to copy link', variant: 'destructive' });
|
||||
}
|
||||
}, [event, toast]);
|
||||
|
||||
const isAuthor = user?.pubkey === event.pubkey;
|
||||
const showRSVP = !!user && !isAuthor;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto pb-12">
|
||||
{/* Back button */}
|
||||
<div className="sticky top-0 z-10 bg-background/80 backdrop-blur-sm border-b px-4 py-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(-1)} className="gap-1.5">
|
||||
<ArrowLeft className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Cover image */}
|
||||
{image ? (
|
||||
<div className="aspect-video w-full overflow-hidden">
|
||||
<img src={image} alt={title} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-video w-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center">
|
||||
<CalendarDays className="size-16 text-primary/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-4 space-y-6 mt-6">
|
||||
{/* Title + share */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold leading-tight">{title}</h1>
|
||||
<Button variant="outline" size="icon" className="shrink-0" onClick={handleShare}>
|
||||
<Share2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Host */}
|
||||
<PersonRow pubkey={event.pubkey} />
|
||||
|
||||
{/* Date/Time */}
|
||||
<div className="flex items-start gap-3 text-sm">
|
||||
<Clock className="size-4 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<span>{dateStr}</span>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
{location && (
|
||||
<div className="flex items-start gap-3 text-sm">
|
||||
<MapPin className="size-4 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<span>{location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{(event.content || summary) && (
|
||||
<div className="max-w-none">
|
||||
{event.content ? (
|
||||
<NoteContent event={event} className="text-sm leading-relaxed text-foreground" hideEmbedImages={!!image} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{summary}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Participants */}
|
||||
{participantsByRole.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Users className="size-4" /> Participants
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{participantsByRole.map(([role, pubkeys]) =>
|
||||
pubkeys.map((pk) => <PersonRow key={pk} pubkey={pk} label={role} size="sm" />),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* RSVP section */}
|
||||
{showRSVP && (
|
||||
<section className="space-y-3 rounded-lg border p-4">
|
||||
<h2 className="text-sm font-semibold">Your RSVP</h2>
|
||||
|
||||
{myRsvp.status && !selectedStatus && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'mb-2',
|
||||
myRsvp.status === 'accepted' && 'border-green-500 text-green-600',
|
||||
myRsvp.status === 'tentative' && 'border-amber-500 text-amber-600',
|
||||
myRsvp.status === 'declined' && 'border-destructive text-destructive',
|
||||
)}
|
||||
>
|
||||
{myRsvp.status === 'accepted' ? 'Going' : myRsvp.status === 'tentative' ? 'Maybe' : "Can't Go"}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={activeStatus === 'accepted' ? 'default' : 'outline'}
|
||||
className={cn(activeStatus === 'accepted' && 'bg-green-600 hover:bg-green-700 text-white')}
|
||||
onClick={() => setSelectedStatus('accepted')}
|
||||
>
|
||||
<Check className="size-3.5 mr-1" /> Going
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={activeStatus === 'tentative' ? 'default' : 'outline'}
|
||||
className={cn(activeStatus === 'tentative' && 'bg-amber-500 hover:bg-amber-600 text-white')}
|
||||
onClick={() => setSelectedStatus('tentative')}
|
||||
>
|
||||
<HelpCircle className="size-3.5 mr-1" /> Maybe
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={activeStatus === 'declined' ? 'default' : 'outline'}
|
||||
className={cn(activeStatus === 'declined' && 'bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
|
||||
onClick={() => setSelectedStatus('declined')}
|
||||
>
|
||||
<XIcon className="size-3.5 mr-1" /> Can't Go
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{activeStatus && (
|
||||
<Textarea
|
||||
placeholder="Add a note (optional)"
|
||||
value={rsvpNote}
|
||||
onChange={(e) => setRsvpNote(e.target.value)}
|
||||
className="mt-2 resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(hasChanged || (activeStatus && !myRsvp.status)) && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRSVP}
|
||||
disabled={publishRSVP.isPending}
|
||||
className="mt-1"
|
||||
>
|
||||
{publishRSVP.isPending ? 'Updating...' : myRsvp.status ? 'Update RSVP' : 'Submit RSVP'}
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Attendees */}
|
||||
{rsvps.total > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Users className="size-4" /> Attendees
|
||||
</h2>
|
||||
{([
|
||||
['Going', rsvps.accepted, 'border-green-500 text-green-600'],
|
||||
['Maybe', rsvps.tentative, 'border-amber-500 text-amber-600'],
|
||||
["Can't Go", rsvps.declined, 'border-muted-foreground text-muted-foreground'],
|
||||
] as const).map(([label, pks, cls]) => pks.length > 0 && (
|
||||
<div key={label} className="flex items-center gap-3">
|
||||
<Badge variant="outline" className={cn(cls, 'shrink-0')}>{label} ({pks.length})</Badge>
|
||||
<RSVPAvatars pubkeys={pks} maxVisible={8} size="sm" />
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Hashtags */}
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hashtags.map((tag) => (
|
||||
<Link key={tag} to={`/t/${tag}`}><Badge variant="secondary" className="cursor-pointer hover:bg-secondary/80">#{tag}</Badge></Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External links */}
|
||||
{links.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
{links.map((url) => (
|
||||
<a key={url} href={url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-primary hover:underline">
|
||||
<ExternalLink className="size-3.5 shrink-0" /><span className="truncate">{url}</span>
|
||||
</a>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Zap */}
|
||||
<ZapDialog target={event}>
|
||||
<Button variant="outline" size="sm" className="gap-1.5">
|
||||
Zap
|
||||
</Button>
|
||||
</ZapDialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -194,7 +194,7 @@ function SyncScreen({ phase }: { phase: SyncPhase }) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Extra-kind IDs shown in the onboarding content picker, in display order. */
|
||||
const ONBOARDING_CONTENT_IDS = ['vines', 'colors', 'decks', 'treasures', 'webxdc'];
|
||||
const ONBOARDING_CONTENT_IDS = ['events', 'vines', 'colors', 'decks', 'treasures', 'webxdc'];
|
||||
|
||||
/** Onboarding content kinds derived from EXTRA_KINDS — no separate data to maintain. */
|
||||
const CONTENT_KINDS = ONBOARDING_CONTENT_IDS.flatMap((id) => {
|
||||
@@ -243,7 +243,7 @@ function SetupQuestionnaire({ onComplete, onPreload, isSignup = false }: {
|
||||
|
||||
const [step, setStep] = useState<Step>(steps[0]);
|
||||
const [selectedContent, setSelectedContent] = useState<Set<string>>(
|
||||
new Set(['vines']),
|
||||
new Set(['events', 'vines']),
|
||||
);
|
||||
const [selectedCW, setSelectedCW] = useState<ContentWarningPolicy>('blur');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -330,6 +330,8 @@ function SetupQuestionnaire({ onComplete, onPreload, isSignup = false }: {
|
||||
|
||||
const feedSettings = {
|
||||
showArticles: false,
|
||||
showEvents: selectedContent.has('events'),
|
||||
feedIncludeEvents: selectedContent.has('events'),
|
||||
showVines: selectedContent.has('vines'),
|
||||
showPolls: false,
|
||||
showTreasures: selectedContent.has('treasures'),
|
||||
|
||||
@@ -25,6 +25,8 @@ interface LinkEmbedProps {
|
||||
className?: string;
|
||||
/** Show a "Discuss" link to the /i/:uri page. Defaults to true. */
|
||||
showDiscuss?: boolean;
|
||||
/** When true, hides the thumbnail image in generic link preview cards. */
|
||||
hideImage?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -39,7 +41,7 @@ interface LinkEmbedProps {
|
||||
* - Mastodon post URLs → `MastodonEmbed` (iframe embed)
|
||||
* - Everything else → `LinkPreview` (OEmbed link preview card)
|
||||
*/
|
||||
export function LinkEmbed({ url, className, showDiscuss = true }: LinkEmbedProps) {
|
||||
export function LinkEmbed({ url, className, showDiscuss = true, hideImage }: LinkEmbedProps) {
|
||||
const youtubeId = useMemo(() => extractYouTubeId(url), [url]);
|
||||
const tweetId = useMemo(() => extractTweetId(url), [url]);
|
||||
const blueskyPost = useMemo(() => extractBlueskyPost(url), [url]);
|
||||
@@ -63,7 +65,7 @@ export function LinkEmbed({ url, className, showDiscuss = true }: LinkEmbedProps
|
||||
embed = <RedditEmbed url={redditUrl} />;
|
||||
} else {
|
||||
// LinkPreview has its own built-in Discuss button
|
||||
return <LinkPreview url={url} className={className} />;
|
||||
return <LinkPreview url={url} className={className} hideImage={hideImage} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,6 +8,8 @@ import { cn } from '@/lib/utils';
|
||||
interface LinkPreviewProps {
|
||||
url: string;
|
||||
className?: string;
|
||||
/** When true, hides the thumbnail image in the preview card. */
|
||||
hideImage?: boolean;
|
||||
}
|
||||
|
||||
/** Extracts the display domain from a URL (e.g. "www.example.com" -> "example.com"). */
|
||||
@@ -21,7 +23,7 @@ function displayDomain(url: string): string {
|
||||
}
|
||||
|
||||
/** Rich link preview card rendered from OEmbed data. */
|
||||
export function LinkPreview({ url, className }: LinkPreviewProps) {
|
||||
export function LinkPreview({ url, className, hideImage }: LinkPreviewProps) {
|
||||
const { data, isLoading } = useLinkPreview(url);
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -45,7 +47,7 @@ export function LinkPreview({ url, className }: LinkPreviewProps) {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Thumbnail image */}
|
||||
{image && (
|
||||
{image && !hideImage && (
|
||||
<div className="w-full overflow-hidden">
|
||||
<img
|
||||
src={image}
|
||||
|
||||
@@ -51,6 +51,7 @@ import { ContentWarningGuard } from '@/components/ContentWarningGuard';
|
||||
import { getContentWarning } from '@/lib/contentWarning';
|
||||
import { ThemeContent } from '@/components/ThemeContent';
|
||||
import { VoiceMessagePlayer } from '@/components/VoiceMessagePlayer';
|
||||
import { CalendarEventContent } from '@/components/CalendarEventContent';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { getParentEventId, isReplyEvent } from '@/lib/nostrEvents';
|
||||
import { extractVideoUrls, extractAudioUrls } from '@/lib/mediaUrls';
|
||||
@@ -221,13 +222,14 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre
|
||||
const isActiveTheme = event.kind === 16767;
|
||||
const isTheme = isThemeDefinition || isActiveTheme;
|
||||
const isVoiceMessage = event.kind === 1222 || event.kind === 1244;
|
||||
const isCalendarEvent = event.kind === 31922 || event.kind === 31923;
|
||||
const isEmojiPack = event.kind === 30030;
|
||||
const isReaction = event.kind === 7;
|
||||
const isPhoto = event.kind === 20;
|
||||
const isNormalVideo = event.kind === 21;
|
||||
const isShortVideo = event.kind === 22;
|
||||
const isVideo = isNormalVideo || isShortVideo;
|
||||
const isTextNote = !isVine && !isPoll && !isGeocache && !isFoundLog && !isColor && !isFollowPack && !isArticle && !isMagicDeck && !isStream && !isFileMetadata && !isTheme && !isVoiceMessage && !isEmojiPack && !isReaction && !isPhoto && !isVideo;
|
||||
const isTextNote = !isVine && !isPoll && !isGeocache && !isFoundLog && !isColor && !isFollowPack && !isArticle && !isMagicDeck && !isStream && !isFileMetadata && !isTheme && !isVoiceMessage && !isCalendarEvent && !isEmojiPack && !isReaction && !isPhoto && !isVideo;
|
||||
|
||||
// Kind 1 specific — images now render inline in NoteContent, only videos go to NoteMedia
|
||||
const videos = useMemo(() => isTextNote ? extractVideoUrls(event.content) : [], [event.content, isTextNote]);
|
||||
@@ -349,6 +351,8 @@ export function NoteCard({ event, className, repostedBy, compact, threaded, thre
|
||||
<ThemeContent event={event} />
|
||||
) : isVoiceMessage ? (
|
||||
<VoiceMessagePlayer event={event} />
|
||||
) : isCalendarEvent ? (
|
||||
<CalendarEventContent event={event} compact />
|
||||
) : (
|
||||
<TruncatedNoteContent event={event} videos={videos} audios={audios} imetaMap={imetaMap} webxdcApps={webxdcApps} />
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,8 @@ interface NoteContentProps {
|
||||
className?: string;
|
||||
/** When true, renders URLs as inline links instead of link preview cards / embeds. */
|
||||
disableEmbeds?: boolean;
|
||||
/** When true, hides thumbnail images in link preview cards (useful when a cover image is already shown). */
|
||||
hideEmbedImages?: boolean;
|
||||
}
|
||||
|
||||
/** Regex matching `:shortcode:` patterns in text. */
|
||||
@@ -225,6 +227,7 @@ export function NoteContent({
|
||||
event,
|
||||
className,
|
||||
disableEmbeds = false,
|
||||
hideEmbedImages = false,
|
||||
}: NoteContentProps) {
|
||||
const tokens = useMemo(() => {
|
||||
const text = event.content;
|
||||
@@ -471,7 +474,7 @@ export function NoteContent({
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <LinkEmbed key={i} url={token.url} className="my-2.5" />;
|
||||
return <LinkEmbed key={i} url={token.url} className="my-2.5" hideImage={hideEmbedImages} />;
|
||||
case 'inline-link':
|
||||
return (
|
||||
<a
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { NostrMetadata } from '@nostrify/nostrify';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type AvatarSize = 'sm' | 'md';
|
||||
|
||||
const sizeClasses: Record<AvatarSize, string> = {
|
||||
sm: 'size-6 text-[10px]',
|
||||
md: 'size-8 text-xs',
|
||||
};
|
||||
|
||||
const spacingClasses: Record<AvatarSize, string> = {
|
||||
sm: '-space-x-1.5',
|
||||
md: '-space-x-2',
|
||||
};
|
||||
|
||||
/** Resolves a single pubkey's profile and renders the avatar with a tooltip. */
|
||||
function RSVPAvatar({ pubkey, size = 'sm' }: { pubkey: string; size?: AvatarSize }) {
|
||||
const { data } = useAuthor(pubkey);
|
||||
const metadata: NostrMetadata | undefined = data?.metadata;
|
||||
|
||||
const displayName = metadata?.display_name || metadata?.name || genUserName(pubkey);
|
||||
const initial = displayName.charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Avatar className={cn(sizeClasses[size], 'ring-2 ring-background')}>
|
||||
<AvatarImage src={metadata?.picture} />
|
||||
<AvatarFallback className="bg-muted text-muted-foreground">
|
||||
{initial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{displayName}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface RSVPAvatarsProps {
|
||||
pubkeys: string[];
|
||||
maxVisible?: number;
|
||||
size?: AvatarSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RSVPAvatars({ pubkeys, maxVisible = 5, size = 'sm', className }: RSVPAvatarsProps) {
|
||||
const visible = pubkeys.slice(0, maxVisible);
|
||||
const overflow = pubkeys.length - maxVisible;
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center', spacingClasses[size], className)}>
|
||||
{visible.map((pubkey) => (
|
||||
<RSVPAvatar key={pubkey} pubkey={pubkey} size={size} />
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span className="pl-2.5 text-xs text-muted-foreground">+{overflow}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,10 @@ export interface FeedSettings {
|
||||
feedIncludeArticles: boolean;
|
||||
/** Show Articles (kind 30023) link in sidebar */
|
||||
showArticles: boolean;
|
||||
/** Show Events (kind 31922/31923) link in sidebar */
|
||||
showEvents: boolean;
|
||||
/** Include calendar events in the follows/global feed */
|
||||
feedIncludeEvents: boolean;
|
||||
/** Show Vines (kind 34236) link in sidebar */
|
||||
showVines: boolean;
|
||||
/** Show Polls (kind 1068) link in sidebar */
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
interface EventRSVPs {
|
||||
accepted: string[];
|
||||
declined: string[];
|
||||
tentative: string[];
|
||||
total: number;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/** Deduplicate RSVPs by author, keeping the latest event per pubkey. */
|
||||
function deduplicateByAuthor(events: NostrEvent[]): NostrEvent[] {
|
||||
const latest = new Map<string, NostrEvent>();
|
||||
|
||||
for (const event of events) {
|
||||
const existing = latest.get(event.pubkey);
|
||||
if (!existing || event.created_at > existing.created_at) {
|
||||
latest.set(event.pubkey, event);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(latest.values());
|
||||
}
|
||||
|
||||
/** Get the RSVP status from a kind 31925 event. */
|
||||
function getRSVPStatus(event: NostrEvent): string | undefined {
|
||||
return event.tags.find(([name]) => name === 'status')?.[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Query RSVPs for a specific NIP-52 calendar event.
|
||||
*
|
||||
* @param eventCoord - The addressable event coordinate (`<kind>:<pubkey>:<d-tag>`).
|
||||
*/
|
||||
export function useEventRSVPs(eventCoord: string | undefined): EventRSVPs {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['event-rsvps', eventCoord],
|
||||
queryFn: async ({ signal }) => {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [31925], '#a': [eventCoord!], limit: 200 }],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
const deduplicated = deduplicateByAuthor(events);
|
||||
|
||||
const accepted: string[] = [];
|
||||
const declined: string[] = [];
|
||||
const tentative: string[] = [];
|
||||
|
||||
for (const event of deduplicated) {
|
||||
const status = getRSVPStatus(event);
|
||||
switch (status) {
|
||||
case 'accepted':
|
||||
accepted.push(event.pubkey);
|
||||
break;
|
||||
case 'declined':
|
||||
declined.push(event.pubkey);
|
||||
break;
|
||||
case 'tentative':
|
||||
tentative.push(event.pubkey);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { accepted, declined, tentative };
|
||||
},
|
||||
enabled: !!eventCoord,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
return {
|
||||
accepted: data?.accepted ?? [],
|
||||
declined: data?.declined ?? [],
|
||||
tentative: data?.tentative ?? [],
|
||||
total: (data?.accepted.length ?? 0) + (data?.declined.length ?? 0) + (data?.tentative.length ?? 0),
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
type RSVPStatus = 'accepted' | 'declined' | 'tentative';
|
||||
|
||||
interface MyRSVP {
|
||||
status: RSVPStatus | null;
|
||||
event: NostrEvent | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the current user's RSVP status for a specific NIP-52 calendar event.
|
||||
*
|
||||
* @param eventCoord - The addressable event coordinate (`<kind>:<pubkey>:<d-tag>`).
|
||||
*/
|
||||
export function useMyRSVP(eventCoord: string | undefined): MyRSVP {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['my-rsvp', user?.pubkey, eventCoord],
|
||||
queryFn: async ({ signal }) => {
|
||||
const events = await nostr.query(
|
||||
[{
|
||||
kinds: [31925],
|
||||
authors: [user!.pubkey],
|
||||
'#a': [eventCoord!],
|
||||
limit: 5,
|
||||
}],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
if (events.length === 0) {
|
||||
return { status: null, event: null };
|
||||
}
|
||||
|
||||
// Pick the latest RSVP by created_at
|
||||
const latest = events.reduce((a, b) => (a.created_at >= b.created_at ? a : b));
|
||||
const status = latest.tags.find(([name]) => name === 'status')?.[1] as RSVPStatus | undefined;
|
||||
|
||||
return {
|
||||
status: status ?? null,
|
||||
event: latest,
|
||||
};
|
||||
},
|
||||
enabled: !!user && !!eventCoord,
|
||||
});
|
||||
|
||||
return {
|
||||
status: data?.status ?? null,
|
||||
event: data?.event ?? null,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
|
||||
type RSVPStatus = 'accepted' | 'declined' | 'tentative';
|
||||
|
||||
interface PublishRSVPParams {
|
||||
eventCoord: string;
|
||||
eventAuthorPubkey: string;
|
||||
status: RSVPStatus;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish or update an RSVP for a NIP-52 calendar event.
|
||||
*
|
||||
* Creates a kind 31925 addressable event with a random `d` tag,
|
||||
* then invalidates relevant query caches.
|
||||
*/
|
||||
export function usePublishRSVP() {
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: createEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ eventCoord, eventAuthorPubkey, status, note }: PublishRSVPParams) => {
|
||||
// Use a stable d-tag derived from the event coordinate so that
|
||||
// updating an RSVP replaces the previous one (kind 31925 is addressable).
|
||||
const dTag = eventCoord;
|
||||
|
||||
await createEvent({
|
||||
kind: 31925,
|
||||
content: note ?? '',
|
||||
tags: [
|
||||
['a', eventCoord],
|
||||
['d', dTag],
|
||||
['status', status],
|
||||
['p', eventAuthorPubkey],
|
||||
],
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-rsvps', variables.eventCoord] });
|
||||
queryClient.invalidateQueries({ queryKey: ['my-rsvp'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -46,6 +46,8 @@ export interface ExtraKindDef {
|
||||
showKey?: keyof FeedSettings;
|
||||
/** Key in FeedSettings that controls inclusion in mixed feeds (only for entries without subKinds). */
|
||||
feedKey?: keyof FeedSettings;
|
||||
/** Additional kind numbers to include alongside `kind` when the feed toggle is on. */
|
||||
extraFeedKinds?: number[];
|
||||
/** Human-readable label. */
|
||||
label: string;
|
||||
/** Short description. */
|
||||
@@ -191,6 +193,20 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
|
||||
sites: [{ url: 'https://divine.video' }],
|
||||
},
|
||||
// Social
|
||||
{
|
||||
kind: 31923,
|
||||
id: 'events',
|
||||
showKey: 'showEvents',
|
||||
feedKey: 'feedIncludeEvents',
|
||||
extraFeedKinds: [31922],
|
||||
label: 'Events',
|
||||
description: 'Calendar events and meetups (NIP-52)',
|
||||
route: 'events',
|
||||
addressable: true,
|
||||
section: 'social',
|
||||
blurb: 'Events and meetups on Nostr. RSVP and see who else is going. Create and manage events on Plektos.',
|
||||
sites: [{ url: 'https://plektos.app', name: 'Plektos' }],
|
||||
},
|
||||
{
|
||||
kind: 1063,
|
||||
id: 'webxdc',
|
||||
@@ -352,6 +368,9 @@ export function getEnabledFeedKinds(feedSettings: FeedSettings): number[] {
|
||||
}
|
||||
} else if (def.feedKey && feedSettings[def.feedKey]) {
|
||||
kinds.push(def.kind);
|
||||
if (def.extraFeedKinds) {
|
||||
kinds.push(...def.extraFeedKinds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,8 @@ export const FeedSettingsSchema = z.looseObject({
|
||||
feedIncludeReposts: z.boolean().optional(),
|
||||
feedIncludeArticles: z.boolean().optional(),
|
||||
showArticles: z.boolean().optional(),
|
||||
showEvents: z.boolean().optional(),
|
||||
feedIncludeEvents: z.boolean().optional(),
|
||||
showVines: z.boolean().optional(),
|
||||
showPolls: z.boolean().optional(),
|
||||
showTreasures: z.boolean().optional(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
Bell, Search, TrendingUp, User, Bookmark, Settings, SwatchBook, Palette,
|
||||
Clapperboard, BarChart3, PartyPopper, BookOpen, Sparkles, Blocks,
|
||||
MessageSquare, Repeat2, MessageSquareMore, Mic, Smile, Bot, SmilePlus, Camera, Film, Globe,
|
||||
MessageSquare, Repeat2, MessageSquareMore, Mic, Smile, Bot, SmilePlus, Camera, Film, Globe, CalendarDays,
|
||||
} from 'lucide-react';
|
||||
import { PlanetIcon } from '@/components/icons/PlanetIcon';
|
||||
import { ChestIcon } from '@/components/icons/ChestIcon';
|
||||
@@ -46,6 +46,7 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
|
||||
{ id: 'theme', label: 'Vibe', path: '/settings/theme', icon: SwatchBook },
|
||||
{ id: 'ai-chat', label: 'AI Chat', path: '/ai-chat', icon: Bot, requiresAuth: true },
|
||||
// Content types
|
||||
{ id: 'events', label: 'Events', path: '/events', icon: CalendarDays },
|
||||
{ id: 'photos', label: 'Photos', path: '/photos', icon: Camera },
|
||||
{ id: 'videos', label: 'Videos', path: '/videos', icon: Film },
|
||||
{ id: 'articles', label: 'Articles', path: '/articles', icon: BookOpen },
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, CalendarDays, Loader2 } from 'lucide-react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PullToRefresh } from '@/components/PullToRefresh';
|
||||
import { KindInfoButton } from '@/components/KindInfoButton';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { useFeed } from '@/hooks/useFeed';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { getExtraKindDef } from '@/lib/extraKinds';
|
||||
import { sidebarItemIcon } from '@/lib/sidebarItems';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type FeedTab = 'follows' | 'global';
|
||||
|
||||
const eventsDef = getExtraKindDef('events')!;
|
||||
|
||||
/** Extract the first value of a tag by name. */
|
||||
function getTag(tags: string[][], name: string): string | undefined {
|
||||
return tags.find(([n]) => n === name)?.[1];
|
||||
}
|
||||
|
||||
// ─── EventsFeedPage ───────────────────────────────────────────────────────────
|
||||
|
||||
export function EventsFeedPage() {
|
||||
const { config } = useAppContext();
|
||||
const { user } = useCurrentUser();
|
||||
const { muteItems } = useMuteList();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<FeedTab>(user ? 'follows' : 'global');
|
||||
|
||||
useEffect(() => {
|
||||
if (user) setActiveTab('follows');
|
||||
}, [user]);
|
||||
|
||||
useSeoMeta({ title: `Events | ${config.appName}` });
|
||||
useLayoutOptions({ showFAB: true, fabKind: 31923 });
|
||||
|
||||
// Calendar events feed
|
||||
const feedQuery = useFeed(activeTab, { kinds: [31922, 31923] });
|
||||
const queryKey = useMemo(() => ['feed', activeTab], [activeTab]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey });
|
||||
}, [queryClient, queryKey]);
|
||||
|
||||
const {
|
||||
data: rawData,
|
||||
isPending,
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = feedQuery;
|
||||
|
||||
// Auto-fetch page 2 for smoother scrolling
|
||||
useEffect(() => {
|
||||
if (hasNextPage && !isFetchingNextPage && rawData?.pages?.length === 1) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]);
|
||||
|
||||
const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
|
||||
|
||||
useEffect(() => {
|
||||
if (inView && hasNextPage && !isFetchingNextPage) fetchNextPage();
|
||||
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
// Flatten, deduplicate, filter muted, then sort: future events first
|
||||
const feedItems = useMemo(() => {
|
||||
if (!rawData?.pages) return [];
|
||||
const seen = new Set<string>();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const items = (rawData.pages as { items: { event: NostrEvent; repostedBy?: string }[] }[])
|
||||
.flatMap((page) => page.items)
|
||||
.filter((item) => {
|
||||
if (seen.has(item.event.id)) return false;
|
||||
seen.add(item.event.id);
|
||||
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return items.sort((a, b) => {
|
||||
const aStart = parseInt(getTag(a.event.tags, 'start') ?? '0', 10);
|
||||
const bStart = parseInt(getTag(b.event.tags, 'start') ?? '0', 10);
|
||||
const aFuture = aStart >= now;
|
||||
const bFuture = bStart >= now;
|
||||
if (aFuture && !bFuture) return -1;
|
||||
if (!aFuture && bFuture) return 1;
|
||||
if (aFuture && bFuture) return aStart - bStart;
|
||||
return bStart - aStart;
|
||||
});
|
||||
}, [rawData?.pages, muteItems]);
|
||||
|
||||
const showSkeleton = isPending || (isLoading && !rawData);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 px-4 mt-4 mb-1">
|
||||
<Link to="/" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<CalendarDays className="size-5" />
|
||||
<h1 className="text-xl font-bold">Events</h1>
|
||||
</div>
|
||||
<KindInfoButton kindDef={eventsDef} icon={sidebarItemIcon('events', 'size-5')} />
|
||||
</div>
|
||||
|
||||
{/* Follows / Global tabs */}
|
||||
{user && (
|
||||
<div className="flex border-b border-border sticky top-mobile-bar sidebar:top-0 bg-background/80 backdrop-blur-md z-10">
|
||||
<TabButton label="Follows" active={activeTab === 'follows'} onClick={() => setActiveTab('follows')} />
|
||||
<TabButton label="Global" active={activeTab === 'global'} onClick={() => setActiveTab('global')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
{showSkeleton ? (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<EventCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : feedItems.length > 0 ? (
|
||||
<div>
|
||||
{feedItems.map((item) => (
|
||||
<NoteCard key={item.event.id} event={item.event} />
|
||||
))}
|
||||
|
||||
{hasNextPage && (
|
||||
<div ref={scrollRef} className="py-4">
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-16 px-8 text-center space-y-3">
|
||||
<CalendarDays className="size-10 text-muted-foreground/40 mx-auto" />
|
||||
<p className="text-muted-foreground">
|
||||
{activeTab === 'follows'
|
||||
? 'No events from people you follow yet. Try the Global tab.'
|
||||
: 'No calendar events found. Check your relay connections or try again later.'}
|
||||
</p>
|
||||
{activeTab === 'follows' && (
|
||||
<button className="text-sm text-primary hover:underline" onClick={() => setActiveTab('global')}>
|
||||
Switch to Global
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PullToRefresh>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Skeletons ────────────────────────────────────────────────────────────────
|
||||
|
||||
function EventCardSkeleton() {
|
||||
return (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-11 rounded-full shrink-0" />
|
||||
<div className="min-w-0 space-y-1.5 flex-1">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
<Skeleton className="h-20 w-full rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── TabButton ────────────────────────────────────────────────────────────────
|
||||
|
||||
function TabButton({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex-1 py-3.5 text-center text-sm font-medium transition-colors relative hover:bg-secondary/40',
|
||||
active ? 'text-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{active && (
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-16 h-1 bg-primary rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { MagicDeckContent } from '@/components/MagicDeckContent';
|
||||
import { FileMetadataContent } from '@/components/FileMetadataContent';
|
||||
import { ThemeContent } from '@/components/ThemeContent';
|
||||
import { VoiceMessagePlayer } from '@/components/VoiceMessagePlayer';
|
||||
import { CalendarEventDetailPage } from '@/components/CalendarEventDetailPage';
|
||||
import { LiveStreamPage } from '@/components/LiveStreamPage';
|
||||
import { WebxdcEmbed } from '@/components/WebxdcEmbed';
|
||||
import { AudioVisualizer } from '@/components/AudioVisualizer';
|
||||
@@ -46,6 +47,8 @@ const FOLLOW_PACK_KINDS = new Set([30000, 39089]);
|
||||
|
||||
/** Kind 30311 = NIP-53 Live Activities. */
|
||||
const LIVE_STREAM_KIND = 30311;
|
||||
/** NIP-52 Calendar Events. */
|
||||
const CALENDAR_EVENT_KINDS = new Set([31922, 31923]);
|
||||
import { useReplies } from '@/hooks/useReplies';
|
||||
import { useComments } from '@/hooks/useComments';
|
||||
import { CommentContext } from '@/components/CommentContext';
|
||||
@@ -259,6 +262,11 @@ export function AddrPostDetailPage({ addr, relays }: AddrPostDetailPageProps) {
|
||||
return <LiveStreamPage event={resolvedEvent} />;
|
||||
}
|
||||
|
||||
// Calendar events (NIP-52) get a dedicated detail page with RSVP
|
||||
if (CALENDAR_EVENT_KINDS.has(resolvedEvent.kind)) {
|
||||
return <CalendarEventDetailPage event={resolvedEvent} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PostDetailShell>
|
||||
<MutedContentGuard event={resolvedEvent}>
|
||||
|
||||
@@ -38,6 +38,8 @@ export function TestApp({ children }: TestAppProps) {
|
||||
feedIncludeReposts: true,
|
||||
feedIncludeArticles: false,
|
||||
showArticles: false,
|
||||
showEvents: false,
|
||||
feedIncludeEvents: false,
|
||||
showVines: true,
|
||||
showPolls: false,
|
||||
showTreasures: false,
|
||||
|
||||
Reference in New Issue
Block a user