You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

394 lines
15 KiB

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<string, string>()
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 (
<div
className={cn(
'min-w-0 rounded-xl border border-border/70 bg-gradient-to-b from-card to-muted/25 text-sm shadow-sm',
showFull ? 'space-y-2.5 p-3' : 'space-y-3 p-4',
className
)}
data-calendar-event-content
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start gap-3">
{!showFull ? (
<CalendarEventCoverImage
coverUrl={image}
pubkey={event.pubkey}
className="size-10 shrink-0 rounded-lg"
iconClassName="size-5"
/>
) : null}
<div className="min-w-0 flex-1 space-y-2">
<h3
className={cn(
'font-semibold leading-snug tracking-tight text-foreground',
showFull ? 'text-lg' : 'line-clamp-2 text-base'
)}
>
{title || t('Scheduled video call')}
</h3>
{getUsingClient(event) ? (
<div className="min-w-0">
<ClientTag event={event} />
</div>
) : null}
{!showFull && scheduleLine ? (
<p className="flex items-start gap-1.5 text-xs font-medium leading-snug text-foreground">
<Clock className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0">{scheduleLine}</span>
</p>
) : null}
{!showFull && location ? (
<p className="flex items-start gap-1.5 text-xs leading-snug text-muted-foreground">
<MapPin className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0 line-clamp-3">{location}</span>
</p>
) : null}
{topics.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{topics.map((topic) => (
<span
key={topic}
className="inline-flex items-center rounded-full bg-muted/80 px-2 py-0.5 text-xs font-medium text-muted-foreground ring-1 ring-border/50"
>
#{topic}
</span>
))}
</div>
)}
</div>
</div>
{showFull && scheduleLine ? (
<div className="flex gap-2 rounded-md border border-border/60 bg-background/60 px-2.5 py-2">
<Clock className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1 space-y-1">
<p className="text-sm font-medium leading-snug text-foreground">{scheduleLine}</p>
{(startTzid || endTzid) && (
<p className="text-xs leading-snug text-muted-foreground">
{startTzid ? (
<>
<span className="font-medium text-foreground/80">{t('Start time-zone id')}</span>: {startTzid}
</>
) : null}
{startTzid && endTzid && endTzid !== startTzid ? ' · ' : ''}
{endTzid && endTzid !== startTzid ? (
<>
<span className="font-medium text-foreground/80">{t('End time-zone id')}</span>: {endTzid}
</>
) : null}
</p>
)}
</div>
</div>
) : null}
{showFull ? (
<CalendarEventNip52StructuredMeta
placement="beforeDescription"
event={event}
meta={meta}
isDateBased={isDateBased}
/>
) : null}
{markdownBodyDeduped ? (
showFull ? (
<div className="not-prose min-w-0 border-t border-border/50 pt-2" data-calendar-event-markdown>
<MarkdownArticle
event={eventForMarkdown}
className="prose-sm"
hideMetadata
lazyMedia={false}
duplicateWebPreviewCleanedUrlHints={duplicateWebPreviewHints}
/>
</div>
) : (
<>
{/* NIP-52 31922/31923: collapse long summary+body only; card chrome stays outside MainNoteCard Collapsible. */}
<Collapsible threshold={200} collapsedHeight={160} className="min-w-0">
<p className="whitespace-pre-wrap break-words text-sm leading-relaxed text-muted-foreground">
{markdownBodyDeduped}
</p>
</Collapsible>
</>
)
) : null}
{showFull ? (
<CalendarEventNip52StructuredMeta
placement="afterDescription"
event={event}
meta={meta}
isDateBased={isDateBased}
/>
) : null}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-border/40 pt-2">
{!showFull &&
rUrls.map((url) => (
<a
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-2 hover:underline"
>
<ExternalLink className="size-3 shrink-0 opacity-80" aria-hidden />
{t('Open link')}
</a>
))}
{showRsvp && myPubkey && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="default"
size={showFull ? 'lg' : 'default'}
className={cn(
'font-semibold shadow-md',
showFull && 'w-full min-w-0 sm:w-auto sm:min-w-[11rem]'
)}
disabled={isFetching}
>
{myStatus === 'accepted' && (
<CheckCircle className="size-4 shrink-0 text-primary-foreground opacity-95" aria-hidden />
)}
{myStatus === 'tentative' && (
<HelpCircle className="size-4 shrink-0 text-primary-foreground opacity-95" aria-hidden />
)}
{myStatus === 'declined' && (
<XCircle className="size-4 shrink-0 text-primary-foreground opacity-90" aria-hidden />
)}
{myStatus ? t('RSVP: {{status}}', { status: myStatus }) : t('RSVP')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => handleRsvp('accepted')}>
<CheckCircle className="size-4 mr-2 text-green-600" />
{t('Accepted')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleRsvp('tentative')}>
<HelpCircle className="size-4 mr-2 text-amber-600" />
{t('Tentative')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleRsvp('declined')}>
<XCircle className="size-4 mr-2" />
{t('Declined')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{attendeesList.length > 0 && (
<div className="border-t border-border/50 pt-3">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('Attendees')}
</div>
<ul className="space-y-1">
{attendeesList.map(({ pubkey, status, isOrganizer, role }) => (
<li key={pubkey}>
<button
type="button"
onClick={() => push(toProfile(pubkey))}
className={cn(
'flex w-full min-w-0 items-center gap-2.5 rounded-lg px-2 py-2 text-left text-xs transition-colors',
'hover:bg-muted/50'
)}
>
<UserAvatar userId={pubkey} size="xSmall" className="shrink-0" />
<span className="min-w-0 flex-1 truncate">
<Username userId={pubkey} className="text-foreground" skeletonClassName="h-3" />
{role ? (
<span className="mt-0.5 block truncate text-[10px] text-muted-foreground">{role}</span>
) : null}
</span>
<span className="shrink-0 flex items-center gap-1.5 text-muted-foreground">
{isOrganizer && (
<span className="inline-flex items-center rounded bg-muted px-1.5 py-0.5 text-[10px]">
{t('Organizer')}
</span>
)}
{status === 'accepted' && (
<span className="flex items-center gap-1 text-green-600" title={t('Accepted')}>
<CheckCircle className="size-3.5" />
<span className="text-[10px]">{t('Accepted')}</span>
</span>
)}
{status === 'tentative' && (
<span className="flex items-center gap-1 text-amber-600" title={t('Tentative')}>
<HelpCircle className="size-3.5" />
<span className="text-[10px]">{t('Tentative')}</span>
</span>
)}
{status === 'declined' && (
<span className="flex items-center gap-1 text-muted-foreground" title={t('Declined')}>
<XCircle className="size-3.5" />
<span className="text-[10px]">{t('Declined')}</span>
</span>
)}
{status == null && (
<span className="text-[10px] italic text-muted-foreground">
{t('No response')}
</span>
)}
</span>
</button>
</li>
))}
</ul>
</div>
)}
</div>
)
}