- {children}
- {shouldCollapse && !expanded && (
-
-
+
+
+ {children}
+
+ {collapsed ? (
+
+
+
- )}
+ ) : 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 (
+
+ )
+ }
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 (
+
+ )
+ }
return withKindRow(
)
}
if (event.kind === ExtendedKind.PICTURE) {
+ if (forParentReplyBlurb) {
+ const line = getParentReplyBlurbDisplayText(previewEvent)
+ return (
+
+ )
+ }
return withKindRow(
)
}
if (event.kind === ExtendedKind.GROUP_METADATA) {
+ if (forParentReplyBlurb) {
+ const line = getParentReplyBlurbDisplayText(previewEvent)
+ return (
+
+ )
+ }
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 (
+
+ )
+ }
return withKindRow(
)
}
if (event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === kinds.Zap) {
+ if (forParentReplyBlurb) {
+ const line = getParentReplyBlurbDisplayText(previewEvent)
+ return (
+
+ )
+ }
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 (
+
+ )
+ }
return withKindRow(
)
}
if (event.kind === ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION) {
+ if (forParentReplyBlurb) {
+ const line = getParentReplyBlurbDisplayText(previewEvent)
+ return (
+
+ )
+ }
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 (
+
+ )
+ }
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 ? (
+
+ ) : null}
+ {description ? (
+ <>
+ {/* NIP-52 31922/31923 embedded preview: long description only. */}
+
+
+ {description}
+
+
+ >
+ ) : null}
{joinUrl && (
- 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 (
+
+
+ setWeekOffset((w) => w - 1)}
+ >
+
+
+
+ {weekLabel}
+
+ setWeekOffset((w) => w + 1)}
+ >
+
+
+
+
+ {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 (
+ -
+ openEvent(ev)}
+ className={cn(
+ 'w-full rounded-md border border-transparent px-1.5 py-1.5 text-left transition-colors',
+ 'hover:border-border/80 hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
+ )}
+ >
+ {title}
+ {sub ? (
+
+ {sub}
+
+ ) : null}
+
+
+ )
+ })}
+
+ )}
+
+ )
+}
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))
}