33 changed files with 1269 additions and 283 deletions
@ -0,0 +1,278 @@
@@ -0,0 +1,278 @@
|
||||
import { |
||||
calendarOccurrenceOverlapsRange, |
||||
formatCalendarSidebarRow, |
||||
formatSidebarWeekLabel, |
||||
getCalendarOccurrenceWindowMs, |
||||
getLocalMondayWeekBounds |
||||
} from '@/lib/calendar-event' |
||||
import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' |
||||
import { replaceableEventDedupeKey } from '@/lib/event' |
||||
import { toNote } from '@/lib/link' |
||||
import { cn } from '@/lib/utils' |
||||
import { useSmartNoteNavigation } from '@/PageManager' |
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||
import { useFollowListOptional } from '@/providers/follow-list-context' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import client from '@/services/client.service' |
||||
import indexedDb from '@/services/indexed-db.service' |
||||
import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' |
||||
import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' |
||||
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react' |
||||
import { type Event } from 'nostr-tools' |
||||
import { useCallback, useEffect, useMemo, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { Button } from '@/components/ui/button' |
||||
|
||||
/** Global calendar REQ: relays often cap; larger limit reduces “missing” older-published rows for this week. */ |
||||
const FETCH_LIMIT = 1200 |
||||
/** Supplementary `authors` REQ: community calls (e.g. Edufeed) may not appear in the global slice. */ |
||||
const FOLLOWING_CALENDAR_AUTHORS_CAP = 200 |
||||
const FOLLOWING_CALENDAR_AUTHORS_CHUNK = 80 |
||||
const FOLLOWING_CALENDAR_CHUNK_LIMIT = 350 |
||||
/** ~5 note rows at ~48px each */ |
||||
const LIST_MAX_HEIGHT_PX = 240 |
||||
const SIDEBAR_CALENDAR_MAX_RELAYS = 24 |
||||
/** Merge session cache so events already loaded in feeds (but missed by this REQ) still appear. */ |
||||
const SESSION_CALENDAR_MERGE_CAP = 5000 |
||||
|
||||
function dedupeCalendarEvents(events: Event[]): Event[] { |
||||
const map = new Map<string, Event>() |
||||
for (const e of events) { |
||||
const k = replaceableEventDedupeKey(e) |
||||
const prev = map.get(k) |
||||
if (!prev || e.created_at > prev.created_at) map.set(k, e) |
||||
} |
||||
return [...map.values()] |
||||
} |
||||
|
||||
export default function SidebarCalendarWeekWidget() { |
||||
const { t } = useTranslation() |
||||
const { relayList, pubkey } = useNostr() |
||||
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
||||
const followList = useFollowListOptional() |
||||
const { navigateToNote } = useSmartNoteNavigation() |
||||
|
||||
const [weekOffset, setWeekOffset] = useState(0) |
||||
const [rawEvents, setRawEvents] = useState<Event[]>([]) |
||||
const [loading, setLoading] = useState(false) |
||||
|
||||
const relayUrls = useMemo(() => { |
||||
const base = getRelayUrlsWithFavoritesFastReadAndInbox( |
||||
favoriteRelays, |
||||
blockedRelays, |
||||
userReadRelaysWithHttp(relayList), |
||||
{ |
||||
userWriteRelays: relayList?.write ?? [], |
||||
applySocialKindBlockedFilter: false |
||||
} |
||||
) |
||||
return appendCuratedReadOnlyRelays(base, blockedRelays).slice(0, SIDEBAR_CALENDAR_MAX_RELAYS) |
||||
}, [favoriteRelays, blockedRelays, relayList]) |
||||
|
||||
const relayKey = useMemo(() => [...relayUrls].sort().join('|'), [relayUrls]) |
||||
|
||||
const followAuthorsKey = useMemo(() => { |
||||
const raw = followList?.followings ?? [] |
||||
if (!raw.length && !pubkey) return '' |
||||
const set = new Set<string>() |
||||
for (const p of raw) { |
||||
const k = p?.trim().toLowerCase() |
||||
if (k) set.add(k) |
||||
} |
||||
if (pubkey) set.add(pubkey.toLowerCase()) |
||||
return [...set].sort().join('|') |
||||
}, [followList?.followings, pubkey]) |
||||
|
||||
const { weekLabel, sortedForWeek } = useMemo(() => { |
||||
const { weekStartMs: ws, weekEndExclusiveMs: we } = getLocalMondayWeekBounds(weekOffset) |
||||
const label = formatSidebarWeekLabel(ws, we) |
||||
const rows: { event: Event; sortKey: number }[] = [] |
||||
for (const ev of rawEvents) { |
||||
if (!calendarOccurrenceOverlapsRange(ev, ws, we)) continue |
||||
const win = getCalendarOccurrenceWindowMs(ev) |
||||
if (!win) continue |
||||
rows.push({ event: ev, sortKey: win.startMs }) |
||||
} |
||||
rows.sort((a, b) => a.sortKey - b.sortKey) |
||||
return { |
||||
weekLabel: label, |
||||
sortedForWeek: rows.map((r) => r.event) |
||||
} |
||||
}, [rawEvents, weekOffset]) |
||||
|
||||
useEffect(() => { |
||||
let cancelled = false |
||||
let lateMergeTimer: number | null = null |
||||
setLoading(true) |
||||
void (async () => { |
||||
try { |
||||
const { weekStartMs, weekEndExclusiveMs } = getLocalMondayWeekBounds(weekOffset) |
||||
const fromIdb = await indexedDb.getCalendarEventsForOccurrenceWindow(weekStartMs, weekEndExclusiveMs) |
||||
|
||||
if (!relayUrls.length) { |
||||
if (cancelled) return |
||||
const fromSession = client.getSessionEventsMatchingSearch( |
||||
'', |
||||
SESSION_CALENDAR_MERGE_CAP, |
||||
[...CALENDAR_EVENT_KINDS] |
||||
) |
||||
setRawEvents(dedupeCalendarEvents([...fromIdb, ...fromSession])) |
||||
lateMergeTimer = window.setTimeout(() => { |
||||
lateMergeTimer = null |
||||
if (cancelled) return |
||||
const later = client.getSessionEventsMatchingSearch( |
||||
'', |
||||
SESSION_CALENDAR_MERGE_CAP, |
||||
[...CALENDAR_EVENT_KINDS] |
||||
) |
||||
setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later, ...fromIdb])) |
||||
}, 2500) |
||||
return |
||||
} |
||||
|
||||
const batch = await client.fetchEvents( |
||||
relayUrls, |
||||
{ |
||||
kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], |
||||
limit: FETCH_LIMIT |
||||
}, |
||||
{ |
||||
cache: true, |
||||
globalTimeout: 22_000, |
||||
eoseTimeout: 3500, |
||||
firstRelayResultGraceMs: false |
||||
} |
||||
) |
||||
if (cancelled) return |
||||
|
||||
const fromFollowing: Event[] = [] |
||||
if (followAuthorsKey) { |
||||
const authorList = followAuthorsKey.split('|').filter(Boolean).slice(0, FOLLOWING_CALENDAR_AUTHORS_CAP) |
||||
for (let i = 0; i < authorList.length; i += FOLLOWING_CALENDAR_AUTHORS_CHUNK) { |
||||
const authors = authorList.slice(i, i + FOLLOWING_CALENDAR_AUTHORS_CHUNK) |
||||
const chunk = await client.fetchEvents( |
||||
relayUrls, |
||||
{ |
||||
kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], |
||||
authors, |
||||
limit: FOLLOWING_CALENDAR_CHUNK_LIMIT |
||||
}, |
||||
{ |
||||
cache: true, |
||||
globalTimeout: 16_000, |
||||
eoseTimeout: 2800, |
||||
firstRelayResultGraceMs: false |
||||
} |
||||
) |
||||
if (cancelled) return |
||||
fromFollowing.push(...chunk) |
||||
} |
||||
} |
||||
|
||||
const fromSession = client.getSessionEventsMatchingSearch( |
||||
'', |
||||
SESSION_CALENDAR_MERGE_CAP, |
||||
[...CALENDAR_EVENT_KINDS] |
||||
) |
||||
setRawEvents(dedupeCalendarEvents([...batch, ...fromFollowing, ...fromSession, ...fromIdb])) |
||||
lateMergeTimer = window.setTimeout(() => { |
||||
lateMergeTimer = null |
||||
if (cancelled) return |
||||
const later = client.getSessionEventsMatchingSearch( |
||||
'', |
||||
SESSION_CALENDAR_MERGE_CAP, |
||||
[...CALENDAR_EVENT_KINDS] |
||||
) |
||||
setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later])) |
||||
}, 2500) |
||||
} catch { |
||||
if (!cancelled) setRawEvents([]) |
||||
} finally { |
||||
if (!cancelled) setLoading(false) |
||||
} |
||||
})() |
||||
return () => { |
||||
cancelled = true |
||||
if (lateMergeTimer != null) window.clearTimeout(lateMergeTimer) |
||||
} |
||||
}, [relayKey, followAuthorsKey, weekOffset]) |
||||
|
||||
const openEvent = useCallback( |
||||
(ev: Event) => { |
||||
navigateToNote(toNote(ev), ev) |
||||
}, |
||||
[navigateToNote] |
||||
) |
||||
|
||||
return ( |
||||
<div className="max-xl:hidden w-full min-w-0 rounded-lg border border-border/60 bg-card/40 px-2 py-2 shadow-sm"> |
||||
<div className="mb-1.5 flex items-center justify-between gap-1"> |
||||
<Button |
||||
type="button" |
||||
variant="ghost" |
||||
size="icon" |
||||
className="size-7 shrink-0" |
||||
aria-label={t('sidebarCalendarPreviousWeek')} |
||||
onClick={() => setWeekOffset((w) => w - 1)} |
||||
> |
||||
<ChevronLeft className="size-4" /> |
||||
</Button> |
||||
<span |
||||
className="min-w-0 flex-1 truncate text-center text-[11px] font-semibold leading-tight text-foreground" |
||||
title={weekLabel} |
||||
> |
||||
{weekLabel} |
||||
</span> |
||||
<Button |
||||
type="button" |
||||
variant="ghost" |
||||
size="icon" |
||||
className="size-7 shrink-0" |
||||
aria-label={t('sidebarCalendarNextWeek')} |
||||
onClick={() => setWeekOffset((w) => w + 1)} |
||||
> |
||||
<ChevronRight className="size-4" /> |
||||
</Button> |
||||
</div> |
||||
<p className="mb-1.5 text-center text-[10px] font-medium uppercase tracking-wide text-muted-foreground"> |
||||
{t('sidebarCalendarHeading')} |
||||
</p> |
||||
{loading && sortedForWeek.length === 0 ? ( |
||||
<div className="flex items-center justify-center gap-2 py-4 text-muted-foreground"> |
||||
<Loader2 className="size-4 animate-spin" aria-hidden /> |
||||
<span className="text-[11px]">{t('sidebarCalendarLoading')}</span> |
||||
</div> |
||||
) : !relayUrls.length ? ( |
||||
<p className="px-1 py-2 text-center text-[11px] text-muted-foreground">{t('sidebarCalendarNoRelays')}</p> |
||||
) : sortedForWeek.length === 0 ? ( |
||||
<p className="px-1 py-2 text-center text-[11px] text-muted-foreground">{t('sidebarCalendarEmptyWeek')}</p> |
||||
) : ( |
||||
<ul className="min-w-0 space-y-1 overflow-y-auto pr-0.5" style={{ maxHeight: LIST_MAX_HEIGHT_PX }}> |
||||
{sortedForWeek.map((ev) => { |
||||
const title = ev.tags.find((t) => t[0] === 'title')?.[1]?.trim() || t('Scheduled video call') |
||||
const sub = formatCalendarSidebarRow(ev) |
||||
return ( |
||||
<li key={replaceableEventDedupeKey(ev)}> |
||||
<button |
||||
type="button" |
||||
onClick={() => openEvent(ev)} |
||||
className={cn( |
||||
'w-full rounded-md border border-transparent px-1.5 py-1.5 text-left transition-colors', |
||||
'hover:border-border/80 hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' |
||||
)} |
||||
> |
||||
<span className="line-clamp-2 text-[11px] font-medium leading-snug text-foreground">{title}</span> |
||||
{sub ? ( |
||||
<span className="mt-0.5 block line-clamp-2 text-[10px] leading-snug text-muted-foreground"> |
||||
{sub} |
||||
</span> |
||||
) : null} |
||||
</button> |
||||
</li> |
||||
) |
||||
})} |
||||
</ul> |
||||
)} |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
|
||||
import { ExtendedKind, isNip71StyleVideoKind } from '@/constants' |
||||
import { |
||||
getLiveEventMetadataFromEvent, |
||||
getLongFormArticleMetadataFromEvent |
||||
} from '@/lib/event-metadata' |
||||
import { tagNameEquals } from '@/lib/tag' |
||||
import { Event, kinds } from 'nostr-tools' |
||||
|
||||
export const PARENT_REPLY_BLURB_MAX = 150 |
||||
|
||||
/** Strip common markdown / asciidoc / HTML so parent reply strips stay one line (matches NotePage preview). */ |
||||
export function stripMarkupForPreview(content: string): string { |
||||
let text = content |
||||
text = text.replace(/^#{1,6}\s+/gm, '') |
||||
text = text.replace(/\*\*([^*]+)\*\*/g, '$1') |
||||
text = text.replace(/\*([^*]+)\*/g, '$1') |
||||
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') |
||||
text = text.replace(/^=+\s+/gm, '') |
||||
text = text.replace(/_([^_]+)_/g, '$1') |
||||
text = text.replace(/```[\s\S]*?```/g, '') |
||||
text = text.replace(/`([^`]+)`/g, '$1') |
||||
text = text.replace(/<[^>]+>/g, '') |
||||
text = text.replace(/\n{3,}/g, '\n\n') |
||||
return text.trim() |
||||
} |
||||
|
||||
function truncateBlurb(s: string, max: number): string { |
||||
const normalized = s.trim().replace(/\s+/g, ' ') |
||||
if (normalized.length <= max) return normalized |
||||
return `${normalized.slice(0, max)}…` |
||||
} |
||||
|
||||
/** |
||||
* One-line preview for {@link ParentNotePreview}: prefer `title` / `subject` / kind metadata, else first |
||||
* {@link PARENT_REPLY_BLURB_MAX} characters of markup-stripped `content`. |
||||
*/ |
||||
export function getParentReplyBlurbDisplayText( |
||||
event: Event, |
||||
maxLen: number = PARENT_REPLY_BLURB_MAX |
||||
): string { |
||||
const titleTag = event.tags.find(tagNameEquals('title'))?.[1]?.trim() |
||||
if (titleTag) return truncateBlurb(stripMarkupForPreview(titleTag), maxLen) |
||||
|
||||
const subjectTag = event.tags.find(tagNameEquals('subject'))?.[1]?.trim() |
||||
if (subjectTag) return truncateBlurb(stripMarkupForPreview(subjectTag), maxLen) |
||||
|
||||
if ( |
||||
event.kind === kinds.LongFormArticle || |
||||
event.kind === ExtendedKind.PUBLICATION || |
||||
event.kind === ExtendedKind.PUBLICATION_CONTENT |
||||
) { |
||||
const meta = getLongFormArticleMetadataFromEvent(event) |
||||
if (meta.title?.trim()) return truncateBlurb(stripMarkupForPreview(meta.title.trim()), maxLen) |
||||
if (meta.summary?.trim()) { |
||||
return truncateBlurb(stripMarkupForPreview(meta.summary), maxLen) |
||||
} |
||||
} |
||||
|
||||
if (event.kind === kinds.LiveEvent || event.kind === 30312 || event.kind === 30313) { |
||||
const live = getLiveEventMetadataFromEvent(event) |
||||
const rawTitle = live.title?.trim() |
||||
if (rawTitle && rawTitle !== 'no title') return truncateBlurb(stripMarkupForPreview(rawTitle), maxLen) |
||||
if (live.summary?.trim()) return truncateBlurb(stripMarkupForPreview(live.summary), maxLen) |
||||
} |
||||
|
||||
if (event.kind === ExtendedKind.PICTURE || isNip71StyleVideoKind(event.kind)) { |
||||
const cap = truncateBlurb(stripMarkupForPreview(event.content ?? ''), maxLen) |
||||
return cap |
||||
} |
||||
|
||||
return truncateBlurb(stripMarkupForPreview(event.content ?? ''), maxLen) |
||||
} |
||||
|
||||
export function parentReplyPollQuestionBlurb(content: string, maxLen = PARENT_REPLY_BLURB_MAX): string { |
||||
return truncateBlurb(stripMarkupForPreview(content ?? ''), maxLen) |
||||
} |
||||
Loading…
Reference in new issue