diff --git a/src/components/CalendarEventContent/index.tsx b/src/components/CalendarEventContent/index.tsx index 908923b9..61c0c41a 100644 --- a/src/components/CalendarEventContent/index.tsx +++ b/src/components/CalendarEventContent/index.tsx @@ -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' 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({ 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({ return (
e.stopPropagation()} >
- + {!showFull ? ( + + ) : null}

{title || t('Scheduled video call')}

+ {getUsingClient(event) ? ( +
+ +
+ ) : null} {!showFull && scheduleLine ? (

@@ -202,8 +215,8 @@ export default function CalendarEventContent({

{showFull && scheduleLine ? ( -
- +
+

{scheduleLine}

{(startTzid || endTzid) && ( @@ -232,9 +245,9 @@ export default function CalendarEventContent({ isDateBased={isDateBased} /> ) : null} - {markdownBody ? ( + {markdownBodyDeduped ? ( showFull ? ( -
+

- {markdownBody} + {markdownBodyDeduped}

@@ -262,31 +275,42 @@ export default function CalendarEventContent({ isDateBased={isDateBased} /> ) : null} -
+
{!showFull && rUrls.map((url) => ( - + + + {t('Open link')} + ))} {showRsvp && myPubkey && ( diff --git a/src/components/CalendarEventNip52StructuredMeta.tsx b/src/components/CalendarEventNip52StructuredMeta.tsx index fa63fe97..f49d99b2 100644 --- a/src/components/CalendarEventNip52StructuredMeta.tsx +++ b/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 { 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({ 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({ 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 ( -
+
{hasLocations ? ( -
-
+
+
{meta.locations.length > 1 ? t('calendarNip52Locations') : t('calendarNip52Location')}
{meta.locations.length === 1 ? ( -
-

- - {meta.locations[0]} -

- +
+ + {meta.locations[0]} + + + {t('calendarNip52GoogleMaps')} +
) : ( -
    +
      {meta.locations.map((loc, i) => (
    • -
    • ))} @@ -86,106 +94,104 @@ export function CalendarEventNip52StructuredMeta({ ) : null} {summaryTrim ? (
      -
      +
      {t('calendarNip52Summary')}
      -
      +
      {summaryTrim}
      ) : null} {hasGeo ? (
      -
      +
      {t('calendarNip52Geohash')}
      -

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

      +
      ) : null}
      ) } - 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 ( -
      +
      {hasDayD ? ( -
      -
      +
      +
      {t('calendarNip52DayIndices')}
      -
      - {extras.dayGranularities.map((d) => ( - - D={d} - - ))} -
      +

      {dayGranularitySummary}

      +

      {t('calendarNip52DayIndicesHint')}

      ) : null} {hasInclusions ? ( -
      -
      +
      +
      {t('calendarNip52CalendarInclusion')}
      -
        +
          {extras.calendarInclusions.map((row) => (
        • - + + {row.naddr} +
        • ))}
        -

        {t('calendarNip52CalendarInclusionHint')}

        +

        {t('calendarNip52CalendarInclusionHint')}

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

          - +

          +

          + {r.value}

          @@ -196,27 +202,18 @@ export function CalendarEventNip52StructuredMeta({
          ) : null} - {hasD ? ( -
          -
          - {t('calendarNip52Identifier')} -
          -

          {meta.d.trim()}

          -
          - ) : null} - {hasUnknown ? ( -
          -
          +
          +
          {t('calendarNip52OtherTags')}
          -
          +
          {extras.unknownTags.map((tag, idx) => ( -
          +
          {tag[0] || '—'}
          -
          +
          {tag.length > 1 ? tag.slice(1).join(' · ') : '—'}
          diff --git a/src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx b/src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx index 5962b316..9155a7b2 100644 --- a/src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx +++ b/src/components/ConnectedRelays/ActiveRelaysTitlebarButton.tsx @@ -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 = ( - + ) @@ -195,28 +172,19 @@ function TitlebarAccountMenu({ - + ) } -/** - * 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' ? : null - let account: ReactNode if (pubkey) { account = @@ -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 ? ( - - ) : null - return ( <>
          - {help} - {titlebarHelpWhenLoggedOut} {account}
          diff --git a/src/components/KeyboardShortcutsHelp/index.tsx b/src/components/KeyboardShortcutsHelp/index.tsx index 9fc0942d..ba79e66f 100644 --- a/src/components/KeyboardShortcutsHelp/index.tsx +++ b/src/components/KeyboardShortcutsHelp/index.tsx @@ -1,4 +1,3 @@ -import { Button } from '@/components/ui/button' import { Dialog, DialogContent, @@ -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() { {t('shortcuts.sectionApp')}
          - - ? - {t('shortcuts.or')} - F1 - - } - /> + F1} /> ) } - -/** Titlebar-sized help control (e.g. home feed, next to profile). */ -export function KeyboardShortcutsHelpButton() { - const { openHelp } = useKeyboardShortcutsHelp() - const { t } = useTranslation() - return ( - - ) -} diff --git a/src/components/Sidebar/KeyboardShortcutsHelpSidebarButton.tsx b/src/components/Sidebar/KeyboardShortcutsHelpSidebarButton.tsx deleted file mode 100644 index b96dfe81..00000000 --- a/src/components/Sidebar/KeyboardShortcutsHelpSidebarButton.tsx +++ /dev/null @@ -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 ( - - - - ) -} diff --git a/src/hooks/useFetchCalendarRsvps.tsx b/src/hooks/useFetchCalendarRsvps.tsx index e8c23d8f..460286a1 100644 --- a/src/hooks/useFetchCalendarRsvps.tsx +++ b/src/hooks/useFetchCalendarRsvps.tsx @@ -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([ ...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url), @@ -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) { const coordinate = normalizeReplaceableCoordinateString( getReplaceableCoordinateFromEvent(calendarEvent) ) + const calId = /^[0-9a-f]{64}$/i.test(calendarEvent.id) + ? calendarEvent.id.toLowerCase() + : calendarEvent.id const handler = (e: CustomEvent) => { 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, diff --git a/src/i18n/locales/cs.ts b/src/i18n/locales/cs.ts index 2ada3af2..19b9cac0 100644 --- a/src/i18n/locales/cs.ts +++ b/src/i18n/locales/cs.ts @@ -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).", diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index ab957da2..b85b271d 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -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).", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index e3dd93f2..5c3a19df 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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).", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 301b2b32..327b1113 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -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).", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 6c76507d..4e0dfd27 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -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).", diff --git a/src/i18n/locales/nl.ts b/src/i18n/locales/nl.ts index 3d343e88..a700fdd6 100644 --- a/src/i18n/locales/nl.ts +++ b/src/i18n/locales/nl.ts @@ -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).", diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index 3152a792..501e0949 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -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).", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 7ede4b9b..e3e879f8 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -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).", diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index 3641682e..803f32cc 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -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).", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 0110d0f9..01483cfc 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -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).", diff --git a/src/lib/calendar-event.ts b/src/lib/calendar-event.ts index 36c257d9..32b75613 100644 --- a/src/lib/calendar-event.ts +++ b/src/lib/calendar-event.ts @@ -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 { } } +/** + * 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 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> = {} + 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 23–25, 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) diff --git a/src/pages/primary/CalendarPrimaryPage.tsx b/src/pages/primary/CalendarPrimaryPage.tsx index d1bee4ea..5054691f 100644 --- a/src/pages/primary/CalendarPrimaryPage.tsx +++ b/src/pages/primary/CalendarPrimaryPage.tsx @@ -164,6 +164,19 @@ const CalendarPrimaryPage = forwardRef(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(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(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(funct titlebar={ setRefreshKey((k) => k + 1)} />} displayScrollToTopButton > -
          +
          @@ -439,16 +461,14 @@ const CalendarPrimaryPage = forwardRef(funct })}
        {excess > 0 ? ( - + ) : null}
      ) @@ -516,16 +536,14 @@ const CalendarPrimaryPage = forwardRef(funct })}
    {excess > 0 ? ( - + ) : null}
) diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index 6d181e3a..d797b628 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -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 { return out } + /** + * Kind 31925 in session LRU for this calendar replaceable: `a` coordinate match, or `e` pointing at this + * revision’s 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 publisher’s relay hint; without it, note-stats may miss kind 7 that never reached index relays. diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 5db8c112..987ce934 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -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 { try {