33 changed files with 1269 additions and 283 deletions
@ -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 @@ |
|||||||
|
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