6 changed files with 409 additions and 55 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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