Browse Source

fix spells

imwald
Silberengel 1 month ago
parent
commit
10e2e05968
  1. 85
      src/components/CalendarEventContent/index.tsx
  2. 22
      src/components/Collapsible/index.tsx
  3. 145
      src/components/ContentPreview/index.tsx
  4. 66
      src/components/Embedded/EmbeddedCalendarEvent.tsx
  5. 5
      src/components/Embedded/EmbeddedNote.tsx
  6. 98
      src/components/FavoriteRelaysActiveStrip/index.tsx
  7. 6
      src/components/Note/index.tsx
  8. 6
      src/components/NoteCard/MainNoteCard.tsx
  9. 13
      src/components/NoteList/index.tsx
  10. 278
      src/components/Sidebar/SidebarCalendarWeekWidget.tsx
  11. 8
      src/components/Sidebar/index.tsx
  12. 10
      src/constants.ts
  13. 67
      src/hooks/useFetchCalendarRsvps.tsx
  14. 6
      src/i18n/locales/cs.ts
  15. 6
      src/i18n/locales/de.ts
  16. 6
      src/i18n/locales/en.ts
  17. 6
      src/i18n/locales/es.ts
  18. 6
      src/i18n/locales/fr.ts
  19. 6
      src/i18n/locales/nl.ts
  20. 6
      src/i18n/locales/pl.ts
  21. 6
      src/i18n/locales/ru.ts
  22. 6
      src/i18n/locales/tr.ts
  23. 6
      src/i18n/locales/zh.ts
  24. 220
      src/lib/calendar-event.ts
  25. 4
      src/lib/event.ts
  26. 5
      src/lib/live-activities.ts
  27. 76
      src/lib/parent-reply-blurb.ts
  28. 28
      src/pages/secondary/NotePage/index.tsx
  29. 2
      src/providers/LiveActivitiesProvider.tsx
  30. 21
      src/services/client-events.service.ts
  31. 3
      src/services/event-archive.service.ts
  32. 231
      src/services/indexed-db.service.ts
  33. 35
      src/services/note-stats.service.ts

85
src/components/CalendarEventContent/index.tsx

@ -1,8 +1,8 @@ @@ -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' @@ -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({ @@ -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 (
<div
className={cn('rounded-lg border bg-muted/40 p-3 text-sm min-w-0', className)}
className={cn(
'min-w-0 space-y-3 rounded-xl border border-border/70 bg-gradient-to-b from-card to-muted/25 p-4 text-sm shadow-sm',
className
)}
data-calendar-event-content
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start gap-2 mb-2">
<div className="flex items-start gap-3">
{image ? (
<img
src={image}
alt=""
className="size-12 shrink-0 rounded object-cover"
className="size-[4.5rem] shrink-0 rounded-lg object-cover shadow-sm ring-1 ring-border/40"
/>
) : (
<Calendar className="size-4 shrink-0 mt-0.5 text-muted-foreground" />
<div className="flex size-[4.5rem] shrink-0 items-center justify-center rounded-lg bg-primary/10 ring-1 ring-border/40">
<Calendar className="size-7 text-primary/80" aria-hidden />
</div>
)}
<div className="min-w-0 flex-1">
<span className="font-medium text-foreground truncate block">
<div className="min-w-0 flex-1 space-y-2">
<h3 className="text-lg font-semibold leading-snug tracking-tight text-foreground">
{title || t('Scheduled video call')}
</span>
</h3>
{topics.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
<div className="flex flex-wrap gap-1.5">
{topics.map((topic) => (
<span
key={topic}
className="inline-flex items-center rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground"
className="inline-flex items-center rounded-full bg-muted/80 px-2 py-0.5 text-xs font-medium text-muted-foreground ring-1 ring-border/50"
>
#{topic}
</span>
@ -118,32 +130,23 @@ export default function CalendarEventContent({ @@ -118,32 +130,23 @@ export default function CalendarEventContent({
)}
</div>
</div>
{isDateBased ? (
(startDate || endDate) && (
<div className="text-muted-foreground text-xs mb-2">
{startDate ? formatCalendarDate(startDate) : ''}
{endDate && endDate !== startDate && (
<> {formatCalendarDate(endDate)}</>
)}
</div>
)
) : (
start != null &&
!isNaN(start) && (
<div className="text-muted-foreground text-xs mb-2">
{formatCalendarTime(start)}
{end != null && !isNaN(end) && end > start && (
<> {formatCalendarTime(end)}</>
)}
{scheduleLine ? (
<div className="flex gap-2 rounded-lg border border-border/60 bg-background/60 px-3 py-2.5">
<Clock className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
<p className="min-w-0 text-sm font-medium leading-snug text-foreground">{scheduleLine}</p>
</div>
)
)}
{description && (
<p className="text-muted-foreground text-xs mb-2 whitespace-pre-wrap break-words">
) : null}
{description ? (
<>
{/* NIP-52 31922/31923: collapse long summary+body only; card chrome stays outside MainNoteCard Collapsible. */}
<Collapsible threshold={200} collapsedHeight={160} className="min-w-0">
<p className="whitespace-pre-wrap break-words text-sm leading-relaxed text-muted-foreground">
{description}
</p>
)}
<div className="flex flex-wrap items-center gap-2 mt-2">
</Collapsible>
</>
) : null}
<div className="flex flex-wrap items-center gap-2 pt-0.5">
{joinUrl && (
<Button variant="secondary" size="sm" className="gap-2" asChild>
<a href={joinUrl} target="_blank" rel="noopener noreferrer">
@ -187,17 +190,19 @@ export default function CalendarEventContent({ @@ -187,17 +190,19 @@ export default function CalendarEventContent({
)}
</div>
{attendeesList.length > 0 && (
<div className="mt-3 pt-3 border-t border-border/60">
<div className="text-xs font-medium text-muted-foreground mb-2">{t('Attendees')}</div>
<ul className="space-y-1.5">
<div className="border-t border-border/50 pt-3">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('Attendees')}
</div>
<ul className="space-y-1">
{attendeesList.map(({ pubkey, status, isOrganizer }) => (
<li key={pubkey}>
<button
type="button"
onClick={() => push(toProfile(pubkey))}
className={cn(
'w-full flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs',
'hover:bg-muted/60 transition-colors min-w-0'
'flex w-full min-w-0 items-center gap-2.5 rounded-lg px-2 py-2 text-left text-xs transition-colors',
'hover:bg-muted/50'
)}
>
<UserAvatar userId={pubkey} size="xSmall" className="shrink-0" />

22
src/components/Collapsible/index.tsx

@ -46,35 +46,39 @@ export default function Collapsible({ @@ -46,35 +46,39 @@ export default function Collapsible({
}
}, [alwaysExpand, shouldCollapse])
const collapsed = shouldCollapse && !expanded
return (
<div className={cn('text-left', className)} {...props}>
<div
className={cn('relative text-left', className)}
ref={containerRef}
{...props}
style={{
maxHeight: !shouldCollapse || expanded ? 'none' : `${collapsedHeight}px`,
overflow: !shouldCollapse || expanded ? 'visible' : 'hidden'
}}
>
{children}
{shouldCollapse && !expanded && (
</div>
{collapsed ? (
<div className="bg-background" data-collapsible-show-more>
<div
className="pointer-events-none absolute bottom-0 z-20 flex h-40 w-full items-end justify-center bg-gradient-to-b from-transparent to-background/90 pb-4"
data-collapsible-show-more
>
<div className="pointer-events-auto rounded-md">
aria-hidden
className="pointer-events-none h-7 w-full bg-gradient-to-b from-transparent to-background"
/>
<div className="flex justify-center px-2 pb-2 pt-1">
<Button
type="button"
className="bg-foreground text-background hover:bg-foreground/90 hover:text-background"
onClick={(e) => {
e.stopPropagation()
setExpanded(!expanded)
setExpanded(true)
}}
>
{t('Show more')}
</Button>
</div>
</div>
)}
) : null}
</div>
)
}

145
src/components/ContentPreview/index.tsx

@ -10,6 +10,10 @@ import { @@ -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 = { @@ -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({ @@ -143,10 +139,30 @@ export default function ContentPreview({
ExtendedKind.PUBLIC_MESSAGE
].includes(event.kind)
) {
if (forParentReplyBlurb) {
const line = getParentReplyBlurbDisplayText(previewEvent)
return (
<div className={cn('pointer-events-none min-w-0 text-muted-foreground', previewOuter)}>
<div className={cn('min-w-0 truncate text-sm', previewBody)}>
{line || `[${t('Note')}]`}
</div>
</div>
)
}
return withKindRow(<NormalContentPreview event={previewEvent} />)
}
if (event.kind === ExtendedKind.DISCUSSION) {
if (forParentReplyBlurb) {
const line = getParentReplyBlurbDisplayText(previewEvent)
return (
<div className={cn('pointer-events-none min-w-0 text-muted-foreground', previewOuter)}>
<div className={cn('min-w-0 truncate text-sm', previewBody)}>
{line || `[${t('Discussion')}]`}
</div>
</div>
)
}
return (
<div className={cn('flex min-w-0 flex-col gap-1', previewOuter)}>
<NoteKindLabel kind={previewEvent.kind} event={previewEvent} size="small" />
@ -158,6 +174,14 @@ export default function ContentPreview({ @@ -158,6 +174,14 @@ export default function ContentPreview({
}
if (event.kind === kinds.Highlights) {
if (forParentReplyBlurb) {
const line = getParentReplyBlurbDisplayText(previewEvent)
return (
<div className={cn('pointer-events-none min-w-0 text-muted-foreground', previewOuter)}>
<div className={cn('min-w-0 truncate text-sm', previewBody)}>{line || t('Highlight')}</div>
</div>
)
}
return withKindRow(<HighlightPreview event={previewEvent} />)
}
@ -165,6 +189,13 @@ export default function ContentPreview({ @@ -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 (
<div className={cn('pointer-events-none min-w-0 text-muted-foreground', previewOuter)}>
<div className={cn('min-w-0 truncate text-sm', previewBody)}>{line}</div>
</div>
)
}
return withKindRow(<div className={cn('min-w-0 truncate text-sm', previewBody)}>{line}</div>)
}
@ -181,34 +212,100 @@ export default function ContentPreview({ @@ -181,34 +212,100 @@ export default function ContentPreview({
}
if (event.kind === kinds.LongFormArticle) {
if (forParentReplyBlurb) {
const line = getParentReplyBlurbDisplayText(previewEvent)
return (
<div className={cn('pointer-events-none min-w-0 text-muted-foreground', previewOuter)}>
<div className={cn('min-w-0 truncate text-sm', previewBody)}>
{line || `[${t('Long-form Article')}]`}
</div>
</div>
)
}
return withKindRow(<LongFormCard event={previewEvent} interactive={false} />)
}
if (isNip71StyleVideoKind(event.kind)) {
if (forParentReplyBlurb) {
const line = getParentReplyBlurbDisplayText(previewEvent)
return (
<div className={cn('pointer-events-none min-w-0 text-muted-foreground', previewOuter)}>
<div className={cn('min-w-0 truncate text-sm', previewBody)}>{line || t('Video')}</div>
</div>
)
}
return withKindRow(<VideoNotePreview event={previewEvent} />)
}
if (event.kind === ExtendedKind.PICTURE) {
if (forParentReplyBlurb) {
const line = getParentReplyBlurbDisplayText(previewEvent)
return (
<div className={cn('pointer-events-none min-w-0 text-muted-foreground', previewOuter)}>
<div className={cn('min-w-0 truncate text-sm', previewBody)}>{line || t('Image')}</div>
</div>
)
}
return withKindRow(<PictureNotePreview event={previewEvent} />)
}
if (event.kind === ExtendedKind.GROUP_METADATA) {
if (forParentReplyBlurb) {
const line = getParentReplyBlurbDisplayText(previewEvent)
return (
<div className={cn('pointer-events-none min-w-0 text-muted-foreground', previewOuter)}>
<div className={cn('min-w-0 truncate text-sm', previewBody)}>{line || t('Group')}</div>
</div>
)
}
return withKindRow(<GroupMetadataPreview event={previewEvent} />)
}
if (event.kind === kinds.CommunityDefinition) {
if (forParentReplyBlurb) {
const line = getParentReplyBlurbDisplayText(previewEvent)
return (
<div className={cn('pointer-events-none min-w-0 text-muted-foreground', previewOuter)}>
<div className={cn('min-w-0 truncate text-sm', previewBody)}>{line || t('Community')}</div>
</div>
)
}
return withKindRow(<CommunityDefinitionPreview event={previewEvent} />)
}
if (event.kind === kinds.LiveEvent) {
if (forParentReplyBlurb) {
const line = getParentReplyBlurbDisplayText(previewEvent)
return (
<div className={cn('pointer-events-none min-w-0 text-muted-foreground', previewOuter)}>
<div className={cn('min-w-0 truncate text-sm', previewBody)}>{line || t('Live event')}</div>
</div>
)
}
return withKindRow(<LiveEventPreview event={previewEvent} />)
}
if (event.kind === ExtendedKind.ZAP_REQUEST) {
if (forParentReplyBlurb) {
const line = getParentReplyBlurbDisplayText(previewEvent)
return (
<div className={cn('pointer-events-none min-w-0 text-muted-foreground', previewOuter)}>
<div className={cn('min-w-0 truncate text-sm', previewBody)}>{line || t('Zap')}</div>
</div>
)
}
return withKindRow(<ZapPreview event={previewEvent} />)
}
if (event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === kinds.Zap) {
if (forParentReplyBlurb) {
const line = getParentReplyBlurbDisplayText(previewEvent)
return (
<div className={cn('pointer-events-none min-w-0 text-muted-foreground', previewOuter)}>
<div className={cn('min-w-0 truncate text-sm', previewBody)}>{line || t('Zap')}</div>
</div>
)
}
if (previewDensity === 'compact') {
return (
<div className={cn('min-w-0', previewOuter)}>
@ -220,14 +317,38 @@ export default function ContentPreview({ @@ -220,14 +317,38 @@ export default function ContentPreview({
}
if (event.kind === ExtendedKind.APPLICATION_HANDLER_INFO) {
if (forParentReplyBlurb) {
const line = getParentReplyBlurbDisplayText(previewEvent)
return (
<div className={cn('pointer-events-none min-w-0 text-muted-foreground', previewOuter)}>
<div className={cn('min-w-0 truncate text-sm', previewBody)}>{line || t('Note')}</div>
</div>
)
}
return withKindRow(<ApplicationHandlerInfo event={previewEvent} />)
}
if (event.kind === ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION) {
if (forParentReplyBlurb) {
const line = getParentReplyBlurbDisplayText(previewEvent)
return (
<div className={cn('pointer-events-none min-w-0 text-muted-foreground', previewOuter)}>
<div className={cn('min-w-0 truncate text-sm', previewBody)}>{line || t('Note')}</div>
</div>
)
}
return withKindRow(<ApplicationHandlerRecommendation event={previewEvent} />)
}
if (event.kind === ExtendedKind.FOLLOW_PACK) {
if (forParentReplyBlurb) {
const line = getParentReplyBlurbDisplayText(previewEvent)
return (
<div className={cn('pointer-events-none min-w-0 text-muted-foreground', previewOuter)}>
<div className={cn('min-w-0 truncate text-sm', previewBody)}>{line || t('Follow Pack')}</div>
</div>
)
}
return withKindRow(<FollowPackPreview event={previewEvent} />)
}
@ -236,6 +357,14 @@ export default function ContentPreview({ @@ -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 (
<div className={cn('pointer-events-none min-w-0 text-muted-foreground', previewOuter)}>
<div className={cn('min-w-0 truncate text-sm', previewBody)}>{line || t('Note')}</div>
</div>
)
}
return withKindRow(<GitRepublicEventCard variant="compact" event={previewEvent} />)
}

66
src/components/Embedded/EmbeddedCalendarEvent.tsx

@ -1,14 +1,15 @@ @@ -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({ @@ -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 (
<div
className={cn(
'rounded-lg border bg-muted/40 p-3 text-sm min-w-0',
'min-w-0 space-y-2.5 rounded-xl border border-border/70 bg-gradient-to-b from-card to-muted/25 p-3 text-sm shadow-sm',
className
)}
data-embedded-calendar-event
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start gap-2 mb-2">
<div className="flex items-start gap-2.5">
{image ? (
<img
src={image}
alt=""
className="size-12 shrink-0 rounded object-cover"
className="size-14 shrink-0 rounded-lg object-cover ring-1 ring-border/40"
/>
) : (
<Calendar className="size-4 shrink-0 mt-0.5 text-muted-foreground" />
<div className="flex size-14 shrink-0 items-center justify-center rounded-lg bg-primary/10 ring-1 ring-border/40">
<Calendar className="size-6 text-primary/80" aria-hidden />
</div>
)}
<div className="min-w-0 flex-1">
<span className="font-medium text-foreground truncate block">
<div className="min-w-0 flex-1 space-y-1.5">
<span className="block truncate font-semibold leading-snug text-foreground">
{title || t('Scheduled video call')}
</span>
{topics.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
<div className="flex flex-wrap gap-1">
{topics.map((topic) => (
<span
key={topic}
className="inline-flex items-center rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground"
className="inline-flex items-center rounded-full bg-muted/80 px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground ring-1 ring-border/50"
>
#{topic}
</span>
@ -60,31 +69,22 @@ export function EmbeddedCalendarEvent({ @@ -60,31 +69,22 @@ export function EmbeddedCalendarEvent({
)}
</div>
</div>
{isDateBased ? (
(startDate || endDate) && (
<div className="text-muted-foreground text-xs mb-2">
{startDate ? formatCalendarDate(startDate) : ''}
{endDate && endDate !== startDate && (
<> {formatCalendarDate(endDate)}</>
)}
</div>
)
) : (
start != null &&
!isNaN(start) && (
<div className="text-muted-foreground text-xs mb-2">
{formatCalendarTime(start)}
{end != null && !isNaN(end) && end > start && (
<> {formatCalendarTime(end)}</>
)}
{scheduleLine ? (
<div className="flex gap-2 rounded-md border border-border/60 bg-background/60 px-2.5 py-2">
<Clock className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" aria-hidden />
<p className="min-w-0 text-xs font-medium leading-snug text-foreground">{scheduleLine}</p>
</div>
)
)}
{description && (
<p className="text-muted-foreground text-xs mb-2 whitespace-pre-wrap break-words">
) : null}
{description ? (
<>
{/* NIP-52 31922/31923 embedded preview: long description only. */}
<Collapsible threshold={180} collapsedHeight={120} className="min-w-0">
<p className="whitespace-pre-wrap break-words text-xs leading-relaxed text-muted-foreground">
{description}
</p>
)}
</Collapsible>
</>
) : null}
{joinUrl && (
<Button
variant="secondary"

5
src/components/Embedded/EmbeddedNote.tsx

@ -9,6 +9,7 @@ import { @@ -9,6 +9,7 @@ import {
} from '@/constants'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { LIVE_ACTIVITY_KINDS } from '@/lib/live-activities'
import { isCalendarEventKind } from '@/lib/calendar-event'
import { isRenderableNoteKind } from '@/lib/note-renderable-kinds'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { normalizeUrl } from '@/lib/url'
@ -413,8 +414,8 @@ function EmbeddedNoteFetched({ @@ -413,8 +414,8 @@ function EmbeddedNoteFetched({
)
}
// NIP-52 calendar event (scheduled video call) – render as calendar card
if (finalEvent.kind === ExtendedKind.CALENDAR_EVENT_TIME || finalEvent.kind === ExtendedKind.CALENDAR_EVENT_DATE) {
// NIP-52 calendar notes (kinds 31922 / 31923) – render as calendar card
if (isCalendarEventKind(finalEvent.kind)) {
return (
<div
data-embedded-note

98
src/components/FavoriteRelaysActiveStrip/index.tsx

@ -33,70 +33,12 @@ function useRelativePastPhrase(timestampMs: number | null, t: TFunction): string @@ -33,70 +33,12 @@ function useRelativePastPhrase(timestampMs: number | null, t: TFunction): string
}, [timestampMs, t, tick])
}
function ActiveCountGroups({
followCount,
otherCount,
labelClassName,
stackClassName,
variant = 'default',
onOpenFollowsNotes
}: {
followCount: number
otherCount: number
labelClassName: string
stackClassName?: string
variant?: 'default' | 'mobileBar'
onOpenFollowsNotes?: () => void
}) {
const { t } = useTranslation()
const mobileBar = variant === 'mobileBar'
const groupRowClass = mobileBar
? 'flex w-full min-w-0 items-center gap-1.5'
: 'flex min-w-0 items-center gap-1.5'
return (
<div className={cn('flex min-w-0 flex-col gap-1.5', stackClassName)}>
{followCount > 0 ? (
<div className={groupRowClass}>
<span className={cn('tabular-nums', labelClassName)}>
{t('Relay pulse follows', { count: followCount })}
</span>
{onOpenFollowsNotes ? (
<Button
variant="ghost"
size="icon"
className={cn('shrink-0', mobileBar ? 'size-6' : 'size-5')}
aria-label={t('See the newest notes from your follows')}
title={t('See the newest notes from your follows')}
onClick={onOpenFollowsNotes}
>
<FileText className={mobileBar ? 'size-3.5' : 'size-3'} />
</Button>
) : null}
</div>
) : null}
{otherCount > 0 ? (
<span className={cn('min-w-0 tabular-nums', labelClassName)}>
{t('Relay pulse others', { count: otherCount })}
</span>
) : null}
</div>
)
}
/** 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?: @@ -147,6 +89,18 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?:
<div className="flex min-w-0 shrink items-center gap-2">
<p className="text-xs font-medium leading-tight text-foreground">{t('Relay pulse')}</p>
<RelayPulseActiveNpubsOpenButton size="sm" variant="outline" className="h-7 shrink-0" />
{pubkey && followCount > 0 ? (
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
aria-label={t('See the newest notes from your follows')}
title={t('See the newest notes from your follows')}
onClick={() => navigate('follows-latest')}
>
<FileText className="size-3.5" />
</Button>
) : null}
</div>
{lastFetchedAtMs != null && relativeLabel ? (
<p className="shrink-0 text-[0.65rem] text-muted-foreground tabular-nums">
@ -154,14 +108,6 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: @@ -154,14 +108,6 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?:
</p>
) : null}
</div>
<ActiveCountGroups
variant="mobileBar"
followCount={followCount}
otherCount={otherCount}
labelClassName="text-[0.7rem] font-medium text-muted-foreground"
stackClassName="w-full min-w-0 max-w-full"
onOpenFollowsNotes={pubkey ? () => navigate('follows-latest') : undefined}
/>
</div>
</div>
)
@ -172,14 +118,7 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st @@ -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 @@ -266,15 +205,6 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st
</Button>
) : null}
</div>
<div className="max-xl:flex max-xl:justify-center">
<ActiveCountGroups
followCount={followCount}
otherCount={otherCount}
labelClassName="text-[0.6rem] font-medium text-muted-foreground xl:px-1"
stackClassName="w-full max-xl:items-center"
onOpenFollowsNotes={pubkey ? () => navigate('follows-latest') : undefined}
/>
</div>
</div>
)
}

6
src/components/Note/index.tsx

@ -29,6 +29,7 @@ import { muteSetHas } from '@/lib/mute-set' @@ -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({ @@ -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({ @@ -395,7 +395,7 @@ export default function Note({
content = <VideoNote className="mt-2" event={event} />
} else if (event.kind === ExtendedKind.RELAY_REVIEW) {
content = <RelayReview className="mt-2" event={displayEvent} />
} else if (event.kind === ExtendedKind.CALENDAR_EVENT_TIME || event.kind === ExtendedKind.CALENDAR_EVENT_DATE) {
} else if (isCalendarEventKind(event.kind)) {
content = <CalendarEventContent event={displayEvent} className="mt-2" showRsvp />
} else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) {
content = renderEventContent({ hideMetadata: true })

6
src/components/NoteCard/MainNoteCard.tsx

@ -1,4 +1,4 @@ @@ -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({ @@ -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({ @@ -94,7 +96,7 @@ export default function MainNoteCard({
<Pin className="size-4 shrink-0" strokeWidth={1.5} aria-hidden />
</div>
)}
<Collapsible alwaysExpand={embedded}>
<Collapsible alwaysExpand={embedded || isCalendarNoteKind}>
<RepostDescription className={embedded ? '' : 'px-4'} reposter={reposter} />
<Note
className={embedded ? '' : 'px-4'}

13
src/components/NoteList/index.tsx

@ -2778,6 +2778,19 @@ const NoteList = forwardRef( @@ -2778,6 +2778,19 @@ const NoteList = forwardRef(
if (eventsRef.current.length === 0) {
setHasMore(false)
}
// Main feed skeleton also requires `feedTimelineEmptyUiReady` (first onEvents or EOSE). If
// subscribe never wires that path (wedged setup, relay pool churn), `loading` alone going
// false still leaves an infinite skeleton — hard-refresh “fixes” by resetting connections.
let unblockedPaint = false
setFeedTimelineEmptyUiReady((ready) => {
if (ready) return ready
unblockedPaint = true
return true
})
if (unblockedPaint) {
feedPaintLiveRelayDoneRef.current = true
setFeedEmptyToastGateTick((n) => n + 1)
}
}, loadingSafetyMs)
return () => {
cancelled = true

278
src/components/Sidebar/SidebarCalendarWeekWidget.tsx

@ -0,0 +1,278 @@ @@ -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<string, Event>()
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<Event[]>([])
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<string>()
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 (
<div className="max-xl:hidden w-full min-w-0 rounded-lg border border-border/60 bg-card/40 px-2 py-2 shadow-sm">
<div className="mb-1.5 flex items-center justify-between gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0"
aria-label={t('sidebarCalendarPreviousWeek')}
onClick={() => setWeekOffset((w) => w - 1)}
>
<ChevronLeft className="size-4" />
</Button>
<span
className="min-w-0 flex-1 truncate text-center text-[11px] font-semibold leading-tight text-foreground"
title={weekLabel}
>
{weekLabel}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0"
aria-label={t('sidebarCalendarNextWeek')}
onClick={() => setWeekOffset((w) => w + 1)}
>
<ChevronRight className="size-4" />
</Button>
</div>
<p className="mb-1.5 text-center text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{t('sidebarCalendarHeading')}
</p>
{loading && sortedForWeek.length === 0 ? (
<div className="flex items-center justify-center gap-2 py-4 text-muted-foreground">
<Loader2 className="size-4 animate-spin" aria-hidden />
<span className="text-[11px]">{t('sidebarCalendarLoading')}</span>
</div>
) : !relayUrls.length ? (
<p className="px-1 py-2 text-center text-[11px] text-muted-foreground">{t('sidebarCalendarNoRelays')}</p>
) : sortedForWeek.length === 0 ? (
<p className="px-1 py-2 text-center text-[11px] text-muted-foreground">{t('sidebarCalendarEmptyWeek')}</p>
) : (
<ul className="min-w-0 space-y-1 overflow-y-auto pr-0.5" style={{ maxHeight: LIST_MAX_HEIGHT_PX }}>
{sortedForWeek.map((ev) => {
const title = ev.tags.find((t) => t[0] === 'title')?.[1]?.trim() || t('Scheduled video call')
const sub = formatCalendarSidebarRow(ev)
return (
<li key={replaceableEventDedupeKey(ev)}>
<button
type="button"
onClick={() => 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'
)}
>
<span className="line-clamp-2 text-[11px] font-medium leading-snug text-foreground">{title}</span>
{sub ? (
<span className="mt-0.5 block line-clamp-2 text-[10px] leading-snug text-muted-foreground">
{sub}
</span>
) : null}
</button>
</li>
)
})}
</ul>
)}
</div>
)
}

8
src/components/Sidebar/index.tsx

@ -17,6 +17,7 @@ import { FavoriteRelaysActiveStripSidebar } from '@/components/FavoriteRelaysAct @@ -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() { @@ -36,9 +37,6 @@ export default function PrimaryPageSidebar() {
</div>
</div>
<ReadOnlySessionIndicator variant="sidebar" />
<div className="max-xl:hidden w-full min-w-0 px-1">
<LiveActivitiesStrip placement="sidebar" />
</div>
<HomeButton />
<FeedButton />
<DiscussionsButton />
@ -51,6 +49,10 @@ export default function PrimaryPageSidebar() { @@ -51,6 +49,10 @@ export default function PrimaryPageSidebar() {
<FavoriteRelaysActiveStripSidebar />
<ConnectedRelaysSidebarStrip />
<PostButton />
<div className="max-xl:hidden w-full min-w-0 space-y-2 px-1">
<LiveActivitiesStrip placement="sidebar" />
<SidebarCalendarWeekWidget />
</div>
</div>
<div className="space-y-2">
<HelpAndAccountMenu variant="sidebar" />

10
src/constants.ts

@ -738,7 +738,15 @@ export const READ_ALOUD_KINDS: readonly number[] = [ @@ -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

67
src/hooks/useFetchCalendarRsvps.tsx

@ -1,8 +1,12 @@ @@ -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[] { @@ -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<Event[]>([])
@ -39,19 +51,32 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { @@ -39,19 +51,32 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
let cancelled = false
setIsFetching(true)
const coordinate = getReplaceableCoordinateFromEvent(calendarEvent)
const coordinate = normalizeReplaceableCoordinateString(
getReplaceableCoordinateFromEvent(calendarEvent)
)
const userRead = userReadRelaysWithHttp(relayList)
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<string>([
...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
try {
let relayUrls: string[]
try {
const organizerRelays = await client.fetchRelayList(organizerPubkey)
if (!cancelled) {
;[
...(organizerRelays?.httpRead ?? []),
...(organizerRelays?.read ?? []),
@ -61,13 +86,14 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { @@ -61,13 +86,14 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
const u = normalizeAnyRelayUrl(url)
if (u) baseUrls.add(u)
})
return Array.from(baseUrls)
})
.catch(() => Array.from(baseUrls))
.then((relayUrls: string[] | undefined) => {
}
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) { @@ -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) { @@ -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<Event>) => {
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))
}

6
src/i18n/locales/cs.ts

@ -754,6 +754,12 @@ export default { @@ -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)",

6
src/i18n/locales/de.ts

@ -754,6 +754,12 @@ export default { @@ -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)",

6
src/i18n/locales/en.ts

@ -758,6 +758,12 @@ export default { @@ -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)",

6
src/i18n/locales/es.ts

@ -754,6 +754,12 @@ export default { @@ -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)",

6
src/i18n/locales/fr.ts

@ -754,6 +754,12 @@ export default { @@ -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)",

6
src/i18n/locales/nl.ts

@ -754,6 +754,12 @@ export default { @@ -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)",

6
src/i18n/locales/pl.ts

@ -754,6 +754,12 @@ export default { @@ -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)",

6
src/i18n/locales/ru.ts

@ -754,6 +754,12 @@ export default { @@ -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)",

6
src/i18n/locales/tr.ts

@ -754,6 +754,12 @@ export default { @@ -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)",

6
src/i18n/locales/zh.ts

@ -754,6 +754,12 @@ export default { @@ -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)",

220
src/lib/calendar-event.ts

@ -1,4 +1,4 @@ @@ -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 { @@ -60,21 +60,225 @@ export function getCalendarEventMeta(event: Event): CalendarEventMeta {
}
}
const CALENDAR_DISPLAY_LOCALE = 'en-US'
function readFormatParts(
d: Date,
opts: Intl.DateTimeFormatOptions
): Record<Intl.DateTimeFormatPartTypes, string> {
const out: Partial<Record<Intl.DateTimeFormatPartTypes, string>> = {}
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<Intl.DateTimeFormatPartTypes, string>
}
/**
* 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()
}
/** `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. */
/** 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
}

4
src/lib/event.ts

@ -1,4 +1,4 @@ @@ -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) { @@ -154,7 +154,7 @@ export function isReplaceableEvent(kind: number) {
return (
kinds.isReplaceableKind(kind) ||
kinds.isAddressableKind(kind) ||
CALENDAR_EVENT_KINDS.includes(kind)
isNip52CalendarCardKind(kind)
)
}

5
src/lib/live-activities.ts

@ -680,7 +680,10 @@ export function buildLiveActivitiesRelayUrls(options: { @@ -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
})
}

76
src/lib/parent-reply-blurb.ts

@ -0,0 +1,76 @@ @@ -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)
}

28
src/pages/secondary/NotePage/index.tsx

@ -24,6 +24,7 @@ import { @@ -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 { @@ -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 }: @@ -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
}

2
src/providers/LiveActivitiesProvider.tsx

@ -52,7 +52,7 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode @@ -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

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

@ -26,6 +26,7 @@ import { @@ -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 { @@ -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). */

3
src/services/event-archive.service.ts

@ -1,4 +1,4 @@ @@ -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 { @@ -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
}

231
src/services/indexed-db.service.ts

@ -7,7 +7,18 @@ import { tagNameEquals } from '@/lib/tag' @@ -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 = { @@ -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<string> = new Set( @@ -167,11 +199,13 @@ const CACHE_BROWSER_EVENT_SEARCH_EXCLUDED_STORES: ReadonlySet<string> = 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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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<void> {
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<void> {
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<Event[]> {
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<Event[]> {
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()

35
src/services/note-stats.service.ts

@ -57,6 +57,13 @@ class NoteStatsService { @@ -57,6 +57,13 @@ class NoteStatsService {
static instance: NoteStatsService
private noteStatsMap: Map<string, Partial<TNoteStats>> = new Map()
private noteStatsSubscribers = new Map<string, Set<() => 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<string>()
private subscriberNotifyMicrotaskQueued = false
private processingCache = new Set<string>()
private readonly hexNoteStatsIdRe = /^[0-9a-f]{64}$/i
@ -636,13 +643,33 @@ class NoteStatsService { @@ -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<TNoteStats> | undefined {
return this.noteStatsMap.get(this.statsKey(id))
}

Loading…
Cancel
Save