Browse Source

fix spells

imwald
Silberengel 1 month ago
parent
commit
10e2e05968
  1. 91
      src/components/CalendarEventContent/index.tsx
  2. 40
      src/components/Collapsible/index.tsx
  3. 145
      src/components/ContentPreview/index.tsx
  4. 72
      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. 95
      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

91
src/components/CalendarEventContent/index.tsx

@ -1,8 +1,8 @@
import { createCalendarRsvpDraftEvent } from '@/lib/draft-event' import { createCalendarRsvpDraftEvent } from '@/lib/draft-event'
import { import {
getCalendarEventMeta, getCalendarEventMeta,
formatCalendarTime, formatCalendarTimeRange,
formatCalendarDate, formatCalendarDateRange,
isCalendarEventKind isCalendarEventKind
} from '@/lib/calendar-event' } from '@/lib/calendar-event'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
@ -13,8 +13,9 @@ import { useSecondaryPage } from '@/PageManager'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useMemo } from 'react' import { useMemo } from 'react'
import Collapsible from '../Collapsible'
import { Button } from '../ui/button' 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 { cn } from '@/lib/utils'
import { import {
DropdownMenu, DropdownMenu,
@ -84,32 +85,43 @@ export default function CalendarEventContent({
} }
} }
const scheduleLine = isDateBased
? (startDate || endDate) && formatCalendarDateRange(startDate, endDate)
: start != null && !isNaN(start)
? formatCalendarTimeRange(start, end != null && !isNaN(end) ? end : undefined)
: null
return ( return (
<div <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 data-calendar-event-content
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-start gap-2 mb-2"> <div className="flex items-start gap-3">
{image ? ( {image ? (
<img <img
src={image} src={image}
alt="" 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"> <div className="min-w-0 flex-1 space-y-2">
<span className="font-medium text-foreground truncate block"> <h3 className="text-lg font-semibold leading-snug tracking-tight text-foreground">
{title || t('Scheduled video call')} {title || t('Scheduled video call')}
</span> </h3>
{topics.length > 0 && ( {topics.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1"> <div className="flex flex-wrap gap-1.5">
{topics.map((topic) => ( {topics.map((topic) => (
<span <span
key={topic} 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} #{topic}
</span> </span>
@ -118,32 +130,23 @@ export default function CalendarEventContent({
)} )}
</div> </div>
</div> </div>
{isDateBased ? ( {scheduleLine ? (
(startDate || endDate) && ( <div className="flex gap-2 rounded-lg border border-border/60 bg-background/60 px-3 py-2.5">
<div className="text-muted-foreground text-xs mb-2"> <Clock className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
{startDate ? formatCalendarDate(startDate) : ''} <p className="min-w-0 text-sm font-medium leading-snug text-foreground">{scheduleLine}</p>
{endDate && endDate !== startDate && ( </div>
<> {formatCalendarDate(endDate)}</> ) : null}
)} {description ? (
</div> <>
) {/* NIP-52 31922/31923: collapse long summary+body only; card chrome stays outside MainNoteCard Collapsible. */}
) : ( <Collapsible threshold={200} collapsedHeight={160} className="min-w-0">
start != null && <p className="whitespace-pre-wrap break-words text-sm leading-relaxed text-muted-foreground">
!isNaN(start) && ( {description}
<div className="text-muted-foreground text-xs mb-2"> </p>
{formatCalendarTime(start)} </Collapsible>
{end != null && !isNaN(end) && end > start && ( </>
<> {formatCalendarTime(end)}</> ) : null}
)} <div className="flex flex-wrap items-center gap-2 pt-0.5">
</div>
)
)}
{description && (
<p className="text-muted-foreground text-xs mb-2 whitespace-pre-wrap break-words">
{description}
</p>
)}
<div className="flex flex-wrap items-center gap-2 mt-2">
{joinUrl && ( {joinUrl && (
<Button variant="secondary" size="sm" className="gap-2" asChild> <Button variant="secondary" size="sm" className="gap-2" asChild>
<a href={joinUrl} target="_blank" rel="noopener noreferrer"> <a href={joinUrl} target="_blank" rel="noopener noreferrer">
@ -187,17 +190,19 @@ export default function CalendarEventContent({
)} )}
</div> </div>
{attendeesList.length > 0 && ( {attendeesList.length > 0 && (
<div className="mt-3 pt-3 border-t border-border/60"> <div className="border-t border-border/50 pt-3">
<div className="text-xs font-medium text-muted-foreground mb-2">{t('Attendees')}</div> <div className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<ul className="space-y-1.5"> {t('Attendees')}
</div>
<ul className="space-y-1">
{attendeesList.map(({ pubkey, status, isOrganizer }) => ( {attendeesList.map(({ pubkey, status, isOrganizer }) => (
<li key={pubkey}> <li key={pubkey}>
<button <button
type="button" type="button"
onClick={() => push(toProfile(pubkey))} onClick={() => push(toProfile(pubkey))}
className={cn( className={cn(
'w-full flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs', '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/60 transition-colors min-w-0' 'hover:bg-muted/50'
)} )}
> >
<UserAvatar userId={pubkey} size="xSmall" className="shrink-0" /> <UserAvatar userId={pubkey} size="xSmall" className="shrink-0" />

40
src/components/Collapsible/index.tsx

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

145
src/components/ContentPreview/index.tsx

@ -10,6 +10,10 @@ import {
DISCUSSION_UPVOTE_DISPLAY DISCUSSION_UPVOTE_DISPLAY
} from '@/lib/discussion-votes' } from '@/lib/discussion-votes'
import { getWebBookmarkArticleUrl } from '@/lib/rss-article' import { getWebBookmarkArticleUrl } from '@/lib/rss-article'
import {
getParentReplyBlurbDisplayText,
parentReplyPollQuestionBlurb
} from '@/lib/parent-reply-blurb'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useMuteListOptional } from '@/contexts/mute-list-context' import { useMuteListOptional } from '@/contexts/mute-list-context'
@ -48,14 +52,6 @@ const CONTENT_PREVIEW_HOOK_PLACEHOLDER = {
sig: '' sig: ''
} as Event } 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. */ /** Keep spacing/margins on the outer wrapper; put line-clamp on the preview body so it still clamps text. */
function splitPreviewLayoutClasses(className?: string) { function splitPreviewLayoutClasses(className?: string) {
if (!className?.trim()) return { outer: undefined, body: undefined } if (!className?.trim()) return { outer: undefined, body: undefined }
@ -143,10 +139,30 @@ export default function ContentPreview({
ExtendedKind.PUBLIC_MESSAGE ExtendedKind.PUBLIC_MESSAGE
].includes(event.kind) ].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} />) return withKindRow(<NormalContentPreview event={previewEvent} />)
} }
if (event.kind === ExtendedKind.DISCUSSION) { 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 ( return (
<div className={cn('flex min-w-0 flex-col gap-1', previewOuter)}> <div className={cn('flex min-w-0 flex-col gap-1', previewOuter)}>
<NoteKindLabel kind={previewEvent.kind} event={previewEvent} size="small" /> <NoteKindLabel kind={previewEvent.kind} event={previewEvent} size="small" />
@ -158,6 +174,14 @@ export default function ContentPreview({
} }
if (event.kind === kinds.Highlights) { 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} />) return withKindRow(<HighlightPreview event={previewEvent} />)
} }
@ -165,6 +189,13 @@ export default function ContentPreview({
const href = getWebBookmarkArticleUrl(previewEvent) const href = getWebBookmarkArticleUrl(previewEvent)
const title = previewEvent.tags.find((t) => t[0] === 'title')?.[1]?.trim() const title = previewEvent.tags.find((t) => t[0] === 'title')?.[1]?.trim()
const line = title?.trim() || href?.trim() || t('Web bookmark') 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>) return withKindRow(<div className={cn('min-w-0 truncate text-sm', previewBody)}>{line}</div>)
} }
@ -181,34 +212,100 @@ export default function ContentPreview({
} }
if (event.kind === kinds.LongFormArticle) { 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} />) return withKindRow(<LongFormCard event={previewEvent} interactive={false} />)
} }
if (isNip71StyleVideoKind(event.kind)) { 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} />) return withKindRow(<VideoNotePreview event={previewEvent} />)
} }
if (event.kind === ExtendedKind.PICTURE) { 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} />) return withKindRow(<PictureNotePreview event={previewEvent} />)
} }
if (event.kind === ExtendedKind.GROUP_METADATA) { 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} />) return withKindRow(<GroupMetadataPreview event={previewEvent} />)
} }
if (event.kind === kinds.CommunityDefinition) { 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} />) return withKindRow(<CommunityDefinitionPreview event={previewEvent} />)
} }
if (event.kind === kinds.LiveEvent) { 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} />) return withKindRow(<LiveEventPreview event={previewEvent} />)
} }
if (event.kind === ExtendedKind.ZAP_REQUEST) { 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} />) return withKindRow(<ZapPreview event={previewEvent} />)
} }
if (event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === kinds.Zap) { 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') { if (previewDensity === 'compact') {
return ( return (
<div className={cn('min-w-0', previewOuter)}> <div className={cn('min-w-0', previewOuter)}>
@ -220,14 +317,38 @@ export default function ContentPreview({
} }
if (event.kind === ExtendedKind.APPLICATION_HANDLER_INFO) { 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} />) return withKindRow(<ApplicationHandlerInfo event={previewEvent} />)
} }
if (event.kind === ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION) { 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} />) return withKindRow(<ApplicationHandlerRecommendation event={previewEvent} />)
} }
if (event.kind === ExtendedKind.FOLLOW_PACK) { 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} />) return withKindRow(<FollowPackPreview event={previewEvent} />)
} }
@ -236,6 +357,14 @@ export default function ContentPreview({
event.kind === ExtendedKind.GIT_ISSUE || event.kind === ExtendedKind.GIT_ISSUE ||
event.kind === ExtendedKind.GIT_RELEASE 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} />) return withKindRow(<GitRepublicEventCard variant="compact" event={previewEvent} />)
} }

72
src/components/Embedded/EmbeddedCalendarEvent.tsx

@ -1,14 +1,15 @@
import { import {
getCalendarEventMeta, getCalendarEventMeta,
formatCalendarTime, formatCalendarTimeRange,
formatCalendarDate, formatCalendarDateRange,
isCalendarEventKind isCalendarEventKind
} from '@/lib/calendar-event' } from '@/lib/calendar-event'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Collapsible from '../Collapsible'
import { Button } from '../ui/button' import { Button } from '../ui/button'
import { Calendar, Video } from 'lucide-react' import { Calendar, Clock, Video } from 'lucide-react'
export function EmbeddedCalendarEvent({ export function EmbeddedCalendarEvent({
event, event,
@ -23,35 +24,43 @@ export function EmbeddedCalendarEvent({
getCalendarEventMeta(event) getCalendarEventMeta(event)
const description = summary || event.content?.trim() || '' 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 ( return (
<div <div
className={cn( 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 className
)} )}
data-embedded-calendar-event data-embedded-calendar-event
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-start gap-2 mb-2"> <div className="flex items-start gap-2.5">
{image ? ( {image ? (
<img <img
src={image} src={image}
alt="" 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"> <div className="min-w-0 flex-1 space-y-1.5">
<span className="font-medium text-foreground truncate block"> <span className="block truncate font-semibold leading-snug text-foreground">
{title || t('Scheduled video call')} {title || t('Scheduled video call')}
</span> </span>
{topics.length > 0 && ( {topics.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1"> <div className="flex flex-wrap gap-1">
{topics.map((topic) => ( {topics.map((topic) => (
<span <span
key={topic} 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} #{topic}
</span> </span>
@ -60,31 +69,22 @@ export function EmbeddedCalendarEvent({
)} )}
</div> </div>
</div> </div>
{isDateBased ? ( {scheduleLine ? (
(startDate || endDate) && ( <div className="flex gap-2 rounded-md border border-border/60 bg-background/60 px-2.5 py-2">
<div className="text-muted-foreground text-xs mb-2"> <Clock className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" aria-hidden />
{startDate ? formatCalendarDate(startDate) : ''} <p className="min-w-0 text-xs font-medium leading-snug text-foreground">{scheduleLine}</p>
{endDate && endDate !== startDate && ( </div>
<> {formatCalendarDate(endDate)}</> ) : null}
)} {description ? (
</div> <>
) {/* NIP-52 31922/31923 embedded preview: long description only. */}
) : ( <Collapsible threshold={180} collapsedHeight={120} className="min-w-0">
start != null && <p className="whitespace-pre-wrap break-words text-xs leading-relaxed text-muted-foreground">
!isNaN(start) && ( {description}
<div className="text-muted-foreground text-xs mb-2"> </p>
{formatCalendarTime(start)} </Collapsible>
{end != null && !isNaN(end) && end > start && ( </>
<> {formatCalendarTime(end)}</> ) : null}
)}
</div>
)
)}
{description && (
<p className="text-muted-foreground text-xs mb-2 whitespace-pre-wrap break-words">
{description}
</p>
)}
{joinUrl && ( {joinUrl && (
<Button <Button
variant="secondary" variant="secondary"

5
src/components/Embedded/EmbeddedNote.tsx

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

98
src/components/FavoriteRelaysActiveStrip/index.tsx

@ -33,70 +33,12 @@ function useRelativePastPhrase(timestampMs: number | null, t: TFunction): string
}, [timestampMs, t, tick]) }, [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 */ /** Home feed / mobile: full label above the page title */
export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: string }) { export function FavoriteRelaysActiveStripMobileBar({ className }: { className?: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigate } = usePrimaryPage() const { navigate } = usePrimaryPage()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const { const { followCount, totalCount, loading, relayActivityReady, lastFetchedAtMs } = useFavoriteRelaysActivity()
followCount,
otherCount,
totalCount,
loading,
relayActivityReady,
lastFetchedAtMs
} = useFavoriteRelaysActivity()
const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t) const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t)
@ -147,6 +89,18 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?:
<div className="flex min-w-0 shrink items-center gap-2"> <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> <p className="text-xs font-medium leading-tight text-foreground">{t('Relay pulse')}</p>
<RelayPulseActiveNpubsOpenButton size="sm" variant="outline" className="h-7 shrink-0" /> <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> </div>
{lastFetchedAtMs != null && relativeLabel ? ( {lastFetchedAtMs != null && relativeLabel ? (
<p className="shrink-0 text-[0.65rem] text-muted-foreground tabular-nums"> <p className="shrink-0 text-[0.65rem] text-muted-foreground tabular-nums">
@ -154,14 +108,6 @@ export function FavoriteRelaysActiveStripMobileBar({ className }: { className?:
</p> </p>
) : null} ) : null}
</div> </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>
</div> </div>
) )
@ -172,14 +118,7 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st
const { t } = useTranslation() const { t } = useTranslation()
const { navigate } = usePrimaryPage() const { navigate } = usePrimaryPage()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const { const { followCount, totalCount, loading, relayActivityReady, lastFetchedAtMs } = useFavoriteRelaysActivity()
followCount,
otherCount,
totalCount,
loading,
relayActivityReady,
lastFetchedAtMs
} = useFavoriteRelaysActivity()
const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t) const relativeLabel = useRelativePastPhrase(lastFetchedAtMs, t)
@ -266,15 +205,6 @@ export function FavoriteRelaysActiveStripSidebar({ className }: { className?: st
</Button> </Button>
) : null} ) : null}
</div> </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> </div>
) )
} }

6
src/components/Note/index.tsx

@ -29,6 +29,7 @@ import { muteSetHas } from '@/lib/mute-set'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import type { HighlightData } from '@/components/PostEditor/HighlightEditor' import type { HighlightData } from '@/components/PostEditor/HighlightEditor'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { isCalendarEventKind } from '@/lib/calendar-event'
import { mergeTranslatedNote, useNoteTranslation } from '@/lib/note-translation-display' import { mergeTranslatedNote, useNoteTranslation } from '@/lib/note-translation-display'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -186,8 +187,7 @@ export default function Note({
event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION ||
event.kind === ExtendedKind.PUBLICATION_CONTENT || event.kind === ExtendedKind.PUBLICATION_CONTENT ||
event.kind === ExtendedKind.DISCUSSION || event.kind === ExtendedKind.DISCUSSION ||
event.kind === ExtendedKind.CALENDAR_EVENT_TIME || isCalendarEventKind(event.kind) ||
event.kind === ExtendedKind.CALENDAR_EVENT_DATE ||
event.kind === ExtendedKind.COMMENT event.kind === ExtendedKind.COMMENT
const renderEventContent = useCallback( const renderEventContent = useCallback(
@ -395,7 +395,7 @@ export default function Note({
content = <VideoNote className="mt-2" event={event} /> content = <VideoNote className="mt-2" event={event} />
} else if (event.kind === ExtendedKind.RELAY_REVIEW) { } else if (event.kind === ExtendedKind.RELAY_REVIEW) {
content = <RelayReview className="mt-2" event={displayEvent} /> 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 /> content = <CalendarEventContent event={displayEvent} className="mt-2" showRsvp />
} else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { } else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) {
content = renderEventContent({ hideMetadata: true }) content = renderEventContent({ hideMetadata: true })

6
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 { Separator } from '@/components/ui/separator'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
@ -42,6 +42,8 @@ export default function MainNoteCard({
const { navigateToNote } = useSmartNoteNavigationOptional() const { navigateToNote } = useSmartNoteNavigationOptional()
const isZapFeedCard = const isZapFeedCard =
event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === ExtendedKind.ZAP_REQUEST 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 const showNoteStatsRow = !embedded || isZapFeedCard
return ( return (
@ -94,7 +96,7 @@ export default function MainNoteCard({
<Pin className="size-4 shrink-0" strokeWidth={1.5} aria-hidden /> <Pin className="size-4 shrink-0" strokeWidth={1.5} aria-hidden />
</div> </div>
)} )}
<Collapsible alwaysExpand={embedded}> <Collapsible alwaysExpand={embedded || isCalendarNoteKind}>
<RepostDescription className={embedded ? '' : 'px-4'} reposter={reposter} /> <RepostDescription className={embedded ? '' : 'px-4'} reposter={reposter} />
<Note <Note
className={embedded ? '' : 'px-4'} className={embedded ? '' : 'px-4'}

13
src/components/NoteList/index.tsx

@ -2778,6 +2778,19 @@ const NoteList = forwardRef(
if (eventsRef.current.length === 0) { if (eventsRef.current.length === 0) {
setHasMore(false) 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) }, loadingSafetyMs)
return () => { return () => {
cancelled = true cancelled = true

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

10
src/constants.ts

@ -738,7 +738,15 @@ export const READ_ALOUD_KINDS: readonly number[] = [
export const CALENDAR_EVENT_KINDS = [ export const CALENDAR_EVENT_KINDS = [
ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_DATE,
ExtendedKind.CALENDAR_EVENT_TIME 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). */ /** Maximum invitees for calendar event group invites (one kind 24 with all as p-tags). */
export const MAX_CALENDAR_INVITEES = 10 export const MAX_CALENDAR_INVITEES = 10

95
src/hooks/useFetchCalendarRsvps.tsx

@ -1,8 +1,12 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { getReplaceableCoordinateFromEvent } from '@/lib/event' import {
getReplaceableCoordinateFromEvent,
normalizeReplaceableCoordinateString
} from '@/lib/event'
import { isCalendarEventKind } from '@/lib/calendar-event' import { isCalendarEventKind } from '@/lib/calendar-event'
import client from '@/services/client.service' import client from '@/services/client.service'
import { queryService } from '@/services/client.service' import { queryService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react' 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) 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) { export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
const { relayList } = useNostr() const { relayList } = useNostr()
const [rsvps, setRsvps] = useState<Event[]>([]) const [rsvps, setRsvps] = useState<Event[]>([])
@ -39,35 +51,49 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
let cancelled = false let cancelled = false
setIsFetching(true) setIsFetching(true)
const coordinate = getReplaceableCoordinateFromEvent(calendarEvent) const coordinate = normalizeReplaceableCoordinateString(
getReplaceableCoordinateFromEvent(calendarEvent)
)
const userRead = userReadRelaysWithHttp(relayList) const userRead = userReadRelaysWithHttp(relayList)
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) void (async () => {
const organizerPubkey = calendarEvent.pubkey let fromIdb: Event[] = []
client try {
.fetchRelayList(organizerPubkey) fromIdb = await indexedDb.getCalendarRsvpEventsByParentCoordinate(coordinate)
.then((organizerRelays) => { } catch {
if (cancelled) return fromIdb = []
;[ }
...(organizerRelays?.httpRead ?? []), if (cancelled) return
...(organizerRelays?.read ?? []), if (fromIdb.length) setRsvps(fromIdb)
...(organizerRelays?.httpWrite ?? []),
...(organizerRelays?.write ?? []) const baseUrls = new Set<string>([
].forEach((url) => { ...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url),
const u = normalizeAnyRelayUrl(url) ...userRead.map((url) => normalizeAnyRelayUrl(url) || url)
if (u) baseUrls.add(u) ].filter(Boolean) as string[])
})
return Array.from(baseUrls) const organizerPubkey = calendarEvent.pubkey
}) try {
.catch(() => Array.from(baseUrls)) let relayUrls: string[]
.then((relayUrls: string[] | undefined) => { 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 if (cancelled) return
const urls = relayUrls?.length ? relayUrls : Array.from(baseUrls) const urls = relayUrls?.length ? relayUrls : Array.from(baseUrls)
return queryService.fetchEvents( const events = await queryService.fetchEvents(
urls, urls,
{ {
kinds: [ExtendedKind.CALENDAR_EVENT_RSVP], kinds: [ExtendedKind.CALENDAR_EVENT_RSVP],
@ -76,14 +102,12 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
}, },
{ firstRelayResultGraceMs: false } { firstRelayResultGraceMs: false }
) )
})
.then((events) => {
if (cancelled) return if (cancelled) return
setRsvps(events ?? []) setRsvps(mergeRsvpList([...fromIdb, ...(events ?? [])]))
}) } finally {
.finally(() => {
if (!cancelled) setIsFetching(false) if (!cancelled) setIsFetching(false)
}) }
})()
return () => { return () => {
cancelled = true cancelled = true
@ -94,12 +118,15 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) {
useEffect(() => { useEffect(() => {
if (!calendarEvent || !isCalendarEventKind(calendarEvent.kind)) return if (!calendarEvent || !isCalendarEventKind(calendarEvent.kind)) return
const coordinate = getReplaceableCoordinateFromEvent(calendarEvent) const coordinate = normalizeReplaceableCoordinateString(
getReplaceableCoordinateFromEvent(calendarEvent)
)
const handler = (e: CustomEvent<Event>) => { const handler = (e: CustomEvent<Event>) => {
const evt = e.detail const evt = e.detail
if (evt.kind !== ExtendedKind.CALENDAR_EVENT_RSVP) return if (evt.kind !== ExtendedKind.CALENDAR_EVENT_RSVP) return
const aTag = evt.tags.find(tagNameEquals('a')) 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)) setRsvps((prev) => mergeRsvp(prev, evt))
} }

6
src/i18n/locales/cs.ts

@ -754,6 +754,12 @@ export default {
"Added from follows web bookmarks": "Added from follows web bookmarks", "Added from follows web bookmarks": "Added from follows web bookmarks",
"Nothing to load for this feed.": "Nothing to load for this feed.", "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.", "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…", "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.", "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)", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

6
src/i18n/locales/de.ts

@ -754,6 +754,12 @@ export default {
"Added from follows web bookmarks": "Added from follows web bookmarks", "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.", "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.", "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…", "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.", "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)", "Per-relay timeline results ({{count}} connections)": "Ergebnis je Relay ({{count}} Verbindungen)",

6
src/i18n/locales/en.ts

@ -758,6 +758,12 @@ export default {
"Added from follows web bookmarks": "Added from follows web bookmarks", "Added from follows web bookmarks": "Added from follows web bookmarks",
"Nothing to load for this feed.": "Nothing to load for this feed.", "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.", "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…", "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.", "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)", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

6
src/i18n/locales/es.ts

@ -754,6 +754,12 @@ export default {
"Added from follows web bookmarks": "Added from follows web bookmarks", "Added from follows web bookmarks": "Added from follows web bookmarks",
"Nothing to load for this feed.": "Nothing to load for this feed.", "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.", "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…", "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.", "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)", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

6
src/i18n/locales/fr.ts

@ -754,6 +754,12 @@ export default {
"Added from follows web bookmarks": "Added from follows web bookmarks", "Added from follows web bookmarks": "Added from follows web bookmarks",
"Nothing to load for this feed.": "Nothing to load for this feed.", "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.", "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…", "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.", "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)", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

6
src/i18n/locales/nl.ts

@ -754,6 +754,12 @@ export default {
"Added from follows web bookmarks": "Added from follows web bookmarks", "Added from follows web bookmarks": "Added from follows web bookmarks",
"Nothing to load for this feed.": "Nothing to load for this feed.", "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.", "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…", "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.", "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)", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

6
src/i18n/locales/pl.ts

@ -754,6 +754,12 @@ export default {
"Added from follows web bookmarks": "Added from follows web bookmarks", "Added from follows web bookmarks": "Added from follows web bookmarks",
"Nothing to load for this feed.": "Nothing to load for this feed.", "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.", "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…", "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.", "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)", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

6
src/i18n/locales/ru.ts

@ -754,6 +754,12 @@ export default {
"Added from follows web bookmarks": "Added from follows web bookmarks", "Added from follows web bookmarks": "Added from follows web bookmarks",
"Nothing to load for this feed.": "Nothing to load for this feed.", "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.", "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…", "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.", "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)", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

6
src/i18n/locales/tr.ts

@ -754,6 +754,12 @@ export default {
"Added from follows web bookmarks": "Added from follows web bookmarks", "Added from follows web bookmarks": "Added from follows web bookmarks",
"Nothing to load for this feed.": "Nothing to load for this feed.", "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.", "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…", "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.", "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)", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

6
src/i18n/locales/zh.ts

@ -754,6 +754,12 @@ export default {
"Added from follows web bookmarks": "Added from follows web bookmarks", "Added from follows web bookmarks": "Added from follows web bookmarks",
"Nothing to load for this feed.": "Nothing to load for this feed.", "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.", "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…", "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.", "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)", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

220
src/lib/calendar-event.ts

@ -1,4 +1,4 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind, isNip52CalendarCardKind } from '@/constants'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import { Event } from 'nostr-tools' 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<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 { export function formatCalendarTime(ts: number): string {
const d = new Date(ts * 1000) const d = new Date(ts * 1000)
return d.toLocaleString(undefined, { const p = readFormatParts(d, {
dateStyle: 'medium', month: 'long',
timeStyle: 'short' 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 { export function formatCalendarDate(dateStr: string): string {
if (!dateStr) return '' if (!dateStr) return ''
const d = new Date(dateStr + 'T00:00:00') const d = new Date(dateStr + 'T12:00:00')
return d.toLocaleDateString(undefined, { dateStyle: 'long' }) 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 { 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 @@
import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' import { ExtendedKind, isNip52CalendarCardKind } from '@/constants'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { EMBEDDED_EVENT_REGEX, EMBEDDED_MENTION_REGEX, NOSTR_EMBEDDED_NOTE_REGEX } from '@/lib/content-patterns' import { EMBEDDED_EVENT_REGEX, EMBEDDED_MENTION_REGEX, NOSTR_EMBEDDED_NOTE_REGEX } from '@/lib/content-patterns'
import { cleanUrl, normalizeUrl } from '@/lib/url' import { cleanUrl, normalizeUrl } from '@/lib/url'
@ -154,7 +154,7 @@ export function isReplaceableEvent(kind: number) {
return ( return (
kinds.isReplaceableKind(kind) || kinds.isReplaceableKind(kind) ||
kinds.isAddressableKind(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: {
const fav = relayUrlsLocalsFirst(getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)) const fav = relayUrlsLocalsFirst(getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays))
const read = relayUrlsLocalsFirst(relayListRead) const read = relayUrlsLocalsFirst(relayListRead)
const write = relayUrlsLocalsFirst(relayListWrite) 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 applySocialKindBlockedFilter: true
}) })
} }

76
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)
}

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

@ -24,6 +24,7 @@ import {
} from '@/lib/event' } from '@/lib/event'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList } from '@/lib/link' import { toNote, toNoteList } from '@/lib/link'
import { stripMarkupForPreview } from '@/lib/parent-reply-blurb'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Ellipsis } from 'lucide-react' 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 NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: { id?: string; index?: number; hideTitlebar?: boolean; initialEvent?: Event }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
@ -301,7 +277,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
// Generate content preview (matching fallback card) // Generate content preview (matching fallback card)
let contentPreview = '' let contentPreview = ''
if (finalEvent.content) { if (finalEvent.content) {
const stripped = stripMarkdown(finalEvent.content) const stripped = stripMarkupForPreview(finalEvent.content)
contentPreview = stripped.length > 500 ? stripped.substring(0, 500) + '...' : stripped 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
relayListRead: relayRead, relayListRead: relayRead,
relayListWrite: relayWrite relayListWrite: relayWrite
}) })
if (loggedIn && urls.length === 0) { if (urls.length === 0) {
rawItemsRef.current = [] rawItemsRef.current = []
setItems([]) setItems([])
return return

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

@ -26,6 +26,7 @@ import {
queuePersistSeenEvent queuePersistSeenEvent
} from './event-archive.service' } from './event-archive.service'
import { getDefaultSessionLruMaxSync } from '@/lib/event-archive-config' import { getDefaultSessionLruMaxSync } from '@/lib/event-archive-config'
import { isCalendarEventKind } from '@/lib/calendar-event'
import { citationPickerMatchesQuery } from '@/lib/citation-picker-search' import { citationPickerMatchesQuery } from '@/lib/citation-picker-search'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' 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). */ /** 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 @@
import { ExtendedKind, NIP71_VIDEO_KINDS } from '@/constants' import { ExtendedKind, isNip52CalendarCardKind, NIP71_VIDEO_KINDS } from '@/constants'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { getEventArchiveConfig } from '@/lib/event-archive-config' import { getEventArchiveConfig } from '@/lib/event-archive-config'
import { isNip18RepostKind, isNip25ReactionKind, isReplaceableEvent } from '@/lib/event' import { isNip18RepostKind, isNip25ReactionKind, isReplaceableEvent } from '@/lib/event'
@ -41,6 +41,7 @@ function archiveTierForEvent(ev: Event): number {
function shouldSkipArchiving(ev: Event): boolean { function shouldSkipArchiving(ev: Event): boolean {
if (shouldDropEventOnIngest(ev)) return true 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)) { if (isReplaceableEvent(ev.kind) && indexedDb.hasReplaceableEventStoreForKind(ev.kind)) {
return true return true
} }

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

@ -7,7 +7,18 @@ import { tagNameEquals } from '@/lib/tag'
import { TNip66RelayDiscovery, TRelayInfo } from '@/types' import { TNip66RelayDiscovery, TRelayInfo } from '@/types'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { kinds } 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 { citationPickerMatchesQuery } from '@/lib/citation-picker-search'
import logger from '@/lib/logger' 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. */ /** Persisted timeline refs + filter for cold-start hydration. Key: {@link ClientService.generateTimelineKey} hash. */
TIMELINE_STATE: 'timelineState', TIMELINE_STATE: 'timelineState',
/** Piper / read-aloud WAV blobs keyed by SHA-256 of endpoint + text + speed. */ /** 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.). */ /** 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(
StoreNames.FOLLOWING_FAVORITE_RELAYS, StoreNames.FOLLOWING_FAVORITE_RELAYS,
StoreNames.RELAY_SETS, StoreNames.RELAY_SETS,
StoreNames.MUTE_DECRYPTED_TAGS, 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. */ /** 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). */ /** Max age for profile and payment info cache before we refetch (5 min). */
const PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS = 5 * 60 * 1000 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) { } else if (storeName === StoreNames.EVENT_ARCHIVE) {
const store = db.createObjectStore(storeName, { keyPath: 'key' }) const store = db.createObjectStore(storeName, { keyPath: 'key' })
store.createIndex('eviction', ['archiveTier', 'lastAccessAt'], { unique: false }) 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 { } else {
db.createObjectStore(storeName, { keyPath: 'key' }) db.createObjectStore(storeName, { keyPath: 'key' })
} }
@ -392,6 +432,16 @@ class IndexedDbService {
if (event.oldVersion < 34) { if (event.oldVersion < 34) {
// v34: app-side changes (fetch timeouts, timeline hydrate order, discussion list cap) // 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) ensureMissingObjectStores(db)
} }
} }
@ -3109,10 +3159,14 @@ class IndexedDbService {
// Or just event ID for non-replaceable events // Or just event ID for non-replaceable events
const parts = key.split(':') const parts = key.split(':')
if (parts.length === 1) { 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([ await Promise.allSettled([
this.deleteStoreItem(StoreNames.PUBLICATION_EVENTS, key), 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++ removed++
} else if (parts.length >= 2) { } else if (parts.length >= 2) {
@ -3127,6 +3181,18 @@ class IndexedDbService {
await this.deleteStoreItem(storeName, this.getReplaceableEventKey(pubkey.toLowerCase(), d)) await this.deleteStoreItem(storeName, this.getReplaceableEventKey(pubkey.toLowerCase(), d))
removed++ 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 { } catch {
// Ignore errors // Ignore errors
} }
@ -3136,6 +3202,159 @@ class IndexedDbService {
return removed 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() const instance = IndexedDbService.getInstance()

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

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

Loading…
Cancel
Save