Browse Source

lazy load publications

imwald
Silberengel 1 week ago
parent
commit
9d0e633b70
  1. 185
      src/components/Note/PublicationIndexBody.tsx
  2. 144
      src/hooks/useProgressivePublicationContent.tsx
  3. 2
      src/i18n/locales/de.ts
  4. 2
      src/i18n/locales/en.ts
  5. 52
      src/lib/publication-section-loader.test.ts
  6. 71
      src/lib/publication-section-loader.ts

185
src/components/Note/PublicationIndexBody.tsx

@ -2,8 +2,9 @@ import AsciidocArticle from '@/components/Note/AsciidocArticle/AsciidocArticle'
import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle' import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle'
import NoteOptions from '@/components/NoteOptions' import NoteOptions from '@/components/NoteOptions'
import { DOCUMENT_RELAY_URLS, ExtendedKind, FAST_READ_RELAY_URLS, LIBRARY_RELAY_URLS } from '@/constants' import { DOCUMENT_RELAY_URLS, ExtendedKind, FAST_READ_RELAY_URLS, LIBRARY_RELAY_URLS } from '@/constants'
import { useProgressivePublicationContent } from '@/hooks/useProgressivePublicationContent'
import { orderedPublicationRefsFromIndex } from '@/lib/publication-asciidoc-assembler' import { orderedPublicationRefsFromIndex } from '@/lib/publication-asciidoc-assembler'
import { fetchPublicationTreeForExport } from '@/lib/publication-export' import { publicationRefKey } from '@/lib/publication-section-fetch'
import { import {
buildPublicationSectionTree, buildPublicationSectionTree,
flattenPublicationSectionTreeForToc, flattenPublicationSectionTreeForToc,
@ -15,7 +16,7 @@ import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { BookOpen, Loader2 } from 'lucide-react' import { BookOpen, Loader2 } from 'lucide-react'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const ASCIIDOC_CONTENT_KINDS = new Set<number>([ const ASCIIDOC_CONTENT_KINDS = new Set<number>([
@ -63,11 +64,70 @@ function SectionContent({ event }: { event: Event }) {
return null return null
} }
function PublicationSectionNodeView({ node }: { node: PublicationSectionTreeNode }) { function SectionLoadingPlaceholder() {
return (
<div className="mt-2 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-3.5 shrink-0 animate-spin" aria-hidden />
</div>
)
}
function SectionMissingPlaceholder() {
const { t } = useTranslation()
return (
<p className="mt-2 text-sm italic text-muted-foreground/80">
{t('Publication section missing')}
</p>
)
}
function PublicationSectionNodeView({
node,
failedKeys,
loadingKeys,
onRequestLoad,
onReadAhead
}: {
node: PublicationSectionTreeNode
failedKeys: ReadonlySet<string>
loadingKeys: ReadonlySet<string>
onRequestLoad: (ref: PublicationSectionTreeNode['ref'], indexEvent: Event) => void
onReadAhead: () => void
}) {
const Heading = `h${Math.min(6, node.depth + 2)}` as HeadingTag const Heading = `h${Math.min(6, node.depth + 2)}` as HeadingTag
const sectionElRef = useRef<HTMLElement>(null)
const refKey = publicationRefKey(node.ref)
const isMissing = Boolean(refKey && failedKeys.has(refKey))
const isLoading = Boolean(refKey && loadingKeys.has(refKey))
const needsLoad = Boolean(refKey && !node.event && !isMissing && !isLoading)
useEffect(() => {
if (!needsLoad) return
const el = sectionElRef.current
if (!el) return
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
onRequestLoad(node.ref, node.indexEvent)
onReadAhead()
}
},
{ rootMargin: '720px 0px 480px 0px', threshold: 0 }
)
observer.observe(el)
return () => observer.disconnect()
}, [needsLoad, node.ref, node.indexEvent, onRequestLoad, onReadAhead])
return ( return (
<section id={node.sectionId} className="scroll-mt-24 mt-4 first:mt-0"> <section
ref={sectionElRef}
id={node.sectionId}
className="scroll-mt-24 mt-4 first:mt-0"
aria-busy={isLoading || needsLoad}
>
<SectionHeadingRow title={node.title} event={node.event} Heading={Heading} /> <SectionHeadingRow title={node.title} event={node.event} Heading={Heading} />
{node.isPublicationBranch && node.event?.content.trim() ? ( {node.isPublicationBranch && node.event?.content.trim() ? (
<div className="mt-2 whitespace-pre-wrap break-words text-muted-foreground"> <div className="mt-2 whitespace-pre-wrap break-words text-muted-foreground">
@ -78,22 +138,43 @@ function PublicationSectionNodeView({ node }: { node: PublicationSectionTreeNode
node.children.length > 0 ? ( node.children.length > 0 ? (
<div className="mt-4 border-l border-border pl-4"> <div className="mt-4 border-l border-border pl-4">
{node.children.map((child) => ( {node.children.map((child) => (
<PublicationSectionNodeView key={child.path} node={child} /> <PublicationSectionNodeView
key={child.path}
node={child}
failedKeys={failedKeys}
loadingKeys={loadingKeys}
onRequestLoad={onRequestLoad}
onReadAhead={onReadAhead}
/>
))} ))}
</div> </div>
) : needsLoad || isLoading ? (
<SectionLoadingPlaceholder />
) : isMissing ? (
<SectionMissingPlaceholder />
) : null ) : null
) : isMissing ? (
<SectionMissingPlaceholder />
) : isLoading || needsLoad ? (
<SectionLoadingPlaceholder />
) : node.event ? ( ) : node.event ? (
<SectionContent event={node.event} /> <SectionContent event={node.event} />
) : null} ) : (
<SectionMissingPlaceholder />
)}
</section> </section>
) )
} }
function PublicationTableOfContents({ function PublicationTableOfContents({
entries, entries,
readingStarted,
onStartReading,
className className
}: { }: {
entries: ReturnType<typeof flattenPublicationSectionTreeForToc> entries: ReturnType<typeof flattenPublicationSectionTreeForToc>
readingStarted: boolean
onStartReading: () => void
className?: string className?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
@ -118,8 +199,12 @@ function PublicationTableOfContents({
<li key={entry.path}> <li key={entry.path}>
<button <button
type="button" type="button"
className="w-full min-w-0 rounded py-1 pr-2 text-left text-muted-foreground hover:bg-accent hover:text-accent-foreground" className={cn(
'w-full min-w-0 rounded py-1 pr-2 text-left text-muted-foreground',
readingStarted && 'hover:bg-accent hover:text-accent-foreground'
)}
style={{ paddingLeft: `${8 + entry.depth * 14}px` }} style={{ paddingLeft: `${8 + entry.depth * 14}px` }}
disabled={!readingStarted}
onClick={() => scrollToSection(entry.id)} onClick={() => scrollToSection(entry.id)}
> >
<span className="break-words">{entry.title}</span> <span className="break-words">{entry.title}</span>
@ -127,6 +212,15 @@ function PublicationTableOfContents({
</li> </li>
))} ))}
</ol> </ol>
{!readingStarted ? (
<button
type="button"
className="mt-3 w-full rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
onClick={onStartReading}
>
{t('Read this book')}
</button>
) : null}
</nav> </nav>
) )
} }
@ -138,7 +232,6 @@ export default function PublicationIndexBody({
event: Event event: Event
className?: string className?: string
}) { }) {
const { t } = useTranslation()
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays() const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
const { favoriteRelays } = useFavoriteRelays() const { favoriteRelays } = useFavoriteRelays()
const relayUrls = useMemo( const relayUrls = useMemo(
@ -155,36 +248,17 @@ export default function PublicationIndexBody({
[currentBrowsingRelayUrls, favoriteRelays] [currentBrowsingRelayUrls, favoriteRelays]
) )
const [fetched, setFetched] = useState<Map<string, Event> | null>(null) const [readingStarted, setReadingStarted] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
let cancelled = false setReadingStarted(false)
setLoading(true) }, [event.id])
setError(null)
setFetched(null)
fetchPublicationTreeForExport(event, relayUrls)
.then((tree) => {
if (cancelled) return
setFetched(tree)
})
.catch((err: unknown) => {
if (cancelled) return
setError(err instanceof Error ? err.message : String(err))
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => { const { fetched, failedKeys, loadingKeys, requestLoad, readAhead } =
cancelled = true useProgressivePublicationContent(event, relayUrls, { enabled: readingStarted })
}
}, [event, relayUrls])
const sectionTree = useMemo( const sectionTree = useMemo(
() => (fetched ? buildPublicationSectionTree(event, fetched) : []), () => buildPublicationSectionTree(event, fetched),
[event, fetched] [event, fetched]
) )
@ -193,33 +267,42 @@ export default function PublicationIndexBody({
[sectionTree] [sectionTree]
) )
const startReading = useCallback(() => {
setReadingStarted(true)
}, [])
useEffect(() => {
if (!readingStarted) return
const firstId = tocEntries[0]?.id
if (!firstId) return
requestAnimationFrame(() => {
document.getElementById(firstId)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}, [readingStarted, tocEntries])
const hasRefs = orderedPublicationRefsFromIndex(event).length > 0 const hasRefs = orderedPublicationRefsFromIndex(event).length > 0
if (!hasRefs) return null if (!hasRefs) return null
return ( return (
<div className={cn('min-w-0 space-y-4', className)}> <div className={cn('min-w-0 space-y-4', className)}>
{loading ? ( <PublicationTableOfContents
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/20 p-3 text-sm text-muted-foreground"> entries={tocEntries}
<Loader2 className="size-4 shrink-0 animate-spin" aria-hidden /> readingStarted={readingStarted}
{t('Publication contents loading')} onStartReading={startReading}
</div> />
) : null} {readingStarted ? (
{error ? (
<p className="text-sm text-destructive">
{t('Publication contents load failed')}: {error}
</p>
) : null}
{fetched && !error ? (
<>
<PublicationTableOfContents entries={tocEntries} />
<div> <div>
{sectionTree.map((node) => ( {sectionTree.map((node) => (
<PublicationSectionNodeView key={node.path} node={node} /> <PublicationSectionNodeView
key={node.path}
node={node}
failedKeys={failedKeys}
loadingKeys={loadingKeys}
onRequestLoad={requestLoad}
onReadAhead={readAhead}
/>
))} ))}
</div> </div>
</>
) : null} ) : null}
</div> </div>
) )

144
src/hooks/useProgressivePublicationContent.tsx

@ -0,0 +1,144 @@
import { indexPublicationEvents } from '@/lib/publication-asciidoc-assembler'
import {
collectPendingPublicationSectionLoads,
fetchPublicationSection,
type PublicationSectionLoadTask
} from '@/lib/publication-section-loader'
import { publicationRefKey, type PublicationSectionRef } from '@/lib/publication-section-fetch'
import type { Event } from 'nostr-tools'
import { useCallback, useEffect, useRef, useState } from 'react'
const INITIAL_PREFETCH_COUNT = 3
const READ_AHEAD_COUNT = 1
export function useProgressivePublicationContent(
rootIndex: Event,
relayUrls: string[],
options?: { enabled?: boolean }
) {
const enabled = options?.enabled ?? true
const enabledRef = useRef(enabled)
enabledRef.current = enabled
const [fetched, setFetched] = useState<Map<string, Event>>(() => {
const seed = new Map<string, Event>()
indexPublicationEvents(seed, [rootIndex])
return seed
})
const [failedKeys, setFailedKeys] = useState<Set<string>>(() => new Set())
const [loadingKeys, setLoadingKeys] = useState<Set<string>>(() => new Set())
const inFlightRef = useRef<Set<string>>(new Set())
const fetchedRef = useRef(fetched)
const failedRef = useRef(failedKeys)
fetchedRef.current = fetched
failedRef.current = failedKeys
const relayKey = relayUrls.join('|')
useEffect(() => {
const seed = new Map<string, Event>()
indexPublicationEvents(seed, [rootIndex])
setFetched(seed)
setFailedKeys(new Set())
setLoadingKeys(new Set())
inFlightRef.current = new Set()
}, [rootIndex.id, relayKey, rootIndex])
const loadSection = useCallback(
async (ref: PublicationSectionRef, indexEvent: Event) => {
if (!enabledRef.current) return
const key = publicationRefKey(ref)
if (
!key ||
inFlightRef.current.has(key) ||
fetchedRef.current.has(key) ||
failedRef.current.has(key)
) {
return
}
inFlightRef.current.add(key)
setLoadingKeys((prev) => new Set(prev).add(key))
try {
const ev = await fetchPublicationSection(ref, indexEvent, relayUrls)
if (ev) {
setFetched((prev) => {
const next = new Map(prev)
indexPublicationEvents(next, [ev])
return next
})
} else {
setFailedKeys((prev) => new Set(prev).add(key))
}
} catch {
setFailedKeys((prev) => new Set(prev).add(key))
} finally {
inFlightRef.current.delete(key)
setLoadingKeys((prev) => {
const next = new Set(prev)
next.delete(key)
return next
})
}
},
[relayUrls]
)
const requestLoad = useCallback(
(ref: PublicationSectionRef, indexEvent: Event) => {
if (!enabledRef.current) return
void loadSection(ref, indexEvent)
},
[loadSection]
)
const prefetchTasks = useCallback(
(tasks: PublicationSectionLoadTask[]) => {
for (const task of tasks) {
void loadSection(task.ref, task.indexEvent)
}
},
[loadSection]
)
useEffect(() => {
if (!enabled) return
let cancelled = false
;(async () => {
const pending = collectPendingPublicationSectionLoads(
rootIndex,
fetchedRef.current,
failedRef.current,
inFlightRef.current
)
for (const task of pending.slice(0, INITIAL_PREFETCH_COUNT)) {
if (cancelled) return
await loadSection(task.ref, task.indexEvent)
}
})()
return () => {
cancelled = true
}
}, [enabled, rootIndex.id, relayKey, loadSection, rootIndex])
const readAhead = useCallback(() => {
if (!enabledRef.current) return
const pending = collectPendingPublicationSectionLoads(
rootIndex,
fetchedRef.current,
failedRef.current,
inFlightRef.current
)
prefetchTasks(pending.slice(0, READ_AHEAD_COUNT))
}, [prefetchTasks, rootIndex])
return {
fetched,
failedKeys,
loadingKeys,
requestLoad,
readAhead
}
}

2
src/i18n/locales/de.ts

@ -1673,6 +1673,8 @@ export default {
'Publication table of contents': 'Inhalt', 'Publication table of contents': 'Inhalt',
'Publication contents loading': 'Inhalt wird geladen…', 'Publication contents loading': 'Inhalt wird geladen…',
'Publication contents load failed': 'Inhalt konnte nicht geladen werden', 'Publication contents load failed': 'Inhalt konnte nicht geladen werden',
'Publication section missing': '[Dieser Abschnitt fehlt.]',
'Read this book': 'Dieses Buch lesen',
'libraryIndexCache.sectionTitle': 'Bibliotheks-Publikationsindex', 'libraryIndexCache.sectionTitle': 'Bibliotheks-Publikationsindex',
'libraryIndexCache.sectionBlurb': 'libraryIndexCache.sectionBlurb':
'Zwischengespeicherte Kind-30040-Index-Events für den Bibliotheks-Tab. Beim Leeren wird nur der Entdeckungslisten-Cache entfernt — geöffnete Publikationen bleiben im Lese-Cache.', 'Zwischengespeicherte Kind-30040-Index-Events für den Bibliotheks-Tab. Beim Leeren wird nur der Entdeckungslisten-Cache entfernt — geöffnete Publikationen bleiben im Lese-Cache.',

2
src/i18n/locales/en.ts

@ -1704,6 +1704,8 @@ export default {
'Publication table of contents': 'Contents', 'Publication table of contents': 'Contents',
'Publication contents loading': 'Loading contents…', 'Publication contents loading': 'Loading contents…',
'Publication contents load failed': 'Could not load publication contents', 'Publication contents load failed': 'Could not load publication contents',
'Publication section missing': '[This section is missing.]',
'Read this book': 'Read this book',
'libraryIndexCache.sectionTitle': 'Library publication index', 'libraryIndexCache.sectionTitle': 'Library publication index',
'libraryIndexCache.sectionBlurb': 'libraryIndexCache.sectionBlurb':
'Cached kind-30040 index events used to populate the Library tab. Clearing this only removes the discovery list cache—not publications you have opened for reading.', 'Cached kind-30040 index events used to populate the Library tab. Clearing this only removes the discovery list cache—not publications you have opened for reading.',

52
src/lib/publication-section-loader.test.ts

@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { ExtendedKind } from '@/constants'
import { collectPendingPublicationSectionLoads } from '@/lib/publication-section-loader'
import { publicationRefKey } from '@/lib/publication-section-fetch'
import type { Event } from 'nostr-tools'
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
const sk = generateSecretKey()
const PK = getPublicKey(sk)
function indexEvent(d: string, aTags: string[]): Event {
return finalizeEvent(
{
kind: ExtendedKind.PUBLICATION,
created_at: 100,
content: '',
tags: [['d', d], ['title', d], ...aTags.map((a) => ['a', a] as [string, string])]
},
sk
)
}
function contentEvent(d: string): Event {
return finalizeEvent(
{
kind: ExtendedKind.PUBLICATION_CONTENT,
created_at: 100,
content: 'body',
tags: [['d', d], ['title', d]]
},
sk
)
}
describe('publication-section-loader', () => {
it('collectPendingPublicationSectionLoads walks nested indexes in tag order', () => {
const s1 = `30041:${PK}:s1`
const s2 = `30041:${PK}:s2`
const childAddr = `30040:${PK}:part`
const root = indexEvent('book', [s1, childAddr, s2])
const child = indexEvent('part', [s2])
const fetched = new Map<string, Event>([
[root.id, root],
[publicationRefKey({ type: 'a', coordinate: s1 })!, contentEvent('s1')]
])
const failed = new Set<string>()
const inFlight = new Set<string>()
const pending = collectPendingPublicationSectionLoads(root, fetched, failed, inFlight)
expect(pending.map((task) => publicationRefKey(task.ref))).toEqual([childAddr, s2])
})
})

71
src/lib/publication-section-loader.ts

@ -0,0 +1,71 @@
import { ExtendedKind } from '@/constants'
import { orderedPublicationRefsFromIndex } from '@/lib/publication-asciidoc-assembler'
import {
batchFetchPublicationSectionEvents,
buildPublicationSectionRelayUrls,
publicationRefKey,
type PublicationSectionRef
} from '@/lib/publication-section-fetch'
import type { Event } from 'nostr-tools'
export type PublicationSectionLoadTask = {
ref: PublicationSectionRef
indexEvent: Event
}
function isPublicationBranchRef(ref: PublicationSectionRef): boolean {
if (ref.type !== 'a' || !ref.coordinate) return false
const kind = ref.kind ?? parseInt(ref.coordinate.split(':')[0], 10)
return kind === ExtendedKind.PUBLICATION
}
/** Depth-first list of section refs that still need a network/cache fetch. */
export function collectPendingPublicationSectionLoads(
rootIndex: Event,
fetched: ReadonlyMap<string, Event>,
failed: ReadonlySet<string>,
inFlight: ReadonlySet<string>
): PublicationSectionLoadTask[] {
const out: PublicationSectionLoadTask[] = []
function walk(indexEvent: Event): void {
for (const ref of orderedPublicationRefsFromIndex(indexEvent)) {
const key = publicationRefKey(ref)
if (!key || failed.has(key)) continue
if (!fetched.has(key) && !inFlight.has(key)) {
out.push({ ref, indexEvent })
continue
}
const ev = fetched.get(key)
if (ev && isPublicationBranchRef(ref) && ev.kind === ExtendedKind.PUBLICATION) {
walk(ev)
}
}
}
walk(rootIndex)
return out
}
export async function fetchPublicationSection(
ref: PublicationSectionRef,
indexEvent: Event,
relayUrls: string[]
): Promise<Event | null> {
const key = publicationRefKey(ref)
if (!key) return null
const primaryRelays = await buildPublicationSectionRelayUrls(indexEvent, [ref], 40, false)
const mergedPrimary = [...new Set([...primaryRelays, ...relayUrls])]
let resolved = await batchFetchPublicationSectionEvents([ref], mergedPrimary)
let ev = resolved.get(key) ?? null
if (!ev && ref.type === 'a') {
const fallbackRelays = await buildPublicationSectionRelayUrls(indexEvent, [ref], 80, true)
const mergedFallback = [...new Set([...fallbackRelays, ...relayUrls])]
resolved = await batchFetchPublicationSectionEvents([ref], mergedFallback)
ev = resolved.get(key) ?? null
}
return ev
}
Loading…
Cancel
Save