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

183
src/components/CalendarEventNip52StructuredMeta.tsx

@ -1,10 +1,14 @@ @@ -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 { 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'
import { cn } from '@/lib/utils'
type Placement = 'beforeDescription' | 'afterDescription'
@ -35,6 +39,10 @@ export function CalendarEventNip52StructuredMeta({ @@ -35,6 +39,10 @@ export function CalendarEventNip52StructuredMeta({
const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation()
const extras = useMemo(() => getNip52CalendarEventTagExtras(event), [event])
const dayGranularitySummary = useMemo(
() => summarizeNip52DayGranularityTags(extras.dayGranularities),
[extras.dayGranularities]
)
if (placement === 'beforeDescription') {
const summaryTrim = meta.summary?.trim() ?? ''
@ -42,41 +50,41 @@ export function CalendarEventNip52StructuredMeta({ @@ -42,41 +50,41 @@ export function CalendarEventNip52StructuredMeta({
const hasGeo = !!meta.geo?.trim()
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 (
<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 ? (
<div className="space-y-1.5">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<div className="space-y-1">
<div className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{meta.locations.length > 1 ? t('calendarNip52Locations') : t('calendarNip52Location')}
</div>
{meta.locations.length === 1 ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<p className="flex min-w-0 flex-1 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>
<Button variant="outline" size="sm" className="h-8 w-full shrink-0 sm:w-auto" asChild>
<a href={googleMapsPlaceUrl(meta.locations[0])} target="_blank" rel="noopener noreferrer">
<ExternalLink className="size-3.5" />
{t('calendarNip52GoogleMaps')}
</a>
</Button>
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-1 text-sm leading-snug text-foreground">
<MapPin className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0">{meta.locations[0]}</span>
<a
href={googleMapsPlaceUrl(meta.locations[0])}
target="_blank"
rel="noopener noreferrer"
className={linkClass}
>
<ExternalLink className="size-3 shrink-0 opacity-80" aria-hidden />
{t('calendarNip52GoogleMaps')}
</a>
</div>
) : (
<ul className="min-w-0 space-y-3">
<ul className="min-w-0 space-y-2">
{meta.locations.map((loc, i) => (
<li key={`${i}-${loc.slice(0, 24)}`}>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<p className="flex min-w-0 flex-1 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>
</p>
<Button variant="outline" size="sm" className="h-8 w-full shrink-0 sm:w-auto" asChild>
<a href={googleMapsPlaceUrl(loc)} target="_blank" rel="noopener noreferrer">
<ExternalLink className="size-3.5" />
{t('calendarNip52GoogleMaps')}
</a>
</Button>
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-1 text-sm leading-snug text-foreground">
<MapPin className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0">{loc}</span>
<a href={googleMapsPlaceUrl(loc)} target="_blank" rel="noopener noreferrer" className={linkClass}>
<ExternalLink className="size-3 shrink-0 opacity-80" aria-hidden />
{t('calendarNip52GoogleMaps')}
</a>
</div>
</li>
))}
@ -86,106 +94,104 @@ export function CalendarEventNip52StructuredMeta({ @@ -86,106 +94,104 @@ export function CalendarEventNip52StructuredMeta({
) : null}
{summaryTrim ? (
<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')}
</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}
</blockquote>
</div>
) : null}
{hasGeo ? (
<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')}
</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={nip52GeohashSoftengUrl(meta.geo)} target="_blank" rel="noopener noreferrer">
<ExternalLink className="size-3.5" />
{t('calendarNip52ViewGeohash')}
</a>
</Button>
</p>
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="break-all font-mono text-xs text-foreground">{meta.geo.trim()}</span>
<a
href={nip52GeohashSoftengUrl(meta.geo)}
target="_blank"
rel="noopener noreferrer"
className={linkClass}
>
<ExternalLink className="size-3 shrink-0 opacity-80" aria-hidden />
{t('calendarNip52ViewGeohash')}
</a>
</div>
</div>
) : null}
</div>
)
}
const hasDayD = !isDateBased && extras.dayGranularities.length > 0
const hasDayD = !isDateBased && dayGranularitySummary.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
if (!hasDayD && !hasInclusions && !hasR && !hasUnknown) return null
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 ? (
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<div className="space-y-1">
<div className="text-[11px] 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>
<p className="text-sm font-medium leading-snug text-foreground">{dayGranularitySummary}</p>
<p className="text-[10px] leading-snug text-muted-foreground">{t('calendarNip52DayIndicesHint')}</p>
</div>
) : null}
{hasInclusions ? (
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<div className="space-y-1">
<div className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{t('calendarNip52CalendarInclusion')}
</div>
<ul className="min-w-0 space-y-1.5">
<ul className="min-w-0 space-y-1">
{extras.calendarInclusions.map((row) => (
<li key={row.coordinate}>
<Button
<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)}
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 />
<span className="min-w-0 break-all font-mono text-xs leading-snug">{row.naddr}</span>
</Button>
<Calendar className="size-3.5 shrink-0 opacity-90" aria-hidden />
<span className="min-w-0 break-all font-mono leading-snug">{row.naddr}</span>
</button>
</li>
))}
</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>
) : null}
{hasR ? (
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<div className="space-y-1">
<div className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{t('calendarNip52References')}
</div>
<ul className="min-w-0 space-y-2">
<ul className="min-w-0 space-y-1">
{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>
<a
href={r.value}
target="_blank"
rel="noopener noreferrer"
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"
>
<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">
<p className="flex items-start gap-2 text-xs text-muted-foreground">
<Link2 className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<div className="rounded-md border border-border/50 bg-muted/20 px-2 py-1">
<p className="flex items-start gap-1.5 text-[11px] text-muted-foreground">
<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>
</p>
</div>
@ -196,27 +202,18 @@ export function CalendarEventNip52StructuredMeta({ @@ -196,27 +202,18 @@ export function CalendarEventNip52StructuredMeta({
</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">
<div className="space-y-1">
<div className="text-[11px] 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">
<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) => (
<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">
{tag[0] || '—'}
</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(' · ') : '—'}
</dd>
</div>

27
src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx

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

6
src/components/Embedded/EmbeddedCalendarEvent.tsx

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

59
src/components/HelpAndAccountMenu.tsx

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

45
src/components/KeyboardShortcutsHelp/index.tsx

@ -1,4 +1,3 @@ @@ -1,4 +1,3 @@
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
@ -14,13 +13,9 @@ import { @@ -14,13 +13,9 @@ import {
} from '@/lib/keyboard-shortcuts'
import { cn } from '@/lib/utils'
import postEditorService from '@/services/post-editor.service'
import { CircleHelp } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { marked } from 'marked'
import {
KeyboardShortcutsHelpContext,
useKeyboardShortcutsHelp
} from '@/contexts/keyboard-shortcuts-help-context'
import { KeyboardShortcutsHelpContext } from '@/contexts/keyboard-shortcuts-help-context'
import { useTranslation } from 'react-i18next'
import readmeMarkdown from '../../../README.md?raw'
@ -51,16 +46,7 @@ function ShortcutsPanel() { @@ -51,16 +46,7 @@ function ShortcutsPanel() {
{t('shortcuts.sectionApp')}
</h3>
<div className="space-y-3">
<KbdRow
label={t('shortcuts.openHelp')}
keys={
<>
<Kbd>?</Kbd>
<span className="px-0.5 text-muted-foreground">{t('shortcuts.or')}</span>
<Kbd>F1</Kbd>
</>
}
/>
<KbdRow label={t('shortcuts.openHelp')} keys={<Kbd>F1</Kbd>} />
<KbdRow
label={t('shortcuts.focusPrimary')}
keys={
@ -212,15 +198,6 @@ export function KeyboardShortcutsHelpProvider({ children }: { children: ReactNod @@ -212,15 +198,6 @@ export function KeyboardShortcutsHelpProvider({ children }: { children: ReactNod
if (shouldIgnoreKeyboardShortcutEvent(e.target)) 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) {
e.preventDefault()
setOpen(true)
@ -277,21 +254,3 @@ export function KeyboardShortcutsHelpProvider({ children }: { children: ReactNod @@ -277,21 +254,3 @@ export function KeyboardShortcutsHelpProvider({ children }: { children: ReactNod
</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 @@ @@ -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) { @@ -64,7 +64,10 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
fromIdb = []
}
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>([
...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url),
@ -93,17 +96,31 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { @@ -93,17 +96,31 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
}
if (cancelled) return
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(
urls,
[
{
kinds: [ExtendedKind.CALENDAR_EVENT_RSVP],
'#a': [coordinate],
limit: 200
},
{
kinds: [ExtendedKind.CALENDAR_EVENT_RSVP],
'#e': [calendarHexId],
limit: 200
}
],
{
kinds: [ExtendedKind.CALENDAR_EVENT_RSVP],
'#a': [coordinate],
limit: 200
},
{ firstRelayResultGraceMs: false }
firstRelayResultGraceMs: false,
eoseTimeout: 4500,
globalTimeout: 24_000
}
)
if (cancelled) return
setRsvps(mergeRsvpList([...fromIdb, ...(events ?? [])]))
setRsvps(mergeRsvpList([...fromIdb, ...fromSession, ...(events ?? [])]))
} finally {
if (!cancelled) setIsFetching(false)
}
@ -121,18 +138,24 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { @@ -121,18 +138,24 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
const coordinate = normalizeReplaceableCoordinateString(
getReplaceableCoordinateFromEvent(calendarEvent)
)
const calId = /^[0-9a-f]{64}$/i.test(calendarEvent.id)
? calendarEvent.id.toLowerCase()
: calendarEvent.id
const handler = (e: CustomEvent<Event>) => {
const evt = e.detail
if (evt.kind !== ExtendedKind.CALENDAR_EVENT_RSVP) return
const aTag = evt.tags.find(tagNameEquals('a'))
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))
}
client.addEventListener('newEvent', handler as EventListener)
return () => client.removeEventListener('newEvent', handler as EventListener)
}, [calendarEvent?.id, calendarEvent?.kind])
}, [calendarEvent?.id, calendarEvent?.kind, calendarEvent?.pubkey])
return {
rsvps,

4
src/i18n/locales/cs.ts

@ -238,7 +238,9 @@ export default { @@ -238,7 +238,9 @@ export default {
calendarNip52Geohash: "Geohash",
calendarNip52ViewGeohash: "Mapa geohash",
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",
calendarNip52CalendarInclusionHint:
"This event requests inclusion in the referenced collaborative calendar (kind 31924).",

4
src/i18n/locales/de.ts

@ -238,7 +238,9 @@ export default { @@ -238,7 +238,9 @@ export default {
calendarNip52Geohash: "Geohash",
calendarNip52ViewGeohash: "Geohash-Karte",
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",
calendarNip52CalendarInclusionHint:
"Dieses Ereignis bittet um Aufnahme in den referenzierten gemeinschaftlichen Kalender (Kind 31924).",

4
src/i18n/locales/en.ts

@ -242,7 +242,9 @@ export default { @@ -242,7 +242,9 @@ export default {
calendarNip52Geohash: "Geohash",
calendarNip52ViewGeohash: "Geohash map",
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",
calendarNip52CalendarInclusionHint:
"This event requests inclusion in the referenced collaborative calendar (kind 31924).",

4
src/i18n/locales/es.ts

@ -238,7 +238,9 @@ export default { @@ -238,7 +238,9 @@ export default {
calendarNip52Geohash: "Geohash",
calendarNip52ViewGeohash: "Mapa Geohash",
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",
calendarNip52CalendarInclusionHint:
"This event requests inclusion in the referenced collaborative calendar (kind 31924).",

4
src/i18n/locales/fr.ts

@ -238,7 +238,9 @@ export default { @@ -238,7 +238,9 @@ export default {
calendarNip52Geohash: "Geohash",
calendarNip52ViewGeohash: "Carte Geohash",
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",
calendarNip52CalendarInclusionHint:
"This event requests inclusion in the referenced collaborative calendar (kind 31924).",

4
src/i18n/locales/nl.ts

@ -238,7 +238,9 @@ export default { @@ -238,7 +238,9 @@ export default {
calendarNip52Geohash: "Geohash",
calendarNip52ViewGeohash: "Geohash-kaart",
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",
calendarNip52CalendarInclusionHint:
"This event requests inclusion in the referenced collaborative calendar (kind 31924).",

4
src/i18n/locales/pl.ts

@ -238,7 +238,9 @@ export default { @@ -238,7 +238,9 @@ export default {
calendarNip52Geohash: "Geohash",
calendarNip52ViewGeohash: "Mapa geohash",
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",
calendarNip52CalendarInclusionHint:
"This event requests inclusion in the referenced collaborative calendar (kind 31924).",

4
src/i18n/locales/ru.ts

@ -238,7 +238,9 @@ export default { @@ -238,7 +238,9 @@ export default {
calendarNip52Geohash: "Geohash",
calendarNip52ViewGeohash: "Карта геохэша",
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",
calendarNip52CalendarInclusionHint:
"This event requests inclusion in the referenced collaborative calendar (kind 31924).",

4
src/i18n/locales/tr.ts

@ -238,7 +238,9 @@ export default { @@ -238,7 +238,9 @@ export default {
calendarNip52Geohash: "Geohash",
calendarNip52ViewGeohash: "Geohash haritası",
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",
calendarNip52CalendarInclusionHint:
"This event requests inclusion in the referenced collaborative calendar (kind 31924).",

4
src/i18n/locales/zh.ts

@ -238,7 +238,9 @@ export default { @@ -238,7 +238,9 @@ export default {
calendarNip52Geohash: "Geohash",
calendarNip52ViewGeohash: "Geohash 地图",
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",
calendarNip52CalendarInclusionHint:
"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([ @@ -33,7 +33,9 @@ const NIP52_CALENDAR_EVENT_KNOWN_TAG_NAMES = new Set([
'end',
'start_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}. */
@ -176,6 +178,65 @@ export function getCalendarEventMeta(event: Event): CalendarEventMeta { @@ -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'
function readFormatParts(
@ -264,6 +325,72 @@ export function formatCalendarDateRange(startDate: string, endDate: string): str @@ -264,6 +325,72 @@ export function formatCalendarDateRange(startDate: string, endDate: string): str
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}). */
export function isCalendarEventKind(kind: number): boolean {
return isNip52CalendarCardKind(kind)

152
src/pages/primary/CalendarPrimaryPage.tsx

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

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

@ -4,14 +4,16 @@ import { @@ -4,14 +4,16 @@ import {
getParentATag,
getParentETag,
getQuotedReferenceFromQTags,
getReplaceableCoordinateFromEvent,
getRootATag,
getRootETag,
isNip25ReactionKind,
isReplyNoteEvent,
isReplaceableEvent,
kind1QuotesThreadRoot
kind1QuotesThreadRoot,
normalizeReplaceableCoordinateString
} from '@/lib/event'
import { getFirstHexEventIdFromETags } from '@/lib/tag'
import { getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag'
import type { Event as NEvent, Filter } from 'nostr-tools'
import { kinds, nip19 } from 'nostr-tools'
import DataLoader from 'dataloader'
@ -700,6 +702,36 @@ export class EventService { @@ -700,6 +702,36 @@ export class EventService {
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.
* 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 { @@ -3103,6 +3103,10 @@ class ClientService extends EventTarget {
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[]> {
try {

Loading…
Cancel
Save