diff --git a/src/components/Note/PublicationIndexBody.tsx b/src/components/Note/PublicationIndexBody.tsx index 38b2acb6..83f6c38b 100644 --- a/src/components/Note/PublicationIndexBody.tsx +++ b/src/components/Note/PublicationIndexBody.tsx @@ -2,8 +2,9 @@ import AsciidocArticle from '@/components/Note/AsciidocArticle/AsciidocArticle' import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle' import NoteOptions from '@/components/NoteOptions' 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 { fetchPublicationTreeForExport } from '@/lib/publication-export' +import { publicationRefKey } from '@/lib/publication-section-fetch' import { buildPublicationSectionTree, flattenPublicationSectionTreeForToc, @@ -15,7 +16,7 @@ import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { BookOpen, Loader2 } from 'lucide-react' 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' const ASCIIDOC_CONTENT_KINDS = new Set([ @@ -63,11 +64,70 @@ function SectionContent({ event }: { event: Event }) { return null } -function PublicationSectionNodeView({ node }: { node: PublicationSectionTreeNode }) { +function SectionLoadingPlaceholder() { + return ( +
+ +
+ ) +} + +function SectionMissingPlaceholder() { + const { t } = useTranslation() + return ( +

+ {t('Publication section missing')} +

+ ) +} + +function PublicationSectionNodeView({ + node, + failedKeys, + loadingKeys, + onRequestLoad, + onReadAhead +}: { + node: PublicationSectionTreeNode + failedKeys: ReadonlySet + loadingKeys: ReadonlySet + onRequestLoad: (ref: PublicationSectionTreeNode['ref'], indexEvent: Event) => void + onReadAhead: () => void +}) { const Heading = `h${Math.min(6, node.depth + 2)}` as HeadingTag + const sectionElRef = useRef(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 ( -
+
{node.isPublicationBranch && node.event?.content.trim() ? (
@@ -78,22 +138,43 @@ function PublicationSectionNodeView({ node }: { node: PublicationSectionTreeNode node.children.length > 0 ? (
{node.children.map((child) => ( - + ))}
+ ) : needsLoad || isLoading ? ( + + ) : isMissing ? ( + ) : null + ) : isMissing ? ( + + ) : isLoading || needsLoad ? ( + ) : node.event ? ( - ) : null} + ) : ( + + )}
) } function PublicationTableOfContents({ entries, + readingStarted, + onStartReading, className }: { entries: ReturnType + readingStarted: boolean + onStartReading: () => void className?: string }) { const { t } = useTranslation() @@ -118,8 +199,12 @@ function PublicationTableOfContents({
  • ))} + {!readingStarted ? ( + + ) : null} ) } @@ -138,7 +232,6 @@ export default function PublicationIndexBody({ event: Event className?: string }) { - const { t } = useTranslation() const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays() const { favoriteRelays } = useFavoriteRelays() const relayUrls = useMemo( @@ -155,36 +248,17 @@ export default function PublicationIndexBody({ [currentBrowsingRelayUrls, favoriteRelays] ) - const [fetched, setFetched] = useState | null>(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const [readingStarted, setReadingStarted] = useState(false) useEffect(() => { - let cancelled = false - setLoading(true) - 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 () => { - cancelled = true - } - }, [event, relayUrls]) + setReadingStarted(false) + }, [event.id]) + + const { fetched, failedKeys, loadingKeys, requestLoad, readAhead } = + useProgressivePublicationContent(event, relayUrls, { enabled: readingStarted }) const sectionTree = useMemo( - () => (fetched ? buildPublicationSectionTree(event, fetched) : []), + () => buildPublicationSectionTree(event, fetched), [event, fetched] ) @@ -193,34 +267,43 @@ export default function PublicationIndexBody({ [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 if (!hasRefs) return null return (
    - {loading ? ( -
    - - {t('Publication contents loading')} + + {readingStarted ? ( +
    + {sectionTree.map((node) => ( + + ))}
    ) : null} - - {error ? ( -

    - {t('Publication contents load failed')}: {error} -

    - ) : null} - - {fetched && !error ? ( - <> - -
    - {sectionTree.map((node) => ( - - ))} -
    - - ) : null}
    ) } diff --git a/src/hooks/useProgressivePublicationContent.tsx b/src/hooks/useProgressivePublicationContent.tsx new file mode 100644 index 00000000..9f4a5bdf --- /dev/null +++ b/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>(() => { + const seed = new Map() + indexPublicationEvents(seed, [rootIndex]) + return seed + }) + const [failedKeys, setFailedKeys] = useState>(() => new Set()) + const [loadingKeys, setLoadingKeys] = useState>(() => new Set()) + + const inFlightRef = useRef>(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() + 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 + } +} diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 07e2bf5b..15d1de49 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1673,6 +1673,8 @@ export default { 'Publication table of contents': 'Inhalt', 'Publication contents loading': 'Inhalt wird geladen…', '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.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.', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 3cac94e1..70496ee2 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1704,6 +1704,8 @@ export default { 'Publication table of contents': 'Contents', 'Publication contents loading': 'Loading 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.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.', diff --git a/src/lib/publication-section-loader.test.ts b/src/lib/publication-section-loader.test.ts new file mode 100644 index 00000000..1173230f --- /dev/null +++ b/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([ + [root.id, root], + [publicationRefKey({ type: 'a', coordinate: s1 })!, contentEvent('s1')] + ]) + const failed = new Set() + const inFlight = new Set() + + const pending = collectPendingPublicationSectionLoads(root, fetched, failed, inFlight) + expect(pending.map((task) => publicationRefKey(task.ref))).toEqual([childAddr, s2]) + }) +}) diff --git a/src/lib/publication-section-loader.ts b/src/lib/publication-section-loader.ts new file mode 100644 index 00000000..e2c71af3 --- /dev/null +++ b/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, + failed: ReadonlySet, + inFlight: ReadonlySet +): 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 { + 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 +}