Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
fae65e513a
  1. 108
      src/components/CalendarEventContent/index.tsx
  2. 74
      src/components/CalendarEventCoverImage.tsx
  3. 203
      src/components/CalendarEventNip52StructuredMeta.tsx
  4. 20
      src/components/Embedded/EmbeddedCalendarEvent.tsx
  5. 17
      src/components/Sidebar/SidebarCalendarWeekWidget.tsx
  6. 14
      src/i18n/locales/cs.ts
  7. 14
      src/i18n/locales/de.ts
  8. 14
      src/i18n/locales/en.ts
  9. 14
      src/i18n/locales/es.ts
  10. 14
      src/i18n/locales/fr.ts
  11. 14
      src/i18n/locales/nl.ts
  12. 14
      src/i18n/locales/pl.ts
  13. 14
      src/i18n/locales/ru.ts
  14. 14
      src/i18n/locales/tr.ts
  15. 14
      src/i18n/locales/zh.ts
  16. 88
      src/lib/calendar-event.ts
  17. 29
      src/pages/primary/CalendarPrimaryPage.tsx
  18. 17
      src/pages/secondary/CalendarDayEventsPage/index.tsx
  19. 43
      src/services/local-storage.service.ts

108
src/components/CalendarEventContent/index.tsx

@ -1,6 +1,7 @@
import { createCalendarRsvpDraftEvent } from '@/lib/draft-event' import { createCalendarRsvpDraftEvent } from '@/lib/draft-event'
import { import {
getCalendarEventMeta, getCalendarEventMeta,
getNip52CalendarEventTagExtras,
formatCalendarTimeRange, formatCalendarTimeRange,
formatCalendarDateRange, formatCalendarDateRange,
isCalendarEventKind isCalendarEventKind
@ -10,13 +11,15 @@ import { useFetchCalendarRsvps } from '@/hooks/useFetchCalendarRsvps'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { CalendarEventCoverImage } from '@/components/CalendarEventCoverImage'
import { CalendarEventNip52StructuredMeta } from '@/components/CalendarEventNip52StructuredMeta'
import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle' import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useMemo } from 'react' import { useMemo } from 'react'
import Collapsible from '../Collapsible' import Collapsible from '../Collapsible'
import { Button } from '../ui/button' 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 { cn } from '@/lib/utils'
import { import {
DropdownMenu, DropdownMenu,
@ -56,20 +59,26 @@ export default function CalendarEventContent({
if (!meta) return '' if (!meta) return ''
const s = meta.summary.trim() const s = meta.summary.trim()
const c = event.content?.trim() ?? '' const c = event.content?.trim() ?? ''
if (showFull) {
if (s && c) return c
return s || c || ''
}
if (s && c) return `${s}\n\n${c}` if (s && c) return `${s}\n\n${c}`
return s || c || '' return s || c || ''
}, [meta, event.content]) }, [meta, event.content, showFull])
const eventForMarkdown = useMemo((): Event => { const eventForMarkdown = useMemo((): Event => {
if (!markdownBody) return event if (!markdownBody) return event
return { ...event, content: markdownBody } return { ...event, content: markdownBody }
}, [event, markdownBody]) }, [event, markdownBody])
const duplicateWebPreviewHints = useMemo( const duplicateWebPreviewHints = useMemo(() => {
() => if (!meta) return []
!meta ? [] : [...meta.rUrls, ...(meta.image?.trim() ? [meta.image.trim()] : [])], const httpRs = getNip52CalendarEventTagExtras(event)
[meta] .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 myRsvp = myPubkey ? rsvps.find((r) => r.pubkey === myPubkey) : undefined
const myStatus = myRsvp ? getStatus(myRsvp) : 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. // Organizer + invitees (event p tags) + anyone who sent an RSVP. Each shows response: accepted/tentative/declined or no response.
const attendeesList = useMemo(() => { const attendeesList = useMemo(() => {
const organizerPubkey = event.pubkey 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 const participantPubkeys = event.tags
.filter(tagNameEquals('p')) .filter(tagNameEquals('p'))
.map((t) => t[1]?.trim()) .map((t) => t[1]?.trim())
@ -88,6 +103,7 @@ export default function CalendarEventContent({
const rsvp = rsvps.find((r) => r.pubkey === pubkey) const rsvp = rsvps.find((r) => r.pubkey === pubkey)
return { return {
pubkey, pubkey,
role: roleByPubkey.get(pubkey),
status: (rsvp ? getStatus(rsvp) : null) as RsvpStatus | null, status: (rsvp ? getStatus(rsvp) : null) as RsvpStatus | null,
isOrganizer: pubkey === organizerPubkey isOrganizer: pubkey === organizerPubkey
} }
@ -141,30 +157,15 @@ export default function CalendarEventContent({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{image ? ( <CalendarEventCoverImage
<img coverUrl={image}
src={image} pubkey={event.pubkey}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
className={cn( className={cn(
'shrink-0 rounded-lg object-cover shadow-sm ring-1 ring-border/40', 'shrink-0 rounded-lg',
showFull ? 'size-[4.5rem]' : 'size-10' showFull ? 'size-[4.5rem]' : 'size-10'
)} )}
iconClassName={showFull ? 'size-7' : 'size-5'}
/> />
) : (
<div
className={cn(
'flex shrink-0 items-center justify-center rounded-lg bg-primary/10 ring-1 ring-border/40',
showFull ? 'size-[4.5rem]' : 'size-10'
)}
>
<Calendar
className={cn('text-primary/80', showFull ? 'size-7' : 'size-5')}
aria-hidden
/>
</div>
)}
<div className="min-w-0 flex-1 space-y-2"> <div className="min-w-0 flex-1 space-y-2">
<h3 <h3
className={cn( className={cn(
@ -209,13 +210,13 @@ export default function CalendarEventContent({
<p className="text-xs leading-snug text-muted-foreground"> <p className="text-xs leading-snug text-muted-foreground">
{startTzid ? ( {startTzid ? (
<> <>
<span className="font-medium text-foreground/80">start_tzid</span>: {startTzid} <span className="font-medium text-foreground/80">{t('Start time-zone id')}</span>: {startTzid}
</> </>
) : null} ) : null}
{startTzid && endTzid && endTzid !== startTzid ? ' · ' : ''} {startTzid && endTzid && endTzid !== startTzid ? ' · ' : ''}
{endTzid && endTzid !== startTzid ? ( {endTzid && endTzid !== startTzid ? (
<> <>
<span className="font-medium text-foreground/80">end_tzid</span>: {endTzid} <span className="font-medium text-foreground/80">{t('End time-zone id')}</span>: {endTzid}
</> </>
) : null} ) : null}
</p> </p>
@ -223,11 +224,13 @@ export default function CalendarEventContent({
</div> </div>
</div> </div>
) : null} ) : null}
{showFull && location ? ( {showFull ? (
<div className="flex gap-2 rounded-lg border border-border/60 bg-background/40 px-3 py-2.5"> <CalendarEventNip52StructuredMeta
<MapPin className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden /> placement="beforeDescription"
<p className="min-w-0 text-sm leading-snug text-foreground">{location}</p> event={event}
</div> meta={meta}
isDateBased={isDateBased}
/>
) : null} ) : null}
{markdownBody ? ( {markdownBody ? (
showFull ? ( showFull ? (
@ -251,8 +254,17 @@ export default function CalendarEventContent({
</> </>
) )
) : null} ) : null}
{showFull ? (
<CalendarEventNip52StructuredMeta
placement="afterDescription"
event={event}
meta={meta}
isDateBased={isDateBased}
/>
) : null}
<div className="flex flex-wrap items-center gap-2 pt-0.5"> <div className="flex flex-wrap items-center gap-2 pt-0.5">
{rUrls.map((url) => ( {!showFull &&
rUrls.map((url) => (
<Button key={url} variant="secondary" size="sm" className="gap-2" asChild> <Button key={url} variant="secondary" size="sm" className="gap-2" asChild>
<a href={url} target="_blank" rel="noopener noreferrer"> <a href={url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="size-4 shrink-0" /> <ExternalLink className="size-4 shrink-0" />
@ -300,7 +312,7 @@ export default function CalendarEventContent({
{t('Attendees')} {t('Attendees')}
</div> </div>
<ul className="space-y-1"> <ul className="space-y-1">
{attendeesList.map(({ pubkey, status, isOrganizer }) => ( {attendeesList.map(({ pubkey, status, isOrganizer, role }) => (
<li key={pubkey}> <li key={pubkey}>
<button <button
type="button" type="button"
@ -311,8 +323,11 @@ export default function CalendarEventContent({
)} )}
> >
<UserAvatar userId={pubkey} size="xSmall" className="shrink-0" /> <UserAvatar userId={pubkey} size="xSmall" className="shrink-0" />
<span className="min-w-0 truncate flex-1"> <span className="min-w-0 flex-1 truncate">
<Username userId={pubkey} className="text-foreground" skeletonClassName="h-3" /> <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>
<span className="shrink-0 flex items-center gap-1.5 text-muted-foreground"> <span className="shrink-0 flex items-center gap-1.5 text-muted-foreground">
{isOrganizer && ( {isOrganizer && (
@ -350,25 +365,6 @@ export default function CalendarEventContent({
</ul> </ul>
</div> </div>
)} )}
{showFull && event.tags.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('All tags')}
</div>
<dl className="min-w-0 space-y-2">
{event.tags.map((tag, idx) => (
<div key={`${tag[0]}-${idx}`} className="grid gap-1 sm:grid-cols-[minmax(0,7rem)_1fr] sm:gap-3">
<dt className="font-mono text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{tag[0] || '—'}
</dt>
<dd className="min-w-0 break-all text-xs text-foreground">
{tag.length > 1 ? tag.slice(1).join(' · ') : '—'}
</dd>
</div>
))}
</dl>
</div>
) : null}
</div> </div>
) )
} }

74
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 (
<img
src={trimmedCover}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
className={cn('object-cover shadow-sm ring-1 ring-border/40', className)}
/>
)
}
if (profileThumb && !profileImgFailed) {
return (
<img
src={profileThumb}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
className={cn('object-cover shadow-sm ring-1 ring-border/40', className)}
onError={() => setProfileImgFailed(true)}
/>
)
}
return (
<div
className={cn(
'flex shrink-0 items-center justify-center bg-primary/10 shadow-sm ring-1 ring-border/40',
className
)}
>
<Calendar className={cn('text-primary/80', iconClassName ?? 'size-7')} aria-hidden />
</div>
)
}

203
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 (
<div className="min-w-0 space-y-3 border-t border-border/50 pt-3">
{hasLocations ? (
<div className="space-y-1.5">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{meta.locations.length > 1 ? t('calendarNip52Locations') : t('calendarNip52Location')}
</div>
{meta.locations.length === 1 ? (
<p className="flex gap-2 text-sm leading-snug text-foreground">
<MapPin className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0">{meta.locations[0]}</span>
</p>
) : (
<ul className="min-w-0 space-y-2">
{meta.locations.map((loc, i) => (
<li key={`${i}-${loc.slice(0, 24)}`} className="flex gap-2 text-sm leading-snug text-foreground">
<MapPin className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0">{loc}</span>
</li>
))}
</ul>
)}
</div>
) : null}
{summaryTrim ? (
<div className="space-y-1">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('calendarNip52Summary')}
</div>
<blockquote className="border-l-2 border-primary/40 pl-3 text-sm leading-relaxed text-muted-foreground">
{summaryTrim}
</blockquote>
</div>
) : null}
{hasGeo ? (
<div className="space-y-1">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('calendarNip52Geohash')}
</div>
<p className="flex flex-wrap items-center gap-2 text-sm font-mono text-foreground">
<span className="break-all">{meta.geo.trim()}</span>
<Button variant="outline" size="sm" className="h-8 shrink-0" asChild>
<a
href={`https://geohash.org/${encodeURIComponent(meta.geo.trim())}`}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="size-3.5" />
{t('calendarNip52ViewGeohash')}
</a>
</Button>
</p>
</div>
) : null}
</div>
)
}
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 (
<div className="min-w-0 space-y-4 border-t border-border/50 pt-3">
{hasDayD ? (
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('calendarNip52DayIndices')}
</div>
<div className="flex flex-wrap gap-1.5">
{extras.dayGranularities.map((d) => (
<span
key={d}
className="inline-flex items-center rounded-md bg-muted/80 px-2 py-1 font-mono text-xs font-medium text-foreground ring-1 ring-border/50"
>
D={d}
</span>
))}
</div>
</div>
) : null}
{hasInclusions ? (
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('calendarNip52CalendarInclusion')}
</div>
<ul className="min-w-0 space-y-1.5">
{extras.calendarInclusions.map((row) => (
<li key={row.coordinate}>
<Button
type="button"
variant="secondary"
size="sm"
className="h-auto min-h-9 w-full max-w-full justify-start gap-2 whitespace-normal px-3 py-2 text-left font-normal"
onClick={() => navigateToNote(row.naddr)}
>
<Calendar className="size-4 shrink-0 text-primary" aria-hidden />
<span className="min-w-0 break-all font-mono text-xs leading-snug">{row.naddr}</span>
</Button>
</li>
))}
</ul>
<p className="text-[11px] leading-snug text-muted-foreground">{t('calendarNip52CalendarInclusionHint')}</p>
</div>
) : null}
{hasR ? (
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('calendarNip52References')}
</div>
<ul className="min-w-0 space-y-2">
{extras.rTags.map((r, idx) => (
<li key={`${r.value}-${idx}`} className="min-w-0">
{r.isHttpUrl ? (
<Button variant="outline" size="sm" className="h-auto min-h-9 w-full max-w-full justify-start gap-2 px-3 py-2" asChild>
<a href={r.value} target="_blank" rel="noopener noreferrer">
<Link2 className="size-4 shrink-0" aria-hidden />
<span className="min-w-0 break-all text-left text-xs font-medium">{r.value}</span>
</a>
</Button>
) : (
<div className="rounded-lg border border-border/60 bg-muted/25 px-3 py-2">
<p className="flex items-start gap-2 text-xs text-muted-foreground">
<Link2 className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<span className="min-w-0 break-all font-mono text-foreground">{r.value}</span>
</p>
</div>
)}
</li>
))}
</ul>
</div>
) : null}
{hasD ? (
<div className="space-y-1">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('calendarNip52Identifier')}
</div>
<p className="break-all font-mono text-xs text-foreground">{meta.d.trim()}</p>
</div>
) : null}
{hasUnknown ? (
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('calendarNip52OtherTags')}
</div>
<dl className="min-w-0 space-y-2 rounded-lg border border-border/50 bg-muted/15 p-3">
{extras.unknownTags.map((tag, idx) => (
<div key={`${tag[0]}-${idx}`} className="grid gap-1 sm:grid-cols-[minmax(0,6rem)_1fr] sm:gap-2">
<dt className="font-mono text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{tag[0] || '—'}
</dt>
<dd className="min-w-0 break-all text-xs text-foreground">
{tag.length > 1 ? tag.slice(1).join(' · ') : '—'}
</dd>
</div>
))}
</dl>
</div>
) : null}
</div>
)
}

20
src/components/Embedded/EmbeddedCalendarEvent.tsx

@ -1,3 +1,4 @@
import { CalendarEventCoverImage } from '@/components/CalendarEventCoverImage'
import { import {
getCalendarEventMeta, getCalendarEventMeta,
formatCalendarTimeRange, formatCalendarTimeRange,
@ -9,7 +10,7 @@ import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Collapsible from '../Collapsible' import Collapsible from '../Collapsible'
import { Button } from '../ui/button' import { Button } from '../ui/button'
import { Calendar, Clock, ExternalLink, MapPin } from 'lucide-react' import { Clock, ExternalLink, MapPin } from 'lucide-react'
export function EmbeddedCalendarEvent({ export function EmbeddedCalendarEvent({
event, event,
@ -40,19 +41,12 @@ export function EmbeddedCalendarEvent({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-start gap-2.5"> <div className="flex items-start gap-2.5">
{image ? ( <CalendarEventCoverImage
<img coverUrl={image}
src={image} pubkey={event.pubkey}
alt="" className="size-10 shrink-0 rounded-md"
loading="lazy" iconClassName="size-5"
referrerPolicy="no-referrer"
className="size-10 shrink-0 rounded-md object-cover ring-1 ring-border/40"
/> />
) : (
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-primary/10 ring-1 ring-border/40">
<Calendar className="size-5 text-primary/80" aria-hidden />
</div>
)}
<div className="min-w-0 flex-1 space-y-1.5"> <div className="min-w-0 flex-1 space-y-1.5">
<span className="block line-clamp-2 font-semibold leading-snug text-foreground"> <span className="block line-clamp-2 font-semibold leading-snug text-foreground">
{title || t('Scheduled video call')} {title || t('Scheduled video call')}

17
src/components/Sidebar/SidebarCalendarWeekWidget.tsx

@ -23,6 +23,7 @@ import { CalendarDays, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import { type Event } from 'nostr-tools' import { type Event } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { CalendarEventCoverImage } from '@/components/CalendarEventCoverImage'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
/** Global calendar REQ: relays often cap; larger limit reduces “missing” older-published rows for this week. */ /** 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) => { {sortedForWeek.map((ev) => {
const meta = getCalendarEventMeta(ev) const meta = getCalendarEventMeta(ev)
const title = meta.title?.trim() || t('Scheduled video call') const title = meta.title?.trim() || t('Scheduled video call')
const cover = meta.image?.trim()
const sub = formatCalendarSidebarRow(ev) const sub = formatCalendarSidebarRow(ev)
return ( return (
<li key={replaceableEventDedupeKey(ev)}> <li key={replaceableEventDedupeKey(ev)}>
@ -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' 'hover:border-border/80 hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
)} )}
> >
{cover ? ( <CalendarEventCoverImage
<img coverUrl={meta.image}
src={cover} pubkey={ev.pubkey}
alt="" className="size-8 shrink-0 rounded-md ring-1 ring-border/40"
loading="lazy" iconClassName="size-4"
referrerPolicy="no-referrer"
className="size-8 shrink-0 rounded-md object-cover ring-1 ring-border/40"
/> />
) : (
<div className="size-8 shrink-0 rounded-md bg-muted/50 ring-1 ring-border/30" aria-hidden />
)}
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="line-clamp-2 text-[11px] font-medium leading-snug text-foreground">{title}</span> <span className="line-clamp-2 text-[11px] font-medium leading-snug text-foreground">{title}</span>
{sub ? ( {sub ? (

14
src/i18n/locales/cs.ts

@ -230,6 +230,20 @@ export default {
"Join video call": "Join video call", "Join video call": "Join video call",
"Open link": "Open link", "Open link": "Open link",
"All tags": "All tags", "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", "Scheduled video call": "Scheduled video call",
"Video call": "Video call", "Video call": "Video call",
"Schedule and send invite": "Schedule and send invite", "Schedule and send invite": "Schedule and send invite",

14
src/i18n/locales/de.ts

@ -230,6 +230,20 @@ export default {
"Join video call": "Videoanruf beitreten", "Join video call": "Videoanruf beitreten",
"Open link": "Link öffnen", "Open link": "Link öffnen",
"All tags": "Alle Tags", "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", "Scheduled video call": "Geplanter Videoanruf",
"Video call": "Videoanruf", "Video call": "Videoanruf",
"Schedule and send invite": "Planen und Einladung senden", "Schedule and send invite": "Planen und Einladung senden",

14
src/i18n/locales/en.ts

@ -234,6 +234,20 @@ export default {
"Join video call": "Join video call", "Join video call": "Join video call",
"Open link": "Open link", "Open link": "Open link",
"All tags": "All tags", "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", "Scheduled video call": "Scheduled video call",
"Video call": "Video call", "Video call": "Video call",
"Schedule and send invite": "Schedule and send invite", "Schedule and send invite": "Schedule and send invite",

14
src/i18n/locales/es.ts

@ -230,6 +230,20 @@ export default {
"Join video call": "Join video call", "Join video call": "Join video call",
"Open link": "Open link", "Open link": "Open link",
"All tags": "All tags", "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", "Scheduled video call": "Scheduled video call",
"Video call": "Video call", "Video call": "Video call",
"Schedule and send invite": "Schedule and send invite", "Schedule and send invite": "Schedule and send invite",

14
src/i18n/locales/fr.ts

@ -230,6 +230,20 @@ export default {
"Join video call": "Join video call", "Join video call": "Join video call",
"Open link": "Open link", "Open link": "Open link",
"All tags": "All tags", "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", "Scheduled video call": "Scheduled video call",
"Video call": "Video call", "Video call": "Video call",
"Schedule and send invite": "Schedule and send invite", "Schedule and send invite": "Schedule and send invite",

14
src/i18n/locales/nl.ts

@ -230,6 +230,20 @@ export default {
"Join video call": "Join video call", "Join video call": "Join video call",
"Open link": "Open link", "Open link": "Open link",
"All tags": "All tags", "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", "Scheduled video call": "Scheduled video call",
"Video call": "Video call", "Video call": "Video call",
"Schedule and send invite": "Schedule and send invite", "Schedule and send invite": "Schedule and send invite",

14
src/i18n/locales/pl.ts

@ -230,6 +230,20 @@ export default {
"Join video call": "Join video call", "Join video call": "Join video call",
"Open link": "Open link", "Open link": "Open link",
"All tags": "All tags", "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", "Scheduled video call": "Scheduled video call",
"Video call": "Video call", "Video call": "Video call",
"Schedule and send invite": "Schedule and send invite", "Schedule and send invite": "Schedule and send invite",

14
src/i18n/locales/ru.ts

@ -230,6 +230,20 @@ export default {
"Join video call": "Join video call", "Join video call": "Join video call",
"Open link": "Open link", "Open link": "Open link",
"All tags": "All tags", "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", "Scheduled video call": "Scheduled video call",
"Video call": "Video call", "Video call": "Video call",
"Schedule and send invite": "Schedule and send invite", "Schedule and send invite": "Schedule and send invite",

14
src/i18n/locales/tr.ts

@ -230,6 +230,20 @@ export default {
"Join video call": "Join video call", "Join video call": "Join video call",
"Open link": "Open link", "Open link": "Open link",
"All tags": "All tags", "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", "Scheduled video call": "Scheduled video call",
"Video call": "Video call", "Video call": "Video call",
"Schedule and send invite": "Schedule and send invite", "Schedule and send invite": "Schedule and send invite",

14
src/i18n/locales/zh.ts

@ -230,6 +230,20 @@ export default {
"Join video call": "Join video call", "Join video call": "Join video call",
"Open link": "Open link", "Open link": "Open link",
"All tags": "All tags", "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", "Scheduled video call": "Scheduled video call",
"Video call": "Video call", "Video call": "Video call",
"Schedule and send invite": "Schedule and send invite", "Schedule and send invite": "Schedule and send invite",

88
src/lib/calendar-event.ts

@ -1,7 +1,77 @@
import { ExtendedKind, isNip52CalendarCardKind } from '@/constants' import { ExtendedKind, isNip52CalendarCardKind } from '@/constants'
import { tagNameEquals } from '@/lib/tag' import { generateBech32IdFromATag, tagNameEquals } from '@/lib/tag'
import { Event } from 'nostr-tools' 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 { export interface CalendarEventMeta {
title: string title: string
summary: string summary: string
@ -20,7 +90,9 @@ export interface CalendarEventMeta {
/** Same as {@link joinUrl}; every http(s) `r` value. */ /** Same as {@link joinUrl}; every http(s) `r` value. */
rUrl: string rUrl: string
rUrls: 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 location: string
/** `d` tag (replaceable identifier). */ /** `d` tag (replaceable identifier). */
d: string d: string
@ -34,12 +106,18 @@ export interface CalendarEventMeta {
} }
export function getCalendarEventMeta(event: Event): 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 summary = event.tags.find(tagNameEquals('summary'))?.[1] ?? ''
const image = event.tags.find(tagNameEquals('image'))?.[1] ?? '' const image = event.tags.find(tagNameEquals('image'))?.[1] ?? ''
const startStr = event.tags.find(tagNameEquals('start'))?.[1] const startStr = event.tags.find(tagNameEquals('start'))?.[1]
const endStr = event.tags.find(tagNameEquals('end'))?.[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 d = event.tags.find(tagNameEquals('d'))?.[1] ?? ''
const geo = event.tags.find(tagNameEquals('g'))?.[1] ?? '' const geo = event.tags.find(tagNameEquals('g'))?.[1] ?? ''
const startTzid = event.tags.find(tagNameEquals('start_tzid'))?.[1] ?? '' const startTzid = event.tags.find(tagNameEquals('start_tzid'))?.[1] ?? ''
@ -65,6 +143,7 @@ export function getCalendarEventMeta(event: Event): CalendarEventMeta {
joinUrl, joinUrl,
rUrl, rUrl,
rUrls, rUrls,
locations,
location, location,
d, d,
geo, geo,
@ -87,6 +166,7 @@ export function getCalendarEventMeta(event: Event): CalendarEventMeta {
joinUrl, joinUrl,
rUrl, rUrl,
rUrls, rUrls,
locations,
location, location,
d, d,
geo, geo,

29
src/pages/primary/CalendarPrimaryPage.tsx

@ -22,6 +22,7 @@ import indexedDb from '@/services/indexed-db.service'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants'
import { TPageRef } from '@/types' import { TPageRef } from '@/types'
import { CalendarEventCoverImage } from '@/components/CalendarEventCoverImage'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react' import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react'
import { type Event as NostrEvent } from 'nostr-tools' import { type Event as NostrEvent } from 'nostr-tools'
@ -415,7 +416,6 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
{list.slice(0, 4).map((ev) => { {list.slice(0, 4).map((ev) => {
const meta = getCalendarEventMeta(ev) const meta = getCalendarEventMeta(ev)
const title = meta.title?.trim() || t('calendarPageUntitledEvent') const title = meta.title?.trim() || t('calendarPageUntitledEvent')
const cover = meta.image?.trim()
return ( return (
<li key={replaceableEventDedupeKey(ev)} className="min-w-0"> <li key={replaceableEventDedupeKey(ev)} className="min-w-0">
<button <button
@ -426,15 +426,12 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
'hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' 'hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
)} )}
> >
{cover ? ( <CalendarEventCoverImage
<img coverUrl={meta.image}
src={cover} pubkey={ev.pubkey}
alt="" className="size-8 shrink-0 rounded-md ring-1 ring-border/50"
loading="lazy" iconClassName="size-4"
referrerPolicy="no-referrer"
className="size-8 shrink-0 rounded-md object-cover ring-1 ring-border/50"
/> />
) : null}
<span className="min-w-0 truncate">{title}</span> <span className="min-w-0 truncate">{title}</span>
</button> </button>
</li> </li>
@ -495,7 +492,6 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
{list.slice(0, 4).map((ev) => { {list.slice(0, 4).map((ev) => {
const meta = getCalendarEventMeta(ev) const meta = getCalendarEventMeta(ev)
const title = meta.title?.trim() || t('calendarPageUntitledEvent') const title = meta.title?.trim() || t('calendarPageUntitledEvent')
const cover = meta.image?.trim()
return ( return (
<li key={replaceableEventDedupeKey(ev)} className="min-w-0"> <li key={replaceableEventDedupeKey(ev)} className="min-w-0">
<button <button
@ -507,15 +503,12 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
)} )}
title={title} title={title}
> >
{cover ? ( <CalendarEventCoverImage
<img coverUrl={meta.image}
src={cover} pubkey={ev.pubkey}
alt="" className="size-3.5 shrink-0 rounded-sm ring-1 ring-border/40 md:size-4"
loading="lazy" iconClassName="size-2.5 md:size-3"
referrerPolicy="no-referrer"
className="size-3.5 shrink-0 rounded-sm object-cover ring-1 ring-border/40 md:size-4"
/> />
) : null}
<span className="min-w-0 flex-1 truncate">{title}</span> <span className="min-w-0 flex-1 truncate">{title}</span>
</button> </button>
</li> </li>

17
src/pages/secondary/CalendarDayEventsPage/index.tsx

@ -1,3 +1,4 @@
import { CalendarEventCoverImage } from '@/components/CalendarEventCoverImage'
import { getCalendarEventMeta, getCalendarOccurrenceWindowMs } from '@/lib/calendar-event' import { getCalendarEventMeta, getCalendarOccurrenceWindowMs } from '@/lib/calendar-event'
import { readCalendarDayPanelEvents } from '@/lib/calendar-day-panel-cache' import { readCalendarDayPanelEvents } from '@/lib/calendar-day-panel-cache'
import { replaceableEventDedupeKey } from '@/lib/event' import { replaceableEventDedupeKey } from '@/lib/event'
@ -75,7 +76,6 @@ const CalendarDayEventsPage = forwardRef<TPageRef, { ymd: string; index?: number
{sorted.map((ev) => { {sorted.map((ev) => {
const meta = getCalendarEventMeta(ev) const meta = getCalendarEventMeta(ev)
const label = meta.title?.trim() || t('calendarPageUntitledEvent') const label = meta.title?.trim() || t('calendarPageUntitledEvent')
const cover = meta.image?.trim()
return ( return (
<li key={replaceableEventDedupeKey(ev)}> <li key={replaceableEventDedupeKey(ev)}>
<Button <Button
@ -86,17 +86,12 @@ const CalendarDayEventsPage = forwardRef<TPageRef, { ymd: string; index?: number
)} )}
onClick={() => navigateToNote(toNote(ev), ev)} onClick={() => navigateToNote(toNote(ev), ev)}
> >
{cover ? ( <CalendarEventCoverImage
<img coverUrl={meta.image}
src={cover} pubkey={ev.pubkey}
alt="" className="size-9 shrink-0 rounded-md ring-1 ring-border/50"
loading="lazy" iconClassName="size-4"
referrerPolicy="no-referrer"
className="size-9 shrink-0 rounded-md object-cover ring-1 ring-border/50"
/> />
) : (
<div className="size-9 shrink-0 rounded-md bg-muted/60 ring-1 ring-border/40" aria-hidden />
)}
<span className="min-w-0 flex-1 leading-snug">{label}</span> <span className="min-w-0 flex-1 leading-snug">{label}</span>
</Button> </Button>
</li> </li>

43
src/services/local-storage.service.ts

@ -468,22 +468,55 @@ class LocalStorageService {
/** /**
* Async init: hydrate from IndexedDB when available, otherwise migrate localStorage into IndexedDB. * Async init: hydrate from IndexedDB when available, otherwise migrate localStorage into IndexedDB.
* Call this before app render so settings are read from 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<void> { async initAsync(): Promise<void> {
if (this.initPromise) return this.initPromise if (this.initPromise) return this.initPromise
this.initPromise = (async () => { this.initPromise = (async () => {
await indexedDb.init() await indexedDb.init()
const all = await indexedDb.getAllSettings() let idbBefore = await indexedDb.getAllSettings()
if (Object.keys(all).length > 0) { if (Object.keys(idbBefore).length === 0) {
this.applySettings(all)
} else {
await this.migrateToIdb() await this.migrateToIdb()
idbBefore = await indexedDb.getAllSettings()
} }
const merged = this.mergeSettingsRecordWithLocalStorage(idbBefore)
this.applySettings(merged)
await this.persistSettingsKeysDiffToIdb(idbBefore, merged)
this.clearSettingsFromLocalStorage() this.clearSettingsFromLocalStorage()
})() })()
return this.initPromise return this.initPromise
} }
/** Fill gaps from localStorage (used when IDB predates a key or a write only landed in LS). */
private mergeSettingsRecordWithLocalStorage(idb: Record<string, string>): Record<string, string> {
const out: Record<string, string> = { ...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<string, string>,
merged: Record<string, string>
): Promise<void> {
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. */ /** Remove SETTINGS_KEYS from localStorage so we don't duplicate; source of truth is IndexedDB. */
private clearSettingsFromLocalStorage(): void { private clearSettingsFromLocalStorage(): void {
for (const key of SETTINGS_KEYS) { for (const key of SETTINGS_KEYS) {
@ -597,7 +630,7 @@ class LocalStorageService {
const showRssStr = get(StorageKey.SHOW_RSS_FEED) const showRssStr = get(StorageKey.SHOW_RSS_FEED)
if (showRssStr != null) this.showRssFeed = showRssStr === 'true' if (showRssStr != null) this.showRssFeed = showRssStr === 'true'
const paneStr = get(StorageKey.PANE_MODE) 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() { getRelaySets() {

Loading…
Cancel
Save