7 changed files with 88 additions and 1458 deletions
@ -1,963 +0,0 @@
@@ -1,963 +0,0 @@
|
||||
import { ExtendedKind } from '@/constants' |
||||
import { Event, kinds, nip19 } from 'nostr-tools' |
||||
import { useEffect, useMemo, useState, useCallback, useSyncExternalStore, useRef } from 'react' |
||||
import { usePublicationSectionLoader } from '@/hooks/usePublicationSectionLoader' |
||||
import { parsePublicationATagCoordinate, publicationRefKey } from '@/lib/publication-section-fetch' |
||||
import { cn } from '@/lib/utils' |
||||
import AsciidocArticle from '../AsciidocArticle/AsciidocArticle' |
||||
import MarkdownArticle from '../MarkdownArticle/MarkdownArticle' |
||||
import { generateBech32IdFromATag } from '@/lib/tag' |
||||
import logger from '@/lib/logger' |
||||
import { Button } from '@/components/ui/button' |
||||
import { Skeleton } from '@/components/ui/skeleton' |
||||
import { RefreshCw, ArrowUp } from 'lucide-react' |
||||
import indexedDb from '@/services/indexed-db.service' |
||||
import { useSecondaryPageOptional } from '@/PageManager' |
||||
import { extractBookMetadata } from '@/lib/bookstr-parser' |
||||
import { dTagToTitleCase } from '@/lib/event-metadata' |
||||
import ImageWithLightbox from '@/components/ImageWithLightbox' |
||||
import NoteOptions from '@/components/NoteOptions' |
||||
import { |
||||
getRenderedPublicationEventsVersion, |
||||
getRenderedPublicationEventsDeep, |
||||
subscribeRenderedPublicationEvents, |
||||
upsertRenderedPublicationEvents |
||||
} from '@/lib/publication-rendered-events' |
||||
|
||||
interface PublicationReference { |
||||
coordinate?: string |
||||
/** |
||||
* Optional historical snapshot id (`a` tag field 4) or direct `e` tag id. |
||||
* For `a` references this is metadata only and MUST NOT drive section fetches. |
||||
*/ |
||||
eventId?: string |
||||
event?: Event |
||||
kind?: number |
||||
pubkey?: string |
||||
identifier?: string |
||||
relay?: string |
||||
type: 'a' | 'e' // 'a' for addressable (coordinate), 'e' for event ID
|
||||
nestedRefs?: PublicationReference[] // Discovered nested references
|
||||
} |
||||
|
||||
interface ToCItem { |
||||
title: string |
||||
coordinate: string |
||||
event?: Event |
||||
kind: number |
||||
children?: ToCItem[] |
||||
} |
||||
|
||||
interface PublicationMetadata { |
||||
title?: string |
||||
summary?: string |
||||
image?: string |
||||
author?: string |
||||
version?: string |
||||
type?: string |
||||
source?: string |
||||
publishedOn?: string |
||||
publishedBy?: string |
||||
tags: string[] |
||||
} |
||||
|
||||
function publicationSectionNotesLink(ref: { |
||||
coordinate?: string |
||||
eventId?: string |
||||
relay?: string |
||||
}): string | null { |
||||
if (ref.coordinate) { |
||||
const aTag = ['a', ref.coordinate, ref.relay || '', ref.eventId || ''] |
||||
const bech32Id = generateBech32IdFromATag(aTag) |
||||
if (bech32Id) return `/notes?events=${encodeURIComponent(bech32Id)}` |
||||
} |
||||
if (ref.eventId) { |
||||
if ( |
||||
ref.eventId.startsWith('note1') || |
||||
ref.eventId.startsWith('nevent1') || |
||||
ref.eventId.startsWith('naddr1') |
||||
) { |
||||
return `/notes?events=${encodeURIComponent(ref.eventId)}` |
||||
} |
||||
if (/^[0-9a-f]{64}$/i.test(ref.eventId)) { |
||||
try { |
||||
const nevent = nip19.neventEncode({ id: ref.eventId }) |
||||
return `/notes?events=${encodeURIComponent(nevent)}` |
||||
} catch { |
||||
return `/notes?events=${encodeURIComponent(ref.eventId)}` |
||||
} |
||||
} |
||||
} |
||||
return null |
||||
} |
||||
|
||||
export default function PublicationIndex({ |
||||
event, |
||||
className, |
||||
isNested = false, |
||||
parentImageUrl, |
||||
parentSummary, |
||||
flattenHierarchy = false, |
||||
chapterDepth = 0, |
||||
publicationFootnotesContainerId |
||||
}: { |
||||
event: Event |
||||
className?: string |
||||
isNested?: boolean |
||||
parentImageUrl?: string |
||||
parentSummary?: string |
||||
flattenHierarchy?: boolean |
||||
chapterDepth?: number |
||||
publicationFootnotesContainerId?: string |
||||
}) { |
||||
const secondaryPage = useSecondaryPageOptional() |
||||
const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) |
||||
// Parse publication metadata from event tags
|
||||
const metadata = useMemo<PublicationMetadata>(() => { |
||||
const meta: PublicationMetadata = { tags: [] } |
||||
|
||||
for (const [tagName, tagValue] of event.tags) { |
||||
if (tagName === 'title') { |
||||
meta.title = tagValue |
||||
} else if (tagName === 'summary') { |
||||
meta.summary = tagValue |
||||
} else if (tagName === 'image') { |
||||
meta.image = tagValue |
||||
} else if (tagName === 'author') { |
||||
meta.author = tagValue |
||||
} else if (tagName === 'version') { |
||||
meta.version = tagValue |
||||
} else if (tagName === 'type') { |
||||
meta.type = tagValue |
||||
} else if (tagName === 'source') { |
||||
meta.source = tagValue |
||||
} else if (tagName === 'published_on') { |
||||
meta.publishedOn = tagValue |
||||
} else if (tagName === 'published_by') { |
||||
meta.publishedBy = tagValue |
||||
} else if (tagName === 't' && tagValue) { |
||||
meta.tags.push(tagValue.toLowerCase()) |
||||
} |
||||
} |
||||
|
||||
// Fallback title from d-tag if no title (convert to title case)
|
||||
if (!meta.title) { |
||||
const dTag = event.tags.find(tag => tag[0] === 'd')?.[1] |
||||
if (dTag) { |
||||
meta.title = dTagToTitleCase(dTag) |
||||
} |
||||
} |
||||
|
||||
return meta |
||||
}, [event]) |
||||
const bookMetadata = useMemo(() => extractBookMetadata(event), [event]) |
||||
const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book |
||||
const isTopLevelPublication = !isNested && event.kind === ExtendedKind.PUBLICATION |
||||
const forceFlatHierarchy = flattenHierarchy || isBookstrEvent || isTopLevelPublication |
||||
const initialSectionLoadCount = isNested ? 1 : 3 |
||||
const sectionLoadStep = isNested ? 1 : 3 |
||||
const effectiveParentSummary = metadata.summary || parentSummary |
||||
const resolvedPublicationFootnotesContainerId = useMemo( |
||||
() => |
||||
publicationFootnotesContainerId ?? |
||||
(isTopLevelPublication ? `publication-footnotes-${event.id}` : undefined), |
||||
[publicationFootnotesContainerId, isTopLevelPublication, event.id] |
||||
) |
||||
const [isRetrying, setIsRetrying] = useState(false) |
||||
const [sectionLoadCount, setSectionLoadCount] = useState(initialSectionLoadCount) |
||||
const lazyLoadSentinelRef = useRef<HTMLDivElement | null>(null) |
||||
|
||||
// Extract references from 'a' tags (addressable events) and 'e' tags (event IDs)
|
||||
const referencesData = useMemo(() => { |
||||
const refs: PublicationReference[] = [] |
||||
for (const tag of event.tags) { |
||||
if (tag[0] === 'a' && tag[1]) { |
||||
const parsed = parsePublicationATagCoordinate(tag[1]) |
||||
if (parsed) { |
||||
refs.push({ |
||||
type: 'a', |
||||
coordinate: parsed.coordinate, |
||||
// `a[3]` is historization metadata for this coordinate revision only.
|
||||
// Keep it for diagnostics/UI context; fetches resolve by coordinate, not by this id.
|
||||
eventId: tag[3], |
||||
kind: parsed.kind, |
||||
pubkey: parsed.pubkey, |
||||
identifier: parsed.identifier, |
||||
relay: tag[2] |
||||
}) |
||||
} |
||||
} else if (tag[0] === 'e' && tag[1]) { |
||||
// Event ID reference
|
||||
refs.push({ |
||||
type: 'e', |
||||
eventId: tag[1], |
||||
relay: tag[2] |
||||
}) |
||||
} |
||||
} |
||||
return refs |
||||
}, [event]) |
||||
|
||||
const { requestKeys, retryKeys, failedKeys, referencesWithEvents } = |
||||
usePublicationSectionLoader(event, referencesData, { autoLoad: false }) |
||||
const renderedEventsVersion = useSyncExternalStore( |
||||
subscribeRenderedPublicationEvents, |
||||
getRenderedPublicationEventsVersion, |
||||
getRenderedPublicationEventsVersion |
||||
) |
||||
|
||||
// Helper function to format bookstr titles (remove hyphens, title case)
|
||||
const formatBookstrTitle = useCallback((title: string, event?: Event): string => { |
||||
if (!event) return title |
||||
|
||||
// Check if this is a bookstr event
|
||||
const bookMetadata = extractBookMetadata(event) |
||||
const isBookstr = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book |
||||
|
||||
if (isBookstr) { |
||||
// Remove hyphens and convert to title case
|
||||
return title |
||||
.split('-') |
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) |
||||
.join(' ') |
||||
} |
||||
|
||||
return title |
||||
}, []) |
||||
|
||||
|
||||
// Build table of contents from references (tag-derived titles before sections load)
|
||||
const tableOfContents = useMemo<ToCItem[]>(() => { |
||||
const toc: ToCItem[] = [] |
||||
|
||||
const coordinateOfEvent = (ev: Event): string | null => { |
||||
const d = ev.tags.find((tag) => tag[0] === 'd')?.[1] |
||||
if (!d) return null |
||||
return `${ev.kind}:${ev.pubkey.toLowerCase()}:${d}` |
||||
} |
||||
|
||||
const titleFromEvent = (ev: Event): string => { |
||||
const titleTag = ev.tags.find((tag) => tag[0] === 'title')?.[1] |
||||
if (titleTag) return titleTag |
||||
const dTag = ev.tags.find((tag) => tag[0] === 'd')?.[1] |
||||
if (dTag) return formatBookstrTitle(dTag, ev) |
||||
return 'Untitled' |
||||
} |
||||
|
||||
const titleFromIdentifier = (identifier: string, kind?: number) => { |
||||
const raw = identifier || 'Untitled' |
||||
if ( |
||||
kind === ExtendedKind.PUBLICATION || |
||||
kind === ExtendedKind.PUBLICATION_CONTENT || |
||||
kind === kinds.LongFormArticle || |
||||
kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN |
||||
) { |
||||
return raw |
||||
.split('-') |
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) |
||||
.join(' ') |
||||
} |
||||
return raw |
||||
} |
||||
|
||||
const knownByCoordinate = new Map<string, Event>() |
||||
for (const ref of referencesWithEvents) { |
||||
if (!ref.event) continue |
||||
const coord = coordinateOfEvent(ref.event) |
||||
if (coord) knownByCoordinate.set(coord, ref.event) |
||||
} |
||||
for (const ev of getRenderedPublicationEventsDeep(event.id)) { |
||||
const coord = coordinateOfEvent(ev) |
||||
if (coord && !knownByCoordinate.has(coord)) { |
||||
knownByCoordinate.set(coord, ev) |
||||
} |
||||
} |
||||
|
||||
for (const ref of referencesWithEvents) { |
||||
const coord = ref.coordinate || ref.eventId || '' |
||||
if (!coord) continue |
||||
|
||||
let title: string |
||||
if (ref.event) { |
||||
title = titleFromEvent(ref.event) |
||||
} else if (ref.type === 'a' && ref.kind === kinds.ShortTextNote) { |
||||
title = 'Note' |
||||
} else if (ref.type === 'a' && ref.identifier) { |
||||
title = titleFromIdentifier(ref.identifier, ref.kind) |
||||
} else { |
||||
title = 'Section' |
||||
} |
||||
|
||||
const tocItem: ToCItem = { |
||||
title, |
||||
coordinate: coord, |
||||
event: ref.event, |
||||
kind: ref.kind || ref.event?.kind || 0 |
||||
} |
||||
|
||||
// For nested 30040 publications, recursively get their ToC
|
||||
if (ref.kind === ExtendedKind.PUBLICATION && ref.event) { |
||||
const nestedRefs: ToCItem[] = [] |
||||
|
||||
// Parse nested references from this publication
|
||||
for (const tag of ref.event.tags) { |
||||
if (tag[0] === 'a' && tag[1]) { |
||||
const parsed = parsePublicationATagCoordinate(tag[1]) |
||||
if (!parsed) continue |
||||
const kind = parsed.kind |
||||
|
||||
if ( |
||||
kind === ExtendedKind.PUBLICATION_CONTENT || |
||||
kind === ExtendedKind.WIKI_ARTICLE || |
||||
kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN || |
||||
kind === kinds.LongFormArticle || |
||||
kind === kinds.ShortTextNote || |
||||
kind === ExtendedKind.PUBLICATION |
||||
) { |
||||
const knownNestedEvent = knownByCoordinate.get(parsed.coordinate) |
||||
const nestedTitle = knownNestedEvent |
||||
? titleFromEvent(knownNestedEvent) |
||||
: kind === kinds.ShortTextNote |
||||
? 'Note' |
||||
: titleFromIdentifier(parsed.identifier, kind) |
||||
|
||||
nestedRefs.push({ |
||||
title: nestedTitle, |
||||
coordinate: parsed.coordinate, |
||||
kind, |
||||
event: knownNestedEvent |
||||
}) |
||||
} |
||||
} |
||||
} |
||||
|
||||
if (nestedRefs.length > 0) { |
||||
tocItem.children = nestedRefs |
||||
} |
||||
} |
||||
|
||||
toc.push(tocItem) |
||||
} |
||||
|
||||
return toc |
||||
}, [referencesWithEvents, formatBookstrTitle, event.id, renderedEventsVersion]) |
||||
|
||||
// Scroll to ToC (scroll to top of page)
|
||||
const scrollToToc = useCallback(() => { |
||||
// Find the scrollable container (could be window or a drawer/scrollable div)
|
||||
let scrollContainer: HTMLElement | Window = window |
||||
const tocElement = document.getElementById('publication-toc') |
||||
|
||||
if (tocElement) { |
||||
// Walk up the DOM tree to find the scrollable container
|
||||
let element = tocElement.parentElement |
||||
while (element && element !== document.body) { |
||||
const style = window.getComputedStyle(element) |
||||
const overflowY = style.overflowY |
||||
|
||||
// Check if this element is scrollable
|
||||
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') { |
||||
if (element.scrollHeight > element.clientHeight) { |
||||
scrollContainer = element |
||||
break |
||||
} |
||||
} |
||||
element = element.parentElement |
||||
} |
||||
} |
||||
|
||||
// Scroll to top
|
||||
if (scrollContainer === window) { |
||||
window.scrollTo({ top: 0, behavior: 'smooth' }) |
||||
} else { |
||||
(scrollContainer as HTMLElement).scrollTo({ top: 0, behavior: 'smooth' }) |
||||
} |
||||
}, []) |
||||
|
||||
// Scroll to section
|
||||
const scrollToSection = (coordinate: string) => { |
||||
const targetId = `section-${coordinate.replace(/:/g, '-')}` |
||||
const sectionIndex = referencesWithEvents.findIndex( |
||||
(ref) => (ref.coordinate || ref.eventId || '') === coordinate |
||||
) |
||||
if (sectionIndex >= 0) { |
||||
setSectionLoadCount((prev) => Math.max(prev, sectionIndex + 1)) |
||||
const key = publicationRefKey(referencesWithEvents[sectionIndex] || {}) |
||||
if (key) requestKeys([key]) |
||||
} |
||||
|
||||
let attempts = 0 |
||||
const tryScroll = () => { |
||||
const element = document.getElementById(targetId) |
||||
if (element) { |
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' }) |
||||
return |
||||
} |
||||
if (attempts < 8) { |
||||
attempts += 1 |
||||
window.setTimeout(tryScroll, 80) |
||||
} |
||||
} |
||||
tryScroll() |
||||
} |
||||
|
||||
|
||||
useEffect(() => { |
||||
void indexedDb.putReplaceableEvent(event).catch((err) => { |
||||
logger.error('[PublicationIndex] Error caching publication event:', err) |
||||
}) |
||||
}, [event]) |
||||
|
||||
useEffect(() => { |
||||
const loaded = referencesWithEvents |
||||
.filter((r) => r.event) |
||||
.map((r) => r.event!) |
||||
if (loaded.length > 0) { |
||||
upsertRenderedPublicationEvents(event.id, loaded) |
||||
} |
||||
if (loaded.length === 0) return |
||||
const t = window.setTimeout(() => { |
||||
void indexedDb.putPublicationWithNestedEvents(event, loaded).catch((err) => { |
||||
logger.error('[PublicationIndex] Error caching publication with nested events:', err) |
||||
}) |
||||
}, 400) |
||||
return () => clearTimeout(t) |
||||
}, [referencesWithEvents, event]) |
||||
|
||||
useEffect(() => { |
||||
setSectionLoadCount(initialSectionLoadCount) |
||||
}, [event.id, initialSectionLoadCount]) |
||||
|
||||
useEffect(() => { |
||||
const keysToRequest = referencesWithEvents |
||||
.slice(0, sectionLoadCount) |
||||
.filter((ref) => ref.loadStatus === 'idle') |
||||
.map((ref) => publicationRefKey(ref)) |
||||
.filter(Boolean) |
||||
if (keysToRequest.length > 0) { |
||||
requestKeys(keysToRequest) |
||||
} |
||||
}, [referencesWithEvents, requestKeys, sectionLoadCount]) |
||||
|
||||
useEffect(() => { |
||||
const sentinel = lazyLoadSentinelRef.current |
||||
if (!sentinel) return |
||||
if (sectionLoadCount >= referencesWithEvents.length) return |
||||
const observer = new IntersectionObserver( |
||||
(entries) => { |
||||
if (!entries[0]?.isIntersecting) return |
||||
setSectionLoadCount((prev) => Math.min(prev + sectionLoadStep, referencesWithEvents.length)) |
||||
}, |
||||
{ rootMargin: '220px 0px' } |
||||
) |
||||
observer.observe(sentinel) |
||||
return () => observer.disconnect() |
||||
}, [referencesWithEvents.length, sectionLoadCount, sectionLoadStep]) |
||||
|
||||
const visibleReferences = useMemo( |
||||
() => referencesWithEvents.slice(0, sectionLoadCount), |
||||
[referencesWithEvents, sectionLoadCount] |
||||
) |
||||
|
||||
const handleManualRetry = useCallback(() => { |
||||
setIsRetrying(true) |
||||
const keys = |
||||
failedKeys.length > 0 |
||||
? failedKeys |
||||
: (referencesData.map((r) => r.coordinate || r.eventId).filter(Boolean) as string[]) |
||||
retryKeys(keys) |
||||
window.setTimeout(() => setIsRetrying(false), 600) |
||||
}, [failedKeys, referencesData, retryKeys]) |
||||
|
||||
const normalizedParentImage = (parentImageUrl || '').trim() |
||||
const normalizedOwnImage = (metadata.image || '').trim() |
||||
const normalizedParentSummary = (parentSummary || '').trim() |
||||
const normalizedOwnSummary = (metadata.summary || '').trim() |
||||
const showNestedImagePreview = |
||||
isNested && |
||||
!!normalizedOwnImage && |
||||
normalizedOwnImage !== normalizedParentImage |
||||
const showNestedSummaryPreview = |
||||
isNested && |
||||
!!normalizedOwnSummary && |
||||
normalizedOwnSummary !== normalizedParentSummary |
||||
|
||||
|
||||
return ( |
||||
<div className={cn('space-y-6', className)}> |
||||
{/* Publication Metadata - only show for top-level publications */} |
||||
{!isNested && ( |
||||
<div className="prose prose-zinc max-w-none dark:prose-invert"> |
||||
<header className="mb-8 border-b pb-6"> |
||||
<div className="mb-6 rounded-xl border border-border/50 bg-muted/20 px-5 py-6 text-center"> |
||||
<div className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground/80 mb-2"> |
||||
Publication |
||||
</div> |
||||
<h1 className="font-serif text-4xl md:text-5xl font-semibold leading-tight tracking-wide break-words"> |
||||
{metadata.title || |
||||
(isBookstrEvent |
||||
? bookMetadata.book |
||||
? bookMetadata.book |
||||
.split('-') |
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) |
||||
.join(' ') |
||||
: 'Bookstr Publication' |
||||
: 'Untitled Publication')} |
||||
</h1> |
||||
{metadata.author && ( |
||||
<div className="mt-3 text-sm text-muted-foreground"> |
||||
by <span className="font-medium text-foreground/90">{metadata.author}</span> |
||||
</div> |
||||
)} |
||||
{(metadata.type || metadata.version || metadata.publishedOn || metadata.publishedBy) && ( |
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-y-1 text-xs text-muted-foreground"> |
||||
{[ |
||||
metadata.type ? { label: 'Type', value: metadata.type } : null, |
||||
metadata.version ? { label: 'Version', value: metadata.version } : null, |
||||
metadata.publishedOn ? { label: 'Published', value: metadata.publishedOn } : null, |
||||
metadata.publishedBy ? { label: 'Publisher', value: metadata.publishedBy } : null |
||||
] |
||||
.filter((item): item is { label: string; value: string } => !!item) |
||||
.map((item, index) => ( |
||||
<div key={item.label} className="inline-flex items-center"> |
||||
{index > 0 && ( |
||||
<span aria-hidden className="mx-2 text-muted-foreground/50"> |
||||
· |
||||
</span> |
||||
)} |
||||
<span className="uppercase tracking-[0.14em] text-muted-foreground/80">{item.label}</span> |
||||
<span className="ml-1.5 text-foreground/85">{item.value}</span> |
||||
</div> |
||||
))} |
||||
</div> |
||||
)} |
||||
{metadata.tags.length > 0 && ( |
||||
<div className="mt-4 flex flex-wrap justify-center gap-2"> |
||||
{metadata.tags.map((tag) => ( |
||||
<span |
||||
key={tag} |
||||
className="rounded-full border border-border/60 px-2.5 py-0.5 text-[11px] uppercase tracking-wide text-muted-foreground" |
||||
> |
||||
{tag} |
||||
</span> |
||||
))} |
||||
</div> |
||||
)} |
||||
{metadata.source && ( |
||||
<div className="mt-4 text-xs text-muted-foreground"> |
||||
Source:{' '} |
||||
<a |
||||
href={metadata.source} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
className="text-primary hover:underline break-all" |
||||
> |
||||
{metadata.source} |
||||
</a> |
||||
</div> |
||||
)} |
||||
{/* Display image for top-level 30040 publication */} |
||||
{metadata.image && ( |
||||
<div className="mt-5 flex justify-center"> |
||||
<ImageWithLightbox |
||||
image={{ url: metadata.image, pubkey: event.pubkey }} |
||||
className="max-w-[400px] w-full h-auto rounded-lg" |
||||
classNames={{ |
||||
wrapper: 'rounded-lg' |
||||
}} |
||||
/> |
||||
</div> |
||||
)} |
||||
{metadata.summary && ( |
||||
<div className="mt-6 mx-auto max-w-2xl text-center"> |
||||
<div className="mb-2 text-[10px] uppercase tracking-[0.2em] text-muted-foreground/75"> |
||||
Summary |
||||
</div> |
||||
<p className="break-words text-sm md:text-base leading-relaxed italic text-muted-foreground"> |
||||
{metadata.summary} |
||||
</p> |
||||
</div> |
||||
)} |
||||
<div className="mt-5 mx-auto h-px w-24 bg-border/70" /> |
||||
</div> |
||||
<div className="text-sm text-muted-foreground space-y-1"> |
||||
{isBookstrEvent && ( |
||||
<> |
||||
{bookMetadata.book && ( |
||||
<div> |
||||
<span className="font-semibold">Book:</span> {bookMetadata.book |
||||
.split('-') |
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) |
||||
.join(' ')} |
||||
</div> |
||||
)} |
||||
{bookMetadata.chapter && ( |
||||
<div> |
||||
<span className="font-semibold">Chapter:</span> {bookMetadata.chapter} |
||||
</div> |
||||
)} |
||||
{bookMetadata.verse && ( |
||||
<div> |
||||
<span className="font-semibold">Verse:</span> {bookMetadata.verse} |
||||
</div> |
||||
)} |
||||
{bookMetadata.version && ( |
||||
<div> |
||||
<span className="font-semibold">Version:</span> {bookMetadata.version.toUpperCase()} |
||||
</div> |
||||
)} |
||||
</> |
||||
)} |
||||
</div> |
||||
</header> |
||||
</div> |
||||
)} |
||||
{isNested && (showNestedImagePreview || showNestedSummaryPreview) && ( |
||||
<div className="rounded-lg border border-border/50 bg-muted/15 px-4 py-4"> |
||||
{showNestedImagePreview && metadata.image && ( |
||||
<div className="mb-3 flex justify-center"> |
||||
<ImageWithLightbox |
||||
image={{ url: metadata.image, pubkey: event.pubkey }} |
||||
className="max-w-[260px] w-full h-auto rounded-lg" |
||||
classNames={{ wrapper: 'rounded-lg' }} |
||||
/> |
||||
</div> |
||||
)} |
||||
{showNestedSummaryPreview && metadata.summary && ( |
||||
<p className="mx-auto max-w-2xl text-sm italic leading-relaxed text-muted-foreground text-center break-words"> |
||||
{metadata.summary} |
||||
</p> |
||||
)} |
||||
</div> |
||||
)} |
||||
|
||||
{/* Table of Contents - only show for top-level publications */} |
||||
{!isNested && tableOfContents.length > 0 && ( |
||||
<div id="publication-toc" className="border rounded-lg p-6 bg-muted/30 scroll-mt-24"> |
||||
<h2 className="font-serif text-2xl font-semibold tracking-wide mb-4">Table of Contents</h2> |
||||
<nav> |
||||
<ul className="space-y-2"> |
||||
{tableOfContents.map((item, index) => ( |
||||
<ToCItemComponent
|
||||
key={index}
|
||||
item={item}
|
||||
onItemClick={scrollToSection} |
||||
level={0} |
||||
/> |
||||
))} |
||||
</ul> |
||||
</nav> |
||||
</div> |
||||
)} |
||||
|
||||
{/* Failed sections banner */} |
||||
{!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="flex items-center justify-between gap-4"> |
||||
<div className="text-sm text-yellow-800 dark:text-yellow-200"> |
||||
{failedKeys.length} section{failedKeys.length !== 1 ? 's' : ''} failed to load. |
||||
</div> |
||||
<Button |
||||
variant="outline" |
||||
size="sm" |
||||
onClick={handleManualRetry} |
||||
disabled={isRetrying} |
||||
> |
||||
{isRetrying ? ( |
||||
<Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-sm align-middle" aria-hidden /> |
||||
) : ( |
||||
<RefreshCw className="h-4 w-4 mr-2" /> |
||||
)} |
||||
Retry All |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
)} |
||||
|
||||
{/* Sections */} |
||||
{referencesData.length === 0 ? ( |
||||
<div className="p-6 border rounded-lg bg-muted/30 text-center text-sm text-muted-foreground"> |
||||
This publication index has no linked sections. |
||||
</div> |
||||
) : ( |
||||
<div className="space-y-8"> |
||||
{visibleReferences.map((ref, index) => { |
||||
const sectionKey = publicationRefKey(ref) |
||||
const coordinate = ref.coordinate || ref.eventId || '' |
||||
const sectionId = `section-${coordinate.replace(/:/g, '-')}` |
||||
const notesLink = publicationSectionNotesLink(ref) |
||||
|
||||
if (!ref.event) { |
||||
if (ref.loadStatus === 'error') { |
||||
return ( |
||||
<div key={sectionKey || index} 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="text-sm text-muted-foreground"> |
||||
Section {index + 1}: unable to load{' '} |
||||
{notesLink ? ( |
||||
<a |
||||
href={notesLink} |
||||
onClick={(e) => { |
||||
e.preventDefault() |
||||
e.stopPropagation() |
||||
push(notesLink) |
||||
}} |
||||
className="text-primary hover:underline cursor-pointer" |
||||
> |
||||
{coordinate || 'unknown'} |
||||
</a> |
||||
) : ( |
||||
<span>{coordinate || 'unknown'}</span> |
||||
)} |
||||
</div> |
||||
<Button |
||||
variant="outline" |
||||
size="sm" |
||||
className="shrink-0" |
||||
onClick={() => retryKeys([sectionKey])} |
||||
> |
||||
<RefreshCw className="h-4 w-4" /> |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<div |
||||
key={sectionKey || index} |
||||
id={sectionId} |
||||
className="scroll-mt-24 rounded-lg border border-dashed p-6 bg-muted/20 space-y-3" |
||||
aria-busy |
||||
> |
||||
<Skeleton className="h-5 w-2/3 max-w-md" /> |
||||
<Skeleton className="h-28 w-full" /> |
||||
<Skeleton className="h-28 w-full" /> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
const eventKind = ref.event?.kind ?? ref.kind ?? 0 |
||||
const effectiveParentImageUrl = !isNested ? metadata.image : parentImageUrl |
||||
const sectionSummaryTag = ref.event.tags.find((tag) => tag[0] === 'summary')?.[1] |
||||
const sectionImageTag = ref.event.tags.find((tag) => tag[0] === 'image')?.[1] |
||||
const normalizedParentSummaryForSection = (effectiveParentSummary || '').trim() |
||||
const normalizedSectionSummary = (sectionSummaryTag || '').trim() |
||||
const normalizedParentImageForSection = (effectiveParentImageUrl || '').trim() |
||||
const normalizedSectionImage = (sectionImageTag || '').trim() |
||||
const showSectionSummaryPreview = |
||||
!!normalizedSectionSummary && |
||||
normalizedSectionSummary !== normalizedParentSummaryForSection |
||||
const showSectionImagePreview = |
||||
!!normalizedSectionImage && |
||||
normalizedSectionImage !== normalizedParentImageForSection |
||||
|
||||
if (eventKind === ExtendedKind.PUBLICATION) { |
||||
const publicationTitleTag = ref.event.tags.find((tag) => tag[0] === 'title')?.[1] |
||||
const publicationDTag = ref.event.tags.find((tag) => tag[0] === 'd')?.[1] |
||||
const publicationTitle = publicationTitleTag |
||||
? publicationTitleTag |
||||
: publicationDTag |
||||
? formatBookstrTitle(publicationDTag, ref.event) |
||||
: 'Publication' |
||||
const publicationDepth = chapterDepth + 1 |
||||
const sectionTitleClassName = |
||||
publicationDepth <= 1 |
||||
? 'font-serif text-2xl md:text-3xl font-semibold leading-tight tracking-wide break-words' |
||||
: publicationDepth === 2 |
||||
? 'font-serif text-xl md:text-2xl font-medium leading-tight tracking-wide break-words text-muted-foreground' |
||||
: 'font-serif text-lg md:text-xl font-medium leading-tight tracking-wide break-words text-muted-foreground' |
||||
const useInlinePublicationHeader = forceFlatHierarchy |
||||
const publicationContainerClassName = isNested |
||||
? forceFlatHierarchy |
||||
? 'scroll-mt-24 pt-6 relative' |
||||
: 'border-l-4 border-primary pl-6 scroll-mt-24 pt-6 relative' |
||||
: 'scroll-mt-24 pt-6 relative' |
||||
return ( |
||||
<div |
||||
key={sectionKey || index} |
||||
id={sectionId} |
||||
className={publicationContainerClassName} |
||||
> |
||||
{useInlinePublicationHeader ? ( |
||||
<div className="mb-4 rounded-lg border border-border/50 bg-muted/20 px-4 py-3"> |
||||
<div className="flex items-start justify-end gap-2 mb-2"> |
||||
<div className="flex items-center gap-2 shrink-0"> |
||||
{!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> |
||||
</div> |
||||
<div className="text-center"> |
||||
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground/80 mb-1"> |
||||
Section |
||||
</div> |
||||
<h3 className={sectionTitleClassName}> |
||||
{publicationTitle} |
||||
</h3> |
||||
</div> |
||||
</div> |
||||
) : ( |
||||
<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> |
||||
)} |
||||
<PublicationIndex |
||||
event={ref.event} |
||||
isNested={true} |
||||
parentImageUrl={effectiveParentImageUrl} |
||||
parentSummary={effectiveParentSummary} |
||||
flattenHierarchy={forceFlatHierarchy} |
||||
chapterDepth={publicationDepth} |
||||
publicationFootnotesContainerId={resolvedPublicationFootnotesContainerId} |
||||
/> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
const renderAsAsciidoc = |
||||
eventKind === ExtendedKind.PUBLICATION_CONTENT || |
||||
eventKind === ExtendedKind.WIKI_ARTICLE |
||||
|
||||
if (renderAsAsciidoc) { |
||||
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> |
||||
{(showSectionImagePreview || showSectionSummaryPreview) && ( |
||||
<div className="mb-4 rounded-lg border border-border/50 bg-muted/15 px-4 py-4"> |
||||
{showSectionImagePreview && sectionImageTag && ( |
||||
<div className="mb-3 flex justify-center"> |
||||
<ImageWithLightbox |
||||
image={{ url: sectionImageTag, pubkey: ref.event.pubkey }} |
||||
className="max-w-[260px] w-full h-auto rounded-lg" |
||||
classNames={{ wrapper: 'rounded-lg' }} |
||||
/> |
||||
</div> |
||||
)} |
||||
{showSectionSummaryPreview && sectionSummaryTag && ( |
||||
<p className="mx-auto max-w-2xl text-sm italic leading-relaxed text-muted-foreground text-center break-words"> |
||||
{sectionSummaryTag} |
||||
</p> |
||||
)} |
||||
</div> |
||||
)} |
||||
<AsciidocArticle |
||||
event={ref.event} |
||||
hideImagesAndInfo={true} |
||||
parentImageUrl={effectiveParentImageUrl} |
||||
footnotesContainerId={resolvedPublicationFootnotesContainerId} |
||||
/> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
// 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> |
||||
) |
||||
})} |
||||
{sectionLoadCount < referencesWithEvents.length && ( |
||||
<div ref={lazyLoadSentinelRef} className="h-8" aria-hidden /> |
||||
)} |
||||
</div> |
||||
)} |
||||
{isTopLevelPublication && resolvedPublicationFootnotesContainerId && ( |
||||
<div id={resolvedPublicationFootnotesContainerId} className="mt-10 space-y-8" /> |
||||
)} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
// ToC Item Component - renders nested table of contents items
|
||||
function ToCItemComponent({ |
||||
item, |
||||
onItemClick, |
||||
level |
||||
}: { |
||||
item: ToCItem |
||||
onItemClick: (coordinate: string) => void |
||||
level: number |
||||
}) { |
||||
const indentClass = level > 0 ? `ml-${level * 4}` : '' |
||||
|
||||
return ( |
||||
<li className={cn('list-none', indentClass)}> |
||||
<button |
||||
onClick={() => onItemClick(item.coordinate)} |
||||
className="font-serif text-left text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline cursor-pointer tracking-wide" |
||||
> |
||||
{item.title} |
||||
</button> |
||||
{item.children && item.children.length > 0 && ( |
||||
<ul className="mt-2 space-y-1"> |
||||
{item.children.map((child, childIndex) => ( |
||||
<ToCItemComponent |
||||
key={childIndex} |
||||
item={child} |
||||
onItemClick={onItemClick} |
||||
level={level + 1} |
||||
/> |
||||
))} |
||||
</ul> |
||||
)} |
||||
</li> |
||||
) |
||||
} |
||||
|
||||
@ -1,399 +0,0 @@
@@ -1,399 +0,0 @@
|
||||
import logger from '@/lib/logger' |
||||
import { |
||||
batchFetchPublicationSectionEvents, |
||||
buildPublicationSectionRelayUrls, |
||||
parsePublicationATagCoordinate, |
||||
publicationRefKey, |
||||
resolvePublicationEventIdToHex, |
||||
type PublicationSectionRef |
||||
} from '@/lib/publication-section-fetch' |
||||
import { eventService, queryService } from '@/services/client.service' |
||||
import indexedDb from '@/services/indexed-db.service' |
||||
import type { Event } from 'nostr-tools' |
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
||||
|
||||
type LoadStatus = 'idle' | 'loading' | 'loaded' | 'error' |
||||
|
||||
type Row = PublicationSectionRef & { |
||||
key: string |
||||
event?: Event |
||||
status: LoadStatus |
||||
} |
||||
|
||||
type CachedState = { |
||||
loaded: Map<string, Event> |
||||
failed: Set<string> |
||||
} |
||||
|
||||
const indexCache = new Map<string, CachedState>() |
||||
const SINGLE_REF_TIMEOUT_MS = 6_000 |
||||
|
||||
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> { |
||||
return new Promise<T>((resolve, reject) => { |
||||
const timer = window.setTimeout(() => reject(new Error('timeout')), ms) |
||||
p.then( |
||||
(v) => { |
||||
clearTimeout(timer) |
||||
resolve(v) |
||||
}, |
||||
(err) => { |
||||
clearTimeout(timer) |
||||
reject(err) |
||||
} |
||||
) |
||||
}) |
||||
} |
||||
|
||||
function signatureOfRefs(refs: PublicationSectionRef[]): string { |
||||
return refs.map((r) => publicationRefKey(r)).join('|') |
||||
} |
||||
|
||||
function dedupeRelayUrls(urls: string[]): string[] { |
||||
const out: string[] = [] |
||||
const seen = new Set<string>() |
||||
for (const url of urls) { |
||||
const u = (url || '').trim() |
||||
if (!u || seen.has(u)) continue |
||||
seen.add(u) |
||||
out.push(u) |
||||
} |
||||
return out |
||||
} |
||||
|
||||
export function usePublicationSectionLoader( |
||||
indexEvent: Event, |
||||
refs: PublicationSectionRef[], |
||||
options?: { autoLoad?: boolean } |
||||
) { |
||||
const indexId = indexEvent.id |
||||
const refsSignature = useMemo(() => signatureOfRefs(refs), [refs]) |
||||
const [relayUrls, setRelayUrls] = useState<string[]>([]) |
||||
const [fallbackRelayUrls, setFallbackRelayUrls] = useState<string[]>([]) |
||||
const [rows, setRows] = useState<Row[]>([]) |
||||
const inflightKeysRef = useRef<Set<string>>(new Set()) |
||||
const autoLoadedSignatureRef = useRef<string | null>(null) |
||||
const autoLoad = options?.autoLoad ?? true |
||||
|
||||
useEffect(() => { |
||||
const cached = indexCache.get(indexId) ?? { loaded: new Map(), failed: new Set() } |
||||
const next: Row[] = [] |
||||
for (const ref of refs) { |
||||
const key = publicationRefKey(ref) |
||||
if (!key) continue |
||||
const cachedEvent = cached.loaded.get(key) |
||||
if (cachedEvent) { |
||||
next.push({ ...ref, key, event: cachedEvent, status: 'loaded' }) |
||||
continue |
||||
} |
||||
if (cached.failed.has(key)) { |
||||
next.push({ ...ref, key, status: 'error' }) |
||||
continue |
||||
} |
||||
next.push({ ...ref, key, status: 'idle' }) |
||||
} |
||||
setRows(next) |
||||
}, [indexId, refsSignature, refs]) |
||||
|
||||
useEffect(() => { |
||||
let cancelled = false |
||||
;(async () => { |
||||
const primary = await buildPublicationSectionRelayUrls(indexEvent, refs, 30, false) |
||||
if (cancelled) return |
||||
if (import.meta.env.DEV) { |
||||
logger.info('[PublicationSection] relay_urls_primary', { |
||||
indexId, |
||||
count: primary.length, |
||||
relays: primary |
||||
}) |
||||
} |
||||
setRelayUrls(primary) |
||||
|
||||
const fallback = await buildPublicationSectionRelayUrls(indexEvent, refs, 60, true) |
||||
if (cancelled) return |
||||
if (import.meta.env.DEV) { |
||||
const uniqueExtra = fallback.filter((u) => !primary.includes(u)) |
||||
logger.info('[PublicationSection] relay_urls_searchable_fallback', { |
||||
indexId, |
||||
count: fallback.length, |
||||
extraCount: uniqueExtra.length, |
||||
relays: fallback |
||||
}) |
||||
} |
||||
setFallbackRelayUrls(fallback) |
||||
})().catch((err) => { |
||||
if (import.meta.env.DEV) { |
||||
logger.warn('[PublicationSection] relay_build_failed', { |
||||
indexId, |
||||
message: err instanceof Error ? err.message : String(err) |
||||
}) |
||||
} |
||||
if (!cancelled) { |
||||
setRelayUrls([]) |
||||
setFallbackRelayUrls([]) |
||||
} |
||||
}) |
||||
return () => { |
||||
cancelled = true |
||||
} |
||||
}, [indexId, refsSignature, indexEvent, refs]) |
||||
|
||||
const applyLoadedAndFailed = useCallback( |
||||
(loaded: Map<string, Event>, failedKeys: string[]) => { |
||||
const cached = indexCache.get(indexId) ?? { loaded: new Map(), failed: new Set() } |
||||
for (const [k, ev] of loaded) { |
||||
cached.loaded.set(k, ev) |
||||
cached.failed.delete(k) |
||||
} |
||||
for (const k of failedKeys) { |
||||
if (!loaded.has(k)) cached.failed.add(k) |
||||
} |
||||
indexCache.set(indexId, cached) |
||||
|
||||
setRows((prev) => |
||||
prev.map((row) => { |
||||
const ev = loaded.get(row.key) |
||||
if (ev) return { ...row, event: ev, status: 'loaded' as const } |
||||
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] |
||||
) |
||||
|
||||
const runFetch = useCallback( |
||||
async (keys: string[]) => { |
||||
const selectedRows = rows.filter((r) => keys.includes(r.key)) |
||||
if (selectedRows.length === 0) return |
||||
if (import.meta.env.DEV) { |
||||
logger.info('[PublicationSection] run_fetch_start', { |
||||
indexId, |
||||
keyCount: selectedRows.length, |
||||
keys: selectedRows.map((r) => r.key), |
||||
relayCount: relayUrls.length |
||||
}) |
||||
} |
||||
|
||||
const byDb = new Map<string, Event>() |
||||
const stillNeed: Row[] = [] |
||||
|
||||
await Promise.all( |
||||
selectedRows.map(async (row) => { |
||||
try { |
||||
let ev: Event | undefined |
||||
if (row.type === 'e' && row.eventId) { |
||||
const hex = resolvePublicationEventIdToHex(row.eventId) |
||||
if (hex) ev = await indexedDb.getEventFromPublicationStore(hex) |
||||
} |
||||
if (!ev && row.coordinate) { |
||||
ev = await indexedDb.getPublicationEvent(row.coordinate) |
||||
} |
||||
if (ev) byDb.set(row.key, ev) |
||||
else stillNeed.push(row) |
||||
} catch { |
||||
stillNeed.push(row) |
||||
} |
||||
}) |
||||
) |
||||
|
||||
if (import.meta.env.DEV) { |
||||
logger.info('[PublicationSection] after_idb', { |
||||
fromDb: byDb.size, |
||||
stillNeed: stillNeed.map((r) => r.key) |
||||
}) |
||||
} |
||||
|
||||
let fromNet = new Map<string, Event>() |
||||
if (stillNeed.length > 0 && relayUrls.length > 0) { |
||||
fromNet = await batchFetchPublicationSectionEvents(stillNeed, relayUrls) |
||||
if (import.meta.env.DEV) { |
||||
logger.info('[PublicationSection] after_batch_fetch', { fromNet: fromNet.size }) |
||||
} |
||||
} |
||||
|
||||
const merged = new Map<string, Event>([...byDb, ...fromNet]) |
||||
let unresolved = stillNeed.filter((r) => !merged.has(r.key)) |
||||
|
||||
// Second pass: unresolved refs on broader searchable relay set.
|
||||
if (unresolved.length > 0 && fallbackRelayUrls.length > 0) { |
||||
const fallbackOnly = fallbackRelayUrls.filter((u) => !relayUrls.includes(u)) |
||||
const relaysForFallback = fallbackOnly.length > 0 ? fallbackRelayUrls : [] |
||||
if (relaysForFallback.length > 0) { |
||||
if (import.meta.env.DEV) { |
||||
logger.info('[PublicationSection] searchable_fallback_start', { |
||||
unresolved: unresolved.map((r) => r.key), |
||||
relayCount: relaysForFallback.length |
||||
}) |
||||
} |
||||
const fromSearchFallback = await batchFetchPublicationSectionEvents( |
||||
unresolved, |
||||
relaysForFallback |
||||
) |
||||
for (const [k, ev] of fromSearchFallback) merged.set(k, ev) |
||||
unresolved = unresolved.filter((r) => !merged.has(r.key)) |
||||
if (import.meta.env.DEV) { |
||||
logger.info('[PublicationSection] searchable_fallback_done', { |
||||
fromSearchFallback: fromSearchFallback.size, |
||||
stillNeed: unresolved.map((r) => r.key) |
||||
}) |
||||
} |
||||
} |
||||
} |
||||
const bySingle = new Map<string, Event>() |
||||
|
||||
await Promise.all( |
||||
unresolved.map(async (row) => { |
||||
try { |
||||
// Only `e` refs are fetched by event id; `a` refs resolve by coordinate.
|
||||
if (row.type === 'e' && row.eventId) { |
||||
const ev = await withTimeout( |
||||
eventService.fetchEvent(row.eventId), |
||||
SINGLE_REF_TIMEOUT_MS |
||||
) |
||||
if (ev) bySingle.set(row.key, ev) |
||||
return |
||||
} |
||||
if (row.coordinate) { |
||||
const parsed = parsePublicationATagCoordinate(row.coordinate) |
||||
if (parsed) { |
||||
// Relay hints in `a` tags are often stale. Keep the hint first, but also try
|
||||
// current section relay sets so one dead hinted relay cannot force a false miss.
|
||||
const relaysToTry = dedupeRelayUrls( |
||||
row.relay |
||||
? [row.relay, ...relayUrls, ...fallbackRelayUrls] |
||||
: [...relayUrls, ...fallbackRelayUrls] |
||||
) |
||||
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) |
||||
return |
||||
} |
||||
} |
||||
|
||||
// Last per-ref fallback for `a` tags: try historical snapshot id (tag[3]).
|
||||
// Some publication chains point to a specific revision that is fetchable by id
|
||||
// even when relays don't resolve the coordinate in current indexes.
|
||||
if (row.eventId) { |
||||
const byId = await withTimeout( |
||||
eventService.fetchEvent(row.eventId), |
||||
SINGLE_REF_TIMEOUT_MS |
||||
) |
||||
if (byId) bySingle.set(row.key, byId) |
||||
} |
||||
} |
||||
} catch { |
||||
// unresolved single-ref fallback
|
||||
} |
||||
}) |
||||
) |
||||
|
||||
for (const [k, ev] of bySingle) merged.set(k, ev) |
||||
|
||||
const failed = selectedRows |
||||
.map((r) => r.key) |
||||
.filter((k) => !merged.has(k)) |
||||
|
||||
if (import.meta.env.DEV) { |
||||
logger.info('[PublicationSection] run_fetch_done', { |
||||
indexId, |
||||
loadedCount: merged.size, |
||||
failedCount: failed.length, |
||||
failedKeys: failed |
||||
}) |
||||
} |
||||
|
||||
applyLoadedAndFailed(merged, failed) |
||||
}, |
||||
[applyLoadedAndFailed, fallbackRelayUrls, relayUrls, rows] |
||||
) |
||||
|
||||
const requestKeys = useCallback( |
||||
(keys: string[]) => { |
||||
const unique = [...new Set(keys.filter(Boolean))] |
||||
if (unique.length === 0) return |
||||
const eligible = rows.filter((r) => unique.includes(r.key) && r.status !== 'loaded' && r.status !== 'loading') |
||||
if (eligible.length === 0) return |
||||
|
||||
const keysToLoad = eligible.map((r) => r.key) |
||||
for (const k of keysToLoad) inflightKeysRef.current.add(k) |
||||
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( |
||||
(keys: string[]) => { |
||||
const unique = [...new Set(keys.filter(Boolean))] |
||||
if (unique.length === 0) return |
||||
const cached = indexCache.get(indexId) |
||||
if (cached) { |
||||
for (const key of unique) cached.failed.delete(key) |
||||
} |
||||
setRows((prev) => |
||||
prev.map((r) => (unique.includes(r.key) && r.status !== 'loaded' ? { ...r, status: 'idle' } : r)) |
||||
) |
||||
requestKeys(unique) |
||||
}, |
||||
[indexId, requestKeys] |
||||
) |
||||
|
||||
useEffect(() => { |
||||
if (!autoLoad) return |
||||
if (relayUrls.length === 0) return |
||||
const sig = `${indexId}:${refsSignature}` |
||||
if (autoLoadedSignatureRef.current === sig) return |
||||
const idleKeys = rows.filter((r) => r.status === 'idle').map((r) => r.key) |
||||
if (idleKeys.length === 0) return |
||||
autoLoadedSignatureRef.current = sig |
||||
if (import.meta.env.DEV) { |
||||
logger.info('[PublicationSection] flush_start', { keys: idleKeys, relayCount: relayUrls.length }) |
||||
} |
||||
requestKeys(idleKeys) |
||||
}, [autoLoad, indexId, refsSignature, relayUrls, rows, requestKeys]) |
||||
|
||||
const referencesWithEvents = useMemo( |
||||
() => |
||||
rows.map((row) => ({ |
||||
...row, |
||||
loadStatus: row.status |
||||
})), |
||||
[rows] |
||||
) |
||||
|
||||
const failedKeys = useMemo( |
||||
() => |
||||
rows |
||||
.filter((r) => r.status === 'error') |
||||
.map((r) => r.key), |
||||
[rows] |
||||
) |
||||
|
||||
return { |
||||
requestKeys, |
||||
retryKeys, |
||||
failedKeys, |
||||
referencesWithEvents |
||||
} |
||||
} |
||||
@ -1,58 +0,0 @@
@@ -1,58 +0,0 @@
|
||||
import type { Event } from 'nostr-tools' |
||||
|
||||
const renderedByPublication = new Map<string, Map<string, Event>>() |
||||
let renderedVersion = 0 |
||||
const listeners = new Set<() => void>() |
||||
|
||||
function normId(id: string): string { |
||||
return id.trim().toLowerCase() |
||||
} |
||||
|
||||
export function upsertRenderedPublicationEvents(publicationId: string, events: Event[]): void { |
||||
const pubId = normId(publicationId) |
||||
let byId = renderedByPublication.get(pubId) |
||||
if (!byId) { |
||||
byId = new Map<string, Event>() |
||||
renderedByPublication.set(pubId, byId) |
||||
} |
||||
for (const ev of events) { |
||||
if (!ev?.id) continue |
||||
byId.set(normId(ev.id), ev) |
||||
} |
||||
renderedVersion += 1 |
||||
for (const listener of listeners) listener() |
||||
} |
||||
|
||||
export function subscribeRenderedPublicationEvents(listener: () => void): () => void { |
||||
listeners.add(listener) |
||||
return () => listeners.delete(listener) |
||||
} |
||||
|
||||
export function getRenderedPublicationEventsVersion(): number { |
||||
return renderedVersion |
||||
} |
||||
|
||||
/** |
||||
* Deep collection for nested 30040 publications that were rendered in this session. |
||||
*/ |
||||
export function getRenderedPublicationEventsDeep(publicationId: string, maxDepth = 6): Event[] { |
||||
const seenPublicationIds = new Set<string>() |
||||
const outByEventId = new Map<string, Event>() |
||||
|
||||
const walk = (pubIdRaw: string, depth: number) => { |
||||
const pubId = normId(pubIdRaw) |
||||
if (depth > maxDepth || seenPublicationIds.has(pubId)) return |
||||
seenPublicationIds.add(pubId) |
||||
const direct = renderedByPublication.get(pubId) |
||||
if (!direct) return |
||||
for (const ev of direct.values()) { |
||||
outByEventId.set(normId(ev.id), ev) |
||||
if (ev.kind === 30040) { |
||||
walk(ev.id, depth + 1) |
||||
} |
||||
} |
||||
} |
||||
|
||||
walk(publicationId, 0) |
||||
return [...outByEventId.values()] |
||||
} |
||||
Loading…
Reference in new issue