6 changed files with 409 additions and 55 deletions
@ -0,0 +1,144 @@
@@ -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 |
||||
} |
||||
} |
||||
@ -0,0 +1,52 @@
@@ -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]) |
||||
}) |
||||
}) |
||||
@ -0,0 +1,71 @@
@@ -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…
Reference in new issue