From 10e2e05968a5bc42d3900b4ad1b5569062c5b5e2 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 6 May 2026 10:57:10 +0200 Subject: [PATCH] fix spells --- src/components/CalendarEventContent/index.tsx | 91 +++--- src/components/Collapsible/index.tsx | 40 +-- src/components/ContentPreview/index.tsx | 145 ++++++++- .../Embedded/EmbeddedCalendarEvent.tsx | 72 ++--- src/components/Embedded/EmbeddedNote.tsx | 5 +- .../FavoriteRelaysActiveStrip/index.tsx | 98 +----- src/components/Note/index.tsx | 6 +- src/components/NoteCard/MainNoteCard.tsx | 6 +- src/components/NoteList/index.tsx | 13 + .../Sidebar/SidebarCalendarWeekWidget.tsx | 278 ++++++++++++++++++ src/components/Sidebar/index.tsx | 8 +- src/constants.ts | 10 +- src/hooks/useFetchCalendarRsvps.tsx | 95 +++--- src/i18n/locales/cs.ts | 6 + src/i18n/locales/de.ts | 6 + src/i18n/locales/en.ts | 6 + src/i18n/locales/es.ts | 6 + src/i18n/locales/fr.ts | 6 + src/i18n/locales/nl.ts | 6 + src/i18n/locales/pl.ts | 6 + src/i18n/locales/ru.ts | 6 + src/i18n/locales/tr.ts | 6 + src/i18n/locales/zh.ts | 6 + src/lib/calendar-event.ts | 220 +++++++++++++- src/lib/event.ts | 4 +- src/lib/live-activities.ts | 5 +- src/lib/parent-reply-blurb.ts | 76 +++++ src/pages/secondary/NotePage/index.tsx | 28 +- src/providers/LiveActivitiesProvider.tsx | 2 +- src/services/client-events.service.ts | 21 ++ src/services/event-archive.service.ts | 3 +- src/services/indexed-db.service.ts | 231 ++++++++++++++- src/services/note-stats.service.ts | 35 ++- 33 files changed, 1269 insertions(+), 283 deletions(-) create mode 100644 src/components/Sidebar/SidebarCalendarWeekWidget.tsx create mode 100644 src/lib/parent-reply-blurb.ts diff --git a/src/components/CalendarEventContent/index.tsx b/src/components/CalendarEventContent/index.tsx index 413439b7..91226966 100644 --- a/src/components/CalendarEventContent/index.tsx +++ b/src/components/CalendarEventContent/index.tsx @@ -1,8 +1,8 @@ import { createCalendarRsvpDraftEvent } from '@/lib/draft-event' import { getCalendarEventMeta, - formatCalendarTime, - formatCalendarDate, + formatCalendarTimeRange, + formatCalendarDateRange, isCalendarEventKind } from '@/lib/calendar-event' import { tagNameEquals } from '@/lib/tag' @@ -13,8 +13,9 @@ import { useSecondaryPage } from '@/PageManager' import { Event } from 'nostr-tools' import { useTranslation } from 'react-i18next' import { useMemo } from 'react' +import Collapsible from '../Collapsible' import { Button } from '../ui/button' -import { Calendar, Video, CheckCircle, HelpCircle, XCircle } from 'lucide-react' +import { Calendar, Clock, Video, CheckCircle, HelpCircle, XCircle } from 'lucide-react' import { cn } from '@/lib/utils' import { DropdownMenu, @@ -84,32 +85,43 @@ export default function CalendarEventContent({ } } + const scheduleLine = isDateBased + ? (startDate || endDate) && formatCalendarDateRange(startDate, endDate) + : start != null && !isNaN(start) + ? formatCalendarTimeRange(start, end != null && !isNaN(end) ? end : undefined) + : null + return (
e.stopPropagation()} > -
+
{image ? ( ) : ( - +
+ +
)} -
- +
+

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

{topics.length > 0 && ( -
+
{topics.map((topic) => ( #{topic} @@ -118,32 +130,23 @@ export default function CalendarEventContent({ )}
- {isDateBased ? ( - (startDate || endDate) && ( -
- {startDate ? formatCalendarDate(startDate) : ''} - {endDate && endDate !== startDate && ( - <> – {formatCalendarDate(endDate)} - )} -
- ) - ) : ( - start != null && - !isNaN(start) && ( -
- {formatCalendarTime(start)} - {end != null && !isNaN(end) && end > start && ( - <> – {formatCalendarTime(end)} - )} -
- ) - )} - {description && ( -

- {description} -

- )} -
+ {scheduleLine ? ( +
+ +

{scheduleLine}

+
+ ) : null} + {description ? ( + <> + {/* NIP-52 31922/31923: collapse long summary+body only; card chrome stays outside MainNoteCard Collapsible. */} + +

+ {description} +

+
+ + ) : null} + {attendeesList.length > 0 && ( -
-
{t('Attendees')}
-
    +
    +
    + {t('Attendees')} +
    +
      {attendeesList.map(({ pubkey, status, isOrganizer }) => (
- )} + ) : null}
) } diff --git a/src/components/ContentPreview/index.tsx b/src/components/ContentPreview/index.tsx index 05025070..2b1aeee6 100644 --- a/src/components/ContentPreview/index.tsx +++ b/src/components/ContentPreview/index.tsx @@ -10,6 +10,10 @@ import { DISCUSSION_UPVOTE_DISPLAY } from '@/lib/discussion-votes' import { getWebBookmarkArticleUrl } from '@/lib/rss-article' +import { + getParentReplyBlurbDisplayText, + parentReplyPollQuestionBlurb +} from '@/lib/parent-reply-blurb' import { cn } from '@/lib/utils' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useMuteListOptional } from '@/contexts/mute-list-context' @@ -48,14 +52,6 @@ const CONTENT_PREVIEW_HOOK_PLACEHOLDER = { sig: '' } as Event -const PARENT_REPLY_POLL_BLURB_MAX = 150 - -function parentReplyPollQuestionBlurb(content: string): string { - const normalized = content.trim().replace(/\s+/g, ' ') - if (normalized.length <= PARENT_REPLY_POLL_BLURB_MAX) return normalized - return `${normalized.slice(0, PARENT_REPLY_POLL_BLURB_MAX)}…` -} - /** Keep spacing/margins on the outer wrapper; put line-clamp on the preview body so it still clamps text. */ function splitPreviewLayoutClasses(className?: string) { if (!className?.trim()) return { outer: undefined, body: undefined } @@ -143,10 +139,30 @@ export default function ContentPreview({ ExtendedKind.PUBLIC_MESSAGE ].includes(event.kind) ) { + if (forParentReplyBlurb) { + const line = getParentReplyBlurbDisplayText(previewEvent) + return ( +
+
+ {line || `[${t('Note')}]`} +
+
+ ) + } return withKindRow() } if (event.kind === ExtendedKind.DISCUSSION) { + if (forParentReplyBlurb) { + const line = getParentReplyBlurbDisplayText(previewEvent) + return ( +
+
+ {line || `[${t('Discussion')}]`} +
+
+ ) + } return (
@@ -158,6 +174,14 @@ export default function ContentPreview({ } if (event.kind === kinds.Highlights) { + if (forParentReplyBlurb) { + const line = getParentReplyBlurbDisplayText(previewEvent) + return ( +
+
{line || t('Highlight')}
+
+ ) + } return withKindRow() } @@ -165,6 +189,13 @@ export default function ContentPreview({ const href = getWebBookmarkArticleUrl(previewEvent) const title = previewEvent.tags.find((t) => t[0] === 'title')?.[1]?.trim() const line = title?.trim() || href?.trim() || t('Web bookmark') + if (forParentReplyBlurb) { + return ( +
+
{line}
+
+ ) + } return withKindRow(
{line}
) } @@ -181,34 +212,100 @@ export default function ContentPreview({ } if (event.kind === kinds.LongFormArticle) { + if (forParentReplyBlurb) { + const line = getParentReplyBlurbDisplayText(previewEvent) + return ( +
+
+ {line || `[${t('Long-form Article')}]`} +
+
+ ) + } return withKindRow() } if (isNip71StyleVideoKind(event.kind)) { + if (forParentReplyBlurb) { + const line = getParentReplyBlurbDisplayText(previewEvent) + return ( +
+
{line || t('Video')}
+
+ ) + } return withKindRow() } if (event.kind === ExtendedKind.PICTURE) { + if (forParentReplyBlurb) { + const line = getParentReplyBlurbDisplayText(previewEvent) + return ( +
+
{line || t('Image')}
+
+ ) + } return withKindRow() } if (event.kind === ExtendedKind.GROUP_METADATA) { + if (forParentReplyBlurb) { + const line = getParentReplyBlurbDisplayText(previewEvent) + return ( +
+
{line || t('Group')}
+
+ ) + } return withKindRow() } if (event.kind === kinds.CommunityDefinition) { + if (forParentReplyBlurb) { + const line = getParentReplyBlurbDisplayText(previewEvent) + return ( +
+
{line || t('Community')}
+
+ ) + } return withKindRow() } if (event.kind === kinds.LiveEvent) { + if (forParentReplyBlurb) { + const line = getParentReplyBlurbDisplayText(previewEvent) + return ( +
+
{line || t('Live event')}
+
+ ) + } return withKindRow() } if (event.kind === ExtendedKind.ZAP_REQUEST) { + if (forParentReplyBlurb) { + const line = getParentReplyBlurbDisplayText(previewEvent) + return ( +
+
{line || t('Zap')}
+
+ ) + } return withKindRow() } if (event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === kinds.Zap) { + if (forParentReplyBlurb) { + const line = getParentReplyBlurbDisplayText(previewEvent) + return ( +
+
{line || t('Zap')}
+
+ ) + } if (previewDensity === 'compact') { return (
@@ -220,14 +317,38 @@ export default function ContentPreview({ } if (event.kind === ExtendedKind.APPLICATION_HANDLER_INFO) { + if (forParentReplyBlurb) { + const line = getParentReplyBlurbDisplayText(previewEvent) + return ( +
+
{line || t('Note')}
+
+ ) + } return withKindRow() } if (event.kind === ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION) { + if (forParentReplyBlurb) { + const line = getParentReplyBlurbDisplayText(previewEvent) + return ( +
+
{line || t('Note')}
+
+ ) + } return withKindRow() } if (event.kind === ExtendedKind.FOLLOW_PACK) { + if (forParentReplyBlurb) { + const line = getParentReplyBlurbDisplayText(previewEvent) + return ( +
+
{line || t('Follow Pack')}
+
+ ) + } return withKindRow() } @@ -236,6 +357,14 @@ export default function ContentPreview({ event.kind === ExtendedKind.GIT_ISSUE || event.kind === ExtendedKind.GIT_RELEASE ) { + if (forParentReplyBlurb) { + const line = getParentReplyBlurbDisplayText(previewEvent) + return ( +
+
{line || t('Note')}
+
+ ) + } return withKindRow() } diff --git a/src/components/Embedded/EmbeddedCalendarEvent.tsx b/src/components/Embedded/EmbeddedCalendarEvent.tsx index 2b146618..61ca077e 100644 --- a/src/components/Embedded/EmbeddedCalendarEvent.tsx +++ b/src/components/Embedded/EmbeddedCalendarEvent.tsx @@ -1,14 +1,15 @@ import { getCalendarEventMeta, - formatCalendarTime, - formatCalendarDate, + formatCalendarTimeRange, + formatCalendarDateRange, isCalendarEventKind } from '@/lib/calendar-event' import { cn } from '@/lib/utils' import { Event } from 'nostr-tools' import { useTranslation } from 'react-i18next' +import Collapsible from '../Collapsible' import { Button } from '../ui/button' -import { Calendar, Video } from 'lucide-react' +import { Calendar, Clock, Video } from 'lucide-react' export function EmbeddedCalendarEvent({ event, @@ -23,35 +24,43 @@ export function EmbeddedCalendarEvent({ getCalendarEventMeta(event) const description = summary || event.content?.trim() || '' + const scheduleLine = isDateBased + ? (startDate || endDate) && formatCalendarDateRange(startDate, endDate) + : start != null && !isNaN(start) + ? formatCalendarTimeRange(start, end != null && !isNaN(end) ? end : undefined) + : null + return (
e.stopPropagation()} > -
+
{image ? ( ) : ( - +
+ +
)} -
- +
+ {title || t('Scheduled video call')} {topics.length > 0 && ( -
+
{topics.map((topic) => ( #{topic} @@ -60,31 +69,22 @@ export function EmbeddedCalendarEvent({ )}
- {isDateBased ? ( - (startDate || endDate) && ( -
- {startDate ? formatCalendarDate(startDate) : ''} - {endDate && endDate !== startDate && ( - <> – {formatCalendarDate(endDate)} - )} -
- ) - ) : ( - start != null && - !isNaN(start) && ( -
- {formatCalendarTime(start)} - {end != null && !isNaN(end) && end > start && ( - <> – {formatCalendarTime(end)} - )} -
- ) - )} - {description && ( -

- {description} -

- )} + {scheduleLine ? ( +
+ +

{scheduleLine}

+
+ ) : null} + {description ? ( + <> + {/* NIP-52 31922/31923 embedded preview: long description only. */} + +

+ {description} +

+
+ + ) : null} {joinUrl && ( - ) : null} -
- ) : null} - {otherCount > 0 ? ( - - {t('Relay pulse others', { count: otherCount })} - - ) : null} -
- ) -} - /** Home feed / mobile: full label above the page title */ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: string }) { const { t } = useTranslation() const { navigate } = usePrimaryPage() const { pubkey } = useNostr() - const { - followCount, - otherCount, - totalCount, - loading, - relayActivityReady, - lastFetchedAtMs - } = useFavoriteRelaysActivity() + const { followCount, totalCount, loading, relayActivityReady, lastFetchedAtMs } = useFavoriteRelaysActivity() const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t) @@ -147,6 +89,18 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?:

{t('Relay pulse')}

+ {pubkey && followCount > 0 ? ( + + ) : null}
{lastFetchedAtMs != null && relativeLabel ? (

@@ -154,14 +108,6 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?:

) : null}
- navigate('follows-latest') : undefined} - />
) @@ -172,14 +118,7 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st const { t } = useTranslation() const { navigate } = usePrimaryPage() const { pubkey } = useNostr() - const { - followCount, - otherCount, - totalCount, - loading, - relayActivityReady, - lastFetchedAtMs - } = useFavoriteRelaysActivity() + const { followCount, totalCount, loading, relayActivityReady, lastFetchedAtMs } = useFavoriteRelaysActivity() const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t) @@ -266,15 +205,6 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st ) : null}
-
- navigate('follows-latest') : undefined} - /> -
) } diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index bd143c4a..e35c86cd 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -29,6 +29,7 @@ import { muteSetHas } from '@/lib/mute-set' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import type { HighlightData } from '@/components/PostEditor/HighlightEditor' import { Event, kinds } from 'nostr-tools' +import { isCalendarEventKind } from '@/lib/calendar-event' import { mergeTranslatedNote, useNoteTranslation } from '@/lib/note-translation-display' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -186,8 +187,7 @@ export default function Note({ event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT || event.kind === ExtendedKind.DISCUSSION || - event.kind === ExtendedKind.CALENDAR_EVENT_TIME || - event.kind === ExtendedKind.CALENDAR_EVENT_DATE || + isCalendarEventKind(event.kind) || event.kind === ExtendedKind.COMMENT const renderEventContent = useCallback( @@ -395,7 +395,7 @@ export default function Note({ content = } else if (event.kind === ExtendedKind.RELAY_REVIEW) { content = - } else if (event.kind === ExtendedKind.CALENDAR_EVENT_TIME || event.kind === ExtendedKind.CALENDAR_EVENT_DATE) { + } else if (isCalendarEventKind(event.kind)) { content = } else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { content = renderEventContent({ hideMetadata: true }) diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx index 1f2f55a6..d60060e6 100644 --- a/src/components/NoteCard/MainNoteCard.tsx +++ b/src/components/NoteCard/MainNoteCard.tsx @@ -1,4 +1,4 @@ -import { ExtendedKind } from '@/constants' +import { ExtendedKind, isNip52CalendarCardKind } from '@/constants' import { Separator } from '@/components/ui/separator' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { toNote } from '@/lib/link' @@ -42,6 +42,8 @@ export default function MainNoteCard({ const { navigateToNote } = useSmartNoteNavigationOptional() const isZapFeedCard = event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === ExtendedKind.ZAP_REQUEST + /** NIP-52 kinds 31922 / 31923: card-level {@link Collapsible} clips the stats row; description collapses inside the card. */ + const isCalendarNoteKind = isNip52CalendarCardKind(event.kind) const showNoteStatsRow = !embedded || isZapFeedCard return ( @@ -94,7 +96,7 @@ export default function MainNoteCard({
)} - + { + if (ready) return ready + unblockedPaint = true + return true + }) + if (unblockedPaint) { + feedPaintLiveRelayDoneRef.current = true + setFeedEmptyToastGateTick((n) => n + 1) + } }, loadingSafetyMs) return () => { cancelled = true diff --git a/src/components/Sidebar/SidebarCalendarWeekWidget.tsx b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx new file mode 100644 index 00000000..51413fd7 --- /dev/null +++ b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx @@ -0,0 +1,278 @@ +import { + calendarOccurrenceOverlapsRange, + formatCalendarSidebarRow, + formatSidebarWeekLabel, + getCalendarOccurrenceWindowMs, + getLocalMondayWeekBounds +} from '@/lib/calendar-event' +import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { replaceableEventDedupeKey } from '@/lib/event' +import { toNote } from '@/lib/link' +import { cn } from '@/lib/utils' +import { useSmartNoteNavigation } from '@/PageManager' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useFollowListOptional } from '@/providers/follow-list-context' +import { useNostr } from '@/providers/NostrProvider' +import client from '@/services/client.service' +import indexedDb from '@/services/indexed-db.service' +import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' +import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' +import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react' +import { type Event } from 'nostr-tools' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button } from '@/components/ui/button' + +/** Global calendar REQ: relays often cap; larger limit reduces “missing” older-published rows for this week. */ +const FETCH_LIMIT = 1200 +/** Supplementary `authors` REQ: community calls (e.g. Edufeed) may not appear in the global slice. */ +const FOLLOWING_CALENDAR_AUTHORS_CAP = 200 +const FOLLOWING_CALENDAR_AUTHORS_CHUNK = 80 +const FOLLOWING_CALENDAR_CHUNK_LIMIT = 350 +/** ~5 note rows at ~48px each */ +const LIST_MAX_HEIGHT_PX = 240 +const SIDEBAR_CALENDAR_MAX_RELAYS = 24 +/** Merge session cache so events already loaded in feeds (but missed by this REQ) still appear. */ +const SESSION_CALENDAR_MERGE_CAP = 5000 + +function dedupeCalendarEvents(events: Event[]): Event[] { + const map = new Map() + for (const e of events) { + const k = replaceableEventDedupeKey(e) + const prev = map.get(k) + if (!prev || e.created_at > prev.created_at) map.set(k, e) + } + return [...map.values()] +} + +export default function SidebarCalendarWeekWidget() { + const { t } = useTranslation() + const { relayList, pubkey } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const followList = useFollowListOptional() + const { navigateToNote } = useSmartNoteNavigation() + + const [weekOffset, setWeekOffset] = useState(0) + const [rawEvents, setRawEvents] = useState([]) + const [loading, setLoading] = useState(false) + + const relayUrls = useMemo(() => { + const base = getRelayUrlsWithFavoritesFastReadAndInbox( + favoriteRelays, + blockedRelays, + userReadRelaysWithHttp(relayList), + { + userWriteRelays: relayList?.write ?? [], + applySocialKindBlockedFilter: false + } + ) + return appendCuratedReadOnlyRelays(base, blockedRelays).slice(0, SIDEBAR_CALENDAR_MAX_RELAYS) + }, [favoriteRelays, blockedRelays, relayList]) + + const relayKey = useMemo(() => [...relayUrls].sort().join('|'), [relayUrls]) + + const followAuthorsKey = useMemo(() => { + const raw = followList?.followings ?? [] + if (!raw.length && !pubkey) return '' + const set = new Set() + for (const p of raw) { + const k = p?.trim().toLowerCase() + if (k) set.add(k) + } + if (pubkey) set.add(pubkey.toLowerCase()) + return [...set].sort().join('|') + }, [followList?.followings, pubkey]) + + const { weekLabel, sortedForWeek } = useMemo(() => { + const { weekStartMs: ws, weekEndExclusiveMs: we } = getLocalMondayWeekBounds(weekOffset) + const label = formatSidebarWeekLabel(ws, we) + const rows: { event: Event; sortKey: number }[] = [] + for (const ev of rawEvents) { + if (!calendarOccurrenceOverlapsRange(ev, ws, we)) continue + const win = getCalendarOccurrenceWindowMs(ev) + if (!win) continue + rows.push({ event: ev, sortKey: win.startMs }) + } + rows.sort((a, b) => a.sortKey - b.sortKey) + return { + weekLabel: label, + sortedForWeek: rows.map((r) => r.event) + } + }, [rawEvents, weekOffset]) + + useEffect(() => { + let cancelled = false + let lateMergeTimer: number | null = null + setLoading(true) + void (async () => { + try { + const { weekStartMs, weekEndExclusiveMs } = getLocalMondayWeekBounds(weekOffset) + const fromIdb = await indexedDb.getCalendarEventsForOccurrenceWindow(weekStartMs, weekEndExclusiveMs) + + 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) + return + } + + const batch = await client.fetchEvents( + relayUrls, + { + kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], + limit: FETCH_LIMIT + }, + { + cache: true, + globalTimeout: 22_000, + eoseTimeout: 3500, + firstRelayResultGraceMs: false + } + ) + if (cancelled) return + + const fromFollowing: Event[] = [] + 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) + } + } + + const fromSession = client.getSessionEventsMatchingSearch( + '', + SESSION_CALENDAR_MERGE_CAP, + [...CALENDAR_EVENT_KINDS] + ) + setRawEvents(dedupeCalendarEvents([...batch, ...fromFollowing, ...fromSession, ...fromIdb])) + lateMergeTimer = window.setTimeout(() => { + lateMergeTimer = null + if (cancelled) return + const later = client.getSessionEventsMatchingSearch( + '', + SESSION_CALENDAR_MERGE_CAP, + [...CALENDAR_EVENT_KINDS] + ) + setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later])) + }, 2500) + } catch { + if (!cancelled) setRawEvents([]) + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + if (lateMergeTimer != null) window.clearTimeout(lateMergeTimer) + } + }, [relayKey, followAuthorsKey, weekOffset]) + + const openEvent = useCallback( + (ev: Event) => { + navigateToNote(toNote(ev), ev) + }, + [navigateToNote] + ) + + return ( +
+
+ + + {weekLabel} + + +
+

+ {t('sidebarCalendarHeading')} +

+ {loading && sortedForWeek.length === 0 ? ( +
+ + {t('sidebarCalendarLoading')} +
+ ) : !relayUrls.length ? ( +

{t('sidebarCalendarNoRelays')}

+ ) : sortedForWeek.length === 0 ? ( +

{t('sidebarCalendarEmptyWeek')}

+ ) : ( +
    + {sortedForWeek.map((ev) => { + const title = ev.tags.find((t) => t[0] === 'title')?.[1]?.trim() || t('Scheduled video call') + const sub = formatCalendarSidebarRow(ev) + return ( +
  • + +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index ab2c3f70..44d3faa9 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -17,6 +17,7 @@ import { FavoriteRelaysActiveStripSidebar } from '@/components/FavoriteRelaysAct import PaneModeToggle from './PaneModeToggle' import DownloadDesktopSidebarButton from './DownloadDesktopSidebarButton' import LiveActivitiesStrip from '@/components/LiveActivitiesStrip' +import SidebarCalendarWeekWidget from './SidebarCalendarWeekWidget' import { ReadOnlySessionIndicator } from '@/components/ReadOnlySessionIndicator' export default function PrimaryPageSidebar() { @@ -36,9 +37,6 @@ export default function PrimaryPageSidebar() {
-
- -
@@ -51,6 +49,10 @@ export default function PrimaryPageSidebar() { +
+ + +
diff --git a/src/constants.ts b/src/constants.ts index f51a8794..0eac290b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -738,7 +738,15 @@ export const READ_ALOUD_KINDS: readonly number[] = [ export const CALENDAR_EVENT_KINDS = [ ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME -] +] as const + +/** + * NIP-52 calendar **note** kinds only: **31922** (date-based) and **31923** (time-based). + * Excludes RSVP kind 31925. Prefer this or {@link CALENDAR_EVENT_KINDS} so UI stays aligned with NIP-52. + */ +export function isNip52CalendarCardKind(kind: number): boolean { + return (CALENDAR_EVENT_KINDS as readonly number[]).includes(kind) +} /** Maximum invitees for calendar event group invites (one kind 24 with all as p-tags). */ export const MAX_CALENDAR_INVITEES = 10 diff --git a/src/hooks/useFetchCalendarRsvps.tsx b/src/hooks/useFetchCalendarRsvps.tsx index 0a0a31d0..e8c23d8f 100644 --- a/src/hooks/useFetchCalendarRsvps.tsx +++ b/src/hooks/useFetchCalendarRsvps.tsx @@ -1,8 +1,12 @@ import { ExtendedKind } from '@/constants' -import { getReplaceableCoordinateFromEvent } from '@/lib/event' +import { + getReplaceableCoordinateFromEvent, + normalizeReplaceableCoordinateString +} from '@/lib/event' import { isCalendarEventKind } from '@/lib/calendar-event' import client from '@/services/client.service' import { queryService } from '@/services/client.service' +import indexedDb from '@/services/indexed-db.service' import { useNostr } from '@/providers/NostrProvider' import { Event } from 'nostr-tools' import { useEffect, useState } from 'react' @@ -25,6 +29,14 @@ function mergeRsvp(prev: Event[], evt: Event): Event[] { return [...withoutSamePubkey, evt].sort((a, b) => b.created_at - a.created_at) } +/** Apply RSVPs in time order so the latest per pubkey wins (matches relay merge semantics). */ +function mergeRsvpList(events: Event[]): Event[] { + const asc = [...events].sort((a, b) => a.created_at - b.created_at) + let acc: Event[] = [] + for (const e of asc) acc = mergeRsvp(acc, e) + return acc +} + export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { const { relayList } = useNostr() const [rsvps, setRsvps] = useState([]) @@ -39,35 +51,49 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { let cancelled = false setIsFetching(true) - const coordinate = getReplaceableCoordinateFromEvent(calendarEvent) + const coordinate = normalizeReplaceableCoordinateString( + getReplaceableCoordinateFromEvent(calendarEvent) + ) const userRead = userReadRelaysWithHttp(relayList) - const baseUrls = new Set([ - ...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url), - ...userRead.map((url) => normalizeAnyRelayUrl(url) || url) - ].filter(Boolean) as string[]) - // Include organizer's relays so RSVPs are found when viewing an attendee's profile (RSVPs are often on organizer's outbox/inbox) - const organizerPubkey = calendarEvent.pubkey - client - .fetchRelayList(organizerPubkey) - .then((organizerRelays) => { - if (cancelled) return - ;[ - ...(organizerRelays?.httpRead ?? []), - ...(organizerRelays?.read ?? []), - ...(organizerRelays?.httpWrite ?? []), - ...(organizerRelays?.write ?? []) - ].forEach((url) => { - const u = normalizeAnyRelayUrl(url) - if (u) baseUrls.add(u) - }) - return Array.from(baseUrls) - }) - .catch(() => Array.from(baseUrls)) - .then((relayUrls: string[] | undefined) => { + void (async () => { + let fromIdb: Event[] = [] + try { + fromIdb = await indexedDb.getCalendarRsvpEventsByParentCoordinate(coordinate) + } catch { + fromIdb = [] + } + if (cancelled) return + if (fromIdb.length) setRsvps(fromIdb) + + const baseUrls = new Set([ + ...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url), + ...userRead.map((url) => normalizeAnyRelayUrl(url) || url) + ].filter(Boolean) as string[]) + + const organizerPubkey = calendarEvent.pubkey + try { + let relayUrls: string[] + try { + const organizerRelays = await client.fetchRelayList(organizerPubkey) + if (!cancelled) { + ;[ + ...(organizerRelays?.httpRead ?? []), + ...(organizerRelays?.read ?? []), + ...(organizerRelays?.httpWrite ?? []), + ...(organizerRelays?.write ?? []) + ].forEach((url) => { + const u = normalizeAnyRelayUrl(url) + if (u) baseUrls.add(u) + }) + } + relayUrls = Array.from(baseUrls) + } catch { + relayUrls = Array.from(baseUrls) + } if (cancelled) return const urls = relayUrls?.length ? relayUrls : Array.from(baseUrls) - return queryService.fetchEvents( + const events = await queryService.fetchEvents( urls, { kinds: [ExtendedKind.CALENDAR_EVENT_RSVP], @@ -76,14 +102,12 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { }, { firstRelayResultGraceMs: false } ) - }) - .then((events) => { if (cancelled) return - setRsvps(events ?? []) - }) - .finally(() => { + setRsvps(mergeRsvpList([...fromIdb, ...(events ?? [])])) + } finally { if (!cancelled) setIsFetching(false) - }) + } + })() return () => { cancelled = true @@ -94,12 +118,15 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { useEffect(() => { if (!calendarEvent || !isCalendarEventKind(calendarEvent.kind)) return - const coordinate = getReplaceableCoordinateFromEvent(calendarEvent) + const coordinate = normalizeReplaceableCoordinateString( + getReplaceableCoordinateFromEvent(calendarEvent) + ) const handler = (e: CustomEvent) => { const evt = e.detail if (evt.kind !== ExtendedKind.CALENDAR_EVENT_RSVP) return const aTag = evt.tags.find(tagNameEquals('a')) - if (aTag?.[1] !== coordinate) return + const aCoord = aTag?.[1] ? normalizeReplaceableCoordinateString(aTag[1]) : '' + if (aCoord !== coordinate) return setRsvps((prev) => mergeRsvp(prev, evt)) } diff --git a/src/i18n/locales/cs.ts b/src/i18n/locales/cs.ts index 01f091b6..d4c8a2ba 100644 --- a/src/i18n/locales/cs.ts +++ b/src/i18n/locales/cs.ts @@ -754,6 +754,12 @@ export default { "Added from follows web bookmarks": "Added from follows web bookmarks", "Nothing to load for this feed.": "Nothing to load for this feed.", "No posts loaded for this feed. Try refreshing.": "No posts loaded for this feed. Try refreshing.", + sidebarCalendarHeading: "This week's events", + sidebarCalendarPreviousWeek: "Previous week", + sidebarCalendarNextWeek: "Next week", + sidebarCalendarEmptyWeek: "No calendar events this week.", + sidebarCalendarLoading: "Loading…", + sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.", "Looking for more events…": "Looking for more events…", "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)", diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 2503a5ed..0f3df0b5 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -754,6 +754,12 @@ export default { "Added from follows web bookmarks": "Added from follows web bookmarks", "Nothing to load for this feed.": "Für diesen Feed gibt es nichts zu laden.", "No posts loaded for this feed. Try refreshing.": "Keine Beiträge für diesen Feed geladen. Bitte aktualisieren.", + sidebarCalendarHeading: "Termine dieser Woche", + sidebarCalendarPreviousWeek: "Vorherige Woche", + sidebarCalendarNextWeek: "Nächste Woche", + sidebarCalendarEmptyWeek: "Keine Kalender-Termine in dieser Woche.", + sidebarCalendarLoading: "Laden…", + sidebarCalendarNoRelays: "Lese-Relays in den Einstellungen eintragen, um Kalender-Termine zu laden.", "Looking for more events…": "Looking for more events…", "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Die Relays haben keine Ereignisse für diesen Feed geliefert. Sie können offline sein, langsam antworten oder diese Notizen nicht indexieren.", "Per-relay timeline results ({{count}} connections)": "Ergebnis je Relay ({{count}} Verbindungen)", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index b19fa090..327b927a 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -758,6 +758,12 @@ export default { "Added from follows web bookmarks": "Added from follows web bookmarks", "Nothing to load for this feed.": "Nothing to load for this feed.", "No posts loaded for this feed. Try refreshing.": "No posts loaded for this feed. Try refreshing.", + sidebarCalendarHeading: "This week's events", + sidebarCalendarPreviousWeek: "Previous week", + sidebarCalendarNextWeek: "Next week", + sidebarCalendarEmptyWeek: "No calendar events this week.", + sidebarCalendarLoading: "Loading…", + sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.", "Looking for more events…": "Looking for more events…", "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index d979a3fc..97448037 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -754,6 +754,12 @@ export default { "Added from follows web bookmarks": "Added from follows web bookmarks", "Nothing to load for this feed.": "Nothing to load for this feed.", "No posts loaded for this feed. Try refreshing.": "No posts loaded for this feed. Try refreshing.", + sidebarCalendarHeading: "This week's events", + sidebarCalendarPreviousWeek: "Previous week", + sidebarCalendarNextWeek: "Next week", + sidebarCalendarEmptyWeek: "No calendar events this week.", + sidebarCalendarLoading: "Loading…", + sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.", "Looking for more events…": "Looking for more events…", "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 78456cc9..cffa7520 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -754,6 +754,12 @@ export default { "Added from follows web bookmarks": "Added from follows web bookmarks", "Nothing to load for this feed.": "Nothing to load for this feed.", "No posts loaded for this feed. Try refreshing.": "No posts loaded for this feed. Try refreshing.", + sidebarCalendarHeading: "This week's events", + sidebarCalendarPreviousWeek: "Previous week", + sidebarCalendarNextWeek: "Next week", + sidebarCalendarEmptyWeek: "No calendar events this week.", + sidebarCalendarLoading: "Loading…", + sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.", "Looking for more events…": "Looking for more events…", "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)", diff --git a/src/i18n/locales/nl.ts b/src/i18n/locales/nl.ts index 01f091b6..d4c8a2ba 100644 --- a/src/i18n/locales/nl.ts +++ b/src/i18n/locales/nl.ts @@ -754,6 +754,12 @@ export default { "Added from follows web bookmarks": "Added from follows web bookmarks", "Nothing to load for this feed.": "Nothing to load for this feed.", "No posts loaded for this feed. Try refreshing.": "No posts loaded for this feed. Try refreshing.", + sidebarCalendarHeading: "This week's events", + sidebarCalendarPreviousWeek: "Previous week", + sidebarCalendarNextWeek: "Next week", + sidebarCalendarEmptyWeek: "No calendar events this week.", + sidebarCalendarLoading: "Loading…", + sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.", "Looking for more events…": "Looking for more events…", "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)", diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index b6c0fd18..4ebb4785 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -754,6 +754,12 @@ export default { "Added from follows web bookmarks": "Added from follows web bookmarks", "Nothing to load for this feed.": "Nothing to load for this feed.", "No posts loaded for this feed. Try refreshing.": "No posts loaded for this feed. Try refreshing.", + sidebarCalendarHeading: "This week's events", + sidebarCalendarPreviousWeek: "Previous week", + sidebarCalendarNextWeek: "Next week", + sidebarCalendarEmptyWeek: "No calendar events this week.", + sidebarCalendarLoading: "Loading…", + sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.", "Looking for more events…": "Looking for more events…", "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index a8ac8fba..13a07d52 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -754,6 +754,12 @@ export default { "Added from follows web bookmarks": "Added from follows web bookmarks", "Nothing to load for this feed.": "Nothing to load for this feed.", "No posts loaded for this feed. Try refreshing.": "No posts loaded for this feed. Try refreshing.", + sidebarCalendarHeading: "This week's events", + sidebarCalendarPreviousWeek: "Previous week", + sidebarCalendarNextWeek: "Next week", + sidebarCalendarEmptyWeek: "No calendar events this week.", + sidebarCalendarLoading: "Loading…", + sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.", "Looking for more events…": "Looking for more events…", "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)", diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index 01f091b6..d4c8a2ba 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -754,6 +754,12 @@ export default { "Added from follows web bookmarks": "Added from follows web bookmarks", "Nothing to load for this feed.": "Nothing to load for this feed.", "No posts loaded for this feed. Try refreshing.": "No posts loaded for this feed. Try refreshing.", + sidebarCalendarHeading: "This week's events", + sidebarCalendarPreviousWeek: "Previous week", + sidebarCalendarNextWeek: "Next week", + sidebarCalendarEmptyWeek: "No calendar events this week.", + sidebarCalendarLoading: "Loading…", + sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.", "Looking for more events…": "Looking for more events…", "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 1d13df71..8fe7baf8 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -754,6 +754,12 @@ export default { "Added from follows web bookmarks": "Added from follows web bookmarks", "Nothing to load for this feed.": "Nothing to load for this feed.", "No posts loaded for this feed. Try refreshing.": "No posts loaded for this feed. Try refreshing.", + sidebarCalendarHeading: "This week's events", + sidebarCalendarPreviousWeek: "Previous week", + sidebarCalendarNextWeek: "Next week", + sidebarCalendarEmptyWeek: "No calendar events this week.", + sidebarCalendarLoading: "Loading…", + sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.", "Looking for more events…": "Looking for more events…", "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)", diff --git a/src/lib/calendar-event.ts b/src/lib/calendar-event.ts index 92ffd8d0..c73f1b9b 100644 --- a/src/lib/calendar-event.ts +++ b/src/lib/calendar-event.ts @@ -1,4 +1,4 @@ -import { ExtendedKind } from '@/constants' +import { ExtendedKind, isNip52CalendarCardKind } from '@/constants' import { tagNameEquals } from '@/lib/tag' import { Event } from 'nostr-tools' @@ -60,21 +60,225 @@ export function getCalendarEventMeta(event: Event): CalendarEventMeta { } } +const CALENDAR_DISPLAY_LOCALE = 'en-US' + +function readFormatParts( + d: Date, + opts: Intl.DateTimeFormatOptions +): Record { + const out: Partial> = {} + for (const p of new Intl.DateTimeFormat(CALENDAR_DISPLAY_LOCALE, opts).formatToParts(d)) { + if (p.type !== 'literal') out[p.type] = p.value + } + return out as Record +} + +/** + * Single instant: explicit English month + day + year + 12-hour clock + short timezone + * (e.g. `May 13, 2025 10:30 am EST`) in the viewer's local zone — avoids DD/MM vs MM/DD ambiguity. + */ export function formatCalendarTime(ts: number): string { const d = new Date(ts * 1000) - return d.toLocaleString(undefined, { - dateStyle: 'medium', - timeStyle: 'short' + const p = readFormatParts(d, { + month: 'long', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + timeZoneName: 'short' }) + const ap = (p.dayPeriod ?? '').toLowerCase() + const tz = p.timeZoneName ?? '' + return `${p.month} ${p.day}, ${p.year} ${p.hour}:${p.minute} ${ap} ${tz}`.trim() } -/** Format a YYYY-MM-DD date string for display. */ +/** `start` / `end` Unix seconds; omits end time if invalid or not after start. Same calendar day → one date line. */ +export function formatCalendarTimeRange(start: number, end: number | undefined): string { + const startLine = formatCalendarTime(start) + if (end == null || Number.isNaN(end) || end <= start) return startLine + + const a = new Date(start * 1000) + const b = new Date(end * 1000) + const sameLocalDay = + a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() + + if (!sameLocalDay) { + return `${formatCalendarTime(start)} – ${formatCalendarTime(end)}` + } + + const dateOnly = readFormatParts(a, { + month: 'long', + day: 'numeric', + year: 'numeric' + }) + const dateStr = `${dateOnly.month} ${dateOnly.day}, ${dateOnly.year}` + + const pStart = readFormatParts(a, { + hour: 'numeric', + minute: '2-digit', + hour12: true, + timeZoneName: 'short' + }) + const pEnd = readFormatParts(b, { + hour: 'numeric', + minute: '2-digit', + hour12: true + }) + const apS = (pStart.dayPeriod ?? '').toLowerCase() + const apE = (pEnd.dayPeriod ?? '').toLowerCase() + const tz = pStart.timeZoneName ?? '' + return `${dateStr} · ${pStart.hour}:${pStart.minute} ${apS} – ${pEnd.hour}:${pEnd.minute} ${apE} ${tz}`.trim() +} + +/** Format a YYYY-MM-DD date string for display (English long month, unambiguous). */ export function formatCalendarDate(dateStr: string): string { if (!dateStr) return '' - const d = new Date(dateStr + 'T00:00:00') - return d.toLocaleDateString(undefined, { dateStyle: 'long' }) + const d = new Date(dateStr + 'T12:00:00') + const p = readFormatParts(d, { month: 'long', day: 'numeric', year: 'numeric' }) + return `${p.month} ${p.day}, ${p.year}` } +/** Inclusive start and exclusive end (NIP-52); omits end when same as start. */ +export function formatCalendarDateRange(startDate: string, endDate: string): string { + if (!startDate?.trim() && !endDate?.trim()) return '' + if (!startDate?.trim()) return formatCalendarDate(endDate) + const a = formatCalendarDate(startDate) + if (!endDate?.trim() || endDate === startDate) return a + return `${a} – ${formatCalendarDate(endDate)}` +} + +/** True for NIP-52 calendar note kinds **31922** / **31923** only (via {@link isNip52CalendarCardKind}). */ export function isCalendarEventKind(kind: number): boolean { - return kind === ExtendedKind.CALENDAR_EVENT_DATE || kind === ExtendedKind.CALENDAR_EVENT_TIME + return isNip52CalendarCardKind(kind) +} + +/** Local midnight at start of `YYYY-MM-DD`; invalid pattern → null. */ +export function parseCalendarYmdToLocalStartMs(ymd: string): number | null { + const t = ymd?.trim() + if (!/^\d{4}-\d{2}-\d{2}$/.test(t)) return null + const [y, mo, d] = t.split('-').map(Number) + if (!y || mo < 1 || mo > 12 || d < 1 || d > 31) return null + const ms = new Date(y, mo - 1, d, 0, 0, 0, 0).getTime() + return Number.isNaN(ms) ? null : ms +} + +/** + * Half-open window [startMs, endExclusiveMs) for overlap with a week + * [weekStartMs, weekEndExclusiveMs). Date-based uses NIP-52 exclusive `end` date. + */ +export function getCalendarOccurrenceWindowMs( + event: Event +): { startMs: number; endExclusiveMs: number } | null { + const m = getCalendarEventMeta(event) + if (m.isDateBased) { + const s = m.startDate ? parseCalendarYmdToLocalStartMs(m.startDate) : null + if (s == null) return null + if (m.endDate?.trim()) { + if (m.endDate === m.startDate) { + return { startMs: s, endExclusiveMs: s + 86400000 } + } + const e = parseCalendarYmdToLocalStartMs(m.endDate) + return { startMs: s, endExclusiveMs: e != null ? e : s + 86400000 } + } + return { startMs: s, endExclusiveMs: s + 86400000 } + } + if (m.start == null || Number.isNaN(m.start)) return null + const startMs = m.start * 1000 + const endExclusiveMs = + m.end != null && !Number.isNaN(m.end) && m.end > m.start ? m.end * 1000 : startMs + 3600000 + return { startMs, endExclusiveMs } +} + +export function calendarOccurrenceOverlapsRange( + event: Event, + rangeStartMs: number, + rangeEndExclusiveMs: number +): boolean { + const w = getCalendarOccurrenceWindowMs(event) + if (!w) return false + return w.startMs < rangeEndExclusiveMs && w.endExclusiveMs > rangeStartMs +} + +/** Monday 00:00 local through the following Monday 00:00 (exclusive), shifted by `weekOffset` weeks from the anchor week. */ +export function getLocalMondayWeekBounds( + weekOffset: number, + anchor: Date = new Date() +): { weekStartMs: number; weekEndExclusiveMs: number } { + const d = new Date(anchor) + d.setHours(0, 0, 0, 0) + const day = d.getDay() + const diffFromMonday = day === 0 ? -6 : 1 - day + const monday = new Date(d) + monday.setDate(d.getDate() + diffFromMonday + weekOffset * 7) + monday.setHours(0, 0, 0, 0) + const end = new Date(monday) + end.setDate(monday.getDate() + 7) + return { weekStartMs: monday.getTime(), weekEndExclusiveMs: end.getTime() } +} + +function toYmdLocal(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +/** Compact week banner for sidebar (en-US month names). */ +export function formatSidebarWeekLabel(weekStartMs: number, weekEndExclusiveMs: number): string { + const start = new Date(weekStartMs) + const last = new Date(weekEndExclusiveMs) + last.setDate(last.getDate() - 1) + const y1 = start.getFullYear() + const y2 = last.getFullYear() + const m1 = start.getMonth() + const m2 = last.getMonth() + const d1 = start.getDate() + const d2 = last.getDate() + if (y1 === y2 && m1 === m2 && d1 === d2) { + return formatCalendarDate(toYmdLocal(start)) + } + const p1 = readFormatParts(start, { month: 'short', day: 'numeric', year: y1 !== y2 ? 'numeric' : undefined }) + const p2 = readFormatParts(last, { month: 'short', day: 'numeric', year: 'numeric' }) + const left = + y1 !== y2 + ? `${p1.month} ${p1.day}, ${p1.year}` + : m1 === m2 + ? `${p1.month} ${p1.day}` + : `${p1.month} ${p1.day}` + const right = `${p2.month} ${p2.day}, ${p2.year}` + return `${left} – ${right}` +} + +/** One-line schedule hint for narrow sidebar rows (en-US, includes TZ for timed events). */ +export function formatCalendarSidebarRow(event: Event): string { + const m = getCalendarEventMeta(event) + if (m.isDateBased) { + if (!m.startDate) return '' + const a = formatCalendarDate(m.startDate) + if (m.endDate?.trim() && m.endDate !== m.startDate) { + return `${a} – ${formatCalendarDate(m.endDate)}` + } + return a + } + if (m.start == null || Number.isNaN(m.start)) return '' + const d = new Date(m.start * 1000) + const p = readFormatParts(d, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + timeZoneName: 'short' + }) + const ap = (p.dayPeriod ?? '').toLowerCase() + const base = `${p.month} ${p.day} · ${p.hour}:${p.minute} ${ap} ${p.timeZoneName ?? ''}`.trim() + if (m.end != null && !Number.isNaN(m.end) && m.end > m.start) { + const d2 = new Date(m.end * 1000) + const p2 = readFormatParts(d2, { + hour: 'numeric', + minute: '2-digit', + hour12: true + }) + const ap2 = (p2.dayPeriod ?? '').toLowerCase() + return `${base} – ${p2.hour}:${p2.minute} ${ap2}` + } + return base } diff --git a/src/lib/event.ts b/src/lib/event.ts index 8bf066d7..89fb9f85 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -1,4 +1,4 @@ -import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' +import { ExtendedKind, isNip52CalendarCardKind } from '@/constants' import { muteSetHas } from '@/lib/mute-set' import { EMBEDDED_EVENT_REGEX, EMBEDDED_MENTION_REGEX, NOSTR_EMBEDDED_NOTE_REGEX } from '@/lib/content-patterns' import { cleanUrl, normalizeUrl } from '@/lib/url' @@ -154,7 +154,7 @@ export function isReplaceableEvent(kind: number) { return ( kinds.isReplaceableKind(kind) || kinds.isAddressableKind(kind) || - CALENDAR_EVENT_KINDS.includes(kind) + isNip52CalendarCardKind(kind) ) } diff --git a/src/lib/live-activities.ts b/src/lib/live-activities.ts index fb7e5984..91cb63f4 100644 --- a/src/lib/live-activities.ts +++ b/src/lib/live-activities.ts @@ -680,7 +680,10 @@ export function buildLiveActivitiesRelayUrls(options: { const fav = relayUrlsLocalsFirst(getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)) const read = relayUrlsLocalsFirst(relayListRead) const write = relayUrlsLocalsFirst(relayListWrite) - return mergeRelayPriorityLayers([fav, read, write], blockedRelays, MAX_REQ_RELAY_URLS, { + const fast = dedupeNormalizeRelayUrlsOrdered( + FAST_READ_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) + ) + return mergeRelayPriorityLayers([fav, read, write, fast], blockedRelays, MAX_REQ_RELAY_URLS, { applySocialKindBlockedFilter: true }) } diff --git a/src/lib/parent-reply-blurb.ts b/src/lib/parent-reply-blurb.ts new file mode 100644 index 00000000..2869f2db --- /dev/null +++ b/src/lib/parent-reply-blurb.ts @@ -0,0 +1,76 @@ +import { ExtendedKind, isNip71StyleVideoKind } from '@/constants' +import { + getLiveEventMetadataFromEvent, + getLongFormArticleMetadataFromEvent +} from '@/lib/event-metadata' +import { tagNameEquals } from '@/lib/tag' +import { Event, kinds } from 'nostr-tools' + +export const PARENT_REPLY_BLURB_MAX = 150 + +/** Strip common markdown / asciidoc / HTML so parent reply strips stay one line (matches NotePage preview). */ +export function stripMarkupForPreview(content: string): string { + let text = content + text = text.replace(/^#{1,6}\s+/gm, '') + text = text.replace(/\*\*([^*]+)\*\*/g, '$1') + text = text.replace(/\*([^*]+)\*/g, '$1') + text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + text = text.replace(/^=+\s+/gm, '') + text = text.replace(/_([^_]+)_/g, '$1') + text = text.replace(/```[\s\S]*?```/g, '') + text = text.replace(/`([^`]+)`/g, '$1') + text = text.replace(/<[^>]+>/g, '') + text = text.replace(/\n{3,}/g, '\n\n') + return text.trim() +} + +function truncateBlurb(s: string, max: number): string { + const normalized = s.trim().replace(/\s+/g, ' ') + if (normalized.length <= max) return normalized + return `${normalized.slice(0, max)}…` +} + +/** + * One-line preview for {@link ParentNotePreview}: prefer `title` / `subject` / kind metadata, else first + * {@link PARENT_REPLY_BLURB_MAX} characters of markup-stripped `content`. + */ +export function getParentReplyBlurbDisplayText( + event: Event, + maxLen: number = PARENT_REPLY_BLURB_MAX +): string { + const titleTag = event.tags.find(tagNameEquals('title'))?.[1]?.trim() + if (titleTag) return truncateBlurb(stripMarkupForPreview(titleTag), maxLen) + + const subjectTag = event.tags.find(tagNameEquals('subject'))?.[1]?.trim() + if (subjectTag) return truncateBlurb(stripMarkupForPreview(subjectTag), maxLen) + + if ( + event.kind === kinds.LongFormArticle || + event.kind === ExtendedKind.PUBLICATION || + event.kind === ExtendedKind.PUBLICATION_CONTENT + ) { + const meta = getLongFormArticleMetadataFromEvent(event) + if (meta.title?.trim()) return truncateBlurb(stripMarkupForPreview(meta.title.trim()), maxLen) + if (meta.summary?.trim()) { + return truncateBlurb(stripMarkupForPreview(meta.summary), maxLen) + } + } + + if (event.kind === kinds.LiveEvent || event.kind === 30312 || event.kind === 30313) { + const live = getLiveEventMetadataFromEvent(event) + const rawTitle = live.title?.trim() + if (rawTitle && rawTitle !== 'no title') return truncateBlurb(stripMarkupForPreview(rawTitle), maxLen) + if (live.summary?.trim()) return truncateBlurb(stripMarkupForPreview(live.summary), maxLen) + } + + if (event.kind === ExtendedKind.PICTURE || isNip71StyleVideoKind(event.kind)) { + const cap = truncateBlurb(stripMarkupForPreview(event.content ?? ''), maxLen) + return cap + } + + return truncateBlurb(stripMarkupForPreview(event.content ?? ''), maxLen) +} + +export function parentReplyPollQuestionBlurb(content: string, maxLen = PARENT_REPLY_BLURB_MAX): string { + return truncateBlurb(stripMarkupForPreview(content ?? ''), maxLen) +} diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 33584f6c..f02840b6 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -24,6 +24,7 @@ import { } from '@/lib/event' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNote, toNoteList } from '@/lib/link' +import { stripMarkupForPreview } from '@/lib/parent-reply-blurb' import { tagNameEquals } from '@/lib/tag' import { cn } from '@/lib/utils' import { Ellipsis } from 'lucide-react' @@ -86,31 +87,6 @@ function getEventTypeName(kind: number): string { } } -// Helper function to extract and strip markdown/asciidoc for preview (matching WebPreview) -function stripMarkdown(content: string): string { - let text = content - // Remove markdown headers - text = text.replace(/^#{1,6}\s+/gm, '') - // Remove markdown bold/italic - text = text.replace(/\*\*([^*]+)\*\*/g, '$1') - text = text.replace(/\*([^*]+)\*/g, '$1') - // Remove markdown links - text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') - // Remove asciidoc headers - text = text.replace(/^=+\s+/gm, '') - // Remove asciidoc bold/italic - text = text.replace(/\*\*([^*]+)\*\*/g, '$1') - text = text.replace(/_([^_]+)_/g, '$1') - // Remove code blocks - text = text.replace(/```[\s\S]*?```/g, '') - text = text.replace(/`([^`]+)`/g, '$1') - // Remove HTML tags - text = text.replace(/<[^>]+>/g, '') - // Clean up whitespace - text = text.replace(/\n{3,}/g, '\n\n') - return text.trim() -} - const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: { id?: string; index?: number; hideTitlebar?: boolean; initialEvent?: Event }, ref) => { const { t } = useTranslation() const { registerPrimaryPanelRefresh } = usePrimaryNoteView() @@ -301,7 +277,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: // Generate content preview (matching fallback card) let contentPreview = '' if (finalEvent.content) { - const stripped = stripMarkdown(finalEvent.content) + const stripped = stripMarkupForPreview(finalEvent.content) contentPreview = stripped.length > 500 ? stripped.substring(0, 500) + '...' : stripped } diff --git a/src/providers/LiveActivitiesProvider.tsx b/src/providers/LiveActivitiesProvider.tsx index 96035762..af3b09b8 100644 --- a/src/providers/LiveActivitiesProvider.tsx +++ b/src/providers/LiveActivitiesProvider.tsx @@ -52,7 +52,7 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode relayListRead: relayRead, relayListWrite: relayWrite }) - if (loggedIn && urls.length === 0) { + if (urls.length === 0) { rawItemsRef.current = [] setItems([]) return diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index a6184def..6d181e3a 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -26,6 +26,7 @@ import { queuePersistSeenEvent } from './event-archive.service' import { getDefaultSessionLruMaxSync } from '@/lib/event-archive-config' +import { isCalendarEventKind } from '@/lib/calendar-event' import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' @@ -546,6 +547,26 @@ export class EventService { }) }) } + if (isCalendarEventKind(cleanEvent.kind)) { + void indexedDb.putCalendarEventRow(cleanEvent as NEvent).catch((error: unknown) => { + const err = error instanceof Error ? error : new Error(String(error)) + logger.debug('[EventService] Calendar event IndexedDB persist failed', { + kind: cleanEvent.kind, + eventId: id, + errorMessage: err.message + }) + }) + } + if (cleanEvent.kind === ExtendedKind.CALENDAR_EVENT_RSVP) { + void indexedDb.putCalendarRsvpEventRow(cleanEvent as NEvent).catch((error: unknown) => { + const err = error instanceof Error ? error : new Error(String(error)) + logger.debug('[EventService] Calendar RSVP IndexedDB persist failed', { + kind: cleanEvent.kind, + eventId: id, + errorMessage: err.message + }) + }) + } } /** Apply {@link StorageKey.SESSION_EVENT_LRU_MAX} without reload (copies entries into a new LRU). */ diff --git a/src/services/event-archive.service.ts b/src/services/event-archive.service.ts index 0d26340d..6df2de56 100644 --- a/src/services/event-archive.service.ts +++ b/src/services/event-archive.service.ts @@ -1,4 +1,4 @@ -import { ExtendedKind, NIP71_VIDEO_KINDS } from '@/constants' +import { ExtendedKind, isNip52CalendarCardKind, NIP71_VIDEO_KINDS } from '@/constants' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { getEventArchiveConfig } from '@/lib/event-archive-config' import { isNip18RepostKind, isNip25ReactionKind, isReplaceableEvent } from '@/lib/event' @@ -41,6 +41,7 @@ function archiveTierForEvent(ev: Event): number { function shouldSkipArchiving(ev: Event): boolean { if (shouldDropEventOnIngest(ev)) return true + if (isNip52CalendarCardKind(ev.kind) || ev.kind === ExtendedKind.CALENDAR_EVENT_RSVP) return true if (isReplaceableEvent(ev.kind) && indexedDb.hasReplaceableEventStoreForKind(ev.kind)) { return true } diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 85aae68f..89ccbbfa 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -7,7 +7,18 @@ import { tagNameEquals } from '@/lib/tag' import { TNip66RelayDiscovery, TRelayInfo } from '@/types' import type { Event } from 'nostr-tools' import { kinds } from 'nostr-tools' -import { isReplaceableEvent, getReplaceableCoordinateFromEvent } from '@/lib/event' +import { + calendarOccurrenceOverlapsRange, + getCalendarOccurrenceWindowMs, + isCalendarEventKind +} from '@/lib/calendar-event' +import { + getReplaceableCoordinate, + getReplaceableCoordinateFromEvent, + isReplaceableEvent, + normalizeReplaceableCoordinateString, + replaceableEventDedupeKey +} from '@/lib/event' import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' import logger from '@/lib/logger' @@ -151,7 +162,28 @@ export const StoreNames = { /** Persisted timeline refs + filter for cold-start hydration. Key: {@link ClientService.generateTimelineKey} hash. */ TIMELINE_STATE: 'timelineState', /** Piper / read-aloud WAV blobs keyed by SHA-256 of endpoint + text + speed. */ - PIPER_TTS_CACHE: 'piperTtsCache' + PIPER_TTS_CACHE: 'piperTtsCache', + /** NIP-52 calendar notes (31922/31923). Key: {@link replaceableEventDedupeKey}. Index: `occurrenceStartMs`. */ + CALENDAR_EVENTS: 'calendarEvents', + /** NIP-52 calendar RSVPs (31925). Key: event id. Index: `parentCoordinate` (`a` tag). */ + CALENDAR_RSVP_EVENTS: 'calendarRsvpEvents' +} + +/** Row shape for {@link StoreNames.CALENDAR_EVENTS}. */ +export type TCalendarEventCacheRow = { + key: string + value: Event + addedAt: number + occurrenceStartMs: number + occurrenceEndExclusiveMs: number +} + +/** Row shape for {@link StoreNames.CALENDAR_RSVP_EVENTS}. */ +export type TCalendarRsvpCacheRow = { + key: string + value: Event + addedAt: number + parentCoordinate: string } /** Object stores skipped by full-text cache search (blobs, settings, relay metadata, etc.). */ @@ -167,11 +199,13 @@ const CACHE_BROWSER_EVENT_SEARCH_EXCLUDED_STORES: ReadonlySet = new Set( StoreNames.FOLLOWING_FAVORITE_RELAYS, StoreNames.RELAY_SETS, StoreNames.MUTE_DECRYPTED_TAGS, - StoreNames.FAVORITE_RELAYS + StoreNames.FAVORITE_RELAYS, + StoreNames.CALENDAR_EVENTS, + StoreNames.CALENDAR_RSVP_EVENTS ]) /** Schema version we expect. When adding stores or migrations, bump this. */ -const DB_VERSION = 34 +const DB_VERSION = 35 /** Max age for profile and payment info cache before we refetch (5 min). */ const PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS = 5 * 60 * 1000 @@ -195,6 +229,12 @@ function ensureMissingObjectStores(db: IDBDatabase): void { } else if (storeName === StoreNames.EVENT_ARCHIVE) { const store = db.createObjectStore(storeName, { keyPath: 'key' }) store.createIndex('eviction', ['archiveTier', 'lastAccessAt'], { unique: false }) + } else if (storeName === StoreNames.CALENDAR_EVENTS) { + const cal = db.createObjectStore(storeName, { keyPath: 'key' }) + cal.createIndex('occurrenceStartMs', 'occurrenceStartMs', { unique: false }) + } else if (storeName === StoreNames.CALENDAR_RSVP_EVENTS) { + const rsvp = db.createObjectStore(storeName, { keyPath: 'key' }) + rsvp.createIndex('parentCoordinate', 'parentCoordinate', { unique: false }) } else { db.createObjectStore(storeName, { keyPath: 'key' }) } @@ -392,6 +432,16 @@ class IndexedDbService { if (event.oldVersion < 34) { // v34: app-side changes (fetch timeouts, timeline hydrate order, discussion list cap) } + if (event.oldVersion < 35) { + if (!db.objectStoreNames.contains(StoreNames.CALENDAR_EVENTS)) { + const cal = db.createObjectStore(StoreNames.CALENDAR_EVENTS, { keyPath: 'key' }) + cal.createIndex('occurrenceStartMs', 'occurrenceStartMs', { unique: false }) + } + if (!db.objectStoreNames.contains(StoreNames.CALENDAR_RSVP_EVENTS)) { + const rsvp = db.createObjectStore(StoreNames.CALENDAR_RSVP_EVENTS, { keyPath: 'key' }) + rsvp.createIndex('parentCoordinate', 'parentCoordinate', { unique: false }) + } + } ensureMissingObjectStores(db) } } @@ -3109,10 +3159,14 @@ class IndexedDbService { // Or just event ID for non-replaceable events const parts = key.split(':') if (parts.length === 1) { - // Event ID - remove from publication store + hot archive + // Event ID - remove from publication store + hot archive (+ calendar RSVP by id) + const idLower = /^[0-9a-f]{64}$/i.test(key) ? key.toLowerCase() : key await Promise.allSettled([ this.deleteStoreItem(StoreNames.PUBLICATION_EVENTS, key), - this.deleteArchivedEvent(key) + this.deleteArchivedEvent(key), + ...(this.db?.objectStoreNames.contains(StoreNames.CALENDAR_RSVP_EVENTS) + ? [this.deleteStoreItem(StoreNames.CALENDAR_RSVP_EVENTS, idLower)] + : []) ]) removed++ } else if (parts.length >= 2) { @@ -3127,6 +3181,18 @@ class IndexedDbService { await this.deleteStoreItem(storeName, this.getReplaceableEventKey(pubkey.toLowerCase(), d)) removed++ } + if ( + isCalendarEventKind(kind) && + d != null && + d !== '' && + this.db?.objectStoreNames.contains(StoreNames.CALENDAR_EVENTS) + ) { + const calKey = normalizeReplaceableCoordinateString( + getReplaceableCoordinate(kind, pubkey.toLowerCase(), d) + ) + await this.deleteStoreItem(StoreNames.CALENDAR_EVENTS, calKey) + removed++ + } } catch { // Ignore errors } @@ -3136,6 +3202,159 @@ class IndexedDbService { return removed } + + /** + * Persist a NIP-52 calendar note (31922/31923). Keyed by {@link replaceableEventDedupeKey}; keeps newest + * `created_at` per coordinate. + */ + async putCalendarEventRow(ev: Event): Promise { + if (!isCalendarEventKind(ev.kind)) return + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.CALENDAR_EVENTS)) return + + const key = replaceableEventDedupeKey(ev) + const win = getCalendarOccurrenceWindowMs(ev) + const occurrenceStartMs = win?.startMs ?? ev.created_at * 1000 + const occurrenceEndExclusiveMs = win?.endExclusiveMs ?? occurrenceStartMs + 3_600_000 + + const clean = { ...ev } as Event + delete (clean as { relayStatuses?: unknown }).relayStatuses + if (/^[0-9a-f]{64}$/i.test(clean.id)) { + clean.id = clean.id.toLowerCase() + } + + const row: TCalendarEventCacheRow = { + key, + value: clean, + addedAt: Date.now(), + occurrenceStartMs, + occurrenceEndExclusiveMs + } + + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.CALENDAR_EVENTS, 'readwrite') + const store = tx.objectStore(StoreNames.CALENDAR_EVENTS) + const getReq = store.get(key) + getReq.onerror = (e) => reject(idbEventToError(e)) + getReq.onsuccess = () => { + const prev = getReq.result as TCalendarEventCacheRow | undefined + if (prev?.value?.created_at != null && prev.value.created_at > ev.created_at) { + resolve() + return + } + const putReq = store.put(row) + putReq.onerror = (e) => reject(idbEventToError(e)) + putReq.onsuccess = () => resolve() + } + }) + } + + /** Persist a NIP-52 RSVP (31925). Indexed by normalized `a` parent coordinate. */ + async putCalendarRsvpEventRow(ev: Event): Promise { + if (ev.kind !== ExtendedKind.CALENDAR_EVENT_RSVP) return + const rawA = ev.tags.find(tagNameEquals('a'))?.[1]?.trim() + if (!rawA) return + const parentCoordinate = normalizeReplaceableCoordinateString(rawA) + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.CALENDAR_RSVP_EVENTS)) return + + const id = /^[0-9a-f]{64}$/i.test(ev.id) ? ev.id.toLowerCase() : ev.id + const clean = { ...ev } as Event + delete (clean as { relayStatuses?: unknown }).relayStatuses + clean.id = id + + const row: TCalendarRsvpCacheRow = { + key: id, + value: clean, + addedAt: Date.now(), + parentCoordinate + } + + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.CALENDAR_RSVP_EVENTS, 'readwrite') + const putReq = tx.objectStore(StoreNames.CALENDAR_RSVP_EVENTS).put(row) + putReq.onerror = (e) => reject(idbEventToError(e)) + putReq.onsuccess = () => resolve() + }) + } + + /** + * Calendar events whose occurrence overlaps `[rangeStartMs, rangeEndExclusiveMs)` (local week bounds). + * Uses `occurrenceStartMs` index with a wide lower bound so long-lived date ranges are not missed. + */ + async getCalendarEventsForOccurrenceWindow( + rangeStartMs: number, + rangeEndExclusiveMs: number, + maxScan = 5000 + ): Promise { + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.CALENDAR_EVENTS)) return [] + + const lower = rangeStartMs - 550 * 86_400_000 + const upper = rangeEndExclusiveMs + 86_400_000 + + return new Promise((resolve, reject) => { + const out: Event[] = [] + const tx = this.db!.transaction(StoreNames.CALENDAR_EVENTS, 'readonly') + const store = tx.objectStore(StoreNames.CALENDAR_EVENTS) + let index: IDBIndex + try { + index = store.index('occurrenceStartMs') + } catch { + resolve([]) + return + } + const range = IDBKeyRange.bound(lower, upper, false, false) + const req = index.openCursor(range) + req.onerror = (e) => reject(idbEventToError(e)) + req.onsuccess = () => { + const cursor = req.result as IDBCursorWithValue | null + if (!cursor || out.length >= maxScan) { + resolve(out) + return + } + const row = cursor.value as TCalendarEventCacheRow + if ( + row?.value && + calendarOccurrenceOverlapsRange(row.value, rangeStartMs, rangeEndExclusiveMs) + ) { + out.push(row.value) + } + cursor.continue() + } + }) + } + + /** Cached RSVPs for a calendar replaceable coordinate (`kind:pubkey:d`). */ + async getCalendarRsvpEventsByParentCoordinate( + parentCoordinate: string, + limit = 400 + ): Promise { + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.CALENDAR_RSVP_EVENTS)) return [] + const norm = normalizeReplaceableCoordinateString(parentCoordinate.trim()) + if (!norm) return [] + + return new Promise((resolve, reject) => { + const tx = this.db!.transaction(StoreNames.CALENDAR_RSVP_EVENTS, 'readonly') + const store = tx.objectStore(StoreNames.CALENDAR_RSVP_EVENTS) + let index: IDBIndex + try { + index = store.index('parentCoordinate') + } catch { + resolve([]) + return + } + const req = index.getAll(IDBKeyRange.only(norm)) + req.onerror = (e) => reject(idbEventToError(e)) + req.onsuccess = () => { + const rows = (req.result as TCalendarRsvpCacheRow[]) ?? [] + const events = rows.map((r) => r.value).filter(Boolean) + events.sort((a, b) => b.created_at - a.created_at) + resolve(events.slice(0, limit)) + } + }) + } } const instance = IndexedDbService.getInstance() diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index cfb195e4..c233cce1 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -57,6 +57,13 @@ class NoteStatsService { static instance: NoteStatsService private noteStatsMap: Map> = new Map() private noteStatsSubscribers = new Map void>>() + /** + * Batched, microtask-deferred subscriber wakes. Without this, {@link updateNoteStatsByEvents} called from + * a React state updater (e.g. NoteList `setEvents`) synchronously notifies {@link useSyncExternalStore} listeners + * and triggers "Cannot update NoteBoostBadges while rendering NoteList". + */ + private subscriberNotifyKeys = new Set() + private subscriberNotifyMicrotaskQueued = false private processingCache = new Set() private readonly hexNoteStatsIdRe = /^[0-9a-f]{64}$/i @@ -636,13 +643,33 @@ class NoteStatsService { } } - private notifyNoteStats(noteId: string) { - const set = this.noteStatsSubscribers.get(this.statsKey(noteId)) - if (set) { - set.forEach((cb) => cb()) + private flushNoteStatsSubscribers(): void { + this.subscriberNotifyMicrotaskQueued = false + const keys = [...this.subscriberNotifyKeys] + this.subscriberNotifyKeys.clear() + for (const key of keys) { + const set = this.noteStatsSubscribers.get(key) + if (!set?.size) continue + for (const cb of [...set]) { + try { + cb() + } catch (e) { + logger.warn('[NoteStatsService] subscriber callback failed', { err: e }) + } + } } } + private notifyNoteStats(noteId: string) { + const key = this.statsKey(noteId) + this.subscriberNotifyKeys.add(key) + if (this.subscriberNotifyMicrotaskQueued) return + this.subscriberNotifyMicrotaskQueued = true + queueMicrotask(() => { + this.flushNoteStatsSubscribers() + }) + } + getNoteStats(id: string): Partial | undefined { return this.noteStatsMap.get(this.statsKey(id)) }