@@ -132,7 +212,14 @@ export default function PublicationCard({
hideIfError
holdUntilClick={!autoLoadMedia}
/>
- ) : null}
+ ) : (
+
{titleComponent}
{bookstrMetadataComponent}
diff --git a/src/components/Note/PublicationIndexMetadata.tsx b/src/components/Note/PublicationIndexMetadata.tsx
new file mode 100644
index 00000000..b957bfb9
--- /dev/null
+++ b/src/components/Note/PublicationIndexMetadata.tsx
@@ -0,0 +1,218 @@
+import { ExtendedKind } from '@/constants'
+import {
+ getPublicationIndexMetadataFromEvent,
+ type PublicationAuthor
+} from '@/lib/event-metadata'
+import { toNoteList } from '@/lib/link'
+import { cn } from '@/lib/utils'
+import { useSecondaryPageOptional } from '@/PageManager'
+import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
+import { BookOpen, ExternalLink } from 'lucide-react'
+import { Event, kinds } from 'nostr-tools'
+import { useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+import Image from '../Image'
+
+function formatAuthorLine(authors: PublicationAuthor[]): string {
+ if (authors.length === 0) return ''
+ return authors
+ .map(({ name, role }) => {
+ const normalizedRole = role?.trim().toLowerCase()
+ if (!normalizedRole || normalizedRole === 'author') return name
+ return `${name} (${role})`
+ })
+ .join(' · ')
+}
+
+function formatPublicationType(type: string): string {
+ return type
+ .split(/[\s_-]+/)
+ .filter(Boolean)
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
+ .join(' ')
+}
+
+function sourceHostname(source: string): string {
+ try {
+ return new URL(source).hostname.replace(/^www\./, '')
+ } catch {
+ return source
+ }
+}
+
+function MetaChip({ children, className }: { children: React.ReactNode; className?: string }) {
+ return (
+
+ {children}
+
+ )
+}
+
+export default function PublicationIndexMetadata({
+ event,
+ variant = 'compact',
+ showTitle = true,
+ className
+}: {
+ event: Event
+ variant?: 'compact' | 'full'
+ showTitle?: boolean
+ className?: string
+}) {
+ const { t } = useTranslation()
+ const secondaryPage = useSecondaryPageOptional()
+ const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url })
+ const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event)
+ const metadata = useMemo(() => getPublicationIndexMetadataFromEvent(event), [event])
+
+ if (event.kind !== ExtendedKind.PUBLICATION) return null
+
+ const authorLine = formatAuthorLine(metadata.authors)
+ const isFull = variant === 'full'
+ const title =
+ metadata.title?.trim() ||
+ event.tags.find((tag) => tag[0] === 'd')?.[1]?.replace(/-/g, ' ') ||
+ t('Publication Note')
+
+ const metaChips: React.ReactNode[] = []
+ if (metadata.type) {
+ metaChips.push(
{formatPublicationType(metadata.type)})
+ }
+ if (metadata.language) {
+ metaChips.push(
{metadata.language.toUpperCase()})
+ }
+ if (metadata.version) {
+ metaChips.push(
{t('Publication version', { version: metadata.version })})
+ }
+ if (metadata.sectionCount > 0) {
+ metaChips.push(
+
+ {t('Publication sections', { count: metadata.sectionCount })}
+
+ )
+ }
+
+ const tagsComponent =
+ metadata.tags.length > 0 ? (
+
+ {metadata.tags.map((tag) => (
+
+ ))}
+
+ ) : null
+
+ return (
+
+ {isFull && metadata.image?.trim() ? (
+
+ ) : isFull ? (
+
+
+
+ ) : null}
+
+ {showTitle ? (
+
+ {title}
+
+ ) : null}
+
+ {authorLine ? (
+
+ {authorLine}
+
+ ) : null}
+
+ {metaChips.length > 0 ? (
+
{metaChips}
+ ) : null}
+
+ {metadata.releaseDate ? (
+
+ {t('Publication released', { date: metadata.releaseDate })}
+
+ ) : null}
+
+ {metadata.summary ? (
+
+ {metadata.summary}
+
+ ) : null}
+
+ {metadata.source ? (
+
e.stopPropagation()}
+ >
+
+ {sourceHostname(metadata.source)}
+
+ ) : null}
+
+ {tagsComponent}
+
+ {isFull && metadata.sections.length > 0 ? (
+
+
+
+ {t('Publication table of contents')}
+
+
+ {metadata.sections.map((section, index) => (
+ -
+ {index + 1}.
+
+ {section.label ||
+ section.coordinate.split(':').pop()?.replace(/-/g, ' ') ||
+ section.coordinate}
+
+
+ ))}
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx
index 386ca7ea..a508b01e 100644
--- a/src/components/Note/index.tsx
+++ b/src/components/Note/index.tsx
@@ -12,7 +12,7 @@ import {
import { mergeNip84MarkedIntervals, renderPlaintextWithNip84MergedMarks } from '@/lib/nip84-op-body-marks'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
-import { encodeArticleLikePublicationNaddr, openAlexandriaPublicationFromNaddr, toNote } from '@/lib/link'
+import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils'
import {
DISCUSSION_DOWNVOTE_DISPLAY,
@@ -66,6 +66,7 @@ import LiveEvent from './LiveEvent'
import MarkdownArticle from './MarkdownArticle/MarkdownArticle'
import AsciidocArticle from './AsciidocArticle/AsciidocArticle'
import PublicationCard from './PublicationCard'
+import PublicationIndexMetadata from './PublicationIndexMetadata'
import NostrSpecCard from './NostrSpecCard'
import WikiCard from './WikiCard'
import LongFormCard from './LongFormCard'
@@ -76,7 +77,6 @@ import Poll from './Poll'
import NotificationEventCard from './NotificationEventCard'
import ReactionEmojiDisplay from './ReactionEmojiDisplay'
import UnknownNote from './UnknownNote'
-import { Button } from '@/components/ui/button'
import VideoNote from './VideoNote'
import MusicTrackNote from './MusicTrackNote'
import RelayReview from './RelayReview'
@@ -489,25 +489,7 @@ export default function Note({
)
} else if (event.kind === ExtendedKind.PUBLICATION) {
if (showFull) {
- const naddrFull = encodeArticleLikePublicationNaddr(displayEvent)
- content = (
-
-
- {naddrFull ? (
-
- ) : null}
-
- )
+ content =
} else {
content =
}
diff --git a/src/hooks/useLibraryPublications.ts b/src/hooks/useLibraryPublications.ts
index c932e5c7..da5d5271 100644
--- a/src/hooks/useLibraryPublications.ts
+++ b/src/hooks/useLibraryPublications.ts
@@ -1,26 +1,43 @@
import {
clearAllLibraryIndexCaches,
- filterLibraryPublicationsBySearch,
filterLibraryPublicationsByUser,
buildLibraryRelayUrls,
loadLibraryPublicationIndex,
- type LibraryPublicationEntry
+ peekLibrarySearchResults,
+ searchLibraryPublications,
+ searchLibraryPublicationsOnRelays,
+ type LibraryPublicationEntry,
+ type PublicationEngagementMaps
} from '@/lib/library-publication-index'
+import { getTopLevelIndexEvents } from '@/lib/publication-index'
import logger from '@/lib/logger'
import { useNostr } from '@/providers/NostrProvider'
+import type { Event } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
const SEARCH_DEBOUNCE_MS = 300
const LOAD_TIMEOUT_MS = 120_000
+const EMPTY_ENGAGEMENT: PublicationEngagementMaps = {
+ labelAddresses: new Set(),
+ labelEventIds: new Set(),
+ commentAddresses: new Set(),
+ highlightAddresses: new Set()
+}
+
export function useLibraryPublications(isActive: boolean) {
const { pubkey } = useNostr()
const [entries, setEntries] = useState
([])
+ const [indexEvents, setIndexEvents] = useState([])
+ const [engagement, setEngagement] = useState(EMPTY_ENGAGEMENT)
const [searchQuery, setSearchQuery] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [showOnlyMine, setShowOnlyMine] = useState(false)
const [loading, setLoading] = useState(false)
const [engagementLoading, setEngagementLoading] = useState(false)
+ const [searchLoading, setSearchLoading] = useState(false)
+ const [relaySearchLoading, setRelaySearchLoading] = useState(false)
+ const [searchResults, setSearchResults] = useState(null)
const [error, setError] = useState(null)
const [allIndexCount, setAllIndexCount] = useState(0)
const [topLevelCount, setTopLevelCount] = useState(0)
@@ -53,6 +70,7 @@ export function useLibraryPublications(isActive: boolean) {
onIndexesReady: (snapshot) => {
if (gen !== loadGenRef.current) return
setEntries(snapshot.engaged)
+ setIndexEvents(snapshot.indexEvents)
setAllIndexCount(snapshot.allIndexCount)
setTopLevelCount(snapshot.topLevelCount)
setLoading(false)
@@ -63,6 +81,8 @@ export function useLibraryPublications(isActive: boolean) {
])
if (gen !== loadGenRef.current) return
setEntries(result.engaged)
+ setIndexEvents(result.indexEvents)
+ setEngagement(result.engagement)
setAllIndexCount(result.allIndexCount)
setTopLevelCount(result.topLevelCount)
} finally {
@@ -90,20 +110,79 @@ export function useLibraryPublications(isActive: boolean) {
void load(false)
}, [isActive, load])
+ useEffect(() => {
+ const q = debouncedSearch.trim()
+ if (!q) {
+ setSearchResults(null)
+ setSearchLoading(false)
+ return
+ }
+
+ const cached = peekLibrarySearchResults(q, { indexEvents, engagement })
+ if (cached) {
+ setSearchResults(cached)
+ setSearchLoading(false)
+ return
+ }
+
+ let cancelled = false
+ setSearchLoading(true)
+ void searchLibraryPublications(q, { indexEvents, engagement }).then((results) => {
+ if (cancelled) return
+ setSearchResults(results)
+ setSearchLoading(false)
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [debouncedSearch, indexEvents, engagement])
+
const refresh = useCallback(() => {
void clearAllLibraryIndexCaches().then(() => load(true))
}, [load])
+ const searchOnRelays = useCallback(async () => {
+ const q = searchQuery.trim()
+ if (!q) return
+ setRelaySearchLoading(true)
+ setError(null)
+ try {
+ const relays = await buildLibraryRelayUrls(pubkey || undefined)
+ const { events, mergedIndexEvents, entries, fromCache } = await searchLibraryPublicationsOnRelays(
+ q,
+ relays,
+ { indexEvents, engagement }
+ )
+ setIndexEvents(mergedIndexEvents)
+ setAllIndexCount(mergedIndexEvents.length)
+ setTopLevelCount(getTopLevelIndexEvents(mergedIndexEvents).length)
+ if (import.meta.env.DEV) {
+ logger.info('[Library] relay search merged', {
+ newEvents: events.length,
+ fromCache
+ })
+ }
+ setSearchResults(entries)
+ } catch (e) {
+ const message = e instanceof Error ? e.message : 'Relay search failed'
+ setError(message)
+ if (import.meta.env.DEV) {
+ logger.warn('[Library] relay search failed', { message })
+ }
+ } finally {
+ setRelaySearchLoading(false)
+ }
+ }, [searchQuery, pubkey, indexEvents, engagement])
+
const filteredEntries = useMemo(() => {
- let list = entries
+ const q = debouncedSearch.trim()
+ let list = q ? (searchResults ?? []) : entries
if (showOnlyMine) {
list = filterLibraryPublicationsByUser(list, pubkey)
}
- if (debouncedSearch.trim()) {
- list = filterLibraryPublicationsBySearch(list, debouncedSearch)
- }
return list
- }, [entries, showOnlyMine, pubkey, debouncedSearch])
+ }, [entries, showOnlyMine, pubkey, debouncedSearch, searchResults])
return {
entries: filteredEntries,
@@ -113,9 +192,13 @@ export function useLibraryPublications(isActive: boolean) {
setShowOnlyMine,
loading,
engagementLoading,
+ searchLoading,
+ relaySearchLoading,
error,
allIndexCount,
topLevelCount,
- refresh
+ refresh,
+ searchOnRelays,
+ hasIndexData: indexEvents.length > 0
}
}
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index 43f690b2..3b449fdc 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -1649,16 +1649,24 @@ export default {
'Search on Alexandria': 'Mit Alexandria suchen',
Library: 'Bibliothek',
'Library page title': 'Bibliothek',
- 'Library search placeholder': 'Publikationen nach Titel, Autor oder Tag suchen…',
+ 'Library search placeholder': 'Publikationen nach Titel, Autor, Quelle, Tag oder Abschnitt suchen…',
'Library show only my publications': 'Nur meine Publikationen',
'Library empty': 'Noch keine Publikationen auf deinen Relays gefunden.',
'Library empty filtered': 'Keine Publikationen entsprechen den Filtern.',
'Library loading': 'Publikationen werden von Dokument-Relays geladen…',
'Library engagement loading': 'Engagement-Filter werden aktualisiert…',
+ 'Library search loading': 'Publikationen werden durchsucht…',
+ 'Library search relays': 'Relays durchsuchen',
+ 'Library relay search loading': 'Dokument-Relays werden durchsucht…',
'Library status line': '{{shown}} angezeigt · {{topLevel}} Top-Level · {{total}} Indizes geladen',
'Library badge label': 'Label',
'Library badge comment': 'Kommentar',
'Library badge highlight': 'Markierung',
+ 'Publication version': 'v{{version}}',
+ 'Publication sections_one': '{{count}} Abschnitt',
+ 'Publication sections_other': '{{count}} Abschnitte',
+ 'Publication released': 'Veröffentlicht {{date}}',
+ 'Publication table of contents': 'Inhalt',
'libraryIndexCache.sectionTitle': 'Bibliotheks-Publikationsindex',
'libraryIndexCache.sectionBlurb':
'Zwischengespeicherte Kind-30040-Index-Events für den Bibliotheks-Tab. Beim Leeren wird nur der Entdeckungslisten-Cache entfernt — geöffnete Publikationen bleiben im Lese-Cache.',
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 12081952..24e20822 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -1672,16 +1672,24 @@ export default {
'Search on Alexandria': 'Search on Alexandria',
Library: 'Library',
'Library page title': 'Library',
- 'Library search placeholder': 'Search publications by title, author, or tag…',
+ 'Library search placeholder': 'Search publications by title, author, source, tag, or section…',
'Library show only my publications': 'Show only my publications',
'Library empty': 'No publications found on your relays yet.',
'Library empty filtered': 'No publications match your filters.',
'Library loading': 'Loading publications from document relays…',
'Library engagement loading': 'Updating engagement filters…',
+ 'Library search loading': 'Searching publications…',
+ 'Library search relays': 'Search the relays',
+ 'Library relay search loading': 'Searching document relays…',
'Library status line': '{{shown}} shown · {{topLevel}} top-level · {{total}} indexes loaded',
'Library badge label': 'Label',
'Library badge comment': 'Comment',
'Library badge highlight': 'Highlight',
+ 'Publication version': 'v{{version}}',
+ 'Publication sections_one': '{{count}} section',
+ 'Publication sections_other': '{{count}} sections',
+ 'Publication released': 'Released {{date}}',
+ 'Publication table of contents': 'Contents',
'libraryIndexCache.sectionTitle': 'Library publication index',
'libraryIndexCache.sectionBlurb':
'Cached kind-30040 index events used to populate the Library tab. Clearing this only removes the discovery list cache—not publications you have opened for reading.',
diff --git a/src/lib/event-metadata.publication-index.test.ts b/src/lib/event-metadata.publication-index.test.ts
new file mode 100644
index 00000000..a7c68b9d
--- /dev/null
+++ b/src/lib/event-metadata.publication-index.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest'
+import { ExtendedKind } from '@/constants'
+import { getPublicationIndexMetadataFromEvent } from '@/lib/event-metadata'
+import type { Event } from 'nostr-tools'
+
+const PK = 'a'.repeat(64)
+
+function indexEvent(tags: string[][]): Event {
+ return {
+ id: '1'.repeat(64),
+ kind: ExtendedKind.PUBLICATION,
+ pubkey: PK,
+ created_at: 100,
+ content: '',
+ tags,
+ sig: 'c'.repeat(128)
+ }
+}
+
+describe('getPublicationIndexMetadataFromEvent', () => {
+ it('extracts NKBIP-01 index tags', () => {
+ const event = indexEvent([
+ ['d', 'little-clay-cart'],
+ ['title', 'The Little Clay Cart'],
+ ['author', 'Sudraka', 'author'],
+ ['author', 'Arthur W. Ryder', 'translator'],
+ ['source', 'https://www.gutenberg.org/ebooks/21020'],
+ ['l', 'en', 'ISO-639-1'],
+ ['release_date', 'April 10, 2007'],
+ ['type', 'book'],
+ ['version', '1.0'],
+ ['summary', 'A classic Sanskrit play.'],
+ ['a', `30041:${PK}:chapter-1`, 'wss://relay.example', 'Chapter One'],
+ ['a', `30041:${PK}:chapter-2`]
+ ])
+
+ const meta = getPublicationIndexMetadataFromEvent(event)
+
+ expect(meta.title).toBe('The Little Clay Cart')
+ expect(meta.authors).toEqual([
+ { name: 'Sudraka', role: 'author' },
+ { name: 'Arthur W. Ryder', role: 'translator' }
+ ])
+ expect(meta.source).toBe('https://www.gutenberg.org/ebooks/21020')
+ expect(meta.language).toBe('en')
+ expect(meta.releaseDate).toBe('April 10, 2007')
+ expect(meta.type).toBe('book')
+ expect(meta.version).toBe('1.0')
+ expect(meta.summary).toBe('A classic Sanskrit play.')
+ expect(meta.sectionCount).toBe(2)
+ expect(meta.sections[0].label).toBe('Chapter One')
+ expect(meta.sections[1].label).toBeUndefined()
+ })
+
+ it('falls back to d-tag title casing', () => {
+ const event = indexEvent([['d', 'village-life-in-china'], ['a', `30041:${PK}:intro`]])
+ const meta = getPublicationIndexMetadataFromEvent(event)
+ expect(meta.title).toBe('Village Life In China')
+ expect(meta.sectionCount).toBe(1)
+ })
+})
diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts
index 61e0d577..b2646960 100644
--- a/src/lib/event-metadata.ts
+++ b/src/lib/event-metadata.ts
@@ -655,6 +655,82 @@ export function getLongFormArticleMetadataFromEvent(event: Event) {
return { title, summary, image, tags: Array.from(tags) }
}
+export type PublicationAuthor = {
+ name: string
+ role?: string
+}
+
+export type PublicationSectionRef = {
+ coordinate: string
+ label?: string
+}
+
+export type PublicationIndexMetadata = {
+ title?: string
+ summary?: string
+ image?: string
+ tags: string[]
+ authors: PublicationAuthor[]
+ source?: string
+ type?: string
+ version?: string
+ releaseDate?: string
+ language?: string
+ sectionCount: number
+ sections: PublicationSectionRef[]
+}
+
+/** NKBIP-01 kind 30040 index metadata from tags (content is always empty). */
+export function getPublicationIndexMetadataFromEvent(event: Event): PublicationIndexMetadata {
+ const base = getLongFormArticleMetadataFromEvent(event)
+ const authors: PublicationAuthor[] = []
+ const sections: PublicationSectionRef[] = []
+ let source: string | undefined
+ let type: string | undefined
+ let version: string | undefined
+ let releaseDate: string | undefined
+ let language: string | undefined
+
+ for (const tag of event.tags) {
+ const name = (tag[0] || '').trim().toLowerCase()
+ const value = tag[1]?.trim()
+ if (!value) continue
+
+ if (name === 'author') {
+ const role = tag[2]?.trim()
+ authors.push({ name: value, role: role || undefined })
+ } else if (name === 'source') {
+ source = value
+ } else if (name === 'type') {
+ type = value
+ } else if (name === 'version') {
+ version = value
+ } else if (name === 'release_date') {
+ releaseDate = value
+ } else if (name === 'l' && !language) {
+ language = value
+ } else if (name === 'a') {
+ const label = tag[3]?.trim() || tag[2]?.trim()
+ sections.push({
+ coordinate: value,
+ label: label && !label.startsWith('wss://') && !label.startsWith('ws://') ? label : undefined
+ })
+ }
+ }
+
+ return {
+ ...base,
+ authors,
+ source,
+ type,
+ version,
+ releaseDate,
+ language,
+ sectionCount: sections.length,
+ sections
+ }
+}
+
export function getLiveEventMetadataFromEvent(event: Event) {
let title: string | undefined
let room: string | undefined
diff --git a/src/lib/general-search-text-match.ts b/src/lib/general-search-text-match.ts
index b2e54b94..92fc0ef1 100644
--- a/src/lib/general-search-text-match.ts
+++ b/src/lib/general-search-text-match.ts
@@ -46,6 +46,9 @@ const GENERAL_SEARCH_TEXT_TAG_NAMES = new Set([
'location',
'editor',
'version',
+ 'source',
+ 'type',
+ 'release_date',
'llm'
])
diff --git a/src/lib/index-relay-http.ts b/src/lib/index-relay-http.ts
index dfb3ec8a..a3e70e02 100644
--- a/src/lib/index-relay-http.ts
+++ b/src/lib/index-relay-http.ts
@@ -41,6 +41,9 @@ function nostrFilterToIndexRelayBody(f: Filter): Record {
if (f.kinds?.length) body.kinds = f.kinds
if (f.since != null) body.since = f.since
if (f.until != null) body.until = f.until
+ if (typeof f.search === 'string' && f.search.trim()) {
+ body.search = f.search.trim()
+ }
/** Index relays expect NIP-01 lowercase single-letter tag keys (`#e` not `#E`). */
const tagBuckets = new Map()
for (const key of Object.keys(f)) {
@@ -417,6 +420,68 @@ export async function queryIndexRelayForLibrary(
}
}
+/** Kind-30040 discovery search: keeps NIP-50 `search` (unlike bulk {@link queryIndexRelayForLibrary}). */
+export async function queryIndexRelayPublicationSearch(
+ baseUrl: string,
+ filter: Filter,
+ options?: { signal?: AbortSignal }
+): Promise {
+ const base = devHttpIndexRelayBaseForFetch(baseUrl)
+ const endpoint = indexRelayFilterUrl(base)
+ if (shouldSkipDevIndexRelayFetch(endpoint)) {
+ return { events: [], apiRowCount: 0 }
+ }
+
+ const body = nostrFilterToIndexRelayBody(filter)
+ try {
+ const res = await fetchWithTimeout(endpoint, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(body),
+ signal: options?.signal,
+ timeoutMs: 25_000
+ })
+ if (!res.ok) {
+ if (res.status >= 500) {
+ markDevIndexRelayUnavailableFromHttpStatus(res.status, endpoint)
+ throw new IndexRelayTransportError(new Error(`HTTP ${res.status}`))
+ }
+ return { events: [], apiRowCount: 0 }
+ }
+ clearDevIndexRelayUnavailableThisSession()
+ const json = (await res.json()) as { data?: unknown }
+ const data = json.data
+ if (!Array.isArray(data)) return { events: [], apiRowCount: 0 }
+
+ const events: NEvent[] = []
+ const seen = new Set()
+ for (const item of data) {
+ if (!item || typeof item !== 'object') continue
+ const ev = rawToIndexRelayEvent(item as Record)
+ if (ev && !seen.has(ev.id)) {
+ seen.add(ev.id)
+ events.push(ev)
+ }
+ }
+ return { events, apiRowCount: data.length }
+ } catch (e) {
+ if ((e as Error).name === 'AbortError') throw e
+ if (e instanceof IndexRelayTransportError) throw e
+ if (isIndexRelayTransportFailure(e)) {
+ handleFilterTransportFailure(endpoint, e)
+ throw new IndexRelayTransportError(e)
+ }
+ warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] publication search request error', {
+ endpoint,
+ error: e
+ })
+ return { events: [], apiRowCount: 0 }
+ }
+}
+
function filterForIndexRelay(f: Filter): Filter {
const rest = { ...f } as Filter & { search?: unknown }
delete rest.search
diff --git a/src/lib/library-publication-index.test.ts b/src/lib/library-publication-index.test.ts
index e7a4278d..352ca7de 100644
--- a/src/lib/library-publication-index.test.ts
+++ b/src/lib/library-publication-index.test.ts
@@ -2,10 +2,17 @@ import { describe, expect, it } from 'vitest'
import { ExtendedKind } from '@/constants'
import {
buildEngagementMapsFromEvents,
+ buildLibraryPublicationRelaySearchFilters,
buildRecentPublicationEntries,
+ clearLibrarySearchSessionCache,
filterEngagedPublications,
filterLibraryPublicationsBySearch,
- pickLibraryPublicationEntries
+ pickLibraryPublicationEntries,
+ peekLibrarySearchResults,
+ publicationIndexMatchesSearchQuery,
+ publicationQueryDTagVariants,
+ searchLibraryPublicationIndex,
+ searchLibraryPublications
} from '@/lib/library-publication-index'
import { buildIndexByAddress } from '@/lib/publication-index'
import type { Event } from 'nostr-tools'
@@ -84,6 +91,93 @@ describe('library-publication-index', () => {
expect(filterLibraryPublicationsBySearch(entries, 'missing')).toHaveLength(0)
})
+ it('publicationIndexMatchesSearchQuery matches author, source, and section labels', () => {
+ const root = indexEvent('book', [`30041:${PK}:intro`])
+ root.tags.push(['author', 'Sudraka', 'author'])
+ root.tags.push(['source', 'https://www.gutenberg.org/ebooks/21020'])
+ root.tags.push(['type', 'book'])
+ root.tags.push(['a', `30041:${PK}:intro`, 'wss://relay.example', 'Introduction'])
+
+ expect(publicationIndexMatchesSearchQuery(root, 'sudraka')).toBe(true)
+ expect(publicationIndexMatchesSearchQuery(root, 'gutenberg')).toBe(true)
+ expect(publicationIndexMatchesSearchQuery(root, 'introduction')).toBe(true)
+ expect(publicationIndexMatchesSearchQuery(root, 'missing')).toBe(false)
+ })
+
+ it('buildLibraryPublicationRelaySearchFilters uses kind 30040 for d-tag and search', () => {
+ expect(publicationQueryDTagVariants('Village Life in China')).toContain('village-life-in-china')
+
+ const filters = buildLibraryPublicationRelaySearchFilters({ query: 'Village Life in China' })
+ expect(filters.length).toBeGreaterThan(0)
+ expect(filters.every((f) => f.kinds?.length === 1 && f.kinds[0] === ExtendedKind.PUBLICATION)).toBe(
+ true
+ )
+
+ const dFilter = filters.find((f) => f['#d'])
+ expect(dFilter?.['#d']).toContain('village-life-in-china')
+
+ const searchFilter = filters.find((f) => f.search === 'Village Life in China')
+ expect(searchFilter?.kinds).toEqual([ExtendedKind.PUBLICATION])
+ })
+
+ it('searchLibraryPublications caches results for repeated queries', async () => {
+ clearLibrarySearchSessionCache()
+ const root = indexEvent('book', [`30041:${PK}:intro`])
+ root.tags = [['d', 'book'], ['title', 'Title book'], ['a', `30041:${PK}:intro`]]
+ const indexEvents = [root]
+ const engagement = buildEngagementMapsFromEvents([], [], [])
+
+ const first = await searchLibraryPublications('title book', { indexEvents, engagement })
+ expect(first).toHaveLength(1)
+
+ const peeked = peekLibrarySearchResults('title book', { indexEvents, engagement })
+ expect(peeked?.map((e) => e.event.id)).toEqual([root.id])
+
+ const second = await searchLibraryPublications('title book', { indexEvents, engagement })
+ expect(second.map((e) => e.event.id)).toEqual([root.id])
+ })
+
+ it('searchLibraryPublications cache invalidates when index corpus changes', async () => {
+ clearLibrarySearchSessionCache()
+ const root = indexEvent('book', [`30041:${PK}:intro`])
+ root.tags = [['d', 'book'], ['title', 'Title book'], ['a', `30041:${PK}:intro`]]
+ const other = indexEvent('other', [`30041:${PK}:ch`])
+ other.tags = [['d', 'other'], ['title', 'Other title'], ['a', `30041:${PK}:ch`]]
+ const engagement = buildEngagementMapsFromEvents([], [], [])
+
+ await searchLibraryPublications('title book', { indexEvents: [root, other], engagement })
+ expect(peekLibrarySearchResults('title book', { indexEvents: [root, other], engagement })).toHaveLength(1)
+ expect(peekLibrarySearchResults('title book', { indexEvents: [root], engagement })).toBeNull()
+
+ const results = await searchLibraryPublications('other title', {
+ indexEvents: [root, other],
+ engagement
+ })
+ expect(results).toHaveLength(1)
+ expect(results[0].event.id).toBe(other.id)
+ })
+
+ it('searchLibraryPublicationIndex searches all indexes and maps nested hits to roots', () => {
+ const leafAddr = `30041:${PK}:chapter-1`
+ const childAddr = `30040:${PK}:part-1`
+ const root = indexEvent('book', [childAddr])
+ root.tags = [['d', 'book'], ['title', 'Root Book Title'], ['a', childAddr]]
+ const child = indexEvent('part-1', [leafAddr], '2'.repeat(64))
+ child.tags = [
+ ['d', 'part-1'],
+ ['title', 'Part One'],
+ ['a', leafAddr, 'wss://relay.example', 'Chapter One']
+ ]
+ const indexEvents = [root, child]
+ const indexByAddress = buildIndexByAddress(indexEvents)
+
+ const byRootTitle = searchLibraryPublicationIndex('root book', indexEvents, indexByAddress)
+ expect(byRootTitle.map((ev) => ev.id)).toEqual([root.id])
+
+ const bySection = searchLibraryPublicationIndex('chapter one', indexEvents, indexByAddress)
+ expect(bySection.map((ev) => ev.id)).toEqual([root.id])
+ })
+
it('pickLibraryPublicationEntries falls back to newest roots without engagement', () => {
const older = indexEvent('old-book', [`30041:${PK}:a`], '1'.repeat(64))
older.created_at = 10
diff --git a/src/lib/library-publication-index.ts b/src/lib/library-publication-index.ts
index 42d6e7e2..a222ab5f 100644
--- a/src/lib/library-publication-index.ts
+++ b/src/lib/library-publication-index.ts
@@ -1,12 +1,20 @@
import { ExtendedKind, LIBRARY_RELAY_URLS } from '@/constants'
+import {
+ eventMatchesGeneralSearchQuery,
+ generalSearchHaystack,
+ generalSearchQueryTerms,
+ normalizeGeneralSearchQuery
+} from '@/lib/general-search-text-match'
+import { normalizeToDTag, parseAdvancedSearch } from '@/lib/search-parser'
import logger from '@/lib/logger'
-import { queryIndexRelay, queryIndexRelayForLibrary } from '@/lib/index-relay-http'
+import { queryIndexRelay, queryIndexRelayForLibrary, queryIndexRelayPublicationSearch } from '@/lib/index-relay-http'
import {
buildIndexByAddress,
collectPublicationIndexEventIds,
collectReachableAddressesCached,
eventTagAddress,
filterValidIndexEvents,
+ getReferencedChild30040Addresses,
getTopLevelIndexEvents,
hydrateNestedIndexEvents
} from '@/lib/publication-index'
@@ -37,6 +45,9 @@ const MAX_TARGET_ADDRESSES = 480
const HYDRATE_MISSING_CAP = 64
export const LIBRARY_RECENT_FALLBACK_LIMIT = 120
const ENGAGEMENT_FETCH_TIMEOUT_MS = 25_000
+const LIBRARY_SEARCH_READING_CACHE_LIMIT = 200
+export const LIBRARY_RELAY_SEARCH_LIMIT = 100
+const LIBRARY_RELAY_SEARCH_TIMEOUT_MS = 28_000
const QUERY_OPTS = {
globalTimeout: 18_000,
eoseTimeout: 3_000,
@@ -67,6 +78,69 @@ type LibraryIndexCache = {
let sessionCache: LibraryIndexCache | null = null
+type LibrarySearchSessionRow = {
+ fingerprint: string
+ entries: LibraryPublicationEntry[]
+ mergedIndexEvents: Event[]
+ relaySearched: boolean
+}
+
+const librarySearchSessionCache = new Map()
+
+function librarySearchQueryKey(query: string): string {
+ return normalizeGeneralSearchQuery(query).toLowerCase()
+}
+
+function librarySearchFingerprint(context: LibrarySearchContext): string {
+ const engagement = context.engagement
+ const engagementSize = engagement
+ ? engagement.labelAddresses.size +
+ engagement.labelEventIds.size +
+ engagement.commentAddresses.size +
+ engagement.highlightAddresses.size
+ : 0
+ return `${context.indexEvents.length}:${engagementSize}`
+}
+
+function getLibrarySearchSessionRow(
+ query: string,
+ context: LibrarySearchContext,
+ opts?: { requireRelaySearch?: boolean }
+): LibrarySearchSessionRow | null {
+ const key = librarySearchQueryKey(query)
+ if (!key) return null
+ const row = librarySearchSessionCache.get(key)
+ if (!row) return null
+ if (row.fingerprint !== librarySearchFingerprint(context)) return null
+ if (opts?.requireRelaySearch && !row.relaySearched) return null
+ return row
+}
+
+function putLibrarySearchSessionRow(
+ query: string,
+ context: LibrarySearchContext,
+ row: Omit
+): void {
+ const key = librarySearchQueryKey(query)
+ if (!key) return
+ librarySearchSessionCache.set(key, {
+ ...row,
+ fingerprint: librarySearchFingerprint(context)
+ })
+}
+
+/** Sync read of cached search hits for the current index + engagement snapshot. */
+export function peekLibrarySearchResults(
+ query: string,
+ context: LibrarySearchContext
+): LibraryPublicationEntry[] | null {
+ return getLibrarySearchSessionRow(query, context)?.entries ?? null
+}
+
+export function clearLibrarySearchSessionCache(): void {
+ librarySearchSessionCache.clear()
+}
+
function relaySetKey(urls: string[]): string {
return [...new Set(urls.map((u) => normalizeUrl(u) || u))].sort().join('|')
}
@@ -448,8 +522,201 @@ export function sortLibraryPublications(entries: LibraryPublicationEntry[]): Lib
})
}
-function normalizeSearchQuery(query: string): string {
- return query.trim().toLowerCase()
+const EMPTY_ENGAGEMENT: PublicationEngagementMaps = {
+ labelAddresses: new Set(),
+ labelEventIds: new Set(),
+ commentAddresses: new Set(),
+ highlightAddresses: new Set()
+}
+
+/** Haystack for kind-30040 index search: general fields plus section refs and language tags. */
+export function publicationIndexSearchHaystack(event: Event): string {
+ const base = generalSearchHaystack(event)
+ if (event.kind !== ExtendedKind.PUBLICATION) return base
+
+ const extra: string[] = []
+ for (const tag of event.tags ?? []) {
+ const name = (tag[0] || '').trim().toLowerCase()
+ if (name === 'l' && tag[1]?.trim()) {
+ extra.push(tag[1].trim())
+ } else if (name === 'a') {
+ const coord = tag[1]?.trim()
+ if (coord) extra.push(coord.replace(/:/g, ' ').replace(/-/g, ' '))
+ const label = tag[3]?.trim() || (tag[2]?.trim() && !/^wss?:\/\//i.test(tag[2]) ? tag[2].trim() : '')
+ if (label) extra.push(label)
+ }
+ }
+ if (extra.length === 0) return base
+ return `${base}\n${extra.join('\n')}`.toLowerCase()
+}
+
+export function publicationIndexMatchesSearchQuery(event: Event, query: string): boolean {
+ if (eventMatchesGeneralSearchQuery(event, query)) return true
+ if (event.kind !== ExtendedKind.PUBLICATION) return false
+
+ const raw = query.trim()
+ if (!raw) return false
+
+ const haystack = publicationIndexSearchHaystack(event)
+ const normalized = normalizeGeneralSearchQuery(raw).toLowerCase()
+ const qSpace = normalized.replace(/-/g, ' ')
+ const needles = qSpace !== normalized ? [normalized, qSpace] : [normalized]
+ for (const needle of needles) {
+ if (needle && haystack.includes(needle)) return true
+ }
+
+ const words = generalSearchQueryTerms(raw)
+ if (words.length >= 2 && words.every((w) => haystack.includes(w))) return true
+ return false
+}
+
+function buildAddressToRootMap(
+ topLevel: Event[],
+ indexByAddress: Map
+): Map {
+ const map = new Map()
+ for (const root of topLevel) {
+ const rootAddr = eventTagAddress(root)
+ if (rootAddr) map.set(rootAddr, root)
+ for (const addr of collectReachableAddressesCached(root, indexByAddress)) {
+ map.set(addr, root)
+ }
+ }
+ return map
+}
+
+function libraryEntriesFromRoots(
+ roots: Event[],
+ indexByAddress: Map,
+ engagement: PublicationEngagementMaps
+): LibraryPublicationEntry[] {
+ return roots.map((root) => {
+ const engaged = filterEngagedPublications([root], indexByAddress, engagement)
+ if (engaged.length > 0) return engaged[0]
+ return {
+ event: root,
+ hasLabel: false,
+ hasComment: false,
+ hasHighlight: false,
+ engagementCount: 0
+ }
+ })
+}
+
+/** Search all cached kind-30040 indexes (library index store), mapping nested hits to top-level roots. */
+export function searchLibraryPublicationIndex(
+ query: string,
+ indexEvents: Event[],
+ indexByAddress: Map
+): Event[] {
+ const q = query.trim()
+ if (!q || indexEvents.length === 0) return []
+
+ const topLevel = getTopLevelIndexEvents(indexEvents)
+ const topLevelIds = new Set(topLevel.map((ev) => ev.id))
+ const addressToRoot = buildAddressToRootMap(topLevel, indexByAddress)
+ const roots = new Map()
+
+ for (const ev of indexEvents) {
+ if (ev.kind !== ExtendedKind.PUBLICATION) continue
+ if (!publicationIndexMatchesSearchQuery(ev, q)) continue
+
+ if (topLevelIds.has(ev.id)) {
+ roots.set(ev.id, ev)
+ continue
+ }
+
+ const addr = eventTagAddress(ev)
+ const root = addr ? addressToRoot.get(addr) : undefined
+ if (root) roots.set(root.id, root)
+ }
+
+ return [...roots.values()]
+}
+
+export type LibrarySearchContext = {
+ indexEvents: Event[]
+ engagement?: PublicationEngagementMaps
+}
+
+/**
+ * Search publications across the library index cache (all loaded kind-30040 rows) and the
+ * publication reading cache ({@link StoreNames.PUBLICATION_EVENTS}).
+ */
+export async function searchLibraryPublications(
+ query: string,
+ context: LibrarySearchContext
+): Promise {
+ const q = query.trim()
+ if (!q) return []
+
+ const cached = getLibrarySearchSessionRow(q, context)
+ if (cached) {
+ if (import.meta.env.DEV) {
+ logger.info('[Library] search cache hit', { query: q, relaySearched: cached.relaySearched })
+ }
+ return cached.entries
+ }
+
+ let indexEvents = context.indexEvents
+ if (indexEvents.length === 0) {
+ const cachedIndex = await loadLibraryIndexCacheEvents()
+ indexEvents = filterValidIndexEvents(cachedIndex)
+ }
+
+ const engagement = context.engagement ?? EMPTY_ENGAGEMENT
+ const indexByAddress = buildIndexByAddress(indexEvents)
+ const fromIndex = searchLibraryPublicationIndex(q, indexEvents, indexByAddress)
+ const rootMap = new Map()
+ for (const root of fromIndex) rootMap.set(root.id, root)
+
+ const topLevel = getTopLevelIndexEvents(indexEvents)
+ const addressToRoot = buildAddressToRootMap(topLevel, indexByAddress)
+
+ try {
+ const fromReadingCache = await indexedDb.getCachedEventsForSearch(
+ q,
+ LIBRARY_SEARCH_READING_CACHE_LIMIT,
+ [ExtendedKind.PUBLICATION],
+ { scanBudget: 12_000, collectCap: 400 }
+ )
+ for (const ev of fromReadingCache) {
+ if (ev.kind !== ExtendedKind.PUBLICATION) continue
+ if (!publicationIndexMatchesSearchQuery(ev, q)) continue
+ if (rootMap.has(ev.id)) continue
+
+ const addr = eventTagAddress(ev)
+ const indexedRoot = addr ? addressToRoot.get(addr) : undefined
+ if (indexedRoot) {
+ rootMap.set(indexedRoot.id, indexedRoot)
+ continue
+ }
+
+ if (filterValidIndexEvents([ev]).length === 0) continue
+ const referenced = getReferencedChild30040Addresses(indexEvents)
+ if (addr && referenced.has(addr)) continue
+ rootMap.set(ev.id, ev)
+ }
+ } catch (e) {
+ if (import.meta.env.DEV) {
+ logger.warn('[Library] reading-cache search failed', {
+ message: e instanceof Error ? e.message : String(e)
+ })
+ }
+ }
+
+ const roots = [...rootMap.values()]
+ const entries = sortLibraryPublications(libraryEntriesFromRoots(roots, indexByAddress, engagement))
+
+ const searchContext: LibrarySearchContext = { indexEvents, engagement }
+ const prev = getLibrarySearchSessionRow(q, searchContext)
+ putLibrarySearchSessionRow(q, searchContext, {
+ entries,
+ mergedIndexEvents: prev?.mergedIndexEvents ?? indexEvents,
+ relaySearched: prev?.relaySearched ?? false
+ })
+
+ return entries
}
function tryNpubFromQuery(query: string): string | null {
@@ -466,11 +733,228 @@ function tryNpubFromQuery(query: string): string | null {
return null
}
+/** NIP-54-style d-tag slug (matches publication draft normalization). */
+function normalizePublicationDTag(term: string): string {
+ return term
+ .toLowerCase()
+ .replace(/[^a-z0-9]/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '')
+}
+
+/** d-tag filter values: hyphenated slug variants for relay `#d` REQ. */
+export function publicationQueryDTagVariants(query: string): string[] {
+ const raw = query.trim()
+ if (!raw) return []
+ const seen = new Set()
+ const add = (value: string) => {
+ const v = value.trim().toLowerCase()
+ if (v) seen.add(v)
+ }
+ add(normalizeToDTag(raw))
+ add(normalizePublicationDTag(raw))
+ add(raw.toLowerCase().replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''))
+ return [...seen]
+}
+
+/**
+ * OR-merge REQ filters for kind **30040** publication indexes: `#d` slugs plus NIP-50 `search`
+ * (title, author, summary/description on index relays).
+ */
+export function buildLibraryPublicationRelaySearchFilters(opts: {
+ query: string
+ limit?: number
+}): Filter[] {
+ const searchRaw = opts.query.trim()
+ if (!searchRaw) return []
+
+ const limit = Math.max(1, Math.min(opts.limit ?? LIBRARY_RELAY_SEARCH_LIMIT, 100))
+ const kind = ExtendedKind.PUBLICATION
+ const seen = new Set()
+ const out: Filter[] = []
+ const add = (filter: Filter) => {
+ const key = JSON.stringify(filter)
+ if (seen.has(key)) return
+ seen.add(key)
+ out.push(filter)
+ }
+
+ const npub = tryNpubFromQuery(searchRaw)
+ if (npub) {
+ add({ kinds: [kind], authors: [npub], limit })
+ return out
+ }
+
+ const dTags = publicationQueryDTagVariants(searchRaw)
+ if (dTags.length > 0) {
+ add({ kinds: [kind], '#d': dTags, limit })
+ }
+
+ const searchNorm = normalizeGeneralSearchQuery(searchRaw)
+ add({ kinds: [kind], search: searchRaw, limit })
+ if (searchNorm !== searchRaw) {
+ add({ kinds: [kind], search: searchNorm, limit })
+ }
+
+ const adv = parseAdvancedSearch(searchRaw)
+ const titleValues = adv.title
+ ? Array.isArray(adv.title)
+ ? adv.title
+ : [adv.title]
+ : []
+ for (const title of titleValues) {
+ const t = title.trim()
+ if (!t) continue
+ add({ kinds: [kind], search: t, limit })
+ const titleDTags = publicationQueryDTagVariants(t)
+ if (titleDTags.length > 0) {
+ add({ kinds: [kind], '#d': titleDTags, limit })
+ }
+ }
+
+ const authorValues = adv.author
+ ? Array.isArray(adv.author)
+ ? adv.author
+ : [adv.author]
+ : []
+ for (const author of authorValues) {
+ const a = author.trim()
+ if (a) add({ kinds: [kind], search: a, limit })
+ }
+
+ const descriptionValues = adv.description
+ ? Array.isArray(adv.description)
+ ? adv.description
+ : [adv.description]
+ : []
+ for (const description of descriptionValues) {
+ const d = description.trim()
+ if (d) add({ kinds: [kind], search: d, limit })
+ }
+
+ return out
+}
+
+/** Query document relays for kind-30040 indexes matching {@link buildLibraryPublicationRelaySearchFilters}. */
+export async function searchLibraryPublicationsOnRelays(
+ query: string,
+ relayUrls: string[],
+ context: LibrarySearchContext,
+ options?: { forceRefresh?: boolean }
+): Promise<{
+ events: Event[]
+ entries: LibraryPublicationEntry[]
+ mergedIndexEvents: Event[]
+ fromCache: boolean
+}> {
+ const q = query.trim()
+ if (!q) {
+ return { events: [], entries: [], mergedIndexEvents: context.indexEvents ?? [], fromCache: false }
+ }
+
+ if (!options?.forceRefresh) {
+ const cached = getLibrarySearchSessionRow(q, context, { requireRelaySearch: true })
+ if (cached) {
+ if (import.meta.env.DEV) {
+ logger.info('[Library] relay search cache hit', { query: q })
+ }
+ return {
+ events: [],
+ entries: cached.entries,
+ mergedIndexEvents: cached.mergedIndexEvents,
+ fromCache: true
+ }
+ }
+ }
+
+ const filters = buildLibraryPublicationRelaySearchFilters({ query: q })
+ if (filters.length === 0) {
+ return { events: [], entries: [], mergedIndexEvents: context.indexEvents ?? [], fromCache: false }
+ }
+
+ const indexRelays = libraryIndexRelayUrls(relayUrls)
+ const { wsRelays, httpRelays } = splitWsAndHttpRelays(indexRelays)
+ const batches: Promise[] = []
+
+ if (wsRelays.length > 0) {
+ batches.push(
+ queryService
+ .fetchEvents(wsRelays, filters, {
+ globalTimeout: LIBRARY_RELAY_SEARCH_TIMEOUT_MS,
+ eoseTimeout: 8_000,
+ firstRelayResultGraceMs: false
+ })
+ .catch((e) => {
+ if (import.meta.env.DEV) {
+ logger.warn('[Library] WS publication search failed', {
+ message: e instanceof Error ? e.message : String(e)
+ })
+ }
+ return [] as Event[]
+ })
+ )
+ }
+
+ for (const httpRelay of httpRelays) {
+ for (const filter of filters) {
+ batches.push(
+ queryIndexRelayPublicationSearch(httpRelay, filter)
+ .then((page) => page.events as Event[])
+ .catch((e) => {
+ if (import.meta.env.DEV) {
+ logger.warn('[Library] HTTP publication search failed', {
+ relay: httpRelay,
+ message: e instanceof Error ? e.message : String(e)
+ })
+ }
+ return [] as Event[]
+ })
+ )
+ }
+ }
+
+ const settled = await Promise.all(batches)
+ const networkEvents = dedupeEventsById(settled.flat())
+ const valid = filterValidIndexEvents(networkEvents)
+ if (valid.length > 0) {
+ void persistLibraryIndexCacheEvents(valid)
+ }
+
+ const mergedIndex = dedupeEventsById([...(context.indexEvents ?? []), ...valid])
+ const indexByAddress = buildIndexByAddress(mergedIndex)
+ const roots = searchLibraryPublicationIndex(q, mergedIndex, indexByAddress)
+ const engagement = context.engagement ?? EMPTY_ENGAGEMENT
+ const entries = sortLibraryPublications(
+ libraryEntriesFromRoots(roots, indexByAddress, engagement)
+ )
+
+ const searchContext: LibrarySearchContext = {
+ indexEvents: mergedIndex,
+ engagement
+ }
+ putLibrarySearchSessionRow(q, searchContext, {
+ entries,
+ mergedIndexEvents: mergedIndex,
+ relaySearched: true
+ })
+
+ if (import.meta.env.DEV) {
+ logger.info('[Library] relay search done', {
+ filters: filters.length,
+ network: networkEvents.length,
+ valid: valid.length,
+ roots: roots.length
+ })
+ }
+
+ return { events: valid, entries, mergedIndexEvents: mergedIndex, fromCache: false }
+}
+
export function filterLibraryPublicationsBySearch(
entries: LibraryPublicationEntry[],
query: string
): LibraryPublicationEntry[] {
- const q = normalizeSearchQuery(query)
+ const q = query.trim()
if (!q) return entries
const npub = tryNpubFromQuery(q)
@@ -478,20 +962,7 @@ export function filterLibraryPublicationsBySearch(
return entries.filter(({ event }) => event.pubkey.toLowerCase() === npub)
}
- return entries.filter(({ event }) => {
- const title = event.tags.find((t) => t[0] === 'title')?.[1]?.toLowerCase() ?? ''
- const author = event.tags.find((t) => t[0] === 'author')?.[1]?.toLowerCase() ?? ''
- const nip05 = event.tags.find((t) => t[0] === 'nip05')?.[1]?.toLowerCase() ?? ''
- const dTag = event.tags.find((t) => t[0] === 'd')?.[1]?.toLowerCase() ?? ''
- const pubkey = event.pubkey.toLowerCase()
- return (
- title.includes(q) ||
- author.includes(q) ||
- nip05.includes(q) ||
- dTag.includes(q) ||
- pubkey.includes(q)
- )
- })
+ return entries.filter(({ event }) => publicationIndexMatchesSearchQuery(event, q))
}
export function filterLibraryPublicationsByUser(
@@ -550,12 +1021,15 @@ export async function loadLibraryPublicationIndex(
engaged: LibraryPublicationEntry[]
allIndexCount: number
topLevelCount: number
+ indexEvents: Event[]
}) => void
}
): Promise<{
engaged: LibraryPublicationEntry[]
allIndexCount: number
topLevelCount: number
+ indexEvents: Event[]
+ engagement: PublicationEngagementMaps
}> {
const key = relaySetKey(relayUrls)
if (import.meta.env.DEV) {
@@ -575,7 +1049,9 @@ export async function loadLibraryPublicationIndex(
return {
engaged,
allIndexCount: sessionCache.indexEvents.length,
- topLevelCount: getTopLevelIndexEvents(sessionCache.indexEvents).length
+ topLevelCount: getTopLevelIndexEvents(sessionCache.indexEvents).length,
+ indexEvents: sessionCache.indexEvents,
+ engagement: sessionCache.engagement
}
}
@@ -590,7 +1066,8 @@ export async function loadLibraryPublicationIndex(
options?.onIndexesReady?.({
engaged: buildRecentPublicationEntries(topLevel),
allIndexCount: indexEvents.length,
- topLevelCount: topLevel.length
+ topLevelCount: topLevel.length,
+ indexEvents
})
const topLevelForHydrate = topLevel
@@ -669,17 +1146,21 @@ export async function loadLibraryPublicationIndex(
return {
engaged,
allIndexCount: indexEvents.length,
- topLevelCount: topLevel.length
+ topLevelCount: topLevel.length,
+ indexEvents,
+ engagement
}
}
export function clearLibraryPublicationIndexCache(): void {
sessionCache = null
+ clearLibrarySearchSessionCache()
}
/** Clears Library tab session + IDB index cache only (publication reading cache is unchanged). */
export async function clearAllLibraryIndexCaches(): Promise {
sessionCache = null
+ clearLibrarySearchSessionCache()
await clearLibraryIndexIdbCache()
}
diff --git a/src/pages/primary/LibraryPage/index.tsx b/src/pages/primary/LibraryPage/index.tsx
index 4c392166..9f912f6f 100644
--- a/src/pages/primary/LibraryPage/index.tsx
+++ b/src/pages/primary/LibraryPage/index.tsx
@@ -22,10 +22,14 @@ const LibraryPage = forwardRef((_props, ref) => {
setShowOnlyMine,
loading,
engagementLoading,
+ searchLoading,
+ relaySearchLoading,
error,
allIndexCount,
topLevelCount,
- refresh
+ refresh,
+ searchOnRelays,
+ hasIndexData
} = useLibraryPublications(isActive)
useImperativeHandle(
@@ -60,7 +64,9 @@ const LibraryPage = forwardRef((_props, ref) => {
onSearchQueryChange={setSearchQuery}
showOnlyMine={showOnlyMine}
onShowOnlyMineChange={setShowOnlyMine}
- disabled={loading}
+ onSearchRelays={() => void searchOnRelays()}
+ relaySearchLoading={relaySearchLoading}
+ disabled={loading && !hasIndexData}
/>