Browse Source

remove publication parser

imwald
Silberengel 1 month ago
parent
commit
441b91f52e
  1. 20
      src/components/Note/PublicationCard.tsx
  2. 963
      src/components/Note/PublicationIndex/PublicationIndex.tsx
  3. 32
      src/components/Note/index.tsx
  4. 26
      src/components/NoteOptions/useMenuActions.tsx
  5. 399
      src/hooks/usePublicationSectionLoader.ts
  6. 44
      src/lib/link.ts
  7. 58
      src/lib/publication-rendered-events.ts

20
src/components/Note/PublicationCard.tsx

@ -13,10 +13,13 @@ import { ExtendedKind } from '@/constants'
export default function PublicationCard({ export default function PublicationCard({
event, event,
className className,
disableNavigation = false
}: { }: {
event: Event event: Event
className?: string className?: string
/** When true (e.g. full note view), card is display-only; no navigate-to-note on click. */
disableNavigation?: boolean
}) { }) {
const screenSize = useScreenSizeOptional() const screenSize = useScreenSizeOptional()
const isSmallScreen = screenSize?.isSmallScreen ?? false const isSmallScreen = screenSize?.isSmallScreen ?? false
@ -33,6 +36,7 @@ export default function PublicationCard({
const handleCardClick = (e: React.MouseEvent) => { const handleCardClick = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
if (disableNavigation) return
navigateToNote(toNote(event), event) navigateToNote(toNote(event), event)
} }
@ -83,8 +87,11 @@ export default function PublicationCard({
return ( return (
<div className={cn('w-full min-w-0', className)}> <div className={cn('w-full min-w-0', className)}>
<div <div
className="min-w-0 cursor-pointer rounded-lg border p-4 transition-colors hover:bg-muted/50" className={cn(
onClick={handleCardClick} 'min-w-0 rounded-lg border p-4 transition-colors',
disableNavigation ? '' : 'cursor-pointer hover:bg-muted/50'
)}
onClick={disableNavigation ? undefined : handleCardClick}
> >
{metadata.image && autoLoadMedia && ( {metadata.image && autoLoadMedia && (
<Image <Image
@ -108,8 +115,11 @@ export default function PublicationCard({
return ( return (
<div className={cn('w-full min-w-0', className)}> <div className={cn('w-full min-w-0', className)}>
<div <div
className="min-w-0 cursor-pointer overflow-hidden rounded-lg border p-4 transition-colors hover:bg-muted/50" className={cn(
onClick={handleCardClick} 'min-w-0 overflow-hidden rounded-lg border p-4 transition-colors',
disableNavigation ? '' : 'cursor-pointer hover:bg-muted/50'
)}
onClick={disableNavigation ? undefined : handleCardClick}
> >
<div className="flex min-w-0 gap-4"> <div className="flex min-w-0 gap-4">
{metadata.image && autoLoadMedia && ( {metadata.image && autoLoadMedia && (

963
src/components/Note/PublicationIndex/PublicationIndex.tsx

@ -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>
)
}

32
src/components/Note/index.tsx

@ -12,7 +12,7 @@ import { shouldHideInteractions } from '@/lib/event-filtering'
import { mergeNip84MarkedIntervals, renderPlaintextWithNip84MergedMarks } from '@/lib/nip84-op-body-marks' import { mergeNip84MarkedIntervals, renderPlaintextWithNip84MergedMarks } from '@/lib/nip84-op-body-marks'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { relayHintsFromEventTags } from '@/lib/relay-list-builder' import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { toNote } from '@/lib/link' import { encodeArticleLikePublicationNaddr, openAlexandriaPublicationFromNaddr, toNote } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
DISCUSSION_DOWNVOTE_DISPLAY, DISCUSSION_DOWNVOTE_DISPLAY,
@ -60,7 +60,6 @@ import LiveEvent from './LiveEvent'
import MarkdownArticle from './MarkdownArticle/MarkdownArticle' import MarkdownArticle from './MarkdownArticle/MarkdownArticle'
import AsciidocArticle from './AsciidocArticle/AsciidocArticle' import AsciidocArticle from './AsciidocArticle/AsciidocArticle'
import PublicationCard from './PublicationCard' import PublicationCard from './PublicationCard'
import PublicationIndex from './PublicationIndex/PublicationIndex'
import WikiCard from './WikiCard' import WikiCard from './WikiCard'
import LongFormCard from './LongFormCard' import LongFormCard from './LongFormCard'
import MutedNote from './MutedNote' import MutedNote from './MutedNote'
@ -73,6 +72,7 @@ import ReactionEmojiDisplay from './ReactionEmojiDisplay'
import UnknownNote from './UnknownNote' import UnknownNote from './UnknownNote'
import NoteKindLabel from './NoteKindLabel' import NoteKindLabel from './NoteKindLabel'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@/components/ui/button'
import VideoNote from './VideoNote' import VideoNote from './VideoNote'
import RelayReview from './RelayReview' import RelayReview from './RelayReview'
import Zap from './Zap' import Zap from './Zap'
@ -320,11 +320,29 @@ export default function Note({
<WikiCard className="mt-2" event={displayEvent} /> <WikiCard className="mt-2" event={displayEvent} />
) )
} else if (event.kind === ExtendedKind.PUBLICATION) { } else if (event.kind === ExtendedKind.PUBLICATION) {
content = showFull ? ( if (showFull) {
<PublicationIndex className="mt-2" event={displayEvent} /> const naddrFull = encodeArticleLikePublicationNaddr(displayEvent)
) : ( content = (
<PublicationCard className="mt-2" event={displayEvent} /> <div className="mt-2 space-y-3">
) <PublicationCard event={displayEvent} disableNavigation />
{naddrFull ? (
<Button
type="button"
size="lg"
className="w-full font-semibold"
onClick={(e) => {
e.stopPropagation()
openAlexandriaPublicationFromNaddr(naddrFull)
}}
>
{t('View on Alexandria')}
</Button>
) : null}
</div>
)
} else {
content = <PublicationCard className="mt-2" event={displayEvent} />
}
} else if (event.kind === ExtendedKind.PUBLICATION_CONTENT) { } else if (event.kind === ExtendedKind.PUBLICATION_CONTENT) {
content = showFull ? ( content = showFull ? (
renderEventContent() renderEventContent()

26
src/components/NoteOptions/useMenuActions.tsx

@ -2,7 +2,7 @@ import { ExtendedKind, READ_ALOUD_KINDS } from '@/constants'
import { getNoteBech32Id, isProtectedEvent, getRootEventHexId } from '@/lib/event' import { getNoteBech32Id, isProtectedEvent, getRootEventHexId } from '@/lib/event'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { buildHiveTalkJoinUrl } from '@/lib/hivetalk' import { buildHiveTalkJoinUrl } from '@/lib/hivetalk'
import { toAlexandria } from '@/lib/link' import { toAlexandria, encodeArticleLikePublicationNaddr, openAlexandriaPublicationFromNaddr } from '@/lib/link'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { import {
@ -55,7 +55,6 @@ import {
Languages Languages
} from 'lucide-react' } from 'lucide-react'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { nip19 } from 'nostr-tools'
import { import {
articleHasTranslatableTitle, articleHasTranslatableTitle,
eventHasTranslatableTextBody, eventHasTranslatableTextBody,
@ -509,26 +508,7 @@ export function useMenuActions({
[event.pubkey] [event.pubkey]
) )
// Generate naddr for Alexandria URL const naddr = useMemo(() => encodeArticleLikePublicationNaddr(event) ?? '', [event])
const naddr = useMemo(() => {
if (!isArticleType || !dTag) return ''
try {
const relays = event.tags
.filter(tag => tag[0] === 'relay')
.map(tag => tag[1])
.filter(Boolean)
return nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: dTag,
relays: relays.length > 0 ? relays : undefined
})
} catch (error) {
logger.error('Error generating naddr', { error })
return ''
}
}, [isArticleType, event, dTag])
const menuActions: MenuAction[] = useMemo(() => { const menuActions: MenuAction[] = useMemo(() => {
const rebroadcastEntirePublication = (selectedRelayUrls: string[]) => { const rebroadcastEntirePublication = (selectedRelayUrls: string[]) => {
@ -855,7 +835,7 @@ export function useMenuActions({
const handleViewOnAlexandria = () => { const handleViewOnAlexandria = () => {
if (!naddr) return if (!naddr) return
closeDrawer() closeDrawer()
window.open(`https://next-alexandria.gitcitadel.eu/publication/naddr/${naddr}`, '_blank', 'noopener,noreferrer') openAlexandriaPublicationFromNaddr(naddr)
} }
const handleViewOnDecentNewsroom = () => { const handleViewOnDecentNewsroom = () => {

399
src/hooks/usePublicationSectionLoader.ts

@ -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
}
}

44
src/lib/link.ts

@ -1,7 +1,49 @@
import { Event, nip19 } from 'nostr-tools' import { Event, kinds, nip19 } from 'nostr-tools'
import { ExtendedKind } from '@/constants'
import { getNoteBech32Id, isReplaceableEvent } from './event' import { getNoteBech32Id, isReplaceableEvent } from './event'
import { TSearchParams } from '@/types' import { TSearchParams } from '@/types'
/** Same kinds as {@link useMenuActions} `isArticleType` for naddr + Alexandria publication URLs. */
const ALEXANDRIA_PUBLICATION_NADDR_KINDS = new Set<number>([
kinds.LongFormArticle,
ExtendedKind.PUBLICATION,
ExtendedKind.PUBLICATION_CONTENT,
ExtendedKind.WIKI_ARTICLE,
ExtendedKind.WIKI_ARTICLE_MARKDOWN
])
/** NIP-19 `naddr` for article-like replaceable events (`d` tag required). */
export function encodeArticleLikePublicationNaddr(event: Event): string | null {
if (!ALEXANDRIA_PUBLICATION_NADDR_KINDS.has(event.kind)) return null
const d = event.tags.find((t) => t[0] === 'd')?.[1]
if (!d) return null
try {
const relays = event.tags
.filter((tag) => tag[0] === 'relay')
.map((tag) => tag[1])
.filter(Boolean) as string[]
return nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: d,
relays: relays.length > 0 ? relays : undefined
})
} catch {
return null
}
}
/** Full Alexandria reader URL for a publication `naddr` (matches NoteOptions “View on Alexandria”). */
export function getAlexandriaPublicationUrlFromNaddr(naddr: string): string {
return `https://next-alexandria.gitcitadel.eu/publication/naddr/${naddr}`
}
export function openAlexandriaPublicationFromNaddr(naddr: string): void {
const trimmed = naddr.trim()
if (!trimmed) return
window.open(getAlexandriaPublicationUrlFromNaddr(trimmed), '_blank', 'noopener,noreferrer')
}
/** /**
* Note URL path segment. When `eventOrId` is a 64-char hex id and `hexResolutionEvent` is a loaded * Note URL path segment. When `eventOrId` is a 64-char hex id and `hexResolutionEvent` is a loaded
* replaceable/addressable event for that note, use its naddr/nevent so links stay canonical. * replaceable/addressable event for that note, use its naddr/nevent so links stay canonical.

58
src/lib/publication-rendered-events.ts

@ -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…
Cancel
Save