Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
5661c8538a
  1. 94
      src/components/CalendarEventContent/index.tsx
  2. 183
      src/components/CalendarEventNip52StructuredMeta.tsx
  3. 27
      src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx
  4. 6
      src/components/Embedded/EmbeddedCalendarEvent.tsx
  5. 59
      src/components/HelpAndAccountMenu.tsx
  6. 45
      src/components/KeyboardShortcutsHelp/index.tsx
  7. 13
      src/components/Sidebar/KeyboardShortcutsHelpSidebarButton.tsx
  8. 41
      src/hooks/useFetchCalendarRsvps.tsx
  9. 4
      src/i18n/locales/cs.ts
  10. 4
      src/i18n/locales/de.ts
  11. 4
      src/i18n/locales/en.ts
  12. 4
      src/i18n/locales/es.ts
  13. 4
      src/i18n/locales/fr.ts
  14. 4
      src/i18n/locales/nl.ts
  15. 4
      src/i18n/locales/pl.ts
  16. 4
      src/i18n/locales/ru.ts
  17. 4
      src/i18n/locales/tr.ts
  18. 4
      src/i18n/locales/zh.ts
  19. 129
      src/lib/calendar-event.ts
  20. 152
      src/pages/primary/CalendarPrimaryPage.tsx
  21. 36
      src/services/client-events.service.ts
  22. 4
      src/services/client.service.ts

94
src/components/CalendarEventContent/index.tsx

@ -1,10 +1,12 @@
import { createCalendarRsvpDraftEvent } from '@/lib/draft-event' import { createCalendarRsvpDraftEvent } from '@/lib/draft-event'
import { getUsingClient } from '@/lib/event'
import { import {
getCalendarEventMeta, getCalendarEventMeta,
getNip52CalendarEventTagExtras, getNip52CalendarEventTagExtras,
formatCalendarTimeRange, formatCalendarTimeRange,
formatCalendarDateRange, formatCalendarDateRange,
isCalendarEventKind isCalendarEventKind,
stripCalendarEventRedundantTopicHashtagLines
} from '@/lib/calendar-event' } from '@/lib/calendar-event'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import { useFetchCalendarRsvps } from '@/hooks/useFetchCalendarRsvps' import { useFetchCalendarRsvps } from '@/hooks/useFetchCalendarRsvps'
@ -13,6 +15,7 @@ import { toProfile } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { CalendarEventCoverImage } from '@/components/CalendarEventCoverImage' import { CalendarEventCoverImage } from '@/components/CalendarEventCoverImage'
import { CalendarEventNip52StructuredMeta } from '@/components/CalendarEventNip52StructuredMeta' import { CalendarEventNip52StructuredMeta } from '@/components/CalendarEventNip52StructuredMeta'
import ClientTag from '@/components/ClientTag'
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'
@ -67,10 +70,15 @@ export default function CalendarEventContent({
return s || c || '' return s || c || ''
}, [meta, event.content, showFull]) }, [meta, event.content, showFull])
const markdownBodyDeduped = useMemo(() => {
const topicList = meta?.topics ?? []
return stripCalendarEventRedundantTopicHashtagLines(markdownBody, topicList)
}, [markdownBody, meta])
const eventForMarkdown = useMemo((): Event => { const eventForMarkdown = useMemo((): Event => {
if (!markdownBody) return event if (!markdownBodyDeduped) return event
return { ...event, content: markdownBody } return { ...event, content: markdownBodyDeduped }
}, [event, markdownBody]) }, [event, markdownBodyDeduped])
const duplicateWebPreviewHints = useMemo(() => { const duplicateWebPreviewHints = useMemo(() => {
if (!meta) return [] if (!meta) return []
@ -150,22 +158,22 @@ export default function CalendarEventContent({
return ( return (
<div <div
className={cn( className={cn(
'min-w-0 space-y-3 rounded-xl border border-border/70 bg-gradient-to-b from-card to-muted/25 p-4 text-sm shadow-sm', '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 className
)} )}
data-calendar-event-content data-calendar-event-content
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<CalendarEventCoverImage {!showFull ? (
coverUrl={image} <CalendarEventCoverImage
pubkey={event.pubkey} coverUrl={image}
className={cn( pubkey={event.pubkey}
'shrink-0 rounded-lg', className="size-10 shrink-0 rounded-lg"
showFull ? 'size-[4.5rem]' : 'size-10' iconClassName="size-5"
)} />
iconClassName={showFull ? 'size-7' : 'size-5'} ) : null}
/>
<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(
@ -175,6 +183,11 @@ export default function CalendarEventContent({
> >
{title || t('Scheduled video call')} {title || t('Scheduled video call')}
</h3> </h3>
{getUsingClient(event) ? (
<div className="min-w-0">
<ClientTag event={event} />
</div>
) : null}
{!showFull && scheduleLine ? ( {!showFull && scheduleLine ? (
<p className="flex items-start gap-1.5 text-xs font-medium leading-snug text-foreground"> <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 /> <Clock className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" aria-hidden />
@ -202,8 +215,8 @@ export default function CalendarEventContent({
</div> </div>
</div> </div>
{showFull && scheduleLine ? ( {showFull && scheduleLine ? (
<div className="flex gap-2 rounded-lg border border-border/60 bg-background/60 px-3 py-2.5"> <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-4 shrink-0 text-muted-foreground" aria-hidden /> <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"> <div className="min-w-0 flex-1 space-y-1">
<p className="text-sm font-medium leading-snug text-foreground">{scheduleLine}</p> <p className="text-sm font-medium leading-snug text-foreground">{scheduleLine}</p>
{(startTzid || endTzid) && ( {(startTzid || endTzid) && (
@ -232,9 +245,9 @@ export default function CalendarEventContent({
isDateBased={isDateBased} isDateBased={isDateBased}
/> />
) : null} ) : null}
{markdownBody ? ( {markdownBodyDeduped ? (
showFull ? ( showFull ? (
<div className="not-prose min-w-0 border-t border-border/50 pt-3" data-calendar-event-markdown> <div className="not-prose min-w-0 border-t border-border/50 pt-2" data-calendar-event-markdown>
<MarkdownArticle <MarkdownArticle
event={eventForMarkdown} event={eventForMarkdown}
className="prose-sm" className="prose-sm"
@ -248,7 +261,7 @@ export default function CalendarEventContent({
{/* NIP-52 31922/31923: collapse long summary+body only; card chrome stays outside MainNoteCard Collapsible. */} {/* NIP-52 31922/31923: collapse long summary+body only; card chrome stays outside MainNoteCard Collapsible. */}
<Collapsible threshold={200} collapsedHeight={160} className="min-w-0"> <Collapsible threshold={200} collapsedHeight={160} className="min-w-0">
<p className="whitespace-pre-wrap break-words text-sm leading-relaxed text-muted-foreground"> <p className="whitespace-pre-wrap break-words text-sm leading-relaxed text-muted-foreground">
{markdownBody} {markdownBodyDeduped}
</p> </p>
</Collapsible> </Collapsible>
</> </>
@ -262,31 +275,42 @@ export default function CalendarEventContent({
isDateBased={isDateBased} isDateBased={isDateBased}
/> />
) : null} ) : null}
<div className="flex flex-wrap items-center gap-2 pt-0.5"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-border/40 pt-2">
{!showFull && {!showFull &&
rUrls.map((url) => ( rUrls.map((url) => (
<Button key={url} variant="secondary" size="sm" className="gap-2" asChild> <a
<a href={url} target="_blank" rel="noopener noreferrer"> key={url}
<ExternalLink className="size-4 shrink-0" /> href={url}
{t('Open link')} target="_blank"
</a> rel="noopener noreferrer"
</Button> 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 && ( {showRsvp && myPubkey && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="outline" variant="default"
size="sm" size={showFull ? 'lg' : 'default'}
className="gap-2" className={cn(
'font-semibold shadow-md',
showFull && 'w-full min-w-0 sm:w-auto sm:min-w-[11rem]'
)}
disabled={isFetching} disabled={isFetching}
> >
{myStatus === 'accepted' && <CheckCircle className="size-4 text-green-600" />} {myStatus === 'accepted' && (
{myStatus === 'tentative' && <HelpCircle className="size-4 text-amber-600" />} <CheckCircle className="size-4 shrink-0 text-primary-foreground opacity-95" aria-hidden />
{myStatus === 'declined' && <XCircle className="size-4 text-muted-foreground" />} )}
{myStatus {myStatus === 'tentative' && (
? t('RSVP: {{status}}', { status: myStatus }) <HelpCircle className="size-4 shrink-0 text-primary-foreground opacity-95" aria-hidden />
: t('RSVP')} )}
{myStatus === 'declined' && (
<XCircle className="size-4 shrink-0 text-primary-foreground opacity-90" aria-hidden />
)}
{myStatus ? t('RSVP: {{status}}', { status: myStatus }) : t('RSVP')}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start"> <DropdownMenuContent align="start">

183
src/components/CalendarEventNip52StructuredMeta.tsx

@ -1,10 +1,14 @@
import { getNip52CalendarEventTagExtras, type CalendarEventMeta } from '@/lib/calendar-event' import {
getNip52CalendarEventTagExtras,
summarizeNip52DayGranularityTags,
type CalendarEventMeta
} from '@/lib/calendar-event'
import { useSmartNoteNavigation } from '@/PageManager' import { useSmartNoteNavigation } from '@/PageManager'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Calendar, ExternalLink, Link2, MapPin } from 'lucide-react' import { Calendar, ExternalLink, Link2, MapPin } from 'lucide-react'
import { cn } from '@/lib/utils'
type Placement = 'beforeDescription' | 'afterDescription' type Placement = 'beforeDescription' | 'afterDescription'
@ -35,6 +39,10 @@ export function CalendarEventNip52StructuredMeta({
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
const extras = useMemo(() => getNip52CalendarEventTagExtras(event), [event]) const extras = useMemo(() => getNip52CalendarEventTagExtras(event), [event])
const dayGranularitySummary = useMemo(
() => summarizeNip52DayGranularityTags(extras.dayGranularities),
[extras.dayGranularities]
)
if (placement === 'beforeDescription') { if (placement === 'beforeDescription') {
const summaryTrim = meta.summary?.trim() ?? '' const summaryTrim = meta.summary?.trim() ?? ''
@ -42,41 +50,41 @@ export function CalendarEventNip52StructuredMeta({
const hasGeo = !!meta.geo?.trim() const hasGeo = !!meta.geo?.trim()
if (!hasLocations && !summaryTrim && !hasGeo) return null if (!hasLocations && !summaryTrim && !hasGeo) return null
const linkClass =
'inline-flex shrink-0 items-center gap-1 text-xs font-medium text-primary underline-offset-2 hover:underline'
return ( return (
<div className="min-w-0 space-y-3 border-t border-border/50 pt-3"> <div className="min-w-0 space-y-2.5 border-t border-border/50 pt-2">
{hasLocations ? ( {hasLocations ? (
<div className="space-y-1.5"> <div className="space-y-1">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <div className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{meta.locations.length > 1 ? t('calendarNip52Locations') : t('calendarNip52Location')} {meta.locations.length > 1 ? t('calendarNip52Locations') : t('calendarNip52Location')}
</div> </div>
{meta.locations.length === 1 ? ( {meta.locations.length === 1 ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"> <div className="flex min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-1 text-sm leading-snug text-foreground">
<p className="flex min-w-0 flex-1 gap-2 text-sm leading-snug text-foreground"> <MapPin className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" aria-hidden />
<MapPin className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden /> <span className="min-w-0">{meta.locations[0]}</span>
<span className="min-w-0">{meta.locations[0]}</span> <a
</p> href={googleMapsPlaceUrl(meta.locations[0])}
<Button variant="outline" size="sm" className="h-8 w-full shrink-0 sm:w-auto" asChild> target="_blank"
<a href={googleMapsPlaceUrl(meta.locations[0])} target="_blank" rel="noopener noreferrer"> rel="noopener noreferrer"
<ExternalLink className="size-3.5" /> className={linkClass}
{t('calendarNip52GoogleMaps')} >
</a> <ExternalLink className="size-3 shrink-0 opacity-80" aria-hidden />
</Button> {t('calendarNip52GoogleMaps')}
</a>
</div> </div>
) : ( ) : (
<ul className="min-w-0 space-y-3"> <ul className="min-w-0 space-y-2">
{meta.locations.map((loc, i) => ( {meta.locations.map((loc, i) => (
<li key={`${i}-${loc.slice(0, 24)}`}> <li key={`${i}-${loc.slice(0, 24)}`}>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"> <div className="flex min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-1 text-sm leading-snug text-foreground">
<p className="flex min-w-0 flex-1 gap-2 text-sm leading-snug text-foreground"> <MapPin className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" aria-hidden />
<MapPin className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden /> <span className="min-w-0">{loc}</span>
<span className="min-w-0">{loc}</span> <a href={googleMapsPlaceUrl(loc)} target="_blank" rel="noopener noreferrer" className={linkClass}>
</p> <ExternalLink className="size-3 shrink-0 opacity-80" aria-hidden />
<Button variant="outline" size="sm" className="h-8 w-full shrink-0 sm:w-auto" asChild> {t('calendarNip52GoogleMaps')}
<a href={googleMapsPlaceUrl(loc)} target="_blank" rel="noopener noreferrer"> </a>
<ExternalLink className="size-3.5" />
{t('calendarNip52GoogleMaps')}
</a>
</Button>
</div> </div>
</li> </li>
))} ))}
@ -86,106 +94,104 @@ export function CalendarEventNip52StructuredMeta({
) : null} ) : null}
{summaryTrim ? ( {summaryTrim ? (
<div className="space-y-1"> <div className="space-y-1">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <div className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{t('calendarNip52Summary')} {t('calendarNip52Summary')}
</div> </div>
<blockquote className="border-l-2 border-primary/40 pl-3 text-sm leading-relaxed text-muted-foreground"> <blockquote className="border-l-2 border-primary/40 pl-2.5 text-sm leading-snug text-muted-foreground">
{summaryTrim} {summaryTrim}
</blockquote> </blockquote>
</div> </div>
) : null} ) : null}
{hasGeo ? ( {hasGeo ? (
<div className="space-y-1"> <div className="space-y-1">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <div className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{t('calendarNip52Geohash')} {t('calendarNip52Geohash')}
</div> </div>
<p className="flex flex-wrap items-center gap-2 text-sm font-mono text-foreground"> <div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="break-all">{meta.geo.trim()}</span> <span className="break-all font-mono text-xs text-foreground">{meta.geo.trim()}</span>
<Button variant="outline" size="sm" className="h-8 shrink-0" asChild> <a
<a href={nip52GeohashSoftengUrl(meta.geo)} target="_blank" rel="noopener noreferrer"> href={nip52GeohashSoftengUrl(meta.geo)}
<ExternalLink className="size-3.5" /> target="_blank"
{t('calendarNip52ViewGeohash')} rel="noopener noreferrer"
</a> className={linkClass}
</Button> >
</p> <ExternalLink className="size-3 shrink-0 opacity-80" aria-hidden />
{t('calendarNip52ViewGeohash')}
</a>
</div>
</div> </div>
) : null} ) : null}
</div> </div>
) )
} }
const hasDayD = !isDateBased && extras.dayGranularities.length > 0 const hasDayD = !isDateBased && dayGranularitySummary.length > 0
const hasInclusions = extras.calendarInclusions.length > 0 const hasInclusions = extras.calendarInclusions.length > 0
const hasR = extras.rTags.length > 0 const hasR = extras.rTags.length > 0
const hasD = !!meta.d?.trim()
const hasUnknown = extras.unknownTags.length > 0 const hasUnknown = extras.unknownTags.length > 0
if (!hasDayD && !hasInclusions && !hasR && !hasD && !hasUnknown) return null if (!hasDayD && !hasInclusions && !hasR && !hasUnknown) return null
return ( return (
<div className="min-w-0 space-y-4 border-t border-border/50 pt-3"> <div className="min-w-0 space-y-2.5 border-t border-border/50 pt-2">
{hasDayD ? ( {hasDayD ? (
<div className="space-y-2"> <div className="space-y-1">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <div className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{t('calendarNip52DayIndices')} {t('calendarNip52DayIndices')}
</div> </div>
<div className="flex flex-wrap gap-1.5"> <p className="text-sm font-medium leading-snug text-foreground">{dayGranularitySummary}</p>
{extras.dayGranularities.map((d) => ( <p className="text-[10px] leading-snug text-muted-foreground">{t('calendarNip52DayIndicesHint')}</p>
<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> </div>
) : null} ) : null}
{hasInclusions ? ( {hasInclusions ? (
<div className="space-y-2"> <div className="space-y-1">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <div className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{t('calendarNip52CalendarInclusion')} {t('calendarNip52CalendarInclusion')}
</div> </div>
<ul className="min-w-0 space-y-1.5"> <ul className="min-w-0 space-y-1">
{extras.calendarInclusions.map((row) => ( {extras.calendarInclusions.map((row) => (
<li key={row.coordinate}> <li key={row.coordinate}>
<Button <button
type="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)} onClick={() => navigateToNote(row.naddr)}
className={cn(
'flex w-full min-w-0 items-center gap-1.5 rounded-md border border-transparent py-0.5 text-left',
'text-xs text-primary hover:border-border/60 hover:bg-muted/40'
)}
> >
<Calendar className="size-4 shrink-0 text-primary" aria-hidden /> <Calendar className="size-3.5 shrink-0 opacity-90" aria-hidden />
<span className="min-w-0 break-all font-mono text-xs leading-snug">{row.naddr}</span> <span className="min-w-0 break-all font-mono leading-snug">{row.naddr}</span>
</Button> </button>
</li> </li>
))} ))}
</ul> </ul>
<p className="text-[11px] leading-snug text-muted-foreground">{t('calendarNip52CalendarInclusionHint')}</p> <p className="text-[10px] leading-snug text-muted-foreground">{t('calendarNip52CalendarInclusionHint')}</p>
</div> </div>
) : null} ) : null}
{hasR ? ( {hasR ? (
<div className="space-y-2"> <div className="space-y-1">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <div className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{t('calendarNip52References')} {t('calendarNip52References')}
</div> </div>
<ul className="min-w-0 space-y-2"> <ul className="min-w-0 space-y-1">
{extras.rTags.map((r, idx) => ( {extras.rTags.map((r, idx) => (
<li key={`${r.value}-${idx}`} className="min-w-0"> <li key={`${r.value}-${idx}`} className="min-w-0">
{r.isHttpUrl ? ( {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
<a href={r.value} target="_blank" rel="noopener noreferrer"> href={r.value}
<Link2 className="size-4 shrink-0" aria-hidden /> target="_blank"
<span className="min-w-0 break-all text-left text-xs font-medium">{r.value}</span> rel="noopener noreferrer"
</a> className="inline-flex min-w-0 max-w-full items-start gap-1 break-all text-xs font-medium text-primary underline-offset-2 hover:underline"
</Button> >
<Link2 className="mt-0.5 size-3 shrink-0 opacity-80" aria-hidden />
<span>{r.value}</span>
</a>
) : ( ) : (
<div className="rounded-lg border border-border/60 bg-muted/25 px-3 py-2"> <div className="rounded-md border border-border/50 bg-muted/20 px-2 py-1">
<p className="flex items-start gap-2 text-xs text-muted-foreground"> <p className="flex items-start gap-1.5 text-[11px] text-muted-foreground">
<Link2 className="mt-0.5 size-3.5 shrink-0" aria-hidden /> <Link2 className="mt-0.5 size-3 shrink-0" aria-hidden />
<span className="min-w-0 break-all font-mono text-foreground">{r.value}</span> <span className="min-w-0 break-all font-mono text-foreground">{r.value}</span>
</p> </p>
</div> </div>
@ -196,27 +202,18 @@ export function CalendarEventNip52StructuredMeta({
</div> </div>
) : null} ) : 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 ? ( {hasUnknown ? (
<div className="space-y-2"> <div className="space-y-1">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <div className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{t('calendarNip52OtherTags')} {t('calendarNip52OtherTags')}
</div> </div>
<dl className="min-w-0 space-y-2 rounded-lg border border-border/50 bg-muted/15 p-3"> <dl className="min-w-0 space-y-1.5 rounded-md border border-border/50 bg-muted/15 p-2">
{extras.unknownTags.map((tag, idx) => ( {extras.unknownTags.map((tag, idx) => (
<div key={`${tag[0]}-${idx}`} className="grid gap-1 sm:grid-cols-[minmax(0,6rem)_1fr] sm:gap-2"> <div key={`${tag[0]}-${idx}`} className="grid gap-0.5 sm:grid-cols-[minmax(0,5.5rem)_1fr] sm:gap-2">
<dt className="font-mono text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"> <dt className="font-mono text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{tag[0] || '—'} {tag[0] || '—'}
</dt> </dt>
<dd className="min-w-0 break-all text-xs text-foreground"> <dd className="min-w-0 break-all text-[11px] text-foreground">
{tag.length > 1 ? tag.slice(1).join(' · ') : '—'} {tag.length > 1 ? tag.slice(1).join(' · ') : '—'}
</dd> </dd>
</div> </div>

27
src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx

@ -46,20 +46,28 @@ export function ActiveRelaysTitlebarButton() {
const { rows, connectedCount } = useRelayConnectionRows() const { rows, connectedCount } = useRelayConnectionRows()
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
const countSummary =
rows.length > 0 ? `${connectedCount}/${rows.length}` : ''
const trigger = ( const trigger = (
<Button <Button
variant="ghost" variant="ghost"
size="titlebar-icon" size="titlebar-icon"
className="shrink-0 gap-0.5 text-muted-foreground hover:text-primary disabled:opacity-40" className={cn(
title={t('Active relays')} 'shrink-0 text-muted-foreground hover:text-primary disabled:opacity-40',
aria-label={t('Active relays')} !isSmallScreen && rows.length > 0 && 'gap-0.5'
)}
title={countSummary ? `${t('Active relays')} (${countSummary})` : t('Active relays')}
aria-label={
countSummary ? `${t('Active relays')} (${countSummary})` : t('Active relays')
}
disabled={rows.length === 0} disabled={rows.length === 0}
onClick={() => { onClick={() => {
if (isSmallScreen) setDrawerOpen(true) if (isSmallScreen) setDrawerOpen(true)
}} }}
> >
<Server className="size-5 shrink-0" /> <Server className="size-5 shrink-0" />
{rows.length > 0 ? ( {!isSmallScreen && rows.length > 0 ? (
<span className="text-xs tabular-nums leading-none"> <span className="text-xs tabular-nums leading-none">
<span className="text-foreground">{connectedCount}</span> <span className="text-foreground">{connectedCount}</span>
<span className="text-muted-foreground">/{rows.length}</span> <span className="text-muted-foreground">/{rows.length}</span>
@ -82,8 +90,15 @@ export function ActiveRelaysTitlebarButton() {
dragHandle="vaul" dragHandle="vaul"
className="flex max-h-[min(85dvh,32rem)] flex-col gap-0" className="flex max-h-[min(85dvh,32rem)] flex-col gap-0"
> >
<DrawerHeader className="sr-only"> <DrawerHeader className="border-b border-border/60 px-4 pb-3 pt-1 text-left">
<DrawerTitle>{t('Active relays')}</DrawerTitle> <DrawerTitle className="text-base">{t('Active relays')}</DrawerTitle>
{rows.length > 0 ? (
<p className="mt-1.5 text-sm tabular-nums text-muted-foreground">
<span className="font-semibold text-foreground">{connectedCount}</span>
<span>/</span>
<span>{rows.length}</span>
</p>
) : null}
</DrawerHeader> </DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-1 py-2 pb-4"> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-1 py-2 pb-4">
{rows.map(({ url, connected, sessionStriked }) => ( {rows.map(({ url, connected, sessionStriked }) => (

6
src/components/Embedded/EmbeddedCalendarEvent.tsx

@ -3,7 +3,8 @@ import {
getCalendarEventMeta, getCalendarEventMeta,
formatCalendarTimeRange, formatCalendarTimeRange,
formatCalendarDateRange, formatCalendarDateRange,
isCalendarEventKind isCalendarEventKind,
stripCalendarEventRedundantTopicHashtagLines
} from '@/lib/calendar-event' } from '@/lib/calendar-event'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
@ -23,7 +24,8 @@ export function EmbeddedCalendarEvent({
if (!isCalendarEventKind(event.kind)) return null if (!isCalendarEventKind(event.kind)) return null
const { title, summary, image, start, end, startDate, endDate, isDateBased, rUrls, topics, location } = const { title, summary, image, start, end, startDate, endDate, isDateBased, rUrls, topics, location } =
getCalendarEventMeta(event) getCalendarEventMeta(event)
const description = summary || event.content?.trim() || '' const descriptionRaw = summary || event.content?.trim() || ''
const description = stripCalendarEventRedundantTopicHashtagLines(descriptionRaw, topics)
const scheduleLine = isDateBased const scheduleLine = isDateBased
? (startDate || endDate) && formatCalendarDateRange(startDate, endDate) ? (startDate || endDate) && formatCalendarDateRange(startDate, endDate)

59
src/components/HelpAndAccountMenu.tsx

@ -1,6 +1,5 @@
import LoginDialog from '@/components/LoginDialog' import LoginDialog from '@/components/LoginDialog'
import LogoutDialog from '@/components/LogoutDialog' import LogoutDialog from '@/components/LogoutDialog'
import KeyboardShortcutsHelpSidebarButton from '@/components/Sidebar/KeyboardShortcutsHelpSidebarButton'
import SidebarItem from '@/components/Sidebar/SidebarItem' import SidebarItem from '@/components/Sidebar/SidebarItem'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -16,11 +15,10 @@ import { formatPubkey, formatNpub, generateImageByPubkey, pubkeyToNpub } from '@
import { isVideo } from '@/lib/url' import { isVideo } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useCacheBrowser } from '../contexts/cache-browser-context' import { useCacheBrowser } from '../contexts/cache-browser-context'
import { useKeyboardShortcutsHelp } from '@/contexts/keyboard-shortcuts-help-context'
import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useFetchProfile } from '@/hooks/useFetchProfile' import { useFetchProfile } from '@/hooks/useFetchProfile'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { ArrowDownUp, CircleHelp, Database, LogIn, LogOut, Settings, User, UserRound } from 'lucide-react' import { ArrowDownUp, Database, LogIn, LogOut, Settings, User, UserRound } from 'lucide-react'
import { useMemo, useState, type ReactNode } from 'react' import { useMemo, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -28,34 +26,17 @@ export type HelpAndAccountMenuVariant = 'sidebar' | 'titlebar'
function AccountDropdownItems({ function AccountDropdownItems({
onSwitchAccount, onSwitchAccount,
onLogoutClick, onLogoutClick
includeHelp
}: { }: {
onSwitchAccount: () => void onSwitchAccount: () => void
onLogoutClick: () => void onLogoutClick: () => void
/** Titlebar (mobile): help lives here so the relay strip has more room. */
includeHelp?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigate } = usePrimaryPage() const { navigate } = usePrimaryPage()
const { openBrowseCache } = useCacheBrowser() const { openBrowseCache } = useCacheBrowser()
const { openHelp } = useKeyboardShortcutsHelp()
return ( return (
<> <>
{includeHelp ? (
<>
<DropdownMenuItem
onClick={() => {
openHelp()
}}
>
<CircleHelp className="size-4" />
{t('help.title')}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuItem onClick={() => navigate('profile')}> <DropdownMenuItem onClick={() => navigate('profile')}>
<User className="size-4" /> <User className="size-4" />
{t('Profile')} {t('Profile')}
@ -137,11 +118,7 @@ function SidebarAccountMenu({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="top" align="end" className="z-[220]"> <DropdownMenuContent side="top" align="end" className="z-[220]">
<AccountDropdownItems <AccountDropdownItems onSwitchAccount={onSwitchAccount} onLogoutClick={onLogoutClick} />
onSwitchAccount={onSwitchAccount}
onLogoutClick={onLogoutClick}
includeHelp={false}
/>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) )
@ -195,28 +172,19 @@ function TitlebarAccountMenu({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" className="z-[220]"> <DropdownMenuContent align="end" side="bottom" className="z-[220]">
<AccountDropdownItems <AccountDropdownItems onSwitchAccount={onSwitchAccount} onLogoutClick={onLogoutClick} />
onSwitchAccount={onSwitchAccount}
onLogoutClick={onLogoutClick}
includeHelp
/>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) )
} }
/** /** Sidebar: account / login stack. Titlebar (mobile): compact account or login control. */
* Sidebar: help (?) above account. Titlebar (mobile): help is inside the account menu so the relay strip has more room.
*/
export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccountMenuVariant }) { export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccountMenuVariant }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr() const { pubkey, checkLogin } = useNostr()
const { openHelp } = useKeyboardShortcutsHelp()
const [loginDialogOpen, setLoginDialogOpen] = useState(false) const [loginDialogOpen, setLoginDialogOpen] = useState(false)
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false) const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
const help = variant === 'sidebar' ? <KeyboardShortcutsHelpSidebarButton /> : null
let account: ReactNode let account: ReactNode
if (pubkey) { if (pubkey) {
account = account =
@ -248,26 +216,9 @@ export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccoun
const wrapClass = const wrapClass =
variant === 'titlebar' ? 'flex shrink-0 items-center gap-1' : 'flex flex-col space-y-2' variant === 'titlebar' ? 'flex shrink-0 items-center gap-1' : 'flex flex-col space-y-2'
/** Logged-out titlebar: keep ? next to login so help stays reachable without opening login. */
const titlebarHelpWhenLoggedOut =
variant === 'titlebar' && !pubkey ? (
<Button
type="button"
variant="ghost"
size="titlebar-icon"
onClick={() => openHelp()}
title={t('help.title')}
aria-label={t('help.title')}
>
<CircleHelp />
</Button>
) : null
return ( return (
<> <>
<div className={wrapClass}> <div className={wrapClass}>
{help}
{titlebarHelpWhenLoggedOut}
{account} {account}
</div> </div>
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} /> <LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />

45
src/components/KeyboardShortcutsHelp/index.tsx

@ -1,4 +1,3 @@
import { Button } from '@/components/ui/button'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -14,13 +13,9 @@ import {
} from '@/lib/keyboard-shortcuts' } from '@/lib/keyboard-shortcuts'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import postEditorService from '@/services/post-editor.service' import postEditorService from '@/services/post-editor.service'
import { CircleHelp } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { marked } from 'marked' import { marked } from 'marked'
import { import { KeyboardShortcutsHelpContext } from '@/contexts/keyboard-shortcuts-help-context'
KeyboardShortcutsHelpContext,
useKeyboardShortcutsHelp
} from '@/contexts/keyboard-shortcuts-help-context'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import readmeMarkdown from '../../../README.md?raw' import readmeMarkdown from '../../../README.md?raw'
@ -51,16 +46,7 @@ function ShortcutsPanel() {
{t('shortcuts.sectionApp')} {t('shortcuts.sectionApp')}
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
<KbdRow <KbdRow label={t('shortcuts.openHelp')} keys={<Kbd>F1</Kbd>} />
label={t('shortcuts.openHelp')}
keys={
<>
<Kbd>?</Kbd>
<span className="px-0.5 text-muted-foreground">{t('shortcuts.or')}</span>
<Kbd>F1</Kbd>
</>
}
/>
<KbdRow <KbdRow
label={t('shortcuts.focusPrimary')} label={t('shortcuts.focusPrimary')}
keys={ keys={
@ -212,15 +198,6 @@ export function KeyboardShortcutsHelpProvider({ children }: { children: ReactNod
if (shouldIgnoreKeyboardShortcutEvent(e.target)) return if (shouldIgnoreKeyboardShortcutEvent(e.target)) return
if (isRadixDialogOpen()) return if (isRadixDialogOpen()) return
const isQuestionMark =
e.key === '?' || (e.shiftKey && e.code === 'Slash' && !e.ctrlKey && !e.metaKey && !e.altKey)
if (isQuestionMark && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault()
setOpen(true)
return
}
if (e.key === 'F1' && !e.ctrlKey && !e.metaKey && !e.altKey) { if (e.key === 'F1' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault() e.preventDefault()
setOpen(true) setOpen(true)
@ -277,21 +254,3 @@ export function KeyboardShortcutsHelpProvider({ children }: { children: ReactNod
</KeyboardShortcutsHelpContext.Provider> </KeyboardShortcutsHelpContext.Provider>
) )
} }
/** Titlebar-sized help control (e.g. home feed, next to profile). */
export function KeyboardShortcutsHelpButton() {
const { openHelp } = useKeyboardShortcutsHelp()
const { t } = useTranslation()
return (
<Button
type="button"
variant="ghost"
size="titlebar-icon"
onClick={() => openHelp()}
title={t('help.title')}
aria-label={t('help.title')}
>
<CircleHelp />
</Button>
)
}

13
src/components/Sidebar/KeyboardShortcutsHelpSidebarButton.tsx

@ -1,13 +0,0 @@
import { useKeyboardShortcutsHelp } from '@/contexts/keyboard-shortcuts-help-context'
import { CircleHelp } from 'lucide-react'
import SidebarItem from './SidebarItem'
export default function KeyboardShortcutsHelpSidebarButton() {
const { openHelp } = useKeyboardShortcutsHelp()
return (
<SidebarItem title="help.title" onClick={openHelp}>
<CircleHelp strokeWidth={2.5} />
</SidebarItem>
)
}

41
src/hooks/useFetchCalendarRsvps.tsx

@ -64,7 +64,10 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
fromIdb = [] fromIdb = []
} }
if (cancelled) return if (cancelled) return
if (fromIdb.length) setRsvps(fromIdb)
const fromSession = client.getSessionCalendarRsvpsForCalendarEvent(calendarEvent)
const mergedLocal = mergeRsvpList([...fromIdb, ...fromSession])
if (mergedLocal.length) setRsvps(mergedLocal)
const baseUrls = new Set<string>([ const baseUrls = new Set<string>([
...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url), ...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url),
@ -93,17 +96,31 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
} }
if (cancelled) return if (cancelled) return
const urls = relayUrls?.length ? relayUrls : Array.from(baseUrls) const urls = relayUrls?.length ? relayUrls : Array.from(baseUrls)
const calendarHexId = /^[0-9a-f]{64}$/i.test(calendarEvent.id)
? calendarEvent.id.toLowerCase()
: calendarEvent.id
const events = await queryService.fetchEvents( const events = await queryService.fetchEvents(
urls, urls,
[
{
kinds: [ExtendedKind.CALENDAR_EVENT_RSVP],
'#a': [coordinate],
limit: 200
},
{
kinds: [ExtendedKind.CALENDAR_EVENT_RSVP],
'#e': [calendarHexId],
limit: 200
}
],
{ {
kinds: [ExtendedKind.CALENDAR_EVENT_RSVP], firstRelayResultGraceMs: false,
'#a': [coordinate], eoseTimeout: 4500,
limit: 200 globalTimeout: 24_000
}, }
{ firstRelayResultGraceMs: false }
) )
if (cancelled) return if (cancelled) return
setRsvps(mergeRsvpList([...fromIdb, ...(events ?? [])])) setRsvps(mergeRsvpList([...fromIdb, ...fromSession, ...(events ?? [])]))
} finally { } finally {
if (!cancelled) setIsFetching(false) if (!cancelled) setIsFetching(false)
} }
@ -121,18 +138,24 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
const coordinate = normalizeReplaceableCoordinateString( const coordinate = normalizeReplaceableCoordinateString(
getReplaceableCoordinateFromEvent(calendarEvent) getReplaceableCoordinateFromEvent(calendarEvent)
) )
const calId = /^[0-9a-f]{64}$/i.test(calendarEvent.id)
? calendarEvent.id.toLowerCase()
: calendarEvent.id
const handler = (e: CustomEvent<Event>) => { const handler = (e: CustomEvent<Event>) => {
const evt = e.detail const evt = e.detail
if (evt.kind !== ExtendedKind.CALENDAR_EVENT_RSVP) return if (evt.kind !== ExtendedKind.CALENDAR_EVENT_RSVP) return
const aTag = evt.tags.find(tagNameEquals('a')) const aTag = evt.tags.find(tagNameEquals('a'))
const aCoord = aTag?.[1] ? normalizeReplaceableCoordinateString(aTag[1]) : '' const aCoord = aTag?.[1] ? normalizeReplaceableCoordinateString(aTag[1]) : ''
if (aCoord !== coordinate) return const eTag = evt.tags.find(tagNameEquals('e'))?.[1]?.trim().toLowerCase()
const matchesA = aCoord !== '' && aCoord === coordinate
const matchesE = eTag && /^[0-9a-f]{64}$/.test(eTag) && eTag === calId
if (!matchesA && !matchesE) return
setRsvps((prev) => mergeRsvp(prev, evt)) setRsvps((prev) => mergeRsvp(prev, evt))
} }
client.addEventListener('newEvent', handler as EventListener) client.addEventListener('newEvent', handler as EventListener)
return () => client.removeEventListener('newEvent', handler as EventListener) return () => client.removeEventListener('newEvent', handler as EventListener)
}, [calendarEvent?.id, calendarEvent?.kind]) }, [calendarEvent?.id, calendarEvent?.kind, calendarEvent?.pubkey])
return { return {
rsvps, rsvps,

4
src/i18n/locales/cs.ts

@ -238,7 +238,9 @@ export default {
calendarNip52Geohash: "Geohash", calendarNip52Geohash: "Geohash",
calendarNip52ViewGeohash: "Mapa geohash", calendarNip52ViewGeohash: "Mapa geohash",
calendarNip52GoogleMaps: "Google Maps", calendarNip52GoogleMaps: "Google Maps",
calendarNip52DayIndices: "Day indices (NIP-52)", calendarNip52DayIndices: "Indexed days (UTC)",
calendarNip52DayIndicesHint:
"NIP-52 D tags list whole UTC calendar days (Unix day index) so relays and clients can match timed events to a day; several values mean a multi-day span.",
calendarNip52CalendarInclusion: "Calendar inclusion", calendarNip52CalendarInclusion: "Calendar inclusion",
calendarNip52CalendarInclusionHint: calendarNip52CalendarInclusionHint:
"This event requests inclusion in the referenced collaborative calendar (kind 31924).", "This event requests inclusion in the referenced collaborative calendar (kind 31924).",

4
src/i18n/locales/de.ts

@ -238,7 +238,9 @@ export default {
calendarNip52Geohash: "Geohash", calendarNip52Geohash: "Geohash",
calendarNip52ViewGeohash: "Geohash-Karte", calendarNip52ViewGeohash: "Geohash-Karte",
calendarNip52GoogleMaps: "Google Maps", calendarNip52GoogleMaps: "Google Maps",
calendarNip52DayIndices: "Tages-Indizes (NIP-52)", calendarNip52DayIndices: "Erfasste Tage (UTC)",
calendarNip52DayIndicesHint:
"NIP-52-D-Tags beschreiben ganze UTC-Kalendertage (Unix-Tagesindex), damit Relays und Clients zeitbasierte Termine tagweise zuordnen können; mehrere Werte stehen für mehrtägige Zeiträume.",
calendarNip52CalendarInclusion: "Kalender-Einbindung", calendarNip52CalendarInclusion: "Kalender-Einbindung",
calendarNip52CalendarInclusionHint: calendarNip52CalendarInclusionHint:
"Dieses Ereignis bittet um Aufnahme in den referenzierten gemeinschaftlichen Kalender (Kind 31924).", "Dieses Ereignis bittet um Aufnahme in den referenzierten gemeinschaftlichen Kalender (Kind 31924).",

4
src/i18n/locales/en.ts

@ -242,7 +242,9 @@ export default {
calendarNip52Geohash: "Geohash", calendarNip52Geohash: "Geohash",
calendarNip52ViewGeohash: "Geohash map", calendarNip52ViewGeohash: "Geohash map",
calendarNip52GoogleMaps: "Google Maps", calendarNip52GoogleMaps: "Google Maps",
calendarNip52DayIndices: "Day indices (NIP-52)", calendarNip52DayIndices: "Indexed days (UTC)",
calendarNip52DayIndicesHint:
"NIP-52 D tags list whole UTC calendar days (Unix day index) so relays and clients can match timed events to a day; several values mean a multi-day span.",
calendarNip52CalendarInclusion: "Calendar inclusion", calendarNip52CalendarInclusion: "Calendar inclusion",
calendarNip52CalendarInclusionHint: calendarNip52CalendarInclusionHint:
"This event requests inclusion in the referenced collaborative calendar (kind 31924).", "This event requests inclusion in the referenced collaborative calendar (kind 31924).",

4
src/i18n/locales/es.ts

@ -238,7 +238,9 @@ export default {
calendarNip52Geohash: "Geohash", calendarNip52Geohash: "Geohash",
calendarNip52ViewGeohash: "Mapa Geohash", calendarNip52ViewGeohash: "Mapa Geohash",
calendarNip52GoogleMaps: "Google Maps", calendarNip52GoogleMaps: "Google Maps",
calendarNip52DayIndices: "Day indices (NIP-52)", calendarNip52DayIndices: "Días indexados (UTC)",
calendarNip52DayIndicesHint:
"Las etiquetas D de NIP-52 marcan días civiles UTC enteros (índice de día Unix) para consultar eventos con hora; varios valores cubren un rango de varios días.",
calendarNip52CalendarInclusion: "Calendar inclusion", calendarNip52CalendarInclusion: "Calendar inclusion",
calendarNip52CalendarInclusionHint: calendarNip52CalendarInclusionHint:
"This event requests inclusion in the referenced collaborative calendar (kind 31924).", "This event requests inclusion in the referenced collaborative calendar (kind 31924).",

4
src/i18n/locales/fr.ts

@ -238,7 +238,9 @@ export default {
calendarNip52Geohash: "Geohash", calendarNip52Geohash: "Geohash",
calendarNip52ViewGeohash: "Carte Geohash", calendarNip52ViewGeohash: "Carte Geohash",
calendarNip52GoogleMaps: "Google Maps", calendarNip52GoogleMaps: "Google Maps",
calendarNip52DayIndices: "Day indices (NIP-52)", calendarNip52DayIndices: "Jours référencés (UTC)",
calendarNip52DayIndicesHint:
"Les balises D (NIP-52) indiquent des jours civils UTC entiers (indice de jour Unix) pour indexer les événements horodatés ; plusieurs valeurs couvrent une plage de plusieurs jours.",
calendarNip52CalendarInclusion: "Calendar inclusion", calendarNip52CalendarInclusion: "Calendar inclusion",
calendarNip52CalendarInclusionHint: calendarNip52CalendarInclusionHint:
"This event requests inclusion in the referenced collaborative calendar (kind 31924).", "This event requests inclusion in the referenced collaborative calendar (kind 31924).",

4
src/i18n/locales/nl.ts

@ -238,7 +238,9 @@ export default {
calendarNip52Geohash: "Geohash", calendarNip52Geohash: "Geohash",
calendarNip52ViewGeohash: "Geohash-kaart", calendarNip52ViewGeohash: "Geohash-kaart",
calendarNip52GoogleMaps: "Google Maps", calendarNip52GoogleMaps: "Google Maps",
calendarNip52DayIndices: "Day indices (NIP-52)", calendarNip52DayIndices: "Indexed days (UTC)",
calendarNip52DayIndicesHint:
"NIP-52 D tags list whole UTC calendar days (Unix day index) so relays and clients can match timed events to a day; several values mean a multi-day span.",
calendarNip52CalendarInclusion: "Calendar inclusion", calendarNip52CalendarInclusion: "Calendar inclusion",
calendarNip52CalendarInclusionHint: calendarNip52CalendarInclusionHint:
"This event requests inclusion in the referenced collaborative calendar (kind 31924).", "This event requests inclusion in the referenced collaborative calendar (kind 31924).",

4
src/i18n/locales/pl.ts

@ -238,7 +238,9 @@ export default {
calendarNip52Geohash: "Geohash", calendarNip52Geohash: "Geohash",
calendarNip52ViewGeohash: "Mapa geohash", calendarNip52ViewGeohash: "Mapa geohash",
calendarNip52GoogleMaps: "Google Maps", calendarNip52GoogleMaps: "Google Maps",
calendarNip52DayIndices: "Day indices (NIP-52)", calendarNip52DayIndices: "Indexed days (UTC)",
calendarNip52DayIndicesHint:
"NIP-52 D tags list whole UTC calendar days (Unix day index) so relays and clients can match timed events to a day; several values mean a multi-day span.",
calendarNip52CalendarInclusion: "Calendar inclusion", calendarNip52CalendarInclusion: "Calendar inclusion",
calendarNip52CalendarInclusionHint: calendarNip52CalendarInclusionHint:
"This event requests inclusion in the referenced collaborative calendar (kind 31924).", "This event requests inclusion in the referenced collaborative calendar (kind 31924).",

4
src/i18n/locales/ru.ts

@ -238,7 +238,9 @@ export default {
calendarNip52Geohash: "Geohash", calendarNip52Geohash: "Geohash",
calendarNip52ViewGeohash: "Карта геохэша", calendarNip52ViewGeohash: "Карта геохэша",
calendarNip52GoogleMaps: "Google Maps", calendarNip52GoogleMaps: "Google Maps",
calendarNip52DayIndices: "Day indices (NIP-52)", calendarNip52DayIndices: "Indexed days (UTC)",
calendarNip52DayIndicesHint:
"NIP-52 D tags list whole UTC calendar days (Unix day index) so relays and clients can match timed events to a day; several values mean a multi-day span.",
calendarNip52CalendarInclusion: "Calendar inclusion", calendarNip52CalendarInclusion: "Calendar inclusion",
calendarNip52CalendarInclusionHint: calendarNip52CalendarInclusionHint:
"This event requests inclusion in the referenced collaborative calendar (kind 31924).", "This event requests inclusion in the referenced collaborative calendar (kind 31924).",

4
src/i18n/locales/tr.ts

@ -238,7 +238,9 @@ export default {
calendarNip52Geohash: "Geohash", calendarNip52Geohash: "Geohash",
calendarNip52ViewGeohash: "Geohash haritası", calendarNip52ViewGeohash: "Geohash haritası",
calendarNip52GoogleMaps: "Google Maps", calendarNip52GoogleMaps: "Google Maps",
calendarNip52DayIndices: "Day indices (NIP-52)", calendarNip52DayIndices: "Indexed days (UTC)",
calendarNip52DayIndicesHint:
"NIP-52 D tags list whole UTC calendar days (Unix day index) so relays and clients can match timed events to a day; several values mean a multi-day span.",
calendarNip52CalendarInclusion: "Calendar inclusion", calendarNip52CalendarInclusion: "Calendar inclusion",
calendarNip52CalendarInclusionHint: calendarNip52CalendarInclusionHint:
"This event requests inclusion in the referenced collaborative calendar (kind 31924).", "This event requests inclusion in the referenced collaborative calendar (kind 31924).",

4
src/i18n/locales/zh.ts

@ -238,7 +238,9 @@ export default {
calendarNip52Geohash: "Geohash", calendarNip52Geohash: "Geohash",
calendarNip52ViewGeohash: "Geohash 地图", calendarNip52ViewGeohash: "Geohash 地图",
calendarNip52GoogleMaps: "Google Maps", calendarNip52GoogleMaps: "Google Maps",
calendarNip52DayIndices: "Day indices (NIP-52)", calendarNip52DayIndices: "Indexed days (UTC)",
calendarNip52DayIndicesHint:
"NIP-52 D tags list whole UTC calendar days (Unix day index) so relays and clients can match timed events to a day; several values mean a multi-day span.",
calendarNip52CalendarInclusion: "Calendar inclusion", calendarNip52CalendarInclusion: "Calendar inclusion",
calendarNip52CalendarInclusionHint: calendarNip52CalendarInclusionHint:
"This event requests inclusion in the referenced collaborative calendar (kind 31924).", "This event requests inclusion in the referenced collaborative calendar (kind 31924).",

129
src/lib/calendar-event.ts

@ -33,7 +33,9 @@ const NIP52_CALENDAR_EVENT_KNOWN_TAG_NAMES = new Set([
'end', 'end',
'start_tzid', 'start_tzid',
'end_tzid', 'end_tzid',
'name' 'name',
/** App attribution; rendered under the title in calendar UI, not in “other tags”. */
'client'
]) ])
/** Parsed NIP-52 calendar event tags not fully covered by {@link getCalendarEventMeta}. */ /** Parsed NIP-52 calendar event tags not fully covered by {@link getCalendarEventMeta}. */
@ -176,6 +178,65 @@ export function getCalendarEventMeta(event: Event): CalendarEventMeta {
} }
} }
/**
* Drop leading/trailing lines that are only `#word` tokens when every word matches a NIP-52
* `t` tag (already shown as topic chips). Typical duplicate: body ends with `#run #walk` mirroring `t` tags.
*/
export function stripCalendarEventRedundantTopicHashtagLines(
content: string,
topics: readonly string[]
): string {
const topicSet = new Set(
topics.map((x) => x.trim().toLowerCase()).filter((x): x is string => x.length > 0)
)
if (topicSet.size === 0) return content
const lines = content.split('\n')
const isHashtagOnlyLine = (line: string): boolean => {
const t = line.trim()
if (!t) return false
const parts = t.split(/\s+/).filter(Boolean)
return parts.every((p) => /^#[a-zA-Z0-9_]+$/.test(p))
}
const tagsFromHashtagOnlyLine = (line: string): string[] =>
line
.trim()
.split(/\s+/)
.filter(Boolean)
.map((p) => p.slice(1).toLowerCase())
let start = 0
let end = lines.length
while (start < end) {
const line = lines[start]
if (line.trim() === '') {
start++
continue
}
if (!isHashtagOnlyLine(line)) break
const tags = tagsFromHashtagOnlyLine(line)
if (!tags.every((tag) => topicSet.has(tag))) break
start++
}
while (end > start) {
const line = lines[end - 1]
if (line.trim() === '') {
end--
continue
}
if (!isHashtagOnlyLine(line)) break
const tags = tagsFromHashtagOnlyLine(line)
if (!tags.every((tag) => topicSet.has(tag))) break
end--
}
return lines.slice(start, end).join('\n').trimEnd()
}
const CALENDAR_DISPLAY_LOCALE = 'en-US' const CALENDAR_DISPLAY_LOCALE = 'en-US'
function readFormatParts( function readFormatParts(
@ -264,6 +325,72 @@ export function formatCalendarDateRange(startDate: string, endDate: string): str
return `${a}${formatCalendarDate(endDate)}` return `${a}${formatCalendarDate(endDate)}`
} }
/** Seconds per day for NIP-52 `D` tags: `floor(unix_seconds / 86400)`. */
const NIP52_SECONDS_PER_DAY = 86400
function nip52DayIndexToUtcCalendarParts(dayIndex: number): { month: string; day: string; year: string } {
const ms = dayIndex * NIP52_SECONDS_PER_DAY * 1000
const d = new Date(ms)
const parts = new Intl.DateTimeFormat(CALENDAR_DISPLAY_LOCALE, {
month: 'long',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC'
}).formatToParts(d)
const m: Partial<Record<Intl.DateTimeFormatPartTypes, string>> = {}
for (const p of parts) {
if (p.type !== 'literal') m[p.type] = p.value
}
return {
month: m.month ?? '',
day: m.day ?? '',
year: m.year ?? ''
}
}
/**
* Human-readable summary of NIP-52 `D` (day-granularity) tags: each value is a UTC calendar day index
* from the Unix epoch; publishers repeat `D` for every day a timed event touches so relays can index
* and filter by day. Ranges of consecutive indices are collapsed (e.g. May 2325, 2026).
*/
export function summarizeNip52DayGranularityTags(dayStrings: readonly string[]): string {
const indices = Array.from(
new Set(
dayStrings
.map((s) => String(s).trim())
.filter((s) => /^-?\d+$/.test(s))
.map((s) => parseInt(s, 10))
.filter((n) => Number.isFinite(n))
)
).sort((a, b) => a - b)
if (indices.length === 0) return ''
const ranges: Array<{ start: number; end: number }> = []
for (const n of indices) {
const last = ranges[ranges.length - 1]
if (last && n === last.end + 1) last.end = n
else ranges.push({ start: n, end: n })
}
return ranges
.map(({ start, end }) => {
if (start === end) {
const p = nip52DayIndexToUtcCalendarParts(start)
return `${p.month} ${p.day}, ${p.year}`
}
const a = nip52DayIndexToUtcCalendarParts(start)
const b = nip52DayIndexToUtcCalendarParts(end)
if (a.month === b.month && a.year === b.year) {
return `${a.month} ${a.day}${b.day}, ${a.year}`
}
if (a.year === b.year) {
return `${a.month} ${a.day}${b.month} ${b.day}, ${a.year}`
}
return `${a.month} ${a.day}, ${a.year}${b.month} ${b.day}, ${b.year}`
})
.join(' · ')
}
/** True for NIP-52 calendar note kinds **31922** / **31923** only (via {@link isNip52CalendarCardKind}). */ /** True for NIP-52 calendar note kinds **31922** / **31923** only (via {@link isNip52CalendarCardKind}). */
export function isCalendarEventKind(kind: number): boolean { export function isCalendarEventKind(kind: number): boolean {
return isNip52CalendarCardKind(kind) return isNip52CalendarCardKind(kind)

152
src/pages/primary/CalendarPrimaryPage.tsx

@ -164,6 +164,19 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
let lateMergeTimer: number | null = null let lateMergeTimer: number | null = null
setLoading(true) setLoading(true)
void (async () => { void (async () => {
const scheduleLateSessionMerge = (mergeWithIdb: NostrEvent[]) => {
lateMergeTimer = window.setTimeout(() => {
lateMergeTimer = null
if (cancelled) return
const later = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later, ...mergeWithIdb]))
}, 2500)
}
try { try {
const { rangeStartMs, rangeEndExclusiveMs } = paddedMonthRange const { rangeStartMs, rangeEndExclusiveMs } = paddedMonthRange
const fromIdb = await indexedDb.getCalendarEventsForOccurrenceWindow( const fromIdb = await indexedDb.getCalendarEventsForOccurrenceWindow(
@ -171,66 +184,74 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
rangeEndExclusiveMs, rangeEndExclusiveMs,
MONTH_IDB_MAX_SCAN MONTH_IDB_MAX_SCAN
) )
if (cancelled) return
const fromSessionNow = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents(dedupeCalendarEvents([...fromIdb, ...fromSessionNow]))
setLoading(false)
if (!relayUrls.length) { if (!relayUrls.length) {
if (cancelled) return scheduleLateSessionMerge(fromIdb)
const fromSession = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents(dedupeCalendarEvents([...fromIdb, ...fromSession]))
lateMergeTimer = window.setTimeout(() => {
lateMergeTimer = null
if (cancelled) return
const later = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later, ...fromIdb]))
}, 2500)
return return
} }
const batch = await client.fetchEvents( const mainFetchOpts = {
cache: true as const,
globalTimeout: 22_000,
eoseTimeout: 3500,
firstRelayResultGraceMs: false as const
}
const chunkFetchOpts = {
cache: true as const,
globalTimeout: 12_000,
eoseTimeout: 2200,
firstRelayResultGraceMs: false as const
}
const authorList = followAuthorsKey
? followAuthorsKey.split('|').filter(Boolean).slice(0, FOLLOWING_CALENDAR_AUTHORS_CAP)
: []
const authorChunks: string[][] = []
for (let i = 0; i < authorList.length; i += FOLLOWING_CALENDAR_AUTHORS_CHUNK) {
authorChunks.push(authorList.slice(i, i + FOLLOWING_CALENDAR_AUTHORS_CHUNK))
}
const mainReq = client.fetchEvents(
relayUrls, relayUrls,
{ {
kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME],
limit: FETCH_LIMIT limit: FETCH_LIMIT
}, },
{ mainFetchOpts
cache: true, )
globalTimeout: 22_000, const chunkReqs = authorChunks.map((authors) =>
eoseTimeout: 3500, client.fetchEvents(
firstRelayResultGraceMs: false relayUrls,
} {
kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME],
authors,
limit: FOLLOWING_CALENDAR_CHUNK_LIMIT
},
chunkFetchOpts
)
) )
if (cancelled) return
let batch: NostrEvent[] = []
const fromFollowing: NostrEvent[] = [] const fromFollowing: NostrEvent[] = []
if (followAuthorsKey) { try {
const authorList = followAuthorsKey.split('|').filter(Boolean).slice(0, FOLLOWING_CALENDAR_AUTHORS_CAP) const merged = await Promise.all([mainReq, ...chunkReqs])
for (let i = 0; i < authorList.length; i += FOLLOWING_CALENDAR_AUTHORS_CHUNK) { batch = merged[0] ?? []
const authors = authorList.slice(i, i + FOLLOWING_CALENDAR_AUTHORS_CHUNK) for (let i = 1; i < merged.length; i++) {
const chunk = await client.fetchEvents( fromFollowing.push(...(merged[i] ?? []))
relayUrls,
{
kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME],
authors,
limit: FOLLOWING_CALENDAR_CHUNK_LIMIT
},
{
cache: true,
globalTimeout: 16_000,
eoseTimeout: 2800,
firstRelayResultGraceMs: false
}
)
if (cancelled) return
fromFollowing.push(...chunk)
} }
} catch {
/* keep IndexedDB + session view; relays may be unreachable */
} }
if (cancelled) return
const fromSession = client.getSessionEventsMatchingSearch( const fromSession = client.getSessionEventsMatchingSearch(
'', '',
@ -249,9 +270,10 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later])) setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later]))
}, 2500) }, 2500)
} catch { } catch {
if (!cancelled) setRawEvents([]) if (!cancelled) {
} finally { setRawEvents([])
if (!cancelled) setLoading(false) setLoading(false)
}
} }
})() })()
return () => { return () => {
@ -364,7 +386,7 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
titlebar={<CalendarPageTitlebar onRefresh={() => setRefreshKey((k) => k + 1)} />} titlebar={<CalendarPageTitlebar onRefresh={() => setRefreshKey((k) => k + 1)} />}
displayScrollToTopButton displayScrollToTopButton
> >
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3 p-3 md:p-4"> <div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 p-2 md:p-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button type="button" variant="outline" size="icon" className="size-9" onClick={goPrevMonth} aria-label={t('calendarPagePrevMonth')}> <Button type="button" variant="outline" size="icon" className="size-9" onClick={goPrevMonth} aria-label={t('calendarPagePrevMonth')}>
@ -385,7 +407,7 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
) : null} ) : null}
{useVerticalMonthCalendar ? ( {useVerticalMonthCalendar ? (
<div className="flex min-w-0 flex-col gap-2" aria-label={t('calendarPageGridLabel')}> <div className="flex min-w-0 flex-col gap-1.5" aria-label={t('calendarPageGridLabel')}>
{Array.from({ length: daysInMonth(viewYear, viewMonth) }, (_, idx) => { {Array.from({ length: daysInMonth(viewYear, viewMonth) }, (_, idx) => {
const day = idx + 1 const day = idx + 1
const list = eventsForDay(day) const list = eventsForDay(day)
@ -398,12 +420,12 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
<div <div
key={`m-${viewYear}-${viewMonth}-${day}`} key={`m-${viewYear}-${viewMonth}-${day}`}
className={cn( className={cn(
'flex min-w-0 flex-col gap-1 rounded-lg border border-border bg-card p-3', 'flex min-w-0 flex-col gap-0.5 rounded-lg border border-border bg-card p-2',
inWeek && 'bg-primary/10 ring-1 ring-inset ring-primary/35' inWeek && 'bg-primary/10 ring-1 ring-inset ring-primary/35'
)} )}
> >
<div className="flex items-baseline justify-between gap-2 border-b border-border/50 pb-1"> <div className="flex items-baseline justify-between gap-2 border-b border-border/40 pb-0.5">
<span className="text-sm font-semibold text-foreground"> <span className="text-[13px] font-semibold leading-tight text-foreground">
{weekdayShort} · {day} {weekdayShort} · {day}
</span> </span>
{list.length > 0 ? ( {list.length > 0 ? (
@ -412,7 +434,7 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
</span> </span>
) : null} ) : null}
</div> </div>
<ul className="min-w-0 space-y-0.5"> <ul className="min-w-0 space-y-0">
{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')
@ -422,15 +444,15 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
type="button" type="button"
onClick={() => navigateToNote(toNote(ev), ev)} onClick={() => navigateToNote(toNote(ev), ev)}
className={cn( className={cn(
'flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs font-medium leading-snug text-primary', 'flex w-full min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 text-left text-[11px] font-medium leading-snug text-primary',
'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'
)} )}
> >
<CalendarEventCoverImage <CalendarEventCoverImage
coverUrl={meta.image} coverUrl={meta.image}
pubkey={ev.pubkey} pubkey={ev.pubkey}
className="size-8 shrink-0 rounded-md ring-1 ring-border/50" className="size-7 shrink-0 rounded-md ring-1 ring-border/50"
iconClassName="size-4" iconClassName="size-3.5"
/> />
<span className="min-w-0 truncate">{title}</span> <span className="min-w-0 truncate">{title}</span>
</button> </button>
@ -439,16 +461,14 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
})} })}
</ul> </ul>
{excess > 0 ? ( {excess > 0 ? (
<Button <button
type="button" type="button"
variant="secondary" className="mt-0.5 w-full shrink-0 rounded-md py-1 text-center text-[11px] font-semibold text-primary underline-offset-2 hover:bg-muted/50 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
size="sm"
className="h-9 w-full shrink-0 text-xs font-semibold"
aria-label={t('calendarPageMoreEventsAria', { count: excess })} aria-label={t('calendarPageMoreEventsAria', { count: excess })}
onClick={() => openDayEventsPanel(day)} onClick={() => openDayEventsPanel(day)}
> >
+{excess} +{excess}
</Button> </button>
) : null} ) : null}
</div> </div>
) )
@ -516,16 +536,14 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
})} })}
</ul> </ul>
{excess > 0 ? ( {excess > 0 ? (
<Button <button
type="button" type="button"
variant="secondary" className="mt-0.5 w-full shrink-0 rounded py-0.5 text-center text-[9px] font-semibold text-primary underline-offset-2 hover:bg-muted/50 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:text-[10px]"
size="sm"
className="mt-0.5 h-7 w-full shrink-0 px-1 text-[10px] font-semibold md:h-8 md:text-[11px]"
aria-label={t('calendarPageMoreEventsAria', { count: excess })} aria-label={t('calendarPageMoreEventsAria', { count: excess })}
onClick={() => openDayEventsPanel(day)} onClick={() => openDayEventsPanel(day)}
> >
+{excess} +{excess}
</Button> </button>
) : null} ) : null}
</div> </div>
) )

36
src/services/client-events.service.ts

@ -4,14 +4,16 @@ import {
getParentATag, getParentATag,
getParentETag, getParentETag,
getQuotedReferenceFromQTags, getQuotedReferenceFromQTags,
getReplaceableCoordinateFromEvent,
getRootATag, getRootATag,
getRootETag, getRootETag,
isNip25ReactionKind, isNip25ReactionKind,
isReplyNoteEvent, isReplyNoteEvent,
isReplaceableEvent, isReplaceableEvent,
kind1QuotesThreadRoot kind1QuotesThreadRoot,
normalizeReplaceableCoordinateString
} from '@/lib/event' } from '@/lib/event'
import { getFirstHexEventIdFromETags } from '@/lib/tag' import { getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag'
import type { Event as NEvent, Filter } from 'nostr-tools' import type { Event as NEvent, Filter } from 'nostr-tools'
import { kinds, nip19 } from 'nostr-tools' import { kinds, nip19 } from 'nostr-tools'
import DataLoader from 'dataloader' import DataLoader from 'dataloader'
@ -700,6 +702,36 @@ export class EventService {
return out return out
} }
/**
* Kind 31925 in session LRU for this calendar replaceable: `a` coordinate match, or `e` pointing at this
* revisions id (some clients tag the instance id only). Used so RSVP lists populate from feeds before
* IndexedDB / relay REQ complete.
*/
getSessionCalendarRsvpsForCalendarEvent(calendarEvent: NEvent): NEvent[] {
if (!isCalendarEventKind(calendarEvent.kind)) return []
const coordNorm = normalizeReplaceableCoordinateString(
getReplaceableCoordinateFromEvent(calendarEvent)
)
const calId = /^[0-9a-f]{64}$/i.test(calendarEvent.id)
? calendarEvent.id.toLowerCase()
: calendarEvent.id
const out: NEvent[] = []
for (const [, event] of this.sessionEventCache.entries()) {
if (event.kind !== ExtendedKind.CALENDAR_EVENT_RSVP) continue
if (shouldDropEventOnIngest(event)) continue
const rawA = event.tags.find(tagNameEquals('a'))?.[1]?.trim()
if (rawA && normalizeReplaceableCoordinateString(rawA) === coordNorm) {
out.push(event)
continue
}
const eTag = event.tags.find(tagNameEquals('e'))?.[1]?.trim().toLowerCase()
if (eTag && /^[0-9a-f]{64}$/.test(eTag) && eTag === calId) {
out.push(event)
}
}
return out.sort((a, b) => b.created_at - a.created_at)
}
/** /**
* WebSocket relay URLs from `e`-tag position 3 on session-cached events that reference this hex id. * WebSocket relay URLs from `e`-tag position 3 on session-cached events that reference this hex id.
* Reactions often carry the publishers relay hint; without it, note-stats may miss kind 7 that never reached index relays. * Reactions often carry the publishers relay hint; without it, note-stats may miss kind 7 that never reached index relays.

4
src/services/client.service.ts

@ -3103,6 +3103,10 @@ class ClientService extends EventTarget {
return this.eventService.getSessionEventsMatchingSearch(query, limit, allowedKinds) return this.eventService.getSessionEventsMatchingSearch(query, limit, allowedKinds)
} }
/** Session LRU: RSVPs for this calendar event (by `a` coordinate or `e` parent id). */
getSessionCalendarRsvpsForCalendarEvent(event: NEvent): NEvent[] {
return this.eventService.getSessionCalendarRsvpsForCalendarEvent(event)
}
async fetchFavoriteRelays(pubkey: string): Promise<string[]> { async fetchFavoriteRelays(pubkey: string): Promise<string[]> {
try { try {

Loading…
Cancel
Save