diff --git a/src/components/Library/LibrarySearchBar.tsx b/src/components/Library/LibrarySearchBar.tsx index 263a6645..b9acced7 100644 --- a/src/components/Library/LibrarySearchBar.tsx +++ b/src/components/Library/LibrarySearchBar.tsx @@ -1,7 +1,8 @@ +import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' -import { Search } from 'lucide-react' +import { Loader2, Search, Wifi } from 'lucide-react' import { useTranslation } from 'react-i18next' export default function LibrarySearchBar({ @@ -9,15 +10,20 @@ export default function LibrarySearchBar({ onSearchQueryChange, showOnlyMine, onShowOnlyMineChange, + onSearchRelays, + relaySearchLoading, disabled }: { searchQuery: string onSearchQueryChange: (value: string) => void showOnlyMine: boolean onShowOnlyMineChange: (value: boolean) => void + onSearchRelays?: () => void + relaySearchLoading?: boolean disabled?: boolean }) { const { t } = useTranslation() + const canSearchRelays = searchQuery.trim().length > 0 && !relaySearchLoading return (
@@ -33,6 +39,23 @@ export default function LibrarySearchBar({ aria-label={t('Library search placeholder')} />
+ {onSearchRelays ? ( + + ) : null}
+ +
+ ) +} export default function PublicationCard({ event, @@ -29,10 +50,17 @@ export default function PublicationCard({ const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event) const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) + const indexMetadata = useMemo( + () => (event.kind === ExtendedKind.PUBLICATION ? getPublicationIndexMetadataFromEvent(event) : null), + [event] + ) const bodyBlurb = useMemo(() => cardEventBodyBlurb(event.content), [event.content]) const summaryText = (metadata.summary?.trim() || bodyBlurb).trim() const bookMetadata = useMemo(() => extractBookMetadata(event), [event]) - const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book + const isBookstrEvent = + (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && + !!bookMetadata.book + const isPublicationIndex = event.kind === ExtendedKind.PUBLICATION && !isBookstrEvent const handleCardClick = (e: React.MouseEvent) => { e.stopPropagation() @@ -41,12 +69,14 @@ export default function PublicationCard({ navigateToNote(toNote(event), event) } - const titleComponent = metadata.title ?
{metadata.title}
: null + const titleComponent = metadata.title ? ( +
{metadata.title}
+ ) : null const formatBookName = (book: string) => { return book .split('-') - .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' ') } @@ -84,16 +114,62 @@ export default function PublicationCard({ ) : null - if (isSmallScreen) { + const cardShellClass = cn( + 'min-w-0 rounded-lg border p-4 transition-colors', + disableNavigation ? '' : 'cursor-pointer hover:bg-muted/50' + ) + + if (isPublicationIndex && indexMetadata) { + const coverImage = indexMetadata.image?.trim() + const cover = + coverImage ? ( + + ) : ( + + ) + + if (isSmallScreen) { + return ( +
+
+ {cover} + +
+
+ ) + } + return (
+
+ {cover} + +
+
+
+ ) + } + + if (isSmallScreen) { + return ( +
+
{metadata.image ? ( - ) : null} + ) : ( + + )}
{titleComponent} {bookstrMetadataComponent} @@ -117,10 +200,7 @@ export default function PublicationCard({ return (
@@ -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) => ( +
  1. + {index + 1}. + + {section.label || + section.coordinate.split(':').pop()?.replace(/-/g, ' ') || + section.coordinate} + +
  2. + ))} +
+
+ ) : 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} />
{error ? ( @@ -72,6 +78,10 @@ const LibraryPage = forwardRef((_props, ref) => {

{t('Library loading')}

) : engagementLoading ? (

{t('Library engagement loading')}

+ ) : searchLoading ? ( +

{t('Library search loading')}

+ ) : relaySearchLoading ? ( +

{t('Library relay search loading')}

) : null} {statusLine ? (

{statusLine}