diff --git a/src/components/CalendarEventContent/index.tsx b/src/components/CalendarEventContent/index.tsx index 050d9564..908923b9 100644 --- a/src/components/CalendarEventContent/index.tsx +++ b/src/components/CalendarEventContent/index.tsx @@ -1,6 +1,7 @@ import { createCalendarRsvpDraftEvent } from '@/lib/draft-event' import { getCalendarEventMeta, + getNip52CalendarEventTagExtras, formatCalendarTimeRange, formatCalendarDateRange, isCalendarEventKind @@ -10,13 +11,15 @@ 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 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 { Calendar, Clock, ExternalLink, MapPin, CheckCircle, HelpCircle, XCircle } from 'lucide-react' +import { Clock, ExternalLink, MapPin, CheckCircle, HelpCircle, XCircle } from 'lucide-react' import { cn } from '@/lib/utils' import { DropdownMenu, @@ -56,20 +59,26 @@ export default function CalendarEventContent({ 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]) + }, [meta, event.content, showFull]) const eventForMarkdown = useMemo((): Event => { if (!markdownBody) return event return { ...event, content: markdownBody } }, [event, markdownBody]) - const duplicateWebPreviewHints = useMemo( - () => - !meta ? [] : [...meta.rUrls, ...(meta.image?.trim() ? [meta.image.trim()] : [])], - [meta] - ) + 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 @@ -77,6 +86,12 @@ export default function CalendarEventContent({ // 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()) @@ -88,6 +103,7 @@ export default function CalendarEventContent({ 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 } @@ -141,30 +157,15 @@ export default function CalendarEventContent({ onClick={(e) => e.stopPropagation()} >
- {image ? ( - - ) : ( -
- -
- )} +

{startTzid ? ( <> - start_tzid: {startTzid} + {t('Start time-zone id')}: {startTzid} ) : null} {startTzid && endTzid && endTzid !== startTzid ? ' · ' : ''} {endTzid && endTzid !== startTzid ? ( <> - end_tzid: {endTzid} + {t('End time-zone id')}: {endTzid} ) : null}

@@ -223,11 +224,13 @@ export default function CalendarEventContent({

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

{location}

-
+ {showFull ? ( + ) : null} {markdownBody ? ( showFull ? ( @@ -251,15 +254,24 @@ export default function CalendarEventContent({ ) ) : null} + {showFull ? ( + + ) : null}
- {rUrls.map((url) => ( - - ))} + {!showFull && + rUrls.map((url) => ( + + ))} {showRsvp && myPubkey && ( @@ -300,7 +312,7 @@ export default function CalendarEventContent({ {t('Attendees')}
    - {attendeesList.map(({ pubkey, status, isOrganizer }) => ( + {attendeesList.map(({ pubkey, status, isOrganizer, role }) => (
)} - {showFull && event.tags.length > 0 ? ( -
-
- {t('All tags')} -
-
- {event.tags.map((tag, idx) => ( -
-
- {tag[0] || '—'} -
-
- {tag.length > 1 ? tag.slice(1).join(' · ') : '—'} -
-
- ))} -
-
- ) : null} ) } diff --git a/src/components/CalendarEventCoverImage.tsx b/src/components/CalendarEventCoverImage.tsx new file mode 100644 index 00000000..8e855a87 --- /dev/null +++ b/src/components/CalendarEventCoverImage.tsx @@ -0,0 +1,74 @@ +import { useFetchProfile } from '@/hooks' +import { toNostrBuildThumbUrl } from '@/lib/nostr-build' +import { isVideo } from '@/lib/url' +import { cn } from '@/lib/utils' +import { Calendar } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' + +function profileAvatarThumbUrl(avatar: string | undefined): string { + const a = avatar?.trim() + if (!a || !/^https?:\/\//i.test(a)) return '' + if (isVideo(a)) return a + return toNostrBuildThumbUrl(a) +} + +/** + * NIP-52 calendar card cover: event `image` tag, else author profile picture, else calendar icon. + */ +export function CalendarEventCoverImage({ + coverUrl, + pubkey, + className, + iconClassName +}: { + coverUrl: string + pubkey: string + className?: string + /** Passed to the Lucide {@link Calendar} icon when event image and profile avatar are unavailable. */ + iconClassName?: string +}) { + const trimmedCover = coverUrl?.trim() ?? '' + const { profile } = useFetchProfile(pubkey) + const profileThumb = useMemo(() => profileAvatarThumbUrl(profile?.avatar), [profile?.avatar]) + + const [profileImgFailed, setProfileImgFailed] = useState(false) + useEffect(() => { + setProfileImgFailed(false) + }, [profileThumb, trimmedCover, pubkey]) + + if (trimmedCover) { + return ( + + ) + } + + if (profileThumb && !profileImgFailed) { + return ( + setProfileImgFailed(true)} + /> + ) + } + + return ( +
+ +
+ ) +} diff --git a/src/components/CalendarEventNip52StructuredMeta.tsx b/src/components/CalendarEventNip52StructuredMeta.tsx new file mode 100644 index 00000000..1af0b7b8 --- /dev/null +++ b/src/components/CalendarEventNip52StructuredMeta.tsx @@ -0,0 +1,203 @@ +import { getNip52CalendarEventTagExtras, type CalendarEventMeta } from '@/lib/calendar-event' +import { useSmartNoteNavigation } from '@/PageManager' +import { cn } from '@/lib/utils' +import { Event } from 'nostr-tools' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { Button } from '@/components/ui/button' +import { Calendar, ExternalLink, Link2, MapPin } from 'lucide-react' + +type Placement = 'beforeDescription' | 'afterDescription' + +export function CalendarEventNip52StructuredMeta({ + placement, + event, + meta, + isDateBased +}: { + placement: Placement + event: Event + meta: CalendarEventMeta + isDateBased: boolean +}) { + const { t } = useTranslation() + const { navigateToNote } = useSmartNoteNavigation() + const extras = useMemo(() => getNip52CalendarEventTagExtras(event), [event]) + + if (placement === 'beforeDescription') { + const summaryTrim = meta.summary?.trim() ?? '' + const hasLocations = meta.locations.length > 0 + const hasGeo = !!meta.geo?.trim() + if (!hasLocations && !summaryTrim && !hasGeo) return null + + return ( +
+ {hasLocations ? ( +
+
+ {meta.locations.length > 1 ? t('calendarNip52Locations') : t('calendarNip52Location')} +
+ {meta.locations.length === 1 ? ( +

+ + {meta.locations[0]} +

+ ) : ( +
    + {meta.locations.map((loc, i) => ( +
  • + + {loc} +
  • + ))} +
+ )} +
+ ) : null} + {summaryTrim ? ( +
+
+ {t('calendarNip52Summary')} +
+
+ {summaryTrim} +
+
+ ) : null} + {hasGeo ? ( +
+
+ {t('calendarNip52Geohash')} +
+

+ {meta.geo.trim()} + +

+
+ ) : null} +
+ ) + } + + const hasDayD = !isDateBased && extras.dayGranularities.length > 0 + const hasInclusions = extras.calendarInclusions.length > 0 + const hasR = extras.rTags.length > 0 + const hasD = !!meta.d?.trim() + const hasUnknown = extras.unknownTags.length > 0 + + if (!hasDayD && !hasInclusions && !hasR && !hasD && !hasUnknown) return null + + return ( +
+ {hasDayD ? ( +
+
+ {t('calendarNip52DayIndices')} +
+
+ {extras.dayGranularities.map((d) => ( + + D={d} + + ))} +
+
+ ) : null} + + {hasInclusions ? ( +
+
+ {t('calendarNip52CalendarInclusion')} +
+
    + {extras.calendarInclusions.map((row) => ( +
  • + +
  • + ))} +
+

{t('calendarNip52CalendarInclusionHint')}

+
+ ) : null} + + {hasR ? ( +
+
+ {t('calendarNip52References')} +
+
    + {extras.rTags.map((r, idx) => ( +
  • + {r.isHttpUrl ? ( + + ) : ( +
    +

    + + {r.value} +

    +
    + )} +
  • + ))} +
+
+ ) : null} + + {hasD ? ( +
+
+ {t('calendarNip52Identifier')} +
+

{meta.d.trim()}

+
+ ) : null} + + {hasUnknown ? ( +
+
+ {t('calendarNip52OtherTags')} +
+
+ {extras.unknownTags.map((tag, idx) => ( +
+
+ {tag[0] || '—'} +
+
+ {tag.length > 1 ? tag.slice(1).join(' · ') : '—'} +
+
+ ))} +
+
+ ) : null} +
+ ) +} diff --git a/src/components/Embedded/EmbeddedCalendarEvent.tsx b/src/components/Embedded/EmbeddedCalendarEvent.tsx index f94e12c1..a9d7cc5b 100644 --- a/src/components/Embedded/EmbeddedCalendarEvent.tsx +++ b/src/components/Embedded/EmbeddedCalendarEvent.tsx @@ -1,3 +1,4 @@ +import { CalendarEventCoverImage } from '@/components/CalendarEventCoverImage' import { getCalendarEventMeta, formatCalendarTimeRange, @@ -9,7 +10,7 @@ import { Event } from 'nostr-tools' import { useTranslation } from 'react-i18next' import Collapsible from '../Collapsible' import { Button } from '../ui/button' -import { Calendar, Clock, ExternalLink, MapPin } from 'lucide-react' +import { Clock, ExternalLink, MapPin } from 'lucide-react' export function EmbeddedCalendarEvent({ event, @@ -40,19 +41,12 @@ export function EmbeddedCalendarEvent({ onClick={(e) => e.stopPropagation()} >
- {image ? ( - - ) : ( -
- -
- )} +
{title || t('Scheduled video call')} diff --git a/src/components/Sidebar/SidebarCalendarWeekWidget.tsx b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx index 6c29e93b..ed403d5c 100644 --- a/src/components/Sidebar/SidebarCalendarWeekWidget.tsx +++ b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx @@ -23,6 +23,7 @@ import { CalendarDays, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react' import { type Event } from 'nostr-tools' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { CalendarEventCoverImage } from '@/components/CalendarEventCoverImage' import { Button } from '@/components/ui/button' /** Global calendar REQ: relays often cap; larger limit reduces “missing” older-published rows for this week. */ @@ -265,7 +266,6 @@ export default function SidebarCalendarWeekWidget() { {sortedForWeek.map((ev) => { const meta = getCalendarEventMeta(ev) const title = meta.title?.trim() || t('Scheduled video call') - const cover = meta.image?.trim() const sub = formatCalendarSidebarRow(ev) return (
  • @@ -277,17 +277,12 @@ export default function SidebarCalendarWeekWidget() { 'hover:border-border/80 hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' )} > - {cover ? ( - - ) : ( -
    - )} + {title} {sub ? ( diff --git a/src/i18n/locales/cs.ts b/src/i18n/locales/cs.ts index 4fca24e5..4a8dd2b7 100644 --- a/src/i18n/locales/cs.ts +++ b/src/i18n/locales/cs.ts @@ -230,6 +230,20 @@ export default { "Join video call": "Join video call", "Open link": "Open link", "All tags": "All tags", + "Start time-zone id": "Start time-zone id", + "End time-zone id": "End time-zone id", + calendarNip52Location: "Location", + calendarNip52Locations: "Locations", + calendarNip52Summary: "Summary", + calendarNip52Geohash: "Geohash", + calendarNip52ViewGeohash: "View on map", + calendarNip52DayIndices: "Day indices (NIP-52)", + calendarNip52CalendarInclusion: "Calendar inclusion", + calendarNip52CalendarInclusionHint: + "This event requests inclusion in the referenced collaborative calendar (kind 31924).", + calendarNip52References: "References & links", + calendarNip52Identifier: "Event identifier", + calendarNip52OtherTags: "Other tags", "Scheduled video call": "Scheduled video call", "Video call": "Video call", "Schedule and send invite": "Schedule and send invite", diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index db26fda7..bd747bcf 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -230,6 +230,20 @@ export default { "Join video call": "Videoanruf beitreten", "Open link": "Link öffnen", "All tags": "Alle Tags", + "Start time-zone id": "Start-Zeitzonen-ID", + "End time-zone id": "End-Zeitzonen-ID", + calendarNip52Location: "Ort", + calendarNip52Locations: "Orte", + calendarNip52Summary: "Kurzfassung", + calendarNip52Geohash: "Geohash", + calendarNip52ViewGeohash: "Auf Karte anzeigen", + calendarNip52DayIndices: "Tages-Indizes (NIP-52)", + calendarNip52CalendarInclusion: "Kalender-Einbindung", + calendarNip52CalendarInclusionHint: + "Dieses Ereignis bittet um Aufnahme in den referenzierten gemeinschaftlichen Kalender (Kind 31924).", + calendarNip52References: "Verweise & Links", + calendarNip52Identifier: "Ereignis-ID", + calendarNip52OtherTags: "Weitere Tags", "Scheduled video call": "Geplanter Videoanruf", "Video call": "Videoanruf", "Schedule and send invite": "Planen und Einladung senden", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 99c5ab23..a5ed7fdb 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -234,6 +234,20 @@ export default { "Join video call": "Join video call", "Open link": "Open link", "All tags": "All tags", + "Start time-zone id": "Start time-zone id", + "End time-zone id": "End time-zone id", + calendarNip52Location: "Location", + calendarNip52Locations: "Locations", + calendarNip52Summary: "Summary", + calendarNip52Geohash: "Geohash", + calendarNip52ViewGeohash: "View on map", + calendarNip52DayIndices: "Day indices (NIP-52)", + calendarNip52CalendarInclusion: "Calendar inclusion", + calendarNip52CalendarInclusionHint: + "This event requests inclusion in the referenced collaborative calendar (kind 31924).", + calendarNip52References: "References & links", + calendarNip52Identifier: "Event identifier", + calendarNip52OtherTags: "Other tags", "Scheduled video call": "Scheduled video call", "Video call": "Video call", "Schedule and send invite": "Schedule and send invite", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 60d44bf5..10e6bcc1 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -230,6 +230,20 @@ export default { "Join video call": "Join video call", "Open link": "Open link", "All tags": "All tags", + "Start time-zone id": "Start time-zone id", + "End time-zone id": "End time-zone id", + calendarNip52Location: "Location", + calendarNip52Locations: "Locations", + calendarNip52Summary: "Summary", + calendarNip52Geohash: "Geohash", + calendarNip52ViewGeohash: "View on map", + calendarNip52DayIndices: "Day indices (NIP-52)", + calendarNip52CalendarInclusion: "Calendar inclusion", + calendarNip52CalendarInclusionHint: + "This event requests inclusion in the referenced collaborative calendar (kind 31924).", + calendarNip52References: "References & links", + calendarNip52Identifier: "Event identifier", + calendarNip52OtherTags: "Other tags", "Scheduled video call": "Scheduled video call", "Video call": "Video call", "Schedule and send invite": "Schedule and send invite", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index af9969f3..7a16cfbb 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -230,6 +230,20 @@ export default { "Join video call": "Join video call", "Open link": "Open link", "All tags": "All tags", + "Start time-zone id": "Start time-zone id", + "End time-zone id": "End time-zone id", + calendarNip52Location: "Location", + calendarNip52Locations: "Locations", + calendarNip52Summary: "Summary", + calendarNip52Geohash: "Geohash", + calendarNip52ViewGeohash: "View on map", + calendarNip52DayIndices: "Day indices (NIP-52)", + calendarNip52CalendarInclusion: "Calendar inclusion", + calendarNip52CalendarInclusionHint: + "This event requests inclusion in the referenced collaborative calendar (kind 31924).", + calendarNip52References: "References & links", + calendarNip52Identifier: "Event identifier", + calendarNip52OtherTags: "Other tags", "Scheduled video call": "Scheduled video call", "Video call": "Video call", "Schedule and send invite": "Schedule and send invite", diff --git a/src/i18n/locales/nl.ts b/src/i18n/locales/nl.ts index 4fca24e5..4a8dd2b7 100644 --- a/src/i18n/locales/nl.ts +++ b/src/i18n/locales/nl.ts @@ -230,6 +230,20 @@ export default { "Join video call": "Join video call", "Open link": "Open link", "All tags": "All tags", + "Start time-zone id": "Start time-zone id", + "End time-zone id": "End time-zone id", + calendarNip52Location: "Location", + calendarNip52Locations: "Locations", + calendarNip52Summary: "Summary", + calendarNip52Geohash: "Geohash", + calendarNip52ViewGeohash: "View on map", + calendarNip52DayIndices: "Day indices (NIP-52)", + calendarNip52CalendarInclusion: "Calendar inclusion", + calendarNip52CalendarInclusionHint: + "This event requests inclusion in the referenced collaborative calendar (kind 31924).", + calendarNip52References: "References & links", + calendarNip52Identifier: "Event identifier", + calendarNip52OtherTags: "Other tags", "Scheduled video call": "Scheduled video call", "Video call": "Video call", "Schedule and send invite": "Schedule and send invite", diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index 48dd05e0..78ce757c 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -230,6 +230,20 @@ export default { "Join video call": "Join video call", "Open link": "Open link", "All tags": "All tags", + "Start time-zone id": "Start time-zone id", + "End time-zone id": "End time-zone id", + calendarNip52Location: "Location", + calendarNip52Locations: "Locations", + calendarNip52Summary: "Summary", + calendarNip52Geohash: "Geohash", + calendarNip52ViewGeohash: "View on map", + calendarNip52DayIndices: "Day indices (NIP-52)", + calendarNip52CalendarInclusion: "Calendar inclusion", + calendarNip52CalendarInclusionHint: + "This event requests inclusion in the referenced collaborative calendar (kind 31924).", + calendarNip52References: "References & links", + calendarNip52Identifier: "Event identifier", + calendarNip52OtherTags: "Other tags", "Scheduled video call": "Scheduled video call", "Video call": "Video call", "Schedule and send invite": "Schedule and send invite", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index d14376e7..6ac64d83 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -230,6 +230,20 @@ export default { "Join video call": "Join video call", "Open link": "Open link", "All tags": "All tags", + "Start time-zone id": "Start time-zone id", + "End time-zone id": "End time-zone id", + calendarNip52Location: "Location", + calendarNip52Locations: "Locations", + calendarNip52Summary: "Summary", + calendarNip52Geohash: "Geohash", + calendarNip52ViewGeohash: "View on map", + calendarNip52DayIndices: "Day indices (NIP-52)", + calendarNip52CalendarInclusion: "Calendar inclusion", + calendarNip52CalendarInclusionHint: + "This event requests inclusion in the referenced collaborative calendar (kind 31924).", + calendarNip52References: "References & links", + calendarNip52Identifier: "Event identifier", + calendarNip52OtherTags: "Other tags", "Scheduled video call": "Scheduled video call", "Video call": "Video call", "Schedule and send invite": "Schedule and send invite", diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index 4fca24e5..4a8dd2b7 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -230,6 +230,20 @@ export default { "Join video call": "Join video call", "Open link": "Open link", "All tags": "All tags", + "Start time-zone id": "Start time-zone id", + "End time-zone id": "End time-zone id", + calendarNip52Location: "Location", + calendarNip52Locations: "Locations", + calendarNip52Summary: "Summary", + calendarNip52Geohash: "Geohash", + calendarNip52ViewGeohash: "View on map", + calendarNip52DayIndices: "Day indices (NIP-52)", + calendarNip52CalendarInclusion: "Calendar inclusion", + calendarNip52CalendarInclusionHint: + "This event requests inclusion in the referenced collaborative calendar (kind 31924).", + calendarNip52References: "References & links", + calendarNip52Identifier: "Event identifier", + calendarNip52OtherTags: "Other tags", "Scheduled video call": "Scheduled video call", "Video call": "Video call", "Schedule and send invite": "Schedule and send invite", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index c03082b2..0d3c7970 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -230,6 +230,20 @@ export default { "Join video call": "Join video call", "Open link": "Open link", "All tags": "All tags", + "Start time-zone id": "Start time-zone id", + "End time-zone id": "End time-zone id", + calendarNip52Location: "Location", + calendarNip52Locations: "Locations", + calendarNip52Summary: "Summary", + calendarNip52Geohash: "Geohash", + calendarNip52ViewGeohash: "View on map", + calendarNip52DayIndices: "Day indices (NIP-52)", + calendarNip52CalendarInclusion: "Calendar inclusion", + calendarNip52CalendarInclusionHint: + "This event requests inclusion in the referenced collaborative calendar (kind 31924).", + calendarNip52References: "References & links", + calendarNip52Identifier: "Event identifier", + calendarNip52OtherTags: "Other tags", "Scheduled video call": "Scheduled video call", "Video call": "Video call", "Schedule and send invite": "Schedule and send invite", diff --git a/src/lib/calendar-event.ts b/src/lib/calendar-event.ts index 07f1b029..36c257d9 100644 --- a/src/lib/calendar-event.ts +++ b/src/lib/calendar-event.ts @@ -1,7 +1,77 @@ import { ExtendedKind, isNip52CalendarCardKind } from '@/constants' -import { tagNameEquals } from '@/lib/tag' +import { generateBech32IdFromATag, tagNameEquals } from '@/lib/tag' import { Event } from 'nostr-tools' +/** NIP-52 collaborative calendar (addressable kind). */ +export const NIP52_CALENDAR_KIND = 31924 + +export type Nip52CalendarRTag = { value: string; isHttpUrl: boolean } + +export type Nip52CalendarInclusionRow = { coordinate: string; naddr: string } + +export type Nip52CalendarTagExtras = { + locations: string[] + rTags: Nip52CalendarRTag[] + dayGranularities: string[] + calendarInclusions: Nip52CalendarInclusionRow[] + unknownTags: string[][] +} + +const NIP52_CALENDAR_EVENT_KNOWN_TAG_NAMES = new Set([ + 'd', + 'D', + 'title', + 'summary', + 'image', + 'location', + 'g', + 'p', + 't', + 'r', + 'a', + 'start', + 'end', + 'start_tzid', + 'end_tzid', + 'name' +]) + +/** Parsed NIP-52 calendar event tags not fully covered by {@link getCalendarEventMeta}. */ +export function getNip52CalendarEventTagExtras(event: Event): Nip52CalendarTagExtras { + const locations = event.tags + .filter(tagNameEquals('location')) + .map((t) => t[1]?.trim()) + .filter((x): x is string => !!x) + const rTags: Nip52CalendarRTag[] = event.tags + .filter(tagNameEquals('r')) + .map((t) => { + const v = t[1]?.trim() ?? '' + return { value: v, isHttpUrl: /^https?:\/\//i.test(v) } + }) + .filter((e) => e.value.length > 0) + const dayGranularities = event.tags + .filter((t) => t[0] === 'D') + .map((t) => t[1]?.trim()) + .filter((x): x is string => !!x) + const calendarInclusions: Nip52CalendarInclusionRow[] = [] + for (const t of event.tags.filter(tagNameEquals('a'))) { + const coord = t[1]?.trim() + if (!coord) continue + const kindStr = coord.split(':')[0] ?? '' + const kind = parseInt(kindStr, 10) + if (!Number.isFinite(kind) || kind !== NIP52_CALENDAR_KIND) continue + const naddr = generateBech32IdFromATag(t) + if (!naddr) continue + calendarInclusions.push({ coordinate: coord, naddr }) + } + const unknownTags = event.tags.filter((tag) => { + const n = tag[0] + if (n == null || n === '') return false + return !NIP52_CALENDAR_EVENT_KNOWN_TAG_NAMES.has(n) + }) + return { locations, rTags, dayGranularities, calendarInclusions, unknownTags } +} + export interface CalendarEventMeta { title: string summary: string @@ -20,7 +90,9 @@ export interface CalendarEventMeta { /** Same as {@link joinUrl}; every http(s) `r` value. */ rUrl: string rUrls: string[] - /** `location` tag (venue / address text). */ + /** All `location` tag values (NIP-52 allows repeated). */ + locations: string[] + /** First location, for compact UI. */ location: string /** `d` tag (replaceable identifier). */ d: string @@ -34,12 +106,18 @@ export interface CalendarEventMeta { } export function getCalendarEventMeta(event: Event): CalendarEventMeta { - const title = event.tags.find(tagNameEquals('title'))?.[1] ?? '' + const rawTitle = event.tags.find(tagNameEquals('title'))?.[1]?.trim() ?? '' + const nameFallback = event.tags.find(tagNameEquals('name'))?.[1]?.trim() ?? '' + const title = rawTitle || nameFallback const summary = event.tags.find(tagNameEquals('summary'))?.[1] ?? '' const image = event.tags.find(tagNameEquals('image'))?.[1] ?? '' const startStr = event.tags.find(tagNameEquals('start'))?.[1] const endStr = event.tags.find(tagNameEquals('end'))?.[1] - const location = event.tags.find(tagNameEquals('location'))?.[1] ?? '' + const locations = event.tags + .filter(tagNameEquals('location')) + .map((t) => t[1]?.trim()) + .filter((x): x is string => !!x) + const location = locations[0] ?? '' const d = event.tags.find(tagNameEquals('d'))?.[1] ?? '' const geo = event.tags.find(tagNameEquals('g'))?.[1] ?? '' const startTzid = event.tags.find(tagNameEquals('start_tzid'))?.[1] ?? '' @@ -65,6 +143,7 @@ export function getCalendarEventMeta(event: Event): CalendarEventMeta { joinUrl, rUrl, rUrls, + locations, location, d, geo, @@ -87,6 +166,7 @@ export function getCalendarEventMeta(event: Event): CalendarEventMeta { joinUrl, rUrl, rUrls, + locations, location, d, geo, diff --git a/src/pages/primary/CalendarPrimaryPage.tsx b/src/pages/primary/CalendarPrimaryPage.tsx index ce6f06c7..d1bee4ea 100644 --- a/src/pages/primary/CalendarPrimaryPage.tsx +++ b/src/pages/primary/CalendarPrimaryPage.tsx @@ -22,6 +22,7 @@ import indexedDb from '@/services/indexed-db.service' import storage from '@/services/local-storage.service' import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' import { TPageRef } from '@/types' +import { CalendarEventCoverImage } from '@/components/CalendarEventCoverImage' import { RefreshButton } from '@/components/RefreshButton' import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react' import { type Event as NostrEvent } from 'nostr-tools' @@ -415,7 +416,6 @@ const CalendarPrimaryPage = forwardRef(funct {list.slice(0, 4).map((ev) => { const meta = getCalendarEventMeta(ev) const title = meta.title?.trim() || t('calendarPageUntitledEvent') - const cover = meta.image?.trim() return (
  • @@ -495,7 +492,6 @@ const CalendarPrimaryPage = forwardRef(funct {list.slice(0, 4).map((ev) => { const meta = getCalendarEventMeta(ev) const title = meta.title?.trim() || t('calendarPageUntitledEvent') - const cover = meta.image?.trim() return (
  • diff --git a/src/pages/secondary/CalendarDayEventsPage/index.tsx b/src/pages/secondary/CalendarDayEventsPage/index.tsx index 11a6800b..8623264e 100644 --- a/src/pages/secondary/CalendarDayEventsPage/index.tsx +++ b/src/pages/secondary/CalendarDayEventsPage/index.tsx @@ -1,3 +1,4 @@ +import { CalendarEventCoverImage } from '@/components/CalendarEventCoverImage' import { getCalendarEventMeta, getCalendarOccurrenceWindowMs } from '@/lib/calendar-event' import { readCalendarDayPanelEvents } from '@/lib/calendar-day-panel-cache' import { replaceableEventDedupeKey } from '@/lib/event' @@ -75,7 +76,6 @@ const CalendarDayEventsPage = forwardRef { const meta = getCalendarEventMeta(ev) const label = meta.title?.trim() || t('calendarPageUntitledEvent') - const cover = meta.image?.trim() return (
  • diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index 8bad5af4..64ba7eff 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -468,22 +468,55 @@ class LocalStorageService { /** * Async init: hydrate from IndexedDB when available, otherwise migrate localStorage into IndexedDB. * Call this before app render so settings are read from IndexedDB. + * + * Merges any {@link SETTINGS_KEYS} still present only in localStorage into the IDB-backed map, applies + * them to memory, then writes changed keys back to IndexedDB **before** stripping localStorage. + * Otherwise values like pane mode were applied from LS then lost on the next refresh (LS cleared, IDB never had the row). */ async initAsync(): Promise { if (this.initPromise) return this.initPromise this.initPromise = (async () => { await indexedDb.init() - const all = await indexedDb.getAllSettings() - if (Object.keys(all).length > 0) { - this.applySettings(all) - } else { + let idbBefore = await indexedDb.getAllSettings() + if (Object.keys(idbBefore).length === 0) { await this.migrateToIdb() + idbBefore = await indexedDb.getAllSettings() } + const merged = this.mergeSettingsRecordWithLocalStorage(idbBefore) + this.applySettings(merged) + await this.persistSettingsKeysDiffToIdb(idbBefore, merged) this.clearSettingsFromLocalStorage() })() return this.initPromise } + /** Fill gaps from localStorage (used when IDB predates a key or a write only landed in LS). */ + private mergeSettingsRecordWithLocalStorage(idb: Record): Record { + const out: Record = { ...idb } + for (const key of SETTINGS_KEYS) { + if (out[key] != null) continue + const fromLs = window.localStorage.getItem(key) + if (fromLs != null) { + out[key] = fromLs + } + } + return out + } + + /** Persist keys that differ from the pre-merge IDB snapshot so the next cold load reads from IDB only. */ + private async persistSettingsKeysDiffToIdb( + idbBefore: Record, + merged: Record + ): Promise { + for (const key of SETTINGS_KEYS) { + const v = merged[key] + if (v == null) continue + if (idbBefore[key] !== v) { + await indexedDb.setSetting(key, v).catch(() => {}) + } + } + } + /** Remove SETTINGS_KEYS from localStorage so we don't duplicate; source of truth is IndexedDB. */ private clearSettingsFromLocalStorage(): void { for (const key of SETTINGS_KEYS) { @@ -597,7 +630,7 @@ class LocalStorageService { const showRssStr = get(StorageKey.SHOW_RSS_FEED) if (showRssStr != null) this.showRssFeed = showRssStr === 'true' const paneStr = get(StorageKey.PANE_MODE) - if (paneStr != null && (paneStr === 'single' || paneStr === 'double')) this.panelMode = paneStr + if (paneStr === 'single' || paneStr === 'double') this.panelMode = paneStr } getRelaySets() {