Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
d88d426e8c
  1. 163
      src/components/Note/PublicationIndex/PublicationIndex.tsx
  2. 517
      src/hooks/usePublicationSectionLoader.ts
  3. 344
      src/lib/publication-section-fetch.ts

163
src/components/Note/PublicationIndex/PublicationIndex.tsx

@ -1,6 +1,6 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { Event, kinds, nip19 } from 'nostr-tools' import { Event, kinds, nip19 } from 'nostr-tools'
import { useEffect, useMemo, useState, useCallback, useRef, type ReactNode } from 'react' import { useEffect, useMemo, useState, useCallback } from 'react'
import { usePublicationSectionLoader } from '@/hooks/usePublicationSectionLoader' import { usePublicationSectionLoader } from '@/hooks/usePublicationSectionLoader'
import { parsePublicationATagCoordinate, publicationRefKey } from '@/lib/publication-section-fetch' import { parsePublicationATagCoordinate, publicationRefKey } from '@/lib/publication-section-fetch'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -78,72 +78,6 @@ function publicationSectionNotesLink(ref: {
return null return null
} }
const SECTION_IO_ROOT_MARGIN = '480px'
/**
* IntersectionObserver with `root: null` uses the browser viewport. Note / feed layouts scroll inside
* `overflow-y: auto` panels ({@link SecondaryPageLayout}, {@link PrimaryPageLayout}), so section
* placeholders never intersect the viewport while scrolling only the first prefetch batch loads.
*/
function findScrollPortRoot(from: HTMLElement | null): Element | null {
if (!from) return null
let el: HTMLElement | null = from.parentElement
while (el && el !== document.documentElement) {
const s = window.getComputedStyle(el)
if (s.overflowY === 'auto' || s.overflowY === 'scroll' || s.overflowY === 'overlay') {
return el
}
el = el.parentElement
}
return null
}
/** Request section payload when this block nears the visible scrollport (batched + debounced upstream). */
function PublicationSectionBoundary({
sectionKey,
requestKeys,
children
}: {
sectionKey: string
requestKeys: (keys: string[]) => void
children: ReactNode
}) {
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!sectionKey) return
const el = rootRef.current
if (!el) return
let io: IntersectionObserver | null = null
let cancelled = false
const attach = () => {
if (cancelled) return
const scrollRoot = findScrollPortRoot(el)
io?.disconnect()
io = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) requestKeys([sectionKey])
},
{ root: scrollRoot, rootMargin: SECTION_IO_ROOT_MARGIN, threshold: 0 }
)
io.observe(el)
}
attach()
const raf = requestAnimationFrame(() => {
requestAnimationFrame(attach)
})
return () => {
cancelled = true
cancelAnimationFrame(raf)
io?.disconnect()
}
}, [sectionKey, requestKeys])
return <div ref={rootRef}>{children}</div>
}
export default function PublicationIndex({ export default function PublicationIndex({
event, event,
className, className,
@ -206,8 +140,7 @@ export default function PublicationIndex({
kind: parsed.kind, kind: parsed.kind,
pubkey: parsed.pubkey, pubkey: parsed.pubkey,
identifier: parsed.identifier, identifier: parsed.identifier,
relay: tag[2], relay: tag[2]
eventId: tag[3]
}) })
} }
} else if (tag[0] === 'e' && tag[1]) { } else if (tag[0] === 'e' && tag[1]) {
@ -222,7 +155,7 @@ export default function PublicationIndex({
return refs return refs
}, [event]) }, [event])
const { requestKeys, retryKeys, failedKeys, referencesWithEvents } = const { retryKeys, failedKeys, referencesWithEvents } =
usePublicationSectionLoader(event, referencesData) usePublicationSectionLoader(event, referencesData)
// Helper function to format bookstr titles (remove hyphens, title case) // Helper function to format bookstr titles (remove hyphens, title case)
@ -527,13 +460,12 @@ export default function PublicationIndex({
</div> </div>
)} )}
{/* Failed sections banner — batched fetch missed some payloads */} {/* Failed sections banner */}
{!isNested && failedKeys.length > 0 && referencesWithEvents.length > 0 && ( {!isNested && failedKeys.length > 0 && referencesWithEvents.length > 0 && (
<div className="p-4 border rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800"> <div className="p-4 border rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="text-sm text-yellow-800 dark:text-yellow-200"> <div className="text-sm text-yellow-800 dark:text-yellow-200">
{failedKeys.length} section{failedKeys.length !== 1 ? 's' : ''} failed to load. Scroll near a {failedKeys.length} section{failedKeys.length !== 1 ? 's' : ''} failed to load.
section or retry all.
</div> </div>
<Button <Button
variant="outline" variant="outline"
@ -552,7 +484,7 @@ export default function PublicationIndex({
</div> </div>
)} )}
{/* Sections: intersection-observer triggers debounced batch REQ; placeholders until loaded */} {/* Sections */}
{referencesData.length === 0 ? ( {referencesData.length === 0 ? (
<div className="p-6 border rounded-lg bg-muted/30 text-center text-sm text-muted-foreground"> <div className="p-6 border rounded-lg bg-muted/30 text-center text-sm text-muted-foreground">
This publication index has no linked sections. This publication index has no linked sections.
@ -568,12 +500,7 @@ export default function PublicationIndex({
if (!ref.event) { if (!ref.event) {
if (ref.loadStatus === 'error') { if (ref.loadStatus === 'error') {
return ( return (
<PublicationSectionBoundary <div key={sectionKey || index} id={sectionId} className="scroll-mt-24 p-4 border rounded-lg bg-muted/50">
key={sectionKey || index}
sectionKey={sectionKey}
requestKeys={requestKeys}
>
<div id={sectionId} className="scroll-mt-24 p-4 border rounded-lg bg-muted/50">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Section {index + 1}: unable to load{' '} Section {index + 1}: unable to load{' '}
@ -603,17 +530,12 @@ export default function PublicationIndex({
</Button> </Button>
</div> </div>
</div> </div>
</PublicationSectionBoundary>
) )
} }
return ( return (
<PublicationSectionBoundary
key={sectionKey || index}
sectionKey={sectionKey}
requestKeys={requestKeys}
>
<div <div
key={sectionKey || index}
id={sectionId} id={sectionId}
className="scroll-mt-24 rounded-lg border border-dashed p-6 bg-muted/20 space-y-3" className="scroll-mt-24 rounded-lg border border-dashed p-6 bg-muted/20 space-y-3"
aria-busy aria-busy
@ -622,7 +544,6 @@ export default function PublicationIndex({
<Skeleton className="h-28 w-full" /> <Skeleton className="h-28 w-full" />
<Skeleton className="h-28 w-full" /> <Skeleton className="h-28 w-full" />
</div> </div>
</PublicationSectionBoundary>
) )
} }
@ -660,10 +581,11 @@ export default function PublicationIndex({
) )
} }
if ( const renderAsAsciidoc =
eventKind === ExtendedKind.PUBLICATION_CONTENT || eventKind === ExtendedKind.PUBLICATION_CONTENT ||
eventKind === ExtendedKind.WIKI_ARTICLE eventKind === ExtendedKind.WIKI_ARTICLE
) {
if (renderAsAsciidoc) {
return ( return (
<div key={sectionKey || index} id={sectionId} className="scroll-mt-24 pt-6 relative"> <div key={sectionKey || index} id={sectionId} className="scroll-mt-24 pt-6 relative">
<div className="absolute top-0 right-0 flex items-center gap-2"> <div className="absolute top-0 right-0 flex items-center gap-2">
@ -690,35 +612,7 @@ export default function PublicationIndex({
) )
} }
if (eventKind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) { // All non-publication, non-AsciiDoc section kinds use markdown renderer.
return (
<div key={sectionKey || index} id={sectionId} className="scroll-mt-24 pt-6 relative">
<div className="absolute top-0 right-0 flex items-center gap-2">
{!isNested && (
<Button
variant="ghost"
size="sm"
className="opacity-70 hover:opacity-100"
onClick={scrollToToc}
title="Back to Table of Contents"
>
<ArrowUp className="h-4 w-4 mr-2" />
ToC
</Button>
)}
<NoteOptions event={ref.event} />
</div>
<MarkdownArticle
event={ref.event}
hideMetadata={true}
parentImageUrl={effectiveParentImageUrl}
/>
</div>
)
}
// NIP-23 long-form (30023): same markdown body path as standalone note view
if (eventKind === kinds.LongFormArticle) {
return ( return (
<div key={sectionKey || index} id={sectionId} className="scroll-mt-24 pt-6 relative"> <div key={sectionKey || index} id={sectionId} className="scroll-mt-24 pt-6 relative">
<div className="absolute top-0 right-0 flex items-center gap-2"> <div className="absolute top-0 right-0 flex items-center gap-2">
@ -743,39 +637,6 @@ export default function PublicationIndex({
/> />
</div> </div>
) )
}
// Kind 1: plain text / markdown body like {@link Note}
if (eventKind === kinds.ShortTextNote) {
return (
<div key={sectionKey || index} id={sectionId} className="scroll-mt-24 pt-6 relative">
<div className="absolute top-0 right-0 flex items-center gap-2">
{!isNested && (
<Button
variant="ghost"
size="sm"
className="opacity-70 hover:opacity-100"
onClick={scrollToToc}
title="Back to Table of Contents"
>
<ArrowUp className="h-4 w-4 mr-2" />
ToC
</Button>
)}
<NoteOptions event={ref.event} />
</div>
<MarkdownArticle event={ref.event} hideMetadata={true} />
</div>
)
}
return (
<div key={sectionKey || index} id={sectionId} className="scroll-mt-24 p-4 border rounded-lg">
<div className="text-sm text-muted-foreground">
Section {index + 1}: unsupported kind {eventKind}
</div>
</div>
)
})} })}
</div> </div>
)} )}

517
src/hooks/usePublicationSectionLoader.ts

@ -1,372 +1,295 @@
import logger from '@/lib/logger'
import { import {
batchFetchPublicationSectionEvents, batchFetchPublicationSectionEvents,
buildPublicationSectionRelayUrls, buildPublicationSectionRelayUrls,
parsePublicationATagCoordinate,
publicationRefKey, publicationRefKey,
resolvePublicationEventIdToHex, resolvePublicationEventIdToHex,
type PublicationSectionRef type PublicationSectionRef
} from '@/lib/publication-section-fetch' } from '@/lib/publication-section-fetch'
import { generateBech32IdFromATag } from '@/lib/tag' import { eventService, queryService } from '@/services/client.service'
import { isReplaceableEvent } from '@/lib/event'
import client from '@/services/client.service'
import { eventService } from '@/services/client.service'
import logger from '@/lib/logger'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
const PUB_SEC_LOG = '[PublicationSection]' type LoadStatus = 'idle' | 'loading' | 'loaded' | 'error'
const SINGLE_REF_FALLBACK_TIMEOUT_MS = 7000
function pubLog(message: string, data?: Record<string, unknown>) {
if (!import.meta.env.DEV) return
if (data) logger.info(`${PUB_SEC_LOG} ${message}`, data)
else logger.info(`${PUB_SEC_LOG} ${message}`)
}
export type SectionLoadStatus = 'idle' | 'loading' | 'loaded' | 'error'
export type PublicationSectionRow = { type Row = PublicationSectionRef & {
ref: PublicationSectionRef key: string
status: SectionLoadStatus
event?: Event event?: Event
status: LoadStatus
} }
function refKey(ref: PublicationSectionRef): string { type CachedState = {
return publicationRefKey(ref) loaded: Map<string, Event>
failed: Set<string>
} }
async function hydrateRefsFromIndexedDb(refs: PublicationSectionRef[]): Promise<Map<string, Event>> { const indexCache = new Map<string, CachedState>()
const out = new Map<string, Event>() const SINGLE_REF_TIMEOUT_MS = 6_000
for (const ref of refs) {
const key = refKey(ref)
if (!key) continue
try {
if (ref.type === 'a' && ref.coordinate) {
const ev = await indexedDb.getPublicationEvent(ref.coordinate)
if (ev) out.set(key, ev)
} else if (ref.type === 'e' && ref.eventId) {
const hex = resolvePublicationEventIdToHex(ref.eventId)
if (!hex) continue
let ev = await indexedDb.getEventFromPublicationStore(hex)
if (!ev && ref.kind != null && ref.pubkey && isReplaceableEvent(ref.kind)) {
const rep = await indexedDb.getReplaceableEvent(ref.pubkey, ref.kind)
if (rep && rep.id === hex) ev = rep
}
if (ev) out.set(key, ev)
}
} catch {
/* ignore per-ref */
}
}
return out
}
async function fetchSingleRefFallback(ref: PublicationSectionRef): Promise<Event | undefined> { function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
const withTimeout = <T,>(p: Promise<T>, ms: number): Promise<T | undefined> => return new Promise<T>((resolve, reject) => {
new Promise((resolve) => { const timer = window.setTimeout(() => reject(new Error('timeout')), ms)
const t = setTimeout(() => resolve(undefined), ms) p.then(
p.then((v) => resolve(v)).catch(() => resolve(undefined)).finally(() => clearTimeout(t)) (v) => {
}) clearTimeout(timer)
try { resolve(v)
if (ref.type === 'a' && ref.coordinate) { },
const bech32 = generateBech32IdFromATag(['a', ref.coordinate, ref.relay || '', '']) (err) => {
if (bech32) return await withTimeout(eventService.fetchEvent(bech32), SINGLE_REF_FALLBACK_TIMEOUT_MS) clearTimeout(timer)
} reject(err)
if (ref.type === 'e' && ref.eventId) {
return await withTimeout(eventService.fetchEvent(ref.eventId), SINGLE_REF_FALLBACK_TIMEOUT_MS)
}
} catch {
/* ignore */
} }
return undefined )
})
} }
/** function signatureOfRefs(refs: PublicationSectionRef[]): string {
* Lazy publication sections: debounced batched REQ (chunked `ids` + grouped `authors`/`kinds`/`#d`), return refs.map((r) => publicationRefKey(r)).join('|')
* IndexedDB first, capped relay list. Call {@link requestKeys} from IntersectionObserver.
*/
export function usePublicationSectionLoader(indexEvent: Event, referencesData: PublicationSectionRef[]) {
const orderedKeys = useMemo(() => {
const keys: string[] = []
for (const r of referencesData) {
const k = refKey(r)
if (k) keys.push(k)
} }
return keys
}, [referencesData])
const orderedKeysSignature = useMemo(() => orderedKeys.join('|'), [orderedKeys])
const [rows, setRows] = useState<Map<string, PublicationSectionRow>>(() => new Map()) export function usePublicationSectionLoader(indexEvent: Event, refs: PublicationSectionRef[]) {
const rowsRef = useRef(rows) const indexId = indexEvent.id
rowsRef.current = rows const refsSignature = useMemo(() => signatureOfRefs(refs), [refs])
const [relayUrls, setRelayUrls] = useState<string[]>([])
const [rows, setRows] = useState<Row[]>([])
const inflightKeysRef = useRef<Set<string>>(new Set())
const autoLoadedSignatureRef = useRef<string | null>(null)
useEffect(() => { useEffect(() => {
// Preserve per-key load state across rerenders to avoid reinitializing rows to idle const cached = indexCache.get(indexId) ?? { loaded: new Map(), failed: new Set() }
// when parent components recreate reference objects. const next: Row[] = []
setRows((prev) => { for (const ref of refs) {
const next = new Map<string, PublicationSectionRow>() const key = publicationRefKey(ref)
for (const ref of referencesData) { if (!key) continue
const k = refKey(ref) const cachedEvent = cached.loaded.get(key)
if (!k) continue if (cachedEvent) {
const existing = prev.get(k) next.push({ ...ref, key, event: cachedEvent, status: 'loaded' })
if (existing) { continue
next.set(k, { ...existing, ref })
} else {
next.set(k, { ref, status: 'idle' })
} }
if (cached.failed.has(key)) {
next.push({ ...ref, key, status: 'error' })
continue
} }
return next next.push({ ...ref, key, status: 'idle' })
}) }
}, [orderedKeysSignature, referencesData]) setRows(next)
}, [indexId, refsSignature, refs])
const relayUrlsRef = useRef<string[]>([])
const searchableRelayUrlsRef = useRef<string[]>([])
const [relayReady, setRelayReady] = useState(false)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
void (async () => { ;(async () => {
const [urls, searchableUrls] = await Promise.all([ const primary = await buildPublicationSectionRelayUrls(indexEvent, refs, 22, false)
buildPublicationSectionRelayUrls(indexEvent, referencesData, 22, false), if (cancelled) return
buildPublicationSectionRelayUrls(indexEvent, referencesData, 40, true) if (primary.length > 0) {
]) setRelayUrls(primary)
return
}
const fallback = await buildPublicationSectionRelayUrls(indexEvent, refs, 30, true)
if (cancelled) return if (cancelled) return
relayUrlsRef.current = urls setRelayUrls(fallback)
searchableRelayUrlsRef.current = searchableUrls })().catch((err) => {
setRelayReady(true) if (import.meta.env.DEV) {
})() logger.warn('[PublicationSection] relay_build_failed', {
indexId,
message: err instanceof Error ? err.message : String(err)
})
}
if (!cancelled) setRelayUrls([])
})
return () => { return () => {
cancelled = true cancelled = true
} }
}, [indexEvent.id, orderedKeysSignature]) }, [indexId, refsSignature, indexEvent, refs])
const pendingRef = useRef(new Set<string>())
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const flushInFlightRef = useRef(false)
const runFlush = useCallback(async () => { const applyLoadedAndFailed = useCallback(
if (flushInFlightRef.current) return (loaded: Map<string, Event>, failedKeys: string[]) => {
const keys = [...pendingRef.current] const cached = indexCache.get(indexId) ?? { loaded: new Map(), failed: new Set() }
pendingRef.current.clear() for (const [k, ev] of loaded) {
if (keys.length === 0) return cached.loaded.set(k, ev)
cached.failed.delete(k)
flushInFlightRef.current = true
try {
const snapshot = rowsRef.current
const refsToLoad: PublicationSectionRef[] = []
for (const k of keys) {
const row = snapshot.get(k)
if (!row) continue
// Auto-queue should only process idle rows.
// - loaded rows are done
// - loading rows are already in-flight
// - error rows require explicit retry via retry button
if (row.status !== 'idle') continue
refsToLoad.push(row.ref)
} }
for (const k of failedKeys) {
if (!loaded.has(k)) cached.failed.add(k)
}
indexCache.set(indexId, cached)
if (refsToLoad.length === 0) return setRows((prev) =>
prev.map((row) => {
pubLog('flush_start', { const ev = loaded.get(row.key)
keys: refsToLoad.map((r) => refKey(r)), if (ev) return { ...row, event: ev, status: 'loaded' as const }
relayCount: relayUrlsRef.current.length if (failedKeys.includes(row.key)) return { ...row, status: 'error' as const }
if (inflightKeysRef.current.has(row.key)) return { ...row, status: 'loading' as const }
return row
}) })
)
},
[indexId]
)
setRows((prev) => { const runFetch = useCallback(
const next = new Map(prev) async (keys: string[]) => {
for (const ref of refsToLoad) { const selectedRows = rows.filter((r) => keys.includes(r.key))
const k = refKey(ref) if (selectedRows.length === 0) return
const row = next.get(k)
if (row) next.set(k, { ...row, status: 'loading' })
}
return next
})
const urls = relayUrlsRef.current const byDb = new Map<string, Event>()
const resolved = new Map<string, Event>() const stillNeed: Row[] = []
// Always hydrate from IDB — do not gate on relay URLs (they resolve async after first IO batch). await Promise.all(
const fromDb = await hydrateRefsFromIndexedDb(refsToLoad) selectedRows.map(async (row) => {
for (const [k, ev] of fromDb) { try {
resolved.set(k, ev) let ev: Event | undefined
client.addEventToCache(ev) if (row.type === 'e' && row.eventId) {
const hex = resolvePublicationEventIdToHex(row.eventId)
if (hex) ev = await indexedDb.getEventFromPublicationStore(hex)
} else if (row.coordinate) {
ev = await indexedDb.getPublicationEvent(row.coordinate)
}
if (ev) byDb.set(row.key, ev)
else stillNeed.push(row)
} catch {
stillNeed.push(row)
} }
let stillNeed = refsToLoad.filter((r) => !resolved.has(refKey(r)))
pubLog('after_idb', {
fromDb: fromDb.size,
stillNeed: stillNeed.map((r) => ({ key: refKey(r), type: r.type }))
}) })
)
// No relay list yet: apply DB hits only, re-queue the rest (do not mark error). if (import.meta.env.DEV) {
if (urls.length === 0 && stillNeed.length > 0) { logger.info('[PublicationSection] after_idb', {
for (const r of stillNeed) pendingRef.current.add(refKey(r)) fromDb: byDb.size,
pubLog('defer_net_until_relays', { reQueued: stillNeed.length }) stillNeed: stillNeed.map((r) => r.key)
setRows((prev) => {
const next = new Map(prev)
for (const ref of refsToLoad) {
const k = refKey(ref)
const row = next.get(k)
if (!row) continue
const ev = resolved.get(k)
if (ev) next.set(k, { ...row, event: ev, status: 'loaded' })
else next.set(k, { ...row, status: 'idle', event: undefined })
}
return next
}) })
return
} }
if (urls.length > 0 && stillNeed.length > 0) { let fromNet = new Map<string, Event>()
const fromNet = await batchFetchPublicationSectionEvents(stillNeed, urls) if (stillNeed.length > 0 && relayUrls.length > 0) {
pubLog('after_batch_fetch', { fromNet: fromNet.size }) fromNet = await batchFetchPublicationSectionEvents(stillNeed, relayUrls)
for (const [k, ev] of fromNet) { if (import.meta.env.DEV) {
resolved.set(k, ev) logger.info('[PublicationSection] after_batch_fetch', { fromNet: fromNet.size })
client.addEventToCache(ev)
if (isReplaceableEvent(ev.kind)) void indexedDb.putReplaceableEvent(ev)
} }
} }
stillNeed = refsToLoad.filter((r) => !resolved.has(refKey(r))) const merged = new Map<string, Event>([...byDb, ...fromNet])
if (stillNeed.length > 0) { const unresolved = stillNeed.filter((r) => !merged.has(r.key))
const searchableUrls = searchableRelayUrlsRef.current const bySingle = new Map<string, Event>()
const hasAdditionalSearchable = searchableUrls.some((u) => !urls.includes(u))
if (hasAdditionalSearchable) { await Promise.all(
const fromSearchFallback = await batchFetchPublicationSectionEvents(stillNeed, searchableUrls) unresolved.map(async (row) => {
pubLog('after_searchable_fallback', { try {
fromSearchFallback: fromSearchFallback.size, if (row.type === 'e' && row.eventId) {
stillNeedBefore: stillNeed.length, const ev = await withTimeout(
relayCount: searchableUrls.length eventService.fetchEvent(row.eventId),
}) SINGLE_REF_TIMEOUT_MS
for (const [k, ev] of fromSearchFallback) { )
resolved.set(k, ev) if (ev) bySingle.set(row.key, ev)
client.addEventToCache(ev) return
if (isReplaceableEvent(ev.kind)) void indexedDb.putReplaceableEvent(ev)
} }
if (row.coordinate) {
const parsed = parsePublicationATagCoordinate(row.coordinate)
if (!parsed) return
const relaysToTry = row.relay ? [row.relay] : relayUrls
const ev = await withTimeout(
queryService
.fetchEvents(
relaysToTry,
{
authors: [parsed.pubkey],
kinds: [parsed.kind],
'#d': [parsed.identifier],
limit: 1
},
{
globalTimeout: 6_000,
eoseTimeout: 1_500
} }
)
.then((arr) => arr[0]),
SINGLE_REF_TIMEOUT_MS
)
if (ev) bySingle.set(row.key, ev)
} }
} catch {
const missing = refsToLoad.filter((r) => !resolved.has(refKey(r))) // unresolved single-ref fallback
pubLog('before_fallback', {
missing: missing.map((r) => refKey(r)),
relayUrlsEmpty: urls.length === 0
})
await Promise.all(
missing.map(async (ref) => {
const k = refKey(ref)
const ev = await fetchSingleRefFallback(ref)
if (ev) {
resolved.set(k, ev)
client.addEventToCache(ev)
if (isReplaceableEvent(ev.kind)) void indexedDb.putReplaceableEvent(ev)
} }
}) })
) )
const failed = refsToLoad.filter((r) => !resolved.has(refKey(r))) for (const [k, ev] of bySingle) merged.set(k, ev)
pubLog('flush_done', {
loaded: refsToLoad.length - failed.length,
failed: failed.map((r) => ({
key: refKey(r),
type: r.type,
coordinate: r.coordinate,
eventId: r.eventId
}))
})
setRows((prev) => { const failed = selectedRows
const next = new Map(prev) .map((r) => r.key)
for (const ref of refsToLoad) { .filter((k) => !merged.has(k))
const k = refKey(ref)
const row = next.get(k)
if (!row) continue
const ev = resolved.get(k)
if (ev) {
next.set(k, { ...row, event: ev, status: 'loaded' })
} else {
next.set(k, { ...row, status: 'error', event: undefined })
}
}
return next
})
} finally {
flushInFlightRef.current = false
// While a batch was in flight, debounced runFlush() calls may have returned early
// (flush lock). Drain any keys that accumulated so scroll-triggered sections still load.
// IMPORTANT: if relay URLs are not ready yet, do NOT spin in a tight retry loop.
// The relayReady effect will trigger requestKeys() once relays are available.
if (pendingRef.current.size > 0 && relayUrlsRef.current.length > 0) {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = null
void runFlush()
}, 0)
}
}
}, [])
const requestKeys = useCallback( applyLoadedAndFailed(merged, failed)
(keys: string[]) => {
for (const k of keys) {
if (k) pendingRef.current.add(k)
}
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = null
void runFlush()
}, 56)
}, },
[runFlush] [applyLoadedAndFailed, relayUrls, rows]
) )
useEffect(() => { const requestKeys = useCallback(
if (!relayReady || orderedKeys.length === 0) return (keys: string[]) => {
// Full list: scroll-IO may have fired before relays were ready; those keys were re-queued idle. const unique = [...new Set(keys.filter(Boolean))]
requestKeys(orderedKeys) if (unique.length === 0) return
}, [relayReady, orderedKeysSignature, requestKeys]) const eligible = rows.filter((r) => unique.includes(r.key) && r.status !== 'loaded' && r.status !== 'loading')
if (eligible.length === 0) return
const failedKeys = useMemo( const keysToLoad = eligible.map((r) => r.key)
() => [...rows.entries()].filter(([, v]) => v.status === 'error').map(([k]) => k), for (const k of keysToLoad) inflightKeysRef.current.add(k)
[rows] setRows((prev) => prev.map((r) => (keysToLoad.includes(r.key) ? { ...r, status: 'loading' } : r)))
void runFetch(keysToLoad).finally(() => {
for (const k of keysToLoad) inflightKeysRef.current.delete(k)
})
},
[rows, runFetch]
) )
const retryKeys = useCallback( const retryKeys = useCallback(
(keys: string[]) => { (keys: string[]) => {
setRows((prev) => { const unique = [...new Set(keys.filter(Boolean))]
const next = new Map(prev) if (unique.length === 0) return
for (const k of keys) { const cached = indexCache.get(indexId)
const row = next.get(k) if (cached) {
if (row) next.set(k, { ...row, status: 'idle', event: undefined }) for (const key of unique) cached.failed.delete(key)
} }
return next setRows((prev) =>
}) prev.map((r) => (unique.includes(r.key) && r.status !== 'loaded' ? { ...r, status: 'idle' } : r))
requestKeys(keys) )
requestKeys(unique)
}, },
[requestKeys] [indexId, requestKeys]
) )
const referencesWithEvents = useMemo(() => { useEffect(() => {
return orderedKeys.map((k) => { if (relayUrls.length === 0) return
const row = rows.get(k) const sig = `${indexId}:${refsSignature}`
const ref = row?.ref ?? referencesData.find((r) => refKey(r) === k)! if (autoLoadedSignatureRef.current === sig) return
return { autoLoadedSignatureRef.current = sig
type: ref.type, const idleKeys = rows.filter((r) => r.status === 'idle').map((r) => r.key)
coordinate: ref.coordinate, if (idleKeys.length > 0) {
eventId: ref.eventId, if (import.meta.env.DEV) {
kind: ref.kind, logger.info('[PublicationSection] flush_start', { keys: idleKeys, relayCount: relayUrls.length })
pubkey: ref.pubkey, }
identifier: ref.identifier, requestKeys(idleKeys)
relay: ref.relay, }
event: row?.event, }, [indexId, refsSignature, relayUrls, rows, requestKeys])
loadStatus: row?.status ?? 'idle'
} const referencesWithEvents = useMemo(
}) () =>
}, [orderedKeys, rows, referencesData]) rows.map((row) => ({
...row,
loadStatus: row.status
})),
[rows]
)
const failedKeys = useMemo(
() =>
rows
.filter((r) => r.status === 'error')
.map((r) => r.key),
[rows]
)
return { return {
orderedKeys,
rows,
relayReady,
requestKeys, requestKeys,
retryKeys, retryKeys,
failedKeys, failedKeys,

344
src/lib/publication-section-fetch.ts

@ -1,14 +1,11 @@
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { publicationCoordinateLookupKeys } from '@/lib/publication-coordinate' import { publicationCoordinateLookupKeys, splitPublicationCoordinate } from '@/lib/publication-coordinate'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import client, { queryService } from '@/services/client.service' import client, { queryService } from '@/services/client.service'
import { ExtendedKind } from '@/constants'
import type { Event, Filter } from 'nostr-tools' import type { Event, Filter } from 'nostr-tools'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { kinds } from 'nostr-tools'
/** Parsed a/e reference from publication index tags (same shape as PublicationIndex uses). */
export type PublicationSectionRef = { export type PublicationSectionRef = {
type: 'a' | 'e' type: 'a' | 'e'
coordinate?: string coordinate?: string
@ -23,64 +20,48 @@ export function publicationRefKey(ref: PublicationSectionRef): string {
return (ref.coordinate || ref.eventId || '').trim() return (ref.coordinate || ref.eventId || '').trim()
} }
/**
* Parse NIP-33 `a` coordinate `kind:64-hex-pubkey:d-identifier` where `d` may contain `:`.
* Returns a canonical coordinate with lowercase pubkey for cache / REQ / matching.
*/
export function parsePublicationATagCoordinate(raw: string): { export function parsePublicationATagCoordinate(raw: string): {
kind: number kind: number
pubkey: string pubkey: string
identifier: string identifier: string
coordinate: string coordinate: string
} | null { } | null {
const trimmed = raw.trim() const parsed = splitPublicationCoordinate(raw)
const i0 = trimmed.indexOf(':') if (!parsed) return null
const i1 = trimmed.indexOf(':', i0 + 1)
if (i0 < 1 || i1 <= i0 + 1) return null
const kindStr = trimmed.slice(0, i0)
const pubkeyRaw = trimmed.slice(i0 + 1, i1)
const identifier = trimmed.slice(i1 + 1)
const kind = parseInt(kindStr, 10)
if (Number.isNaN(kind) || !/^[0-9a-fA-F]{64}$/.test(pubkeyRaw)) return null
const pubkey = pubkeyRaw.toLowerCase()
return { return {
kind, kind: parsed.kind,
pubkey, pubkey: parsed.pubkey,
identifier, identifier: parsed.d,
coordinate: `${kind}:${pubkey}:${identifier}` coordinate: `${parsed.kind}:${parsed.pubkey}:${parsed.d}`
} }
} }
export function resolvePublicationEventIdToHex(eventId: string): string | undefined { export function resolvePublicationEventIdToHex(eventId: string): string | undefined {
if (!eventId) return undefined
const trimmed = eventId.trim() const trimmed = eventId.trim()
if (!trimmed) return undefined
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) return trimmed.toLowerCase() if (/^[0-9a-fA-F]{64}$/.test(trimmed)) return trimmed.toLowerCase()
try { try {
const decoded = nip19.decode(trimmed) const decoded = nip19.decode(trimmed)
if (decoded.type === 'note') return decoded.data if (decoded.type === 'note') return decoded.data
if (decoded.type === 'nevent') return decoded.data.id if (decoded.type === 'nevent') return decoded.data.id
} catch { } catch {
/* ignore */ // ignore malformed bech32 ids
} }
return undefined return undefined
} }
function collectRelayHints(refs: PublicationSectionRef[]): string[] { function collectRelayHints(refs: PublicationSectionRef[]): string[] {
const out: string[] = [] const out: string[] = []
for (const r of refs) { for (const ref of refs) {
const h = r.relay?.trim() const relay = ref.relay?.trim()
if (h && (h.startsWith('wss://') || h.startsWith('ws://'))) { if (!relay) continue
const n = normalizeUrl(h) || h if (!relay.startsWith('wss://') && !relay.startsWith('ws://')) continue
out.push(n) const normalized = normalizeUrl(relay) || relay
out.push(normalized)
} }
} return [...new Set(out)]
return out
} }
/**
* Focused relay set for publication sections: hints + author + user + profile/fast read, capped.
* Omits full SEARCHABLE list to avoid opening dozens of relays per publication.
*/
export async function buildPublicationSectionRelayUrls( export async function buildPublicationSectionRelayUrls(
indexEvent: Event, indexEvent: Event,
refs: PublicationSectionRef[], refs: PublicationSectionRef[],
@ -99,28 +80,25 @@ export async function buildPublicationSectionRelayUrls(
includeFavoriteRelays: true, includeFavoriteRelays: true,
includeLocalRelays: true includeLocalRelays: true
}) })
return urls.slice(0, maxRelays) const prioritized = [...new Set([...hints, ...urls])]
return prioritized.slice(0, maxRelays)
} }
const IDS_CHUNK = 44 const IDS_CHUNK = 44
const D_TAGS_CHUNK = 28 const D_CHUNK = 28
const SECTION_KIND_FALLBACK_CANDIDATES = [ const ANY_KIND_LIMIT_PER_D = 12
ExtendedKind.PUBLICATION_CONTENT, // 30041
ExtendedKind.WIKI_ARTICLE, // 30818 function dTagOf(ev: Event): string | undefined {
ExtendedKind.WIKI_ARTICLE_MARKDOWN, // 30817 const d = ev.tags.find((t) => t[0] === 'd')?.[1]
kinds.LongFormArticle, // 30023 return d && d.length > 0 ? d : undefined
kinds.ShortTextNote // 1 }
] as number[]
function coordinateFromEvent(ev: Event): string { function coordinateOfEvent(ev: Event): string | null {
const d = ev.tags.find((t) => t[0] === 'd')?.[1] ?? '' const d = dTagOf(ev)
if (!d) return null
return `${ev.kind}:${ev.pubkey.toLowerCase()}:${d}` return `${ev.kind}:${ev.pubkey.toLowerCase()}:${d}`
} }
/**
* One batched query: chunk `ids` filters and grouped `authors + kinds + #d` filters.
* Caller should hydrate from IndexedDB first. Keys are {@link publicationRefKey}.
*/
export async function batchFetchPublicationSectionEvents( export async function batchFetchPublicationSectionEvents(
refs: PublicationSectionRef[], refs: PublicationSectionRef[],
relayUrls: string[] relayUrls: string[]
@ -128,45 +106,44 @@ export async function batchFetchPublicationSectionEvents(
const out = new Map<string, Event>() const out = new Map<string, Event>()
if (refs.length === 0 || relayUrls.length === 0) return out if (refs.length === 0 || relayUrls.length === 0) return out
const idRefs: PublicationSectionRef[] = [] const eRefs: PublicationSectionRef[] = []
const hexByKey = new Map<string, string>() const eHexByKey = new Map<string, string>()
for (const r of refs) { const aRefs = refs.filter((r) => r.type === 'a' && r.coordinate && r.pubkey && typeof r.kind === 'number')
if (r.type !== 'e' || !r.eventId) continue
const key = publicationRefKey(r)
if (!key) continue
const hex = resolvePublicationEventIdToHex(r.eventId)
if (hex) {
idRefs.push(r)
hexByKey.set(key, hex)
}
}
const aRefs = refs.filter((r) => r.type === 'a' && r.coordinate && r.pubkey && r.kind != null) for (const ref of refs) {
const aGroups = new Map<string, { pubkey: string; kind: number; dTags: string[] }>() if (ref.type !== 'e' || !ref.eventId) continue
for (const r of aRefs) { const key = publicationRefKey(ref)
const idf = r.identifier ?? r.coordinate!.split(':').slice(2).join(':') const hex = resolvePublicationEventIdToHex(ref.eventId)
if (!idf) continue if (!key || !hex) continue
const gk = `${r.pubkey}:${r.kind}` eRefs.push(ref)
let g = aGroups.get(gk) eHexByKey.set(key, hex)
if (!g) {
g = { pubkey: r.pubkey!, kind: r.kind!, dTags: [] }
aGroups.set(gk, g)
}
g.dTags.push(idf)
} }
const filters: Filter[] = [] const filters: Filter[] = []
const hexList = [...new Set([...hexByKey.values()])].filter((id) => /^[0-9a-f]{64}$/.test(id)) const ids = [...new Set([...eHexByKey.values()])]
for (let i = 0; i < hexList.length; i += IDS_CHUNK) { for (let i = 0; i < ids.length; i += IDS_CHUNK) {
const chunk = hexList.slice(i, i + IDS_CHUNK) const chunk = ids.slice(i, i + IDS_CHUNK)
filters.push({ ids: chunk, limit: chunk.length }) filters.push({ ids: chunk, limit: chunk.length })
} }
for (const g of aGroups.values()) { const groupedA = new Map<string, { pubkey: string; kind: number; dTags: string[] }>()
for (const ref of aRefs) {
const d = ref.identifier ?? ref.coordinate!.split(':').slice(2).join(':')
if (!d) continue
const gk = `${ref.pubkey}:${ref.kind}`
let g = groupedA.get(gk)
if (!g) {
g = { pubkey: ref.pubkey!, kind: ref.kind!, dTags: [] }
groupedA.set(gk, g)
}
g.dTags.push(d)
}
for (const g of groupedA.values()) {
const uniqueD = [...new Set(g.dTags)] const uniqueD = [...new Set(g.dTags)]
for (let i = 0; i < uniqueD.length; i += D_TAGS_CHUNK) { for (let i = 0; i < uniqueD.length; i += D_CHUNK) {
const dChunk = uniqueD.slice(i, i + D_TAGS_CHUNK) const dChunk = uniqueD.slice(i, i + D_CHUNK)
filters.push({ filters.push({
authors: [g.pubkey.toLowerCase()], authors: [g.pubkey.toLowerCase()],
kinds: [g.kind], kinds: [g.kind],
@ -176,22 +153,12 @@ export async function batchFetchPublicationSectionEvents(
} }
} }
if (filters.length === 0) {
if (import.meta.env.DEV) {
logger.info('[PublicationSection] batch_fetch_skip — no filters', {
aRefCount: aRefs.length,
idRefCount: idRefs.length
})
}
return out
}
let events: Event[] = [] let events: Event[] = []
if (filters.length > 0) {
try { try {
events = await queryService.fetchEvents(relayUrls, filters, { events = await queryService.fetchEvents(relayUrls, filters, {
globalTimeout: 14_000, globalTimeout: 12_000,
eoseTimeout: 2_500, eoseTimeout: 2_000,
/** Do not early-resolve after the first event; this query must wait for the full batch. */
firstRelayResultGraceMs: false firstRelayResultGraceMs: false
}) })
} catch (err) { } catch (err) {
@ -202,61 +169,137 @@ export async function batchFetchPublicationSectionEvents(
relayCount: relayUrls.length relayCount: relayUrls.length
}) })
} }
return out }
} }
const byId = new Map<string, Event>() const byId = new Map<string, Event>()
const byCoord = new Map<string, Event>() const byCoord = new Map<string, Event>()
for (const ev of events) { for (const ev of events) {
byId.set(ev.id.toLowerCase(), ev) byId.set(ev.id.toLowerCase(), ev)
const d = ev.tags.find((t) => t[0] === 'd')?.[1] const coord = coordinateOfEvent(ev)
if (d !== undefined && d !== '') { if (!coord) continue
const base = coordinateFromEvent(ev) for (const key of publicationCoordinateLookupKeys(coord)) {
for (const k of publicationCoordinateLookupKeys(base)) { const prev = byCoord.get(key)
if (!byCoord.has(k)) byCoord.set(k, ev) if (!prev || ev.created_at > prev.created_at) byCoord.set(key, ev)
}
} }
} }
for (const r of idRefs) { for (const ref of eRefs) {
const key = publicationRefKey(r) const key = publicationRefKey(ref)
const hex = hexByKey.get(key) const hex = eHexByKey.get(key)
if (!hex) continue if (!hex) continue
const ev = byId.get(hex.toLowerCase()) const ev = byId.get(hex)
if (ev) out.set(key, ev) if (ev) out.set(key, ev)
} }
// Fallback for mismatched/legacy kind in `a` tags: for (const ref of aRefs) {
// retry unresolved refs by author + #d across common section kinds. const key = publicationRefKey(ref)
const unresolvedARefs = aRefs.filter((r) => !out.has(publicationRefKey(r))) if (out.has(key)) continue
if (unresolvedARefs.length > 0) { const coord = ref.coordinate!
const fallbackGroups = new Map<string, { pubkey: string; dTags: string[] }>() let ev: Event | undefined
for (const r of unresolvedARefs) { for (const k of publicationCoordinateLookupKeys(coord)) {
const pubkey = r.pubkey?.toLowerCase() ev = byCoord.get(k)
const idf = r.identifier ?? r.coordinate?.split(':').slice(2).join(':') if (ev) break
if (!pubkey || !idf) continue }
let g = fallbackGroups.get(pubkey) if (ev) out.set(key, ev)
}
// Relay-hint targeted pass for unresolved `a` refs.
const unresolvedAfterBatch = aRefs.filter((r) => !out.has(publicationRefKey(r)))
const byHintRelay = new Map<string, PublicationSectionRef[]>()
for (const ref of unresolvedAfterBatch) {
const relay = normalizeUrl(ref.relay || '') || ref.relay?.trim()
if (!relay) continue
const list = byHintRelay.get(relay)
if (list) list.push(ref)
else byHintRelay.set(relay, [ref])
}
for (const [relay, relayRefs] of byHintRelay) {
const hintFilters: Filter[] = []
const groups = new Map<string, { pubkey: string; kind: number; dTags: string[] }>()
for (const ref of relayRefs) {
const d = ref.identifier ?? ref.coordinate!.split(':').slice(2).join(':')
if (!d) continue
const gk = `${ref.pubkey}:${ref.kind}`
let g = groups.get(gk)
if (!g) { if (!g) {
g = { pubkey, dTags: [] } g = { pubkey: ref.pubkey!.toLowerCase(), kind: ref.kind!, dTags: [] }
fallbackGroups.set(pubkey, g) groups.set(gk, g)
}
g.dTags.push(d)
}
for (const g of groups.values()) {
const uniqueD = [...new Set(g.dTags)]
for (let i = 0; i < uniqueD.length; i += D_CHUNK) {
const dChunk = uniqueD.slice(i, i + D_CHUNK)
hintFilters.push({
authors: [g.pubkey],
kinds: [g.kind],
'#d': dChunk,
limit: dChunk.length
})
}
}
if (hintFilters.length === 0) continue
try {
const hintEvents = await queryService.fetchEvents([relay], hintFilters, {
globalTimeout: 8_000,
eoseTimeout: 1_500,
firstRelayResultGraceMs: false
})
const hintByCoord = new Map<string, Event>()
for (const ev of hintEvents) {
const coord = coordinateOfEvent(ev)
if (!coord) continue
for (const key of publicationCoordinateLookupKeys(coord)) {
const prev = hintByCoord.get(key)
if (!prev || ev.created_at > prev.created_at) hintByCoord.set(key, ev)
}
}
for (const ref of relayRefs) {
const key = publicationRefKey(ref)
if (out.has(key)) continue
const coord = ref.coordinate!
let ev: Event | undefined
for (const k of publicationCoordinateLookupKeys(coord)) {
ev = hintByCoord.get(k)
if (ev) break
}
if (ev) out.set(key, ev)
}
} catch {
// ignore per-relay hint failures
} }
g.dTags.push(idf)
} }
// Last fallback: author + #d across any kind.
const unresolvedAfterHint = aRefs.filter((r) => !out.has(publicationRefKey(r)))
if (unresolvedAfterHint.length > 0) {
const fallbackFilters: Filter[] = [] const fallbackFilters: Filter[] = []
for (const g of fallbackGroups.values()) { const groups = new Map<string, { pubkey: string; dTags: string[] }>()
for (const ref of unresolvedAfterHint) {
const d = ref.identifier ?? ref.coordinate!.split(':').slice(2).join(':')
if (!d) continue
const pk = ref.pubkey!.toLowerCase()
let g = groups.get(pk)
if (!g) {
g = { pubkey: pk, dTags: [] }
groups.set(pk, g)
}
g.dTags.push(d)
}
for (const g of groups.values()) {
const uniqueD = [...new Set(g.dTags)] const uniqueD = [...new Set(g.dTags)]
for (let i = 0; i < uniqueD.length; i += D_TAGS_CHUNK) { for (let i = 0; i < uniqueD.length; i += D_CHUNK) {
const dChunk = uniqueD.slice(i, i + D_TAGS_CHUNK) const dChunk = uniqueD.slice(i, i + D_CHUNK)
fallbackFilters.push({ fallbackFilters.push({
authors: [g.pubkey], authors: [g.pubkey],
kinds: [...SECTION_KIND_FALLBACK_CANDIDATES],
'#d': dChunk, '#d': dChunk,
limit: dChunk.length * SECTION_KIND_FALLBACK_CANDIDATES.length limit: dChunk.length * ANY_KIND_LIMIT_PER_D
}) })
} }
} }
if (fallbackFilters.length > 0) { if (fallbackFilters.length > 0) {
try { try {
const fallbackEvents = await queryService.fetchEvents(relayUrls, fallbackFilters, { const fallbackEvents = await queryService.fetchEvents(relayUrls, fallbackFilters, {
@ -264,49 +307,38 @@ export async function batchFetchPublicationSectionEvents(
eoseTimeout: 2_000, eoseTimeout: 2_000,
firstRelayResultGraceMs: false firstRelayResultGraceMs: false
}) })
const byAuthorAndD = new Map<string, Event>() const byAuthorD = new Map<string, Event[]>()
for (const ev of fallbackEvents) { for (const ev of fallbackEvents) {
const d = ev.tags.find((t) => t[0] === 'd')?.[1] const d = dTagOf(ev)
if (!d) continue if (!d) continue
const k = `${ev.pubkey.toLowerCase()}:${d}` const k = `${ev.pubkey.toLowerCase()}:${d}`
const prev = byAuthorAndD.get(k) const arr = byAuthorD.get(k)
if (!prev || ev.created_at > prev.created_at) byAuthorAndD.set(k, ev) if (arr) arr.push(ev)
else byAuthorD.set(k, [ev])
} }
for (const r of unresolvedARefs) { for (const ref of unresolvedAfterHint) {
const key = publicationRefKey(r) const key = publicationRefKey(ref)
if (out.has(key)) continue if (out.has(key)) continue
const pubkey = r.pubkey?.toLowerCase() const d = ref.identifier ?? ref.coordinate!.split(':').slice(2).join(':')
const idf = r.identifier ?? r.coordinate?.split(':').slice(2).join(':') const candidates = byAuthorD.get(`${ref.pubkey!.toLowerCase()}:${d}`)
if (!pubkey || !idf) continue if (!candidates || candidates.length === 0) continue
const ev = byAuthorAndD.get(`${pubkey}:${idf}`) const preferred = candidates.filter((ev) => ev.kind === ref.kind)
if (ev) out.set(key, ev) const src = preferred.length > 0 ? preferred : candidates
} let newest = src[0]
} catch (err) { for (let i = 1; i < src.length; i++) {
if (import.meta.env.DEV) { if (src[i].created_at > newest.created_at) newest = src[i]
logger.warn('[PublicationSection] batch_fetch_fallback_error', {
message: err instanceof Error ? err.message : String(err),
filterCount: fallbackFilters.length,
relayCount: relayUrls.length
})
}
} }
out.set(key, newest)
} }
} catch {
// ignore fallback errors
} }
for (const r of aRefs) {
const key = publicationRefKey(r)
const coord = r.coordinate!
let ev: Event | undefined
for (const k of publicationCoordinateLookupKeys(coord)) {
ev = byCoord.get(k)
if (ev) break
} }
if (ev) out.set(key, ev)
} }
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
const unmatchedA = aRefs.filter((r) => !out.has(publicationRefKey(r))) const unmatchedA = aRefs.filter((r) => !out.has(publicationRefKey(r)))
const unmatchedE = idRefs.filter((r) => !out.has(publicationRefKey(r))) const unmatchedE = eRefs.filter((r) => !out.has(publicationRefKey(r)))
logger.info('[PublicationSection] batch_fetch_result', { logger.info('[PublicationSection] batch_fetch_result', {
relayCount: relayUrls.length, relayCount: relayUrls.length,
filterCount: filters.length, filterCount: filters.length,
@ -315,11 +347,7 @@ export async function batchFetchPublicationSectionEvents(
resolved: out.size, resolved: out.size,
unmatchedACount: unmatchedA.length, unmatchedACount: unmatchedA.length,
unmatchedECount: unmatchedE.length, unmatchedECount: unmatchedE.length,
unmatchedAKeys: unmatchedA.map((r) => publicationRefKey(r)).slice(0, 12), unmatchedAKeys: unmatchedA.map((r) => publicationRefKey(r)).slice(0, 12)
sampleEventCoords: events.slice(0, 3).map((ev) => {
const d = ev.tags.find((t) => t[0] === 'd')?.[1]
return d !== undefined && d !== '' ? coordinateFromEvent(ev) : `${ev.kind}:${ev.pubkey.slice(0, 8)}`
})
}) })
} }

Loading…
Cancel
Save