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:
Mary Kate
2026-03-02 05:07:13 +00:00
20 changed files with 1177 additions and 9 deletions
+2
View File
@@ -53,6 +53,8 @@ const hardcodedConfig: AppConfig = {
feedIncludeReposts: true,
feedIncludeArticles: false,
showArticles: false,
showEvents: false,
feedIncludeEvents: false,
showVines: false,
showPolls: false,
showTreasures: false,
+2
View File
@@ -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 */}
+231
View File
@@ -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>
);
}
+418
View File
@@ -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>
);
}
+4 -2
View File
@@ -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'),
+4 -2
View File
@@ -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 (
+4 -2
View File
@@ -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}
+5 -1
View File
@@ -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} />
)}
+4 -1
View File
@@ -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
+67
View File
@@ -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>
);
}
+4
View File
@@ -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 */
+83
View File
@@ -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,
};
}
+59
View File
@@ -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,
};
}
+46
View File
@@ -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'] });
},
});
}
+19
View File
@@ -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);
}
}
}
+2
View File
@@ -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(),
+2 -1
View File
@@ -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 },
+211
View File
@@ -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>
);
}
+8
View File
@@ -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}>
+2
View File
@@ -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,