import { createCalendarRsvpDraftEvent } from '@/lib/draft-event' import { getUsingClient } from '@/lib/event' import { getCalendarEventMeta, getNip52CalendarEventTagExtras, formatCalendarTimeRange, formatCalendarDateRange, isCalendarEventKind, stripCalendarEventRedundantTopicHashtagLines } from '@/lib/calendar-event' import { tagNameEquals } from '@/lib/tag' import { useFetchCalendarRsvps } from '@/hooks/useFetchCalendarRsvps' import { useNostr } from '@/providers/NostrProvider' import { toProfile } from '@/lib/link' import { useSecondaryPage } from '@/PageManager' import { CalendarEventCoverImage } from '@/components/CalendarEventCoverImage' import { CalendarEventNip52StructuredMeta } from '@/components/CalendarEventNip52StructuredMeta' import ClientTag from '@/components/ClientTag' import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle' import { Event } from 'nostr-tools' import { useTranslation } from 'react-i18next' import { useMemo } from 'react' import Collapsible from '../Collapsible' import { Button } from '../ui/button' import { Clock, ExternalLink, MapPin, CheckCircle, HelpCircle, XCircle } from 'lucide-react' import { cn } from '@/lib/utils' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../ui/dropdown-menu' import UserAvatar from '../UserAvatar' import Username from '../Username' import { toast } from 'sonner' type RsvpStatus = 'accepted' | 'tentative' | 'declined' export default function CalendarEventContent({ event, className, showRsvp = true, showFull = false }: { event: Event className?: string showRsvp?: boolean /** Note page / full detail: markdown body + complete tag list. */ showFull?: boolean }) { const { t } = useTranslation() const { push } = useSecondaryPage() const { pubkey: myPubkey, publish } = useNostr() const { rsvps, isFetching, getRsvpStatus: getStatus } = useFetchCalendarRsvps(event) const meta = useMemo(() => { if (!isCalendarEventKind(event.kind)) return null return getCalendarEventMeta(event) }, [event]) const markdownBody = useMemo(() => { if (!meta) return '' const s = meta.summary.trim() const c = event.content?.trim() ?? '' if (showFull) { if (s && c) return c return s || c || '' } if (s && c) return `${s}\n\n${c}` return s || c || '' }, [meta, event.content, showFull]) const markdownBodyDeduped = useMemo(() => { const topicList = meta?.topics ?? [] return stripCalendarEventRedundantTopicHashtagLines(markdownBody, topicList) }, [markdownBody, meta]) const eventForMarkdown = useMemo((): Event => { if (!markdownBodyDeduped) return event return { ...event, content: markdownBodyDeduped } }, [event, markdownBodyDeduped]) const duplicateWebPreviewHints = useMemo(() => { if (!meta) return [] const httpRs = getNip52CalendarEventTagExtras(event) .rTags.filter((r) => r.isHttpUrl) .map((r) => r.value) return [...httpRs, ...(meta.image?.trim() ? [meta.image.trim()] : [])] }, [meta, event]) const myRsvp = myPubkey ? rsvps.find((r) => r.pubkey === myPubkey) : undefined const myStatus = myRsvp ? getStatus(myRsvp) : undefined // Organizer + invitees (event p tags) + anyone who sent an RSVP. Each shows response: accepted/tentative/declined or no response. const attendeesList = useMemo(() => { const organizerPubkey = event.pubkey const roleByPubkey = new Map() for (const t of event.tags.filter(tagNameEquals('p'))) { const pk = t[1]?.trim() const role = t[3]?.trim() if (pk && role && !roleByPubkey.has(pk)) roleByPubkey.set(pk, role) } const participantPubkeys = event.tags .filter(tagNameEquals('p')) .map((t) => t[1]?.trim()) .filter(Boolean) as string[] const allPubkeys = Array.from( new Set([organizerPubkey, ...participantPubkeys, ...rsvps.map((r) => r.pubkey)]) ) return allPubkeys.map((pubkey) => { const rsvp = rsvps.find((r) => r.pubkey === pubkey) return { pubkey, role: roleByPubkey.get(pubkey), status: (rsvp ? getStatus(rsvp) : null) as RsvpStatus | null, isOrganizer: pubkey === organizerPubkey } }) }, [event.pubkey, event.tags, rsvps]) if (!meta) return null const { title, image, start, end, startDate, endDate, isDateBased, rUrls, location, startTzid, endTzid, topics } = meta const handleRsvp = async (status: RsvpStatus) => { if (!myPubkey) { toast.error(t('You need to log in to RSVP')) return } try { const draft = createCalendarRsvpDraftEvent(event, status) await publish(draft) toast.success(t('RSVP updated')) } catch (err) { toast.error(err instanceof Error ? err.message : t('Failed to update RSVP')) } } const scheduleLine = isDateBased ? (startDate || endDate) && formatCalendarDateRange(startDate, endDate) : start != null && !isNaN(start) ? formatCalendarTimeRange(start, end != null && !isNaN(end) ? end : undefined) : null return (
e.stopPropagation()} >
{!showFull ? ( ) : null}

{title || t('Scheduled video call')}

{getUsingClient(event) ? (
) : null} {!showFull && scheduleLine ? (

{scheduleLine}

) : null} {!showFull && location ? (

{location}

) : null} {topics.length > 0 && (
{topics.map((topic) => ( #{topic} ))}
)}
{showFull && scheduleLine ? (

{scheduleLine}

{(startTzid || endTzid) && (

{startTzid ? ( <> {t('Start time-zone id')}: {startTzid} ) : null} {startTzid && endTzid && endTzid !== startTzid ? ' ยท ' : ''} {endTzid && endTzid !== startTzid ? ( <> {t('End time-zone id')}: {endTzid} ) : null}

)}
) : null} {showFull ? ( ) : null} {markdownBodyDeduped ? ( showFull ? (
) : ( <> {/* NIP-52 31922/31923: collapse long summary+body only; card chrome stays outside MainNoteCard Collapsible. */}

{markdownBodyDeduped}

) ) : null} {showFull ? ( ) : null}
{!showFull && rUrls.map((url) => ( {t('Open link')} ))} {showRsvp && myPubkey && ( handleRsvp('accepted')}> {t('Accepted')} handleRsvp('tentative')}> {t('Tentative')} handleRsvp('declined')}> {t('Declined')} )}
{attendeesList.length > 0 && (
{t('Attendees')}
    {attendeesList.map(({ pubkey, status, isOrganizer, role }) => (
  • ))}
)}
) }