You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1636 lines
62 KiB
1636 lines
62 KiB
import { ExtendedKind } from '@/constants' |
|
import { Event, nip19 } from 'nostr-tools' |
|
import { useEffect, useMemo, useState, useCallback } from 'react' |
|
import { cn } from '@/lib/utils' |
|
import { normalizeUrl } from '@/lib/url' |
|
import AsciidocArticle from '../AsciidocArticle/AsciidocArticle' |
|
import MarkdownArticle from '../MarkdownArticle/MarkdownArticle' |
|
import { generateBech32IdFromATag } from '@/lib/tag' |
|
import client from '@/services/client.service' |
|
import logger from '@/lib/logger' |
|
import { Button } from '@/components/ui/button' |
|
import { RefreshCw, ArrowUp } from 'lucide-react' |
|
import indexedDb from '@/services/indexed-db.service' |
|
import { isReplaceableEvent } from '@/lib/event' |
|
import { useSecondaryPage } from '@/PageManager' |
|
import { extractBookMetadata } from '@/lib/bookstr-parser' |
|
import { dTagToTitleCase } from '@/lib/event-metadata' |
|
import Image from '@/components/Image' |
|
import NoteOptions from '@/components/NoteOptions' |
|
|
|
interface PublicationReference { |
|
coordinate?: string |
|
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 |
|
tags: string[] |
|
} |
|
|
|
export default function PublicationIndex({ |
|
event, |
|
className, |
|
isNested = false, |
|
parentImageUrl |
|
}: { |
|
event: Event |
|
className?: string |
|
isNested?: boolean |
|
parentImageUrl?: string |
|
}) { |
|
const { push } = useSecondaryPage() |
|
// 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 === '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 [references, setReferences] = useState<PublicationReference[]>([]) |
|
const [visitedIndices, setVisitedIndices] = useState<Set<string>>(new Set()) |
|
const [isLoading, setIsLoading] = useState(true) |
|
const [retryCount, setRetryCount] = useState(0) |
|
const [isRetrying, setIsRetrying] = useState(false) |
|
const [failedReferences, setFailedReferences] = useState<PublicationReference[]>([]) |
|
const maxRetries = 5 |
|
|
|
// 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]) { |
|
// Addressable event (kind:pubkey:identifier) |
|
const [kindStr, pubkey, identifier] = tag[1].split(':') |
|
const kind = parseInt(kindStr) |
|
if (!isNaN(kind)) { |
|
refs.push({ |
|
type: 'a', |
|
coordinate: tag[1], |
|
kind, |
|
pubkey, |
|
identifier: identifier || '', |
|
relay: tag[2], |
|
eventId: tag[3] // Optional event ID for version tracking |
|
}) |
|
} |
|
} else if (tag[0] === 'e' && tag[1]) { |
|
// Event ID reference |
|
refs.push({ |
|
type: 'e', |
|
eventId: tag[1], |
|
relay: tag[2] |
|
}) |
|
} |
|
} |
|
return refs |
|
}, [event]) |
|
|
|
// 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 |
|
}, []) |
|
|
|
// Recursive helper function to build ToC item from a reference |
|
const buildToCItemFromRef = useCallback((ref: PublicationReference, allReferences: PublicationReference[], visitedCoords: Set<string> = new Set()): ToCItem | null => { |
|
if (!ref.event) { |
|
// If no event but we have a coordinate/eventId, create placeholder |
|
if (ref.coordinate || ref.eventId) { |
|
return { |
|
title: 'Loading...', |
|
coordinate: ref.coordinate || ref.eventId || '', |
|
kind: ref.kind || 0 |
|
} |
|
} |
|
return null |
|
} |
|
|
|
// Extract title from the event - prioritize 'title' tag, only use 'd' tag as fallback |
|
const titleTag = ref.event.tags.find(tag => tag[0] === 'title')?.[1] |
|
const dTag = ref.event.tags.find(tag => tag[0] === 'd')?.[1] |
|
|
|
// Use title tag if available, otherwise format d-tag for bookstr events |
|
let rawTitle: string |
|
if (titleTag) { |
|
rawTitle = titleTag |
|
} else if (dTag) { |
|
// Only use d-tag as fallback, and format it for bookstr events |
|
rawTitle = dTag |
|
} else { |
|
rawTitle = 'Untitled' |
|
} |
|
|
|
// Format title for bookstr events (only if we're using d-tag, title tag should already be formatted) |
|
const title = titleTag ? rawTitle : formatBookstrTitle(rawTitle, ref.event) |
|
|
|
const coordinate = ref.coordinate || ref.eventId || '' |
|
const coordKey = ref.coordinate || ref.eventId || '' |
|
|
|
// Prevent infinite recursion |
|
if (visitedCoords.has(coordKey)) { |
|
return null |
|
} |
|
visitedCoords.add(coordKey) |
|
|
|
const tocItem: ToCItem = { |
|
title, |
|
coordinate, |
|
event: ref.event, |
|
kind: ref.kind || ref.event?.kind || 0 |
|
} |
|
|
|
// Build children recursively - check both nestedRefs and event tags |
|
const children: ToCItem[] = [] |
|
const processedCoords = new Set<string>() |
|
|
|
// First, process discovered nestedRefs if they exist |
|
if (ref.nestedRefs && ref.nestedRefs.length > 0) { |
|
for (const nestedRef of ref.nestedRefs) { |
|
const nestedCoord = nestedRef.coordinate || nestedRef.eventId || '' |
|
if (nestedCoord && !processedCoords.has(nestedCoord)) { |
|
processedCoords.add(nestedCoord) |
|
|
|
// Look up the full reference (with fetched event) from allReferences |
|
const fullNestedRef = allReferences.find(r => |
|
(r.coordinate && nestedRef.coordinate && r.coordinate === nestedRef.coordinate) || |
|
(r.eventId && nestedRef.eventId && r.eventId === nestedRef.eventId) || |
|
(r.event && nestedRef.event && r.event.id === nestedRef.event.id) |
|
) || nestedRef |
|
|
|
const nestedToCItem = buildToCItemFromRef(fullNestedRef, allReferences, new Set(visitedCoords)) |
|
if (nestedToCItem) { |
|
children.push(nestedToCItem) |
|
} |
|
} |
|
} |
|
} |
|
|
|
// Also process tags from publication events (for publications that reference other publications) |
|
if ((ref.kind === ExtendedKind.PUBLICATION || ref.event?.kind === ExtendedKind.PUBLICATION) && ref.event) { |
|
for (const tag of ref.event.tags) { |
|
if (tag[0] === 'a' && tag[1]) { |
|
const [kindStr, , identifier] = tag[1].split(':') |
|
const kind = parseInt(kindStr) |
|
|
|
if (!isNaN(kind) && (kind === ExtendedKind.PUBLICATION_CONTENT || |
|
kind === ExtendedKind.WIKI_ARTICLE || |
|
kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN || |
|
kind === ExtendedKind.PUBLICATION)) { |
|
const tagCoord = tag[1] |
|
if (!processedCoords.has(tagCoord)) { |
|
processedCoords.add(tagCoord) |
|
|
|
// Look up the fetched event from allReferences |
|
const fetchedNestedEvent = allReferences.find(r => |
|
r.coordinate === tagCoord || |
|
(r.type === 'a' && r.coordinate === tagCoord) || |
|
(r.event && r.event.kind === kind && r.event.pubkey && tagCoord.includes(r.event.pubkey)) |
|
) |
|
|
|
if (fetchedNestedEvent) { |
|
const nestedToCItem = buildToCItemFromRef(fetchedNestedEvent, allReferences, new Set(visitedCoords)) |
|
if (nestedToCItem) { |
|
children.push(nestedToCItem) |
|
} |
|
} else { |
|
// Event not fetched yet, create placeholder |
|
// Format identifier for bookstr events (if it looks like a bookstr identifier) |
|
const formattedIdentifier = identifier |
|
? identifier |
|
.split('-') |
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) |
|
.join(' ') |
|
: 'Untitled' |
|
children.push({ |
|
title: formattedIdentifier, |
|
coordinate: tagCoord, |
|
kind |
|
}) |
|
} |
|
} |
|
} |
|
} else if (tag[0] === 'e' && tag[1]) { |
|
const eventId = tag[1] |
|
if (!processedCoords.has(eventId)) { |
|
processedCoords.add(eventId) |
|
|
|
// Look up the fetched event from allReferences |
|
const fetchedNestedEvent = allReferences.find(r => |
|
(r.type === 'e' && r.eventId === eventId) || |
|
(r.event && r.event.id === eventId) || |
|
(r.coordinate === eventId) || |
|
(r.eventId === eventId) |
|
) |
|
|
|
if (fetchedNestedEvent) { |
|
const nestedToCItem = buildToCItemFromRef(fetchedNestedEvent, allReferences, new Set(visitedCoords)) |
|
if (nestedToCItem) { |
|
children.push(nestedToCItem) |
|
} |
|
} else { |
|
// Event not fetched yet, create placeholder |
|
children.push({ |
|
title: 'Loading...', |
|
coordinate: eventId, |
|
kind: 0 |
|
}) |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
if (children.length > 0) { |
|
tocItem.children = children |
|
} |
|
|
|
return tocItem |
|
}, [formatBookstrTitle]) |
|
|
|
// Build table of contents from references |
|
const tableOfContents = useMemo<ToCItem[]>(() => { |
|
const toc: ToCItem[] = [] |
|
|
|
for (const ref of references) { |
|
if (!ref.event) continue |
|
|
|
// Extract title from the event - prioritize 'title' tag, only use 'd' tag as fallback |
|
const titleTag = ref.event.tags.find(tag => tag[0] === 'title')?.[1] |
|
const dTag = ref.event.tags.find(tag => tag[0] === 'd')?.[1] |
|
|
|
// Use title tag if available, otherwise format d-tag for bookstr events |
|
let rawTitle: string |
|
if (titleTag) { |
|
rawTitle = titleTag |
|
} else if (dTag) { |
|
// Only use d-tag as fallback, and format it for bookstr events |
|
rawTitle = dTag |
|
} else { |
|
rawTitle = 'Untitled' |
|
} |
|
|
|
// Format title for bookstr events (only if we're using d-tag, title tag should already be formatted) |
|
const title = titleTag ? rawTitle : formatBookstrTitle(rawTitle, ref.event) |
|
|
|
const tocItem: ToCItem = { |
|
title, |
|
coordinate: ref.coordinate || ref.eventId || '', |
|
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 [kindStr, , identifier] = tag[1].split(':') |
|
const kind = parseInt(kindStr) |
|
|
|
if (!isNaN(kind) && (kind === ExtendedKind.PUBLICATION_CONTENT || |
|
kind === ExtendedKind.WIKI_ARTICLE || |
|
kind === ExtendedKind.PUBLICATION)) { |
|
// For this simplified version, we'll just extract the title from the coordinate |
|
const rawNestedTitle = identifier || 'Untitled' |
|
// Format for bookstr events (check if kind is bookstr-related) |
|
const nestedTitle = (kind === ExtendedKind.PUBLICATION || kind === ExtendedKind.PUBLICATION_CONTENT) |
|
? rawNestedTitle |
|
.split('-') |
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) |
|
.join(' ') |
|
: rawNestedTitle |
|
|
|
nestedRefs.push({ |
|
title: nestedTitle, |
|
coordinate: tag[1], |
|
kind |
|
}) |
|
} |
|
} |
|
} |
|
|
|
if (nestedRefs.length > 0) { |
|
tocItem.children = nestedRefs |
|
} |
|
} |
|
|
|
toc.push(tocItem) |
|
} |
|
|
|
return toc |
|
}, [references, formatBookstrTitle]) |
|
|
|
// Scroll to ToC |
|
const scrollToToc = useCallback(() => { |
|
const tocElement = document.getElementById('publication-toc') |
|
if (tocElement) { |
|
const rect = tocElement.getBoundingClientRect() |
|
const elementTop = rect.top + window.scrollY |
|
const offset = 96 |
|
const scrollPosition = Math.max(0, elementTop - offset) |
|
window.scrollTo({ top: scrollPosition, behavior: 'smooth' }) |
|
} |
|
}, []) |
|
|
|
// Scroll to section |
|
const scrollToSection = (coordinate: string) => { |
|
const element = document.getElementById(`section-${coordinate.replace(/:/g, '-')}`) |
|
if (element) { |
|
element.scrollIntoView({ behavior: 'smooth', block: 'start' }) |
|
} |
|
} |
|
|
|
|
|
// Add current event to visited set |
|
const currentCoordinate = useMemo(() => { |
|
const dTag = event.tags.find(tag => tag[0] === 'd')?.[1] || '' |
|
return `${event.kind}:${event.pubkey}:${dTag}` |
|
}, [event]) |
|
|
|
useEffect(() => { |
|
setVisitedIndices(prev => new Set([...prev, currentCoordinate])) |
|
|
|
// Cache the current publication index event as replaceable event |
|
indexedDb.putReplaceableEvent(event).catch(err => { |
|
logger.error('[PublicationIndex] Error caching publication event:', err) |
|
}) |
|
}, [currentCoordinate, event]) |
|
|
|
// Helper function to build comprehensive relay list |
|
const buildComprehensiveRelayList = useCallback(async ( |
|
additionalRelays: string[] = [] |
|
): Promise<string[]> => { |
|
const { FAST_READ_RELAY_URLS, BIG_RELAY_URLS, SEARCHABLE_RELAY_URLS } = await import('@/constants') |
|
const relayUrls = new Set<string>() |
|
|
|
// Add FAST_READ_RELAY_URLS |
|
FAST_READ_RELAY_URLS.forEach(url => { |
|
const normalized = normalizeUrl(url) |
|
if (normalized) relayUrls.add(normalized) |
|
}) |
|
|
|
// Add additional relays (from tag relay hints) |
|
additionalRelays.forEach(url => { |
|
const normalized = normalizeUrl(url) |
|
if (normalized) relayUrls.add(normalized) |
|
}) |
|
|
|
// Add user's favorite relays (kind 10012) and relay list (kind 10002) if logged in |
|
try { |
|
const userPubkey = (client as any).pubkey |
|
if (userPubkey) { |
|
// Fetch user's relay list (includes cache relays) |
|
const userRelayList = await client.fetchRelayList(userPubkey) |
|
if (userRelayList?.read) { |
|
userRelayList.read.forEach((url: string) => { |
|
const normalized = normalizeUrl(url) |
|
if (normalized) relayUrls.add(normalized) |
|
}) |
|
} |
|
|
|
// Fetch user's favorite relays (kind 10012) |
|
try { |
|
const { ExtendedKind } = await import('@/constants') |
|
const favoriteRelaysEvent = await (client as any).fetchReplaceableEvent?.(userPubkey, ExtendedKind.FAVORITE_RELAYS) |
|
if (favoriteRelaysEvent) { |
|
favoriteRelaysEvent.tags.forEach(([tagName, tagValue]: [string, string]) => { |
|
if (tagName === 'relay' && tagValue) { |
|
const normalized = normalizeUrl(tagValue) |
|
if (normalized) relayUrls.add(normalized) |
|
} |
|
}) |
|
} |
|
} catch (error) { |
|
// Ignore if favorite relays can't be fetched |
|
} |
|
} |
|
} catch (error) { |
|
// Ignore if user relay list can't be fetched |
|
} |
|
|
|
// Add BIG_RELAY_URLS as fallback |
|
BIG_RELAY_URLS.forEach(url => { |
|
const normalized = normalizeUrl(url) |
|
if (normalized) relayUrls.add(normalized) |
|
}) |
|
|
|
// Add SEARCHABLE_RELAY_URLS |
|
SEARCHABLE_RELAY_URLS.forEach(url => { |
|
const normalized = normalizeUrl(url) |
|
if (normalized) relayUrls.add(normalized) |
|
}) |
|
|
|
return Array.from(relayUrls) |
|
}, []) |
|
|
|
// Helper function to fetch event using subscription-style query with comprehensive relay list |
|
const fetchEventWithSubscription = useCallback(async ( |
|
filter: any, |
|
relayUrls: string[], |
|
logPrefix: string |
|
): Promise<Event | undefined> => { |
|
try { |
|
let foundEvent: Event | undefined = undefined |
|
let hasEosed = false |
|
let subscriptionClosed = false |
|
|
|
const { closer } = await client.subscribeTimeline( |
|
[{ urls: relayUrls, filter }], |
|
{ |
|
onEvents: (events, eosed) => { |
|
if (events.length > 0 && !foundEvent) { |
|
foundEvent = events[0] |
|
logger.debug(`[PublicationIndex] Found event via ${logPrefix} subscription`) |
|
} |
|
if (eosed) { |
|
hasEosed = true |
|
} |
|
// Close subscription once we have an event and eosed |
|
if ((foundEvent || hasEosed) && !subscriptionClosed) { |
|
subscriptionClosed = true |
|
closer() |
|
} |
|
}, |
|
onNew: () => {} // Not needed for one-time fetch |
|
}, |
|
{ needSort: false } |
|
) |
|
|
|
// Wait for up to 10 seconds for events to arrive or eosed |
|
const startTime = Date.now() |
|
while (!foundEvent && !hasEosed && Date.now() - startTime < 10000) { |
|
await new Promise(resolve => setTimeout(resolve, 100)) |
|
} |
|
|
|
// Close subscription if still open |
|
if (!subscriptionClosed) { |
|
closer() |
|
} |
|
|
|
if (foundEvent) { |
|
return foundEvent |
|
} |
|
} catch (subError) { |
|
logger.warn(`[PublicationIndex] Subscription error for ${logPrefix}, falling back to fetchEvents:`, subError) |
|
// Fallback to regular fetchEvents if subscription fails |
|
const events = await client.fetchEvents(relayUrls, [filter]) |
|
if (events.length > 0) { |
|
logger.debug(`[PublicationIndex] Found event via ${logPrefix} fetchEvents fallback`) |
|
return events[0] |
|
} |
|
} |
|
|
|
return undefined |
|
}, []) |
|
|
|
// Unified method to fetch event for both a and e tags |
|
const fetchEventFromRelay = useCallback(async ( |
|
filter: any, |
|
additionalRelays: string[], |
|
logPrefix: string |
|
): Promise<Event | undefined> => { |
|
// Build comprehensive relay list |
|
const finalRelayUrls = await buildComprehensiveRelayList(additionalRelays) |
|
logger.debug(`[PublicationIndex] Using ${finalRelayUrls.length} relays for ${logPrefix} query`) |
|
|
|
// Fetch using subscription-style query with comprehensive relay list |
|
return await fetchEventWithSubscription(filter, finalRelayUrls, logPrefix) |
|
}, [buildComprehensiveRelayList, fetchEventWithSubscription]) |
|
|
|
// Fetch a single reference with retry logic |
|
const fetchSingleReference = useCallback(async ( |
|
ref: PublicationReference, |
|
currentVisited: Set<string>, |
|
isRetry = false |
|
): Promise<PublicationReference | null> => { |
|
// Skip if this is a 30040 event we've already visited (prevent circular references) |
|
if (ref.type === 'a' && ref.kind === ExtendedKind.PUBLICATION && ref.coordinate) { |
|
if (currentVisited.has(ref.coordinate)) { |
|
logger.debug('[PublicationIndex] Skipping visited 30040 index:', ref.coordinate) |
|
return { ...ref, event: undefined } |
|
} |
|
} |
|
|
|
try { |
|
let fetchedEvent: Event | undefined = undefined |
|
|
|
if (ref.type === 'a' && ref.coordinate) { |
|
// Handle addressable event (a tag) |
|
const aTag = ['a', ref.coordinate, ref.relay || '', ref.eventId || ''] |
|
const bech32Id = generateBech32IdFromATag(aTag) |
|
|
|
if (bech32Id) { |
|
// Try to get by coordinate (replaceable event) |
|
fetchedEvent = await indexedDb.getPublicationEvent(ref.coordinate) |
|
|
|
// If not found, try to fetch from relay |
|
if (!fetchedEvent) { |
|
// For naddr, always use subscription-style query with comprehensive relay list (more reliable) |
|
if (bech32Id.startsWith('naddr1')) { |
|
try { |
|
const { nip19 } = await import('nostr-tools') |
|
const decoded = nip19.decode(bech32Id) |
|
if (decoded.type === 'naddr') { |
|
const filter: any = { |
|
authors: [decoded.data.pubkey], |
|
kinds: [decoded.data.kind], |
|
limit: 1 |
|
} |
|
if (decoded.data.identifier) { |
|
filter['#d'] = [decoded.data.identifier] |
|
} |
|
|
|
// Build comprehensive relay list and fetch using unified method |
|
const additionalRelays = decoded.data.relays || [] |
|
fetchedEvent = await fetchEventFromRelay(filter, additionalRelays, 'naddr') |
|
} |
|
} catch (error) { |
|
logger.warn('[PublicationIndex] Error trying naddr filter query:', error) |
|
} |
|
} else { |
|
// For non-naddr (nevent/note), try fetchEvent first, then force retry |
|
if (isRetry) { |
|
fetchedEvent = await client.fetchEventForceRetry(bech32Id) |
|
} else { |
|
fetchedEvent = await client.fetchEvent(bech32Id) |
|
} |
|
} |
|
|
|
// Save to cache as replaceable event if we fetched it |
|
if (fetchedEvent) { |
|
await indexedDb.putReplaceableEvent(fetchedEvent) |
|
logger.debug('[PublicationIndex] Cached event with coordinate:', ref.coordinate) |
|
} |
|
} else { |
|
logger.debug('[PublicationIndex] Loaded from cache by coordinate:', ref.coordinate) |
|
} |
|
} else { |
|
logger.warn('[PublicationIndex] Could not generate bech32 ID for:', ref.coordinate) |
|
} |
|
} else if (ref.type === 'e' && ref.eventId) { |
|
// Handle event ID reference (e tag) - same as a tags |
|
// First check indexedDb PUBLICATION_EVENTS store (events cached as part of publications) |
|
const hexId = ref.eventId.length === 64 ? ref.eventId : undefined |
|
if (hexId) { |
|
try { |
|
// Check PUBLICATION_EVENTS store first (for non-replaceable events stored with master) |
|
fetchedEvent = await indexedDb.getEventFromPublicationStore(hexId) |
|
if (fetchedEvent) { |
|
logger.debug('[PublicationIndex] Loaded from indexedDb PUBLICATION_EVENTS store by event ID:', ref.eventId) |
|
} |
|
} catch (error) { |
|
logger.debug('[PublicationIndex] PUBLICATION_EVENTS store lookup failed:', error) |
|
} |
|
|
|
// Also check if it's a replaceable event (check by pubkey and kind if we have them) |
|
if (!fetchedEvent && ref.kind && ref.pubkey && isReplaceableEvent(ref.kind)) { |
|
try { |
|
const replaceableEvent = await indexedDb.getReplaceableEvent(ref.pubkey, ref.kind) |
|
if (replaceableEvent && replaceableEvent.id === hexId) { |
|
fetchedEvent = replaceableEvent |
|
logger.debug('[PublicationIndex] Loaded from indexedDb replaceable cache by event ID:', ref.eventId) |
|
} |
|
} catch (error) { |
|
logger.debug('[PublicationIndex] Replaceable cache lookup failed:', error) |
|
} |
|
} |
|
} |
|
|
|
// If not found in indexedDb cache, try to fetch from relay using unified method |
|
if (!fetchedEvent) { |
|
// Build comprehensive relay list and fetch using unified method |
|
const additionalRelays = ref.relay ? [ref.relay] : [] |
|
const filter = { ids: [hexId || ref.eventId], limit: 1 } |
|
fetchedEvent = await fetchEventFromRelay(filter, additionalRelays, 'e tag') |
|
|
|
// Cache the fetched event if found |
|
if (fetchedEvent) { |
|
// Check if this is a replaceable event kind |
|
if (isReplaceableEvent(fetchedEvent.kind)) { |
|
// Save to cache as replaceable event (will be linked to master via putPublicationWithNestedEvents) |
|
await indexedDb.putReplaceableEvent(fetchedEvent) |
|
logger.debug('[PublicationIndex] Cached replaceable event with ID:', ref.eventId) |
|
} else { |
|
// For non-replaceable events, we'll link them to master later via putPublicationWithNestedEvents |
|
logger.debug('[PublicationIndex] Fetched non-replaceable event with ID (will link to master):', ref.eventId) |
|
} |
|
} |
|
} |
|
|
|
if (!fetchedEvent) { |
|
logger.warn('[PublicationIndex] Could not fetch event for ID:', ref.eventId) |
|
} |
|
} |
|
|
|
if (fetchedEvent) { |
|
// Check if this event has nested references we haven't seen yet |
|
const nestedRefs: PublicationReference[] = [] |
|
for (const tag of fetchedEvent.tags) { |
|
if (tag[0] === 'a' && tag[1]) { |
|
const [kindStr, pubkey, identifier] = tag[1].split(':') |
|
const kind = parseInt(kindStr) |
|
if (!isNaN(kind)) { |
|
const coordinate = tag[1] |
|
const nestedRef: PublicationReference = { |
|
type: 'a', |
|
coordinate, |
|
kind, |
|
pubkey, |
|
identifier: identifier || '', |
|
relay: tag[2], |
|
eventId: tag[3] |
|
} |
|
|
|
// Check if we already have this reference |
|
const existingRef = referencesData.find(r => |
|
r.coordinate === coordinate || |
|
(r.type === 'a' && r.coordinate === coordinate) |
|
) |
|
|
|
if (!existingRef && !currentVisited.has(coordinate)) { |
|
nestedRefs.push(nestedRef) |
|
} |
|
} |
|
} else if (tag[0] === 'e' && tag[1]) { |
|
const eventId = tag[1] |
|
const nestedRef: PublicationReference = { |
|
type: 'e', |
|
eventId, |
|
relay: tag[2] |
|
} |
|
|
|
// Check if we already have this reference |
|
const existingRef = referencesData.find(r => |
|
r.eventId === eventId || |
|
(r.type === 'e' && r.eventId === eventId) |
|
) |
|
|
|
if (!existingRef) { |
|
nestedRefs.push(nestedRef) |
|
} |
|
} |
|
} |
|
|
|
// For e-tags, ensure coordinate is set to eventId if not already set |
|
const updatedRef = { ...ref, event: fetchedEvent, nestedRefs } |
|
if (ref.type === 'e' && ref.eventId && !updatedRef.coordinate) { |
|
updatedRef.coordinate = ref.eventId |
|
} |
|
return updatedRef |
|
} else { |
|
// For e-tags, ensure coordinate is set to eventId even if fetch failed |
|
const updatedRef = { ...ref, event: undefined } |
|
if (ref.type === 'e' && ref.eventId && !updatedRef.coordinate) { |
|
updatedRef.coordinate = ref.eventId |
|
} |
|
return updatedRef |
|
} |
|
} catch (error) { |
|
logger.error('[PublicationIndex] Error fetching reference:', error) |
|
// For e-tags, ensure coordinate is set to eventId even on error |
|
const updatedRef = { ...ref, event: undefined } |
|
if (ref.type === 'e' && ref.eventId && !updatedRef.coordinate) { |
|
updatedRef.coordinate = ref.eventId |
|
} |
|
return updatedRef |
|
} |
|
}, [referencesData]) |
|
|
|
// Helper function to extract nested references from an event |
|
const extractNestedReferences = useCallback(( |
|
event: Event, |
|
existingRefs: Map<string, PublicationReference>, |
|
visited: Set<string> |
|
): PublicationReference[] => { |
|
const nestedRefs: PublicationReference[] = [] |
|
|
|
for (const tag of event.tags) { |
|
if (tag[0] === 'a' && tag[1]) { |
|
const [kindStr, pubkey, identifier] = tag[1].split(':') |
|
const kind = parseInt(kindStr) |
|
if (!isNaN(kind)) { |
|
const coordinate = tag[1] |
|
// Skip if already visited (prevent circular references) |
|
if (kind === ExtendedKind.PUBLICATION && visited.has(coordinate)) { |
|
continue |
|
} |
|
|
|
const key = coordinate |
|
if (!existingRefs.has(key)) { |
|
nestedRefs.push({ |
|
type: 'a', |
|
coordinate, |
|
kind, |
|
pubkey, |
|
identifier: identifier || '', |
|
relay: tag[2], |
|
eventId: tag[3] |
|
}) |
|
} |
|
} |
|
} else if (tag[0] === 'e' && tag[1]) { |
|
const eventId = tag[1] |
|
if (!existingRefs.has(eventId)) { |
|
nestedRefs.push({ |
|
type: 'e', |
|
eventId, |
|
relay: tag[2] |
|
}) |
|
} |
|
} |
|
} |
|
|
|
return nestedRefs |
|
}, []) |
|
|
|
// Batch fetch all references efficiently |
|
const batchFetchReferences = useCallback(async ( |
|
initialRefs: PublicationReference[], |
|
currentVisited: Set<string>, |
|
isRetry: boolean, |
|
onProgress?: (fetchedRefs: PublicationReference[]) => void |
|
): Promise<{ fetched: PublicationReference[]; failed: PublicationReference[] }> => { |
|
const CONCURRENCY_LIMIT = 10 // Limit concurrent fetches |
|
const BATCH_SIZE = 50 // Process in batches |
|
|
|
// Step 1: Collect ALL references (including nested ones) by traversing the tree |
|
const allRefs = new Map<string, PublicationReference>() |
|
const refsToProcess = [...initialRefs] |
|
|
|
logger.info('[PublicationIndex] Starting batch fetch, collecting all references...') |
|
|
|
// First pass: collect all top-level references |
|
for (const ref of initialRefs) { |
|
const key = ref.coordinate || ref.eventId || '' |
|
if (key && !allRefs.has(key)) { |
|
allRefs.set(key, ref) |
|
} |
|
} |
|
|
|
// Step 2: Check cache in bulk for all collected references |
|
logger.info('[PublicationIndex] Checking cache for', allRefs.size, 'references...') |
|
const cachedEvents = new Map<string, Event>() |
|
const refsToFetch: PublicationReference[] = [] |
|
|
|
for (const [key, ref] of allRefs) { |
|
let cached: Event | undefined = undefined |
|
|
|
// Check cache based on reference type |
|
if (ref.type === 'a' && ref.coordinate) { |
|
cached = await indexedDb.getPublicationEvent(ref.coordinate) |
|
} else if (ref.type === 'e' && ref.eventId) { |
|
const hexId = ref.eventId.length === 64 ? ref.eventId : undefined |
|
if (hexId) { |
|
cached = await indexedDb.getEventFromPublicationStore(hexId) |
|
if (!cached && ref.kind && ref.pubkey && isReplaceableEvent(ref.kind)) { |
|
const replaceable = await indexedDb.getReplaceableEvent(ref.pubkey, ref.kind) |
|
if (replaceable && replaceable.id === hexId) { |
|
cached = replaceable |
|
} |
|
} |
|
} |
|
} |
|
|
|
if (cached) { |
|
cachedEvents.set(key, cached) |
|
// Extract nested references from cached event |
|
const nestedRefs = extractNestedReferences(cached, allRefs, currentVisited) |
|
for (const nestedRef of nestedRefs) { |
|
const nestedKey = nestedRef.coordinate || nestedRef.eventId || '' |
|
if (nestedKey && !allRefs.has(nestedKey)) { |
|
allRefs.set(nestedKey, nestedRef) |
|
refsToProcess.push(nestedRef) |
|
// Check if nested ref is cached, if not add to fetch queue |
|
// (We'll check cache for it in the next iteration) |
|
} |
|
} |
|
} else { |
|
refsToFetch.push(ref) |
|
} |
|
} |
|
|
|
// Continue processing nested references discovered from cached events |
|
while (refsToProcess.length > 0) { |
|
const ref = refsToProcess.shift()! |
|
const key = ref.coordinate || ref.eventId || '' |
|
if (!key || allRefs.has(key)) continue |
|
|
|
allRefs.set(key, ref) |
|
|
|
// Check cache for this nested reference |
|
let cached: Event | undefined = undefined |
|
if (ref.type === 'a' && ref.coordinate) { |
|
cached = await indexedDb.getPublicationEvent(ref.coordinate) |
|
} else if (ref.type === 'e' && ref.eventId) { |
|
const hexId = ref.eventId.length === 64 ? ref.eventId : undefined |
|
if (hexId) { |
|
cached = await indexedDb.getEventFromPublicationStore(hexId) |
|
if (!cached && ref.kind && ref.pubkey && isReplaceableEvent(ref.kind)) { |
|
const replaceable = await indexedDb.getReplaceableEvent(ref.pubkey, ref.kind) |
|
if (replaceable && replaceable.id === hexId) { |
|
cached = replaceable |
|
} |
|
} |
|
} |
|
} |
|
|
|
if (cached) { |
|
cachedEvents.set(key, cached) |
|
// Extract nested references from this cached event |
|
const nestedRefs = extractNestedReferences(cached, allRefs, currentVisited) |
|
for (const nestedRef of nestedRefs) { |
|
const nestedKey = nestedRef.coordinate || nestedRef.eventId || '' |
|
if (nestedKey && !allRefs.has(nestedKey)) { |
|
allRefs.set(nestedKey, nestedRef) |
|
refsToProcess.push(nestedRef) |
|
} |
|
} |
|
} else { |
|
refsToFetch.push(ref) |
|
} |
|
} |
|
|
|
logger.info('[PublicationIndex] Cache check complete:', { |
|
cached: cachedEvents.size, |
|
toFetch: refsToFetch.length, |
|
total: allRefs.size |
|
}) |
|
|
|
// Step 3: Fetch missing events in parallel batches with concurrency control |
|
const fetchedRefs: PublicationReference[] = [] |
|
const failedRefs: PublicationReference[] = [] |
|
const pendingRefs = [...refsToFetch] // Queue of references to fetch |
|
|
|
// Process in batches to avoid overwhelming relays |
|
while (pendingRefs.length > 0) { |
|
const batch = pendingRefs.splice(0, BATCH_SIZE) |
|
logger.info('[PublicationIndex] Processing batch', '(', batch.length, 'references,', pendingRefs.length, 'remaining)') |
|
|
|
// Process batch with concurrency limit |
|
const batchPromises: Promise<void>[] = [] |
|
let activeCount = 0 |
|
|
|
for (const ref of batch) { |
|
// Wait if we've hit concurrency limit |
|
while (activeCount >= CONCURRENCY_LIMIT) { |
|
await new Promise(resolve => setTimeout(resolve, 10)) |
|
} |
|
|
|
activeCount++ |
|
const promise = (async () => { |
|
try { |
|
const result = await fetchSingleReference(ref, currentVisited, isRetry) |
|
if (result) { |
|
if (result.event) { |
|
fetchedRefs.push(result) |
|
// Extract and add nested references |
|
const nestedRefs = extractNestedReferences(result.event, allRefs, currentVisited) |
|
for (const nestedRef of nestedRefs) { |
|
const nestedKey = nestedRef.coordinate || nestedRef.eventId || '' |
|
if (nestedKey && !allRefs.has(nestedKey)) { |
|
allRefs.set(nestedKey, nestedRef) |
|
// Check if nested ref is cached |
|
let nestedCached: Event | undefined = undefined |
|
if (nestedRef.type === 'a' && nestedRef.coordinate) { |
|
nestedCached = await indexedDb.getPublicationEvent(nestedRef.coordinate) |
|
} else if (nestedRef.type === 'e' && nestedRef.eventId) { |
|
const hexId = nestedRef.eventId.length === 64 ? nestedRef.eventId : undefined |
|
if (hexId) { |
|
nestedCached = await indexedDb.getEventFromPublicationStore(hexId) |
|
if (!nestedCached && nestedRef.kind && nestedRef.pubkey && isReplaceableEvent(nestedRef.kind)) { |
|
const replaceable = await indexedDb.getReplaceableEvent(nestedRef.pubkey, nestedRef.kind) |
|
if (replaceable && replaceable.id === hexId) { |
|
nestedCached = replaceable |
|
} |
|
} |
|
} |
|
} |
|
|
|
if (nestedCached) { |
|
cachedEvents.set(nestedKey, nestedCached) |
|
// Extract nested references from this cached event |
|
const deeperNestedRefs = extractNestedReferences(nestedCached, allRefs, currentVisited) |
|
for (const deeperRef of deeperNestedRefs) { |
|
const deeperKey = deeperRef.coordinate || deeperRef.eventId || '' |
|
if (deeperKey && !allRefs.has(deeperKey)) { |
|
allRefs.set(deeperKey, deeperRef) |
|
// Will be checked in next iteration |
|
} |
|
} |
|
} else { |
|
// Add to queue for fetching |
|
pendingRefs.push(nestedRef) |
|
} |
|
} |
|
} |
|
} else { |
|
failedRefs.push(result) |
|
} |
|
} |
|
} catch (error) { |
|
logger.error('[PublicationIndex] Error fetching reference in batch:', error) |
|
failedRefs.push({ ...ref, event: undefined }) |
|
} finally { |
|
activeCount-- |
|
} |
|
})() |
|
|
|
batchPromises.push(promise) |
|
} |
|
|
|
await Promise.all(batchPromises) |
|
|
|
// Update progress after each batch |
|
if (onProgress) { |
|
const currentFetched: PublicationReference[] = [] |
|
for (const [key, ref] of allRefs) { |
|
const cached = cachedEvents.get(key) |
|
if (cached) { |
|
currentFetched.push({ ...ref, event: cached }) |
|
} else { |
|
const fetched = fetchedRefs.find(r => (r.coordinate || r.eventId) === key) |
|
if (fetched) { |
|
currentFetched.push(fetched) |
|
} |
|
} |
|
} |
|
onProgress(currentFetched) |
|
} |
|
} |
|
|
|
// Combine cached and fetched references |
|
const allFetchedRefs: PublicationReference[] = [] |
|
for (const [key, ref] of allRefs) { |
|
const cached = cachedEvents.get(key) |
|
if (cached) { |
|
allFetchedRefs.push({ ...ref, event: cached }) |
|
} else { |
|
const fetched = fetchedRefs.find(r => (r.coordinate || r.eventId) === key) |
|
if (fetched) { |
|
allFetchedRefs.push(fetched) |
|
} else { |
|
const failed = failedRefs.find(r => (r.coordinate || r.eventId) === key) |
|
if (failed) { |
|
allFetchedRefs.push(failed) |
|
} else { |
|
allFetchedRefs.push({ ...ref, event: undefined }) |
|
} |
|
} |
|
} |
|
} |
|
|
|
logger.info('[PublicationIndex] Batch fetch complete:', { |
|
total: allFetchedRefs.length, |
|
fetched: fetchedRefs.length, |
|
cached: cachedEvents.size, |
|
failed: failedRefs.length |
|
}) |
|
|
|
return { |
|
fetched: allFetchedRefs, |
|
failed: allFetchedRefs.filter(ref => !ref.event) |
|
} |
|
}, [fetchSingleReference, extractNestedReferences]) |
|
|
|
// Fetch referenced events |
|
useEffect(() => { |
|
let isMounted = true |
|
|
|
const fetchReferences = async (isManualRetry = false) => { |
|
if (isManualRetry) { |
|
setIsRetrying(true) |
|
} else { |
|
setIsLoading(true) |
|
} |
|
|
|
// Capture current visitedIndices at the start of the fetch |
|
const currentVisited = visitedIndices |
|
|
|
// Add a timeout to prevent infinite loading on mobile |
|
const timeout = setTimeout(() => { |
|
if (isMounted) { |
|
logger.warn('[PublicationIndex] Fetch timeout reached, setting loaded state') |
|
setIsLoading(false) |
|
setIsRetrying(false) |
|
} |
|
}, 60000) // 60 second timeout for large publications |
|
|
|
try { |
|
// Combine original references with failed references if this is a retry |
|
const refsToFetch = isManualRetry && failedReferences.length > 0 |
|
? [...referencesData, ...failedReferences] |
|
: referencesData |
|
|
|
if (refsToFetch.length === 0) { |
|
setIsLoading(false) |
|
setIsRetrying(false) |
|
return |
|
} |
|
|
|
// Use batch fetching |
|
const { fetched, failed } = await batchFetchReferences( |
|
refsToFetch, |
|
currentVisited, |
|
isManualRetry, |
|
(fetchedRefs) => { |
|
if (isMounted) { |
|
// Update state progressively as events are fetched |
|
setReferences(fetchedRefs) |
|
} |
|
} |
|
) |
|
|
|
if (isMounted) { |
|
setReferences(fetched) |
|
setFailedReferences(failed) |
|
setIsLoading(false) |
|
setIsRetrying(false) |
|
|
|
// Store master publication with all nested events |
|
const nestedEvents = fetched.filter(ref => ref.event).map(ref => ref.event!).filter((e): e is Event => e !== undefined) |
|
if (nestedEvents.length > 0) { |
|
indexedDb.putPublicationWithNestedEvents(event, nestedEvents).catch(err => { |
|
logger.error('[PublicationIndex] Error caching publication with nested events:', err) |
|
}) |
|
} |
|
} |
|
} catch (error) { |
|
logger.error('[PublicationIndex] Error in fetchReferences:', error) |
|
if (isMounted) { |
|
setIsLoading(false) |
|
setIsRetrying(false) |
|
} |
|
} finally { |
|
clearTimeout(timeout) |
|
} |
|
} |
|
|
|
if (referencesData.length > 0) { |
|
fetchReferences(false).then(() => { |
|
// Auto-retry failed references after initial load |
|
setFailedReferences(prevFailed => { |
|
if (prevFailed.length > 0 && retryCount < maxRetries) { |
|
const delay = Math.min(1000 * Math.pow(2, retryCount), 10000) // Exponential backoff, max 10s |
|
setTimeout(() => { |
|
setRetryCount(prev => prev + 1) |
|
fetchReferences(true) |
|
}, delay) |
|
} |
|
return prevFailed |
|
}) |
|
}) |
|
} else { |
|
setIsLoading(false) |
|
} |
|
|
|
return () => { |
|
isMounted = false |
|
} |
|
}, [referencesData, visitedIndices, fetchSingleReference]) // Include fetchSingleReference in dependencies |
|
|
|
// Manual retry function |
|
const handleManualRetry = useCallback(async () => { |
|
setRetryCount(0) |
|
setIsRetrying(true) |
|
|
|
const fetchReferences = async () => { |
|
const updatedRefs: Map<string, PublicationReference> = new Map() |
|
const newRefs: PublicationReference[] = [] |
|
const failedRefs: PublicationReference[] = [] |
|
const discoveredRefs: PublicationReference[] = [] |
|
const currentVisited = visitedIndices |
|
|
|
// Create a map of existing references for quick lookup |
|
references.forEach(ref => { |
|
const id = ref.coordinate || ref.eventId || '' |
|
if (id) { |
|
updatedRefs.set(id, ref) |
|
} |
|
}) |
|
|
|
// Only retry failed references, not all references |
|
const refsToRetry = failedReferences.length > 0 ? failedReferences : references.filter(ref => !ref.event) |
|
|
|
if (refsToRetry.length === 0) { |
|
setIsRetrying(false) |
|
return |
|
} |
|
|
|
logger.info('[PublicationIndex] Retrying', refsToRetry.length, 'failed references') |
|
|
|
for (const ref of refsToRetry) { |
|
const result = await fetchSingleReference(ref, currentVisited, true) |
|
|
|
if (result) { |
|
const id = result.coordinate || result.eventId || '' |
|
|
|
if (result.event) { |
|
// Successfully fetched - update existing reference or add new one |
|
if (id) { |
|
updatedRefs.set(id, result) |
|
} else { |
|
newRefs.push(result) |
|
} |
|
|
|
// Collect discovered nested references |
|
if ((result as any).nestedRefs && (result as any).nestedRefs.length > 0) { |
|
for (const nestedRef of (result as any).nestedRefs) { |
|
const nestedId = nestedRef.coordinate || nestedRef.eventId || '' |
|
if (!nestedId) continue |
|
|
|
// Check if we already have this reference |
|
const existingInMap = updatedRefs.has(nestedId) |
|
const existingInNew = newRefs.find(r => { |
|
const rid = r.coordinate || r.eventId || '' |
|
return rid === nestedId |
|
}) |
|
const existingInDiscovered = discoveredRefs.find(r => { |
|
const rid = r.coordinate || r.eventId || '' |
|
return rid === nestedId |
|
}) |
|
|
|
if (!existingInMap && !existingInNew && !existingInDiscovered) { |
|
discoveredRefs.push(nestedRef) |
|
} |
|
} |
|
} |
|
} else { |
|
// Still failed |
|
if (id) { |
|
updatedRefs.set(id, result) |
|
} else { |
|
failedRefs.push(result) |
|
} |
|
} |
|
} |
|
} |
|
|
|
// Fetch discovered nested references |
|
if (discoveredRefs.length > 0) { |
|
logger.info('[PublicationIndex] Found', discoveredRefs.length, 'new nested references on retry') |
|
for (const nestedRef of discoveredRefs) { |
|
const result = await fetchSingleReference(nestedRef, currentVisited, true) |
|
|
|
if (result) { |
|
const id = result.coordinate || result.eventId || '' |
|
if (result.event) { |
|
if (id) { |
|
updatedRefs.set(id, result) |
|
} else { |
|
newRefs.push(result) |
|
} |
|
} else { |
|
if (id) { |
|
updatedRefs.set(id, result) |
|
} else { |
|
failedRefs.push(result) |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
// Update state with merged results |
|
const finalRefs = Array.from(updatedRefs.values()).concat(newRefs) |
|
const stillFailed = finalRefs.filter(ref => !ref.event) |
|
|
|
setReferences(finalRefs) |
|
setFailedReferences(stillFailed) |
|
setIsRetrying(false) |
|
|
|
// Store master publication with all nested events |
|
const nestedEvents = finalRefs.filter(ref => ref.event).map(ref => ref.event!).filter((e): e is Event => e !== undefined) |
|
if (nestedEvents.length > 0) { |
|
indexedDb.putPublicationWithNestedEvents(event, nestedEvents).catch(err => { |
|
logger.error('[PublicationIndex] Error caching publication with nested events:', err) |
|
}) |
|
} |
|
} |
|
|
|
await fetchReferences() |
|
}, [failedReferences, visitedIndices, fetchSingleReference, references, event]) |
|
|
|
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="flex items-start justify-between gap-4 mb-4"> |
|
{metadata.title && <h1 className="text-4xl font-bold leading-tight break-words flex-1">{metadata.title}</h1>} |
|
{!metadata.title && isBookstrEvent && ( |
|
<div className="flex-1"> |
|
<h1 className="text-4xl font-bold leading-tight break-words"> |
|
{bookMetadata.book |
|
? bookMetadata.book |
|
.split('-') |
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) |
|
.join(' ') |
|
: 'Bookstr Publication'} |
|
</h1> |
|
</div> |
|
)} |
|
</div> |
|
{metadata.summary && ( |
|
<blockquote className="border-l-4 border-primary pl-6 italic text-muted-foreground mb-4 text-lg leading-relaxed"> |
|
<p className="break-words">{metadata.summary}</p> |
|
</blockquote> |
|
)} |
|
{/* Display image for top-level 30040 publication */} |
|
{metadata.image && ( |
|
<div className="mb-4"> |
|
<Image |
|
image={{ url: metadata.image, pubkey: event.pubkey }} |
|
className="max-w-[400px] w-full h-auto rounded-lg" |
|
classNames={{ |
|
wrapper: 'rounded-lg', |
|
errorPlaceholder: 'aspect-square h-[30vh]' |
|
}} |
|
/> |
|
</div> |
|
)} |
|
<div className="text-sm text-muted-foreground space-y-1"> |
|
{metadata.author && ( |
|
<div> |
|
<span className="font-semibold">Author:</span> {metadata.author} |
|
</div> |
|
)} |
|
{metadata.version && !isBookstrEvent && ( |
|
<div> |
|
<span className="font-semibold">Version:</span> {metadata.version} |
|
</div> |
|
)} |
|
{metadata.type && !isBookstrEvent && ( |
|
<div> |
|
<span className="font-semibold">Type:</span> {metadata.type} |
|
</div> |
|
)} |
|
{isBookstrEvent && ( |
|
<> |
|
{bookMetadata.type && ( |
|
<div> |
|
<span className="font-semibold">Type:</span> {bookMetadata.type} |
|
</div> |
|
)} |
|
{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> |
|
)} |
|
|
|
{/* Table of Contents - only show for top-level publications */} |
|
{!isNested && !isLoading && tableOfContents.length > 0 && ( |
|
<div id="publication-toc" className="border rounded-lg p-6 bg-muted/30 scroll-mt-24"> |
|
<h2 className="text-xl font-semibold 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 References Banner - only show for top-level publications */} |
|
{!isNested && !isLoading && failedReferences.length > 0 && references.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"> |
|
{failedReferences.length} reference{failedReferences.length !== 1 ? 's' : ''} failed to load. Click retry to attempt loading again. |
|
</div> |
|
<Button |
|
variant="outline" |
|
size="sm" |
|
onClick={handleManualRetry} |
|
disabled={isRetrying} |
|
> |
|
<RefreshCw className={cn("h-4 w-4 mr-2", isRetrying && "animate-spin")} /> |
|
Retry All |
|
</Button> |
|
</div> |
|
</div> |
|
)} |
|
|
|
{/* Content - render referenced events */} |
|
{isLoading ? ( |
|
<div className="text-muted-foreground"> |
|
<div>Loading publication content...</div> |
|
<div className="text-xs mt-2">If this takes too long, the content may not be available.</div> |
|
</div> |
|
) : references.length === 0 ? ( |
|
<div className="p-6 border rounded-lg bg-muted/30 text-center"> |
|
<div className="text-lg font-semibold mb-2">No content loaded</div> |
|
<div className="text-sm text-muted-foreground mb-4"> |
|
Unable to load publication content. The referenced events may not be available on the current relays. |
|
</div> |
|
<Button |
|
variant="outline" |
|
onClick={handleManualRetry} |
|
disabled={isRetrying} |
|
> |
|
<RefreshCw className={cn("h-4 w-4 mr-2", isRetrying && "animate-spin")} /> |
|
Retry Loading |
|
</Button> |
|
</div> |
|
) : ( |
|
<div className="space-y-8"> |
|
{references.map((ref, index) => { |
|
if (!ref.event) { |
|
// Generate naddr from coordinate or eventId for link |
|
let notesLink: string | null = null |
|
if (ref.coordinate) { |
|
const aTag = ['a', ref.coordinate, ref.relay || '', ref.eventId || ''] |
|
const bech32Id = generateBech32IdFromATag(aTag) |
|
if (bech32Id) { |
|
// Construct URL as /notes?events=naddr1... |
|
notesLink = `/notes?events=${encodeURIComponent(bech32Id)}` |
|
} |
|
} else if (ref.eventId) { |
|
// For event IDs, try to construct a note/nevent, otherwise use as-is |
|
if (ref.eventId.startsWith('note1') || ref.eventId.startsWith('nevent1') || ref.eventId.startsWith('naddr1')) { |
|
notesLink = `/notes?events=${encodeURIComponent(ref.eventId)}` |
|
} else if (/^[0-9a-f]{64}$/i.test(ref.eventId)) { |
|
// Hex event ID - try to create nevent |
|
try { |
|
const nevent = nip19.neventEncode({ id: ref.eventId }) |
|
notesLink = `/notes?events=${encodeURIComponent(nevent)}` |
|
} catch { |
|
// Fallback to hex ID |
|
notesLink = `/notes?events=${encodeURIComponent(ref.eventId)}` |
|
} |
|
} |
|
} |
|
|
|
return ( |
|
<div key={index} className="p-4 border rounded-lg bg-muted/50"> |
|
<div className="flex items-center justify-between gap-2"> |
|
<div className="text-sm text-muted-foreground"> |
|
Reference {index + 1}: Unable to load event{' '} |
|
{notesLink ? ( |
|
<a |
|
href={notesLink} |
|
onClick={(e) => { |
|
e.preventDefault() |
|
e.stopPropagation() |
|
push(notesLink!) |
|
}} |
|
className="text-primary hover:underline cursor-pointer" |
|
> |
|
{ref.coordinate || ref.eventId || 'unknown'} |
|
</a> |
|
) : ( |
|
<span>{ref.coordinate || ref.eventId || 'unknown'}</span> |
|
)} |
|
</div> |
|
<Button |
|
variant="outline" |
|
size="sm" |
|
onClick={handleManualRetry} |
|
disabled={isRetrying} |
|
className="shrink-0" |
|
> |
|
<RefreshCw className={cn("h-4 w-4", isRetrying && "animate-spin")} /> |
|
Retry |
|
</Button> |
|
</div> |
|
</div> |
|
) |
|
} |
|
|
|
// Render based on event kind |
|
// Use the same coordinate logic as ToC: coordinate || eventId |
|
// For e-tags, coordinate might be empty, so use eventId |
|
// For a-tags, coordinate is set (kind:pubkey:identifier) |
|
const coordinate = ref.coordinate || ref.eventId || '' |
|
const sectionId = `section-${coordinate.replace(/:/g, '-')}` |
|
const eventKind = ref.kind || ref.event.kind |
|
|
|
// Debug: log section ID generation |
|
logger.debug('[PublicationIndex] Rendering section:', { |
|
coordinate, |
|
sectionId, |
|
hasCoordinate: !!ref.coordinate, |
|
hasEventId: !!ref.eventId, |
|
eventId: ref.eventId?.substring(0, 16) + '...' |
|
}) |
|
|
|
if (eventKind === ExtendedKind.PUBLICATION) { |
|
// Recursively render nested 30040 publication index |
|
// Use the top-level publication's image as parent for nested publications |
|
const effectiveParentImageUrl = !isNested ? metadata.image : parentImageUrl |
|
return ( |
|
<div key={index} id={sectionId} className="border-l-4 border-primary pl-6 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> |
|
<PublicationIndex event={ref.event} isNested={true} parentImageUrl={effectiveParentImageUrl} /> |
|
</div> |
|
) |
|
} else if (eventKind === ExtendedKind.PUBLICATION_CONTENT || eventKind === ExtendedKind.WIKI_ARTICLE) { |
|
// Render 30041 or 30818 content as AsciidocArticle |
|
// Pass parent image URL to avoid showing duplicate cover images |
|
// Use the top-level publication's image as parent, or the passed parentImageUrl for nested publications |
|
const effectiveParentImageUrl = !isNested ? metadata.image : parentImageUrl |
|
return ( |
|
<div key={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> |
|
<AsciidocArticle event={ref.event} hideImagesAndInfo={true} parentImageUrl={effectiveParentImageUrl} /> |
|
</div> |
|
) |
|
} else if (eventKind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) { |
|
// Render 30817 content as MarkdownArticle |
|
// Pass parent image URL to avoid showing duplicate cover images |
|
// Use the top-level publication's image as parent, or the passed parentImageUrl for nested publications |
|
const effectiveParentImageUrl = !isNested ? metadata.image : parentImageUrl |
|
return ( |
|
<div key={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> |
|
) |
|
} else { |
|
// Fallback for other kinds - just show a placeholder |
|
return ( |
|
<div key={index} className="p-4 border rounded-lg"> |
|
<div className="text-sm text-muted-foreground"> |
|
Reference {index + 1}: Unsupported kind {eventKind} |
|
</div> |
|
</div> |
|
) |
|
} |
|
})} |
|
</div> |
|
)} |
|
</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="text-left text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline cursor-pointer" |
|
> |
|
{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> |
|
) |
|
} |
|
|
|
|