Browse Source

bug-fix publications

imwald
Silberengel 5 months ago
parent
commit
03b3005eee
  1. 5
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  2. 4
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  3. 631
      src/components/Note/PublicationIndex/PublicationIndex.tsx
  4. 28
      src/services/indexed-db.service.ts

5
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -365,6 +365,11 @@ export default function AsciidocArticle({
</header> </header>
)} )}
{/* Show title inline when used as nested content */}
{hideImagesAndInfo && metadata.title && (
<h2 className="text-2xl font-bold mb-4 leading-tight break-words">{metadata.title}</h2>
)}
{/* Render AsciiDoc content (everything is now processed as AsciiDoc) */} {/* Render AsciiDoc content (everything is now processed as AsciiDoc) */}
<div <div
ref={contentRef} ref={contentRef}

4
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -499,6 +499,10 @@ export default function MarkdownArticle({
<p className="break-words">{metadata.summary}</p> <p className="break-words">{metadata.summary}</p>
</blockquote> </blockquote>
)} )}
{/* Show title inline when metadata is hidden (for nested content) */}
{hideMetadata && metadata.title && (
<h2 className="text-2xl font-bold mb-4 leading-tight break-words">{metadata.title}</h2>
)}
{!hideMetadata && metadata.image && (() => { {!hideMetadata && metadata.image && (() => {
// Find the index of the metadata image in allImages // Find the index of the metadata image in allImages
const cleanedMetadataImage = cleanUrl(metadata.image) const cleanedMetadataImage = cleanUrl(metadata.image)

631
src/components/Note/PublicationIndex/PublicationIndex.tsx

@ -9,7 +9,7 @@ import { generateBech32IdFromATag } from '@/lib/tag'
import client from '@/services/client.service' import client from '@/services/client.service'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { MoreVertical, RefreshCw } from 'lucide-react' import { MoreVertical, RefreshCw, ArrowUp } from 'lucide-react'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { isReplaceableEvent } from '@/lib/event' import { isReplaceableEvent } from '@/lib/event'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
@ -91,80 +91,317 @@ export default function PublicationIndex({
const [failedReferences, setFailedReferences] = useState<PublicationReference[]>([]) const [failedReferences, setFailedReferences] = useState<PublicationReference[]>([])
const maxRetries = 5 const maxRetries = 5
// Build table of contents from references // Extract references from 'a' tags (addressable events) and 'e' tags (event IDs)
const tableOfContents = useMemo<ToCItem[]>(() => { const referencesData = useMemo(() => {
const toc: ToCItem[] = [] 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])
for (const ref of references) { // Recursive helper function to build ToC item from a reference
if (!ref.event) continue 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 // Extract title from the event
const title = ref.event.tags.find(tag => tag[0] === 'title')?.[1] || const title = ref.event.tags.find(tag => tag[0] === 'title')?.[1] ||
ref.event.tags.find(tag => tag[0] === 'd')?.[1] || ref.event.tags.find(tag => tag[0] === 'd')?.[1] ||
'Untitled' 'Untitled'
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 = { const tocItem: ToCItem = {
title, title,
coordinate: ref.coordinate || ref.eventId || '', coordinate,
event: ref.event, event: ref.event,
kind: ref.kind || ref.event?.kind || 0 kind: ref.kind || ref.event?.kind || 0
} }
// For nested 30040 publications, recursively get their ToC // Build children recursively - check both nestedRefs and event tags
if ((ref.kind === ExtendedKind.PUBLICATION || ref.event?.kind === ExtendedKind.PUBLICATION) && ref.event) { const children: ToCItem[] = []
const nestedRefs: 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)
// Parse nested references from this publication (both 'a' and 'e' tags) // 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) { for (const tag of ref.event.tags) {
if (tag[0] === 'a' && tag[1]) { if (tag[0] === 'a' && tag[1]) {
const [kindStr, , identifier] = tag[1].split(':') const [kindStr, , identifier] = tag[1].split(':')
const kind = parseInt(kindStr) const kind = parseInt(kindStr)
if (!isNaN(kind) && kind === ExtendedKind.PUBLICATION_CONTENT || if (!isNaN(kind) && (kind === ExtendedKind.PUBLICATION_CONTENT ||
kind === ExtendedKind.WIKI_ARTICLE || kind === ExtendedKind.WIKI_ARTICLE ||
kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN || kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN ||
kind === ExtendedKind.PUBLICATION) { kind === ExtendedKind.PUBLICATION)) {
// For this simplified version, we'll just extract the title from the coordinate const tagCoord = tag[1]
const nestedTitle = identifier || 'Untitled' 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))
)
nestedRefs.push({ if (fetchedNestedEvent) {
title: nestedTitle, const nestedToCItem = buildToCItemFromRef(fetchedNestedEvent, allReferences, new Set(visitedCoords))
coordinate: tag[1], if (nestedToCItem) {
children.push(nestedToCItem)
}
} else {
// Event not fetched yet, create placeholder
children.push({
title: identifier || 'Untitled',
coordinate: tagCoord,
kind kind
}) })
} }
}
}
} else if (tag[0] === 'e' && tag[1]) { } else if (tag[0] === 'e' && tag[1]) {
// For 'e' tags, we can't extract title from the tag alone const eventId = tag[1]
// The title will come from the fetched event if available if (!processedCoords.has(eventId)) {
const nestedTitle = ref.event?.tags.find(t => t[0] === 'title')?.[1] || 'Untitled' processedCoords.add(eventId)
nestedRefs.push({ // Look up the fetched event from allReferences
title: nestedTitle, const fetchedNestedEvent = allReferences.find(r =>
coordinate: tag[1], // Use event ID as coordinate (r.type === 'e' && r.eventId === eventId) ||
kind: ref.event?.kind (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 (nestedRefs.length > 0) {
tocItem.children = nestedRefs
} }
} }
}
if (children.length > 0) {
tocItem.children = children
}
return tocItem
}, [])
// Build table of contents from references
// Also include items from main event tags if references haven't loaded yet
const tableOfContents = useMemo<ToCItem[]>(() => {
const toc: ToCItem[] = []
// Build ToC from all fetched references (recursively)
for (const ref of references) {
// Only add top-level references (those directly in the main event)
const isTopLevel = referencesData.some(rd =>
(rd.coordinate && ref.coordinate && rd.coordinate === ref.coordinate) ||
(rd.eventId && ref.eventId && rd.eventId === ref.eventId)
)
if (isTopLevel) {
const tocItem = buildToCItemFromRef(ref, references)
if (tocItem) {
toc.push(tocItem) toc.push(tocItem)
} }
}
}
// If no references have loaded yet, build initial ToC from main event tags
if (toc.length === 0 && references.length === 0) {
// Parse a and e tags from the main event
for (const tag of 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)) {
toc.push({
title: identifier || 'Untitled',
coordinate: tag[1],
kind
})
}
} else if (tag[0] === 'e' && tag[1]) {
// For e-tags without fetched event, use event ID as placeholder
toc.push({
title: 'Loading...',
coordinate: tag[1],
kind: 0
})
}
}
}
return toc return toc
}, [references]) }, [references, event, referencesData, buildToCItemFromRef])
// 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 with offset for fixed header
const scrollToSection = useCallback((coordinate: string) => {
if (!coordinate) {
console.warn('[PublicationIndex] Cannot scroll: coordinate is empty')
return
}
// For e-tags, coordinate is the event ID (hex string, no colons)
// For a-tags, coordinate is kind:pubkey:identifier (has colons)
const sectionId = `section-${coordinate.replace(/:/g, '-')}`
console.log('[PublicationIndex] Scrolling to section:', { coordinate, sectionId })
// Try to find the element, with retry for elements that might still be rendering
const findAndScroll = (attempt = 0) => {
// Try the primary section ID format
let element = document.getElementById(sectionId)
// Scroll to section // Try alternative ID formats if primary not found
const scrollToSection = (coordinate: string) => { if (!element) {
const element = document.getElementById(`section-${coordinate.replace(/:/g, '-')}`) const altId1 = `section-${coordinate}`
element = document.getElementById(altId1)
if (element) { if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' }) console.log('[PublicationIndex] Found section with alternative ID:', altId1)
}
}
if (element) {
// Get the element's position relative to the viewport
const rect = element.getBoundingClientRect()
const elementTop = rect.top + window.scrollY
// Account for fixed header/titlebar (typically around 4rem/64px, using 6rem/96px for safety)
const offset = 96
const scrollPosition = Math.max(0, elementTop - offset)
console.log('[PublicationIndex] Found section element, scrolling to:', {
scrollPosition,
elementTop,
elementId: element.id,
windowScrollY: window.scrollY,
rectTop: rect.top
})
// Use requestAnimationFrame for smoother scrolling
requestAnimationFrame(() => {
window.scrollTo({ top: scrollPosition, behavior: 'smooth' })
})
return true
}
// Retry up to 10 times with increasing delay if element not found (might still be rendering)
if (attempt < 10) {
const delay = Math.min(100 * (attempt + 1), 1000) // Max 1 second delay
setTimeout(() => findAndScroll(attempt + 1), delay)
return false
} }
// Log all section IDs for debugging
const allSectionIds = Array.from(document.querySelectorAll('[id^="section-"]'))
.map(el => el.id)
.filter(id => id) // Filter out empty IDs
console.warn('[PublicationIndex] Section not found after retries:', {
coordinate,
sectionId,
altId1: `section-${coordinate}`,
attempt,
availableSections: allSectionIds.slice(0, 20) // Limit output
})
// Show a helpful message
console.warn(`[PublicationIndex] Could not find section with ID: ${sectionId}. Available sections:`, allSectionIds.slice(0, 10))
return false
} }
findAndScroll()
}, [])
// Export publication as AsciiDoc // Export publication as AsciiDoc
const exportPublication = async () => { const exportPublication = async () => {
try { try {
@ -203,37 +440,6 @@ export default function PublicationIndex({
} }
} }
// 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])
// Add current event to visited set // Add current event to visited set
const currentCoordinate = useMemo(() => { const currentCoordinate = useMemo(() => {
const dTag = event.tags.find(tag => tag[0] === 'd')?.[1] || '' const dTag = event.tags.find(tag => tag[0] === 'd')?.[1] || ''
@ -249,52 +455,11 @@ export default function PublicationIndex({
}) })
}, [currentCoordinate, event]) }, [currentCoordinate, event])
// Fetch a single reference with retry logic // Helper function to build comprehensive relay list
const fetchSingleReference = useCallback(async ( const buildComprehensiveRelayList = useCallback(async (
ref: PublicationReference, additionalRelays: string[] = []
currentVisited: Set<string>, ): Promise<string[]> => {
isRetry = false const { FAST_READ_RELAY_URLS, BIG_RELAY_URLS, SEARCHABLE_RELAY_URLS } = await import('@/constants')
): 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]
}
// Use comprehensive relay list (same as initial fetch in client.service)
// Build relay list: FAST_READ_RELAY_URLS, user's favorite relays, user's relay list, decoded relays, BIG_RELAY_URLS
const { FAST_READ_RELAY_URLS, BIG_RELAY_URLS } = await import('@/constants')
const relayUrls = new Set<string>() const relayUrls = new Set<string>()
// Add FAST_READ_RELAY_URLS // Add FAST_READ_RELAY_URLS
@ -303,6 +468,12 @@ export default function PublicationIndex({
if (normalized) relayUrls.add(normalized) 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 // Add user's favorite relays (kind 10012) and relay list (kind 10002) if logged in
try { try {
const userPubkey = (client as any).pubkey const userPubkey = (client as any).pubkey
@ -336,45 +507,39 @@ export default function PublicationIndex({
// Ignore if user relay list can't be fetched // Ignore if user relay list can't be fetched
} }
// Add relays from decoded naddr if available
if (decoded.data.relays && decoded.data.relays.length > 0) {
decoded.data.relays.forEach((url: string) => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
}
// Add BIG_RELAY_URLS as fallback // Add BIG_RELAY_URLS as fallback
BIG_RELAY_URLS.forEach(url => { BIG_RELAY_URLS.forEach(url => {
const normalized = normalizeUrl(url) const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized) if (normalized) relayUrls.add(normalized)
}) })
// Add SEARCHABLE_RELAY_URLS (important for finding events that search page finds) // Add SEARCHABLE_RELAY_URLS
const { SEARCHABLE_RELAY_URLS } = await import('@/constants')
SEARCHABLE_RELAY_URLS.forEach(url => { SEARCHABLE_RELAY_URLS.forEach(url => {
const normalized = normalizeUrl(url) const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized) if (normalized) relayUrls.add(normalized)
}) })
const finalRelayUrls = Array.from(relayUrls) return Array.from(relayUrls)
logger.debug('[PublicationIndex] Using', finalRelayUrls.length, 'relays for naddr query') }, [])
// Fetch using subscription-style query (more reliable for naddr) // Helper function to fetch event using subscription-style query with comprehensive relay list
// Use subscribeTimeline approach for better reliability (waits for eosed signals) const fetchEventWithSubscription = useCallback(async (
// This is the same approach NoteListPage uses, which successfully finds events filter: any,
relayUrls: string[],
logPrefix: string
): Promise<Event | undefined> => {
try { try {
let foundEvent: Event | undefined = undefined let foundEvent: Event | undefined = undefined
let hasEosed = false let hasEosed = false
let subscriptionClosed = false let subscriptionClosed = false
const { closer } = await client.subscribeTimeline( const { closer } = await client.subscribeTimeline(
[{ urls: finalRelayUrls, filter }], [{ urls: relayUrls, filter }],
{ {
onEvents: (events, eosed) => { onEvents: (events, eosed) => {
if (events.length > 0 && !foundEvent) { if (events.length > 0 && !foundEvent) {
foundEvent = events[0] foundEvent = events[0]
logger.debug('[PublicationIndex] Found event via naddr subscription:', ref.coordinate) logger.debug(`[PublicationIndex] Found event via ${logPrefix} subscription`)
} }
if (eosed) { if (eosed) {
hasEosed = true hasEosed = true
@ -402,17 +567,81 @@ export default function PublicationIndex({
} }
if (foundEvent) { if (foundEvent) {
fetchedEvent = foundEvent return foundEvent
} }
} catch (subError) { } catch (subError) {
logger.warn('[PublicationIndex] Subscription error, falling back to fetchEvents:', subError) logger.warn(`[PublicationIndex] Subscription error for ${logPrefix}, falling back to fetchEvents:`, subError)
// Fallback to regular fetchEvents if subscription fails // Fallback to regular fetchEvents if subscription fails
const events = await client.fetchEvents(finalRelayUrls, [filter]) const events = await client.fetchEvents(relayUrls, [filter])
if (events.length > 0) { if (events.length > 0) {
fetchedEvent = events[0] logger.debug(`[PublicationIndex] Found event via ${logPrefix} fetchEvents fallback`)
logger.debug('[PublicationIndex] Found event via naddr fetchEvents fallback:', ref.coordinate) 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) { } catch (error) {
logger.warn('[PublicationIndex] Error trying naddr filter query:', error) logger.warn('[PublicationIndex] Error trying naddr filter query:', error)
@ -438,15 +667,42 @@ export default function PublicationIndex({
logger.warn('[PublicationIndex] Could not generate bech32 ID for:', ref.coordinate) logger.warn('[PublicationIndex] Could not generate bech32 ID for:', ref.coordinate)
} }
} else if (ref.type === 'e' && ref.eventId) { } else if (ref.type === 'e' && ref.eventId) {
// Handle event ID reference (e tag) // Handle event ID reference (e tag) - same as a tags
// Try to fetch by event ID first // First check indexedDb PUBLICATION_EVENTS store (events cached as part of publications)
if (isRetry) { const hexId = ref.eventId.length === 64 ? ref.eventId : undefined
// On retry, use force retry to try more relays if (hexId) {
fetchedEvent = await client.fetchEventForceRetry(ref.eventId) try {
} else { // Check PUBLICATION_EVENTS store first (for non-replaceable events stored with master)
fetchedEvent = await client.fetchEvent(ref.eventId) 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) { if (fetchedEvent) {
// Check if this is a replaceable event kind // Check if this is a replaceable event kind
if (isReplaceableEvent(fetchedEvent.kind)) { if (isReplaceableEvent(fetchedEvent.kind)) {
@ -455,9 +711,12 @@ export default function PublicationIndex({
logger.debug('[PublicationIndex] Cached replaceable event with ID:', ref.eventId) logger.debug('[PublicationIndex] Cached replaceable event with ID:', ref.eventId)
} else { } else {
// For non-replaceable events, we'll link them to master later via putPublicationWithNestedEvents // For non-replaceable events, we'll link them to master later via putPublicationWithNestedEvents
logger.debug('[PublicationIndex] Cached non-replaceable event with ID (will link to master):', ref.eventId) logger.debug('[PublicationIndex] Fetched non-replaceable event with ID (will link to master):', ref.eventId)
} }
} else { }
}
if (!fetchedEvent) {
logger.warn('[PublicationIndex] Could not fetch event for ID:', ref.eventId) logger.warn('[PublicationIndex] Could not fetch event for ID:', ref.eventId)
} }
} }
@ -511,13 +770,28 @@ export default function PublicationIndex({
} }
} }
return { ...ref, event: fetchedEvent, nestedRefs } // 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 { } else {
return { ...ref, event: undefined } // 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) { } catch (error) {
logger.error('[PublicationIndex] Error fetching reference:', error) logger.error('[PublicationIndex] Error fetching reference:', error)
return { ...ref, event: undefined } // 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]) }, [referencesData])
@ -826,7 +1100,7 @@ export default function PublicationIndex({
{/* Table of Contents - only show for top-level publications */} {/* Table of Contents - only show for top-level publications */}
{!isNested && !isLoading && tableOfContents.length > 0 && ( {!isNested && !isLoading && tableOfContents.length > 0 && (
<div className="border rounded-lg p-6 bg-muted/30"> <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> <h2 className="text-xl font-semibold mb-4">Table of Contents</h2>
<nav> <nav>
<ul className="space-y-2"> <ul className="space-y-2">
@ -950,28 +1224,76 @@ export default function PublicationIndex({
} }
// Render based on event kind // 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 coordinate = ref.coordinate || ref.eventId || ''
const sectionId = `section-${coordinate.replace(/:/g, '-')}` const sectionId = `section-${coordinate.replace(/:/g, '-')}`
const eventKind = ref.kind || ref.event.kind 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) { if (eventKind === ExtendedKind.PUBLICATION) {
// Recursively render nested 30040 publication index // Recursively render nested 30040 publication index
return ( return (
<div key={index} id={sectionId} className="border-l-4 border-primary pl-6 scroll-mt-4"> <div key={index} id={sectionId} className="border-l-4 border-primary pl-6 scroll-mt-24 pt-6 relative">
{!isNested && (
<Button
variant="ghost"
size="sm"
className="absolute top-0 right-0 opacity-70 hover:opacity-100"
onClick={scrollToToc}
title="Back to Table of Contents"
>
<ArrowUp className="h-4 w-4 mr-2" />
ToC
</Button>
)}
<PublicationIndex event={ref.event} isNested={true} /> <PublicationIndex event={ref.event} isNested={true} />
</div> </div>
) )
} else if (eventKind === ExtendedKind.PUBLICATION_CONTENT || eventKind === ExtendedKind.WIKI_ARTICLE) { } else if (eventKind === ExtendedKind.PUBLICATION_CONTENT || eventKind === ExtendedKind.WIKI_ARTICLE) {
// Render 30041 or 30818 content as AsciidocArticle // Render 30041 or 30818 content as AsciidocArticle
return ( return (
<div key={index} id={sectionId} className="scroll-mt-4"> <div key={index} id={sectionId} className="scroll-mt-24 pt-6 relative">
{!isNested && (
<Button
variant="ghost"
size="sm"
className="absolute top-0 right-0 opacity-70 hover:opacity-100"
onClick={scrollToToc}
title="Back to Table of Contents"
>
<ArrowUp className="h-4 w-4 mr-2" />
ToC
</Button>
)}
<AsciidocArticle event={ref.event} hideImagesAndInfo={true} /> <AsciidocArticle event={ref.event} hideImagesAndInfo={true} />
</div> </div>
) )
} else if (eventKind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) { } else if (eventKind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) {
// Render 30817 content as MarkdownArticle // Render 30817 content as MarkdownArticle
return ( return (
<div key={index} id={sectionId} className="scroll-mt-4"> <div key={index} id={sectionId} className="scroll-mt-24 pt-6 relative">
{!isNested && (
<Button
variant="ghost"
size="sm"
className="absolute top-0 right-0 opacity-70 hover:opacity-100"
onClick={scrollToToc}
title="Back to Table of Contents"
>
<ArrowUp className="h-4 w-4 mr-2" />
ToC
</Button>
)}
<MarkdownArticle event={ref.event} showImageGallery={false} hideMetadata={true} /> <MarkdownArticle event={ref.event} showImageGallery={false} hideMetadata={true} />
</div> </div>
) )
@ -1004,10 +1326,19 @@ function ToCItemComponent({
}) { }) {
const indentClass = level > 0 ? `ml-${level * 4}` : '' const indentClass = level > 0 ? `ml-${level * 4}` : ''
const handleClick = () => {
if (!item.coordinate) {
console.warn('[PublicationIndex] ToC item has no coordinate:', item)
return
}
console.debug('[PublicationIndex] ToC item clicked:', { title: item.title, coordinate: item.coordinate })
onItemClick(item.coordinate)
}
return ( return (
<li className={cn('list-none', indentClass)}> <li className={cn('list-none', indentClass)}>
<button <button
onClick={() => onItemClick(item.coordinate)} onClick={handleClick}
className="text-left text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline cursor-pointer" 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} {item.title}

28
src/services/indexed-db.service.ts

@ -661,6 +661,34 @@ class IndexedDbService {
return Promise.resolve(undefined) return Promise.resolve(undefined)
} }
async getEventFromPublicationStore(eventId: string): Promise<Event | undefined> {
// Get event from PUBLICATION_EVENTS store by event ID
// This is used for non-replaceable events stored as part of publications
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
if (!this.db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) {
return resolve(undefined)
}
const transaction = this.db.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly')
const store = transaction.objectStore(StoreNames.PUBLICATION_EVENTS)
const request = store.get(eventId)
request.onsuccess = () => {
transaction.commit()
const result = request.result as TValue<Event> | undefined
resolve(result?.value || undefined)
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async getPublicationStoreItems(storeName: string): Promise<Array<{ key: string; value: any; addedAt: number; nestedCount?: number }>> { async getPublicationStoreItems(storeName: string): Promise<Array<{ key: string; value: any; addedAt: number; nestedCount?: number }>> {
// For publication stores, only return master events with nested counts // For publication stores, only return master events with nested counts
await this.initPromise await this.initPromise

Loading…
Cancel
Save