You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1175 lines
37 KiB
1175 lines
37 KiB
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, queryIndexRelayPublicationSearch } from '@/lib/index-relay-http' |
|
import { |
|
buildIndexByAddress, |
|
collectPublicationIndexEventIds, |
|
collectReachableAddressesCached, |
|
eventTagAddress, |
|
filterValidIndexEvents, |
|
getReferencedChild30040Addresses, |
|
getTopLevelIndexEvents, |
|
hydrateNestedIndexEvents |
|
} from '@/lib/publication-index' |
|
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' |
|
import { |
|
clearLibraryIndexIdbCache, |
|
loadLibraryIndexCacheEvents, |
|
persistLibraryIndexCacheEvents |
|
} from '@/lib/library-index-idb-cache' |
|
import client from '@/services/client.service' |
|
import indexedDb from '@/services/indexed-db.service' |
|
import { |
|
canonicalRelaySessionKey, |
|
httpIndexBasesForRelayQuery, |
|
normalizeHttpRelayUrl, |
|
normalizeUrl |
|
} from '@/lib/url' |
|
import { queryService } from '@/services/client.service' |
|
import type { Event, Filter } from 'nostr-tools' |
|
import { kinds, nip19 } from 'nostr-tools' |
|
|
|
const INDEX_FETCH_LIMIT = 500 |
|
const INDEX_HTTP_PAGE_LIMIT = 100 |
|
const INDEX_HTTP_MAX_PAGES = 5 |
|
const ENGAGEMENT_ADDRESS_CHUNK = 36 |
|
const ENGAGEMENT_EVENT_ID_CHUNK = 44 |
|
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, |
|
firstRelayResultGraceMs: false as const |
|
} |
|
|
|
export type PublicationEngagementMaps = { |
|
labelAddresses: Set<string> |
|
labelEventIds: Set<string> |
|
commentAddresses: Set<string> |
|
highlightAddresses: Set<string> |
|
} |
|
|
|
export type LibraryPublicationEntry = { |
|
event: Event |
|
hasLabel: boolean |
|
hasComment: boolean |
|
hasHighlight: boolean |
|
engagementCount: number |
|
} |
|
|
|
type LibraryIndexCache = { |
|
relayKey: string |
|
indexEvents: Event[] |
|
indexByAddress: Map<string, Event> |
|
engagement: PublicationEngagementMaps |
|
} |
|
|
|
let sessionCache: LibraryIndexCache | null = null |
|
|
|
type LibrarySearchSessionRow = { |
|
fingerprint: string |
|
entries: LibraryPublicationEntry[] |
|
mergedIndexEvents: Event[] |
|
relaySearched: boolean |
|
} |
|
|
|
const librarySearchSessionCache = new Map<string, LibrarySearchSessionRow>() |
|
|
|
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<LibrarySearchSessionRow, 'fingerprint'> |
|
): 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('|') |
|
} |
|
|
|
function splitWsAndHttpRelays(relayUrls: string[]): { wsRelays: string[]; httpRelays: string[] } { |
|
const httpKeys = new Set( |
|
httpIndexBasesForRelayQuery(relayUrls, []).map((u) => canonicalRelaySessionKey(u)) |
|
) |
|
const wsRelays: string[] = [] |
|
const httpRelays: string[] = [] |
|
for (const url of relayUrls) { |
|
const key = canonicalRelaySessionKey(normalizeUrl(url) || url) |
|
if (httpKeys.has(key)) httpRelays.push(url) |
|
else if (!/^https?:\/\//i.test(url.trim())) wsRelays.push(url) |
|
} |
|
return { wsRelays, httpRelays } |
|
} |
|
|
|
function dedupeEventsById(events: Event[]): Event[] { |
|
const byId = new Map<string, Event>() |
|
for (const ev of events) { |
|
const prev = byId.get(ev.id) |
|
if (!prev || ev.created_at > prev.created_at) byId.set(ev.id, ev) |
|
} |
|
return [...byId.values()] |
|
} |
|
|
|
function chunkArray<T>(items: T[], size: number): T[][] { |
|
const out: T[][] = [] |
|
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)) |
|
return out |
|
} |
|
|
|
async function fetchPaginatedFromHttpIndexRelay(baseUrl: string, filter: Filter): Promise<Event[]> { |
|
const out: Event[] = [] |
|
const seen = new Set<string>() |
|
let until: number | undefined |
|
|
|
for (let page = 0; page < INDEX_HTTP_MAX_PAGES; page++) { |
|
const pageFilter: Filter = { |
|
...filter, |
|
limit: INDEX_HTTP_PAGE_LIMIT, |
|
...(until != null ? { until } : {}) |
|
} |
|
let batch: Event[] = [] |
|
let apiRowCount = 0 |
|
try { |
|
const pageResult = await queryIndexRelayForLibrary(baseUrl, pageFilter) |
|
batch = pageResult.events |
|
apiRowCount = pageResult.apiRowCount |
|
} catch (e) { |
|
if (import.meta.env.DEV) { |
|
logger.warn('[Library] HTTP index page failed', { |
|
baseUrl, |
|
page, |
|
message: e instanceof Error ? e.message : String(e) |
|
}) |
|
} |
|
break |
|
} |
|
if (apiRowCount === 0) break |
|
|
|
let oldest = batch[0]?.created_at ?? Number.MAX_SAFE_INTEGER |
|
for (const ev of batch) { |
|
if (ev.created_at < oldest) oldest = ev.created_at |
|
if (seen.has(ev.id)) continue |
|
seen.add(ev.id) |
|
out.push(ev) |
|
} |
|
|
|
if (apiRowCount < INDEX_HTTP_PAGE_LIMIT) break |
|
if (oldest === Number.MAX_SAFE_INTEGER) break |
|
until = oldest - 1 |
|
} |
|
|
|
return out |
|
} |
|
|
|
function normalizeLibraryRelayUrl(url: string): string { |
|
const trimmed = url.trim() |
|
if (!trimmed) return '' |
|
const http = normalizeHttpRelayUrl(trimmed) |
|
if (http) return http |
|
return normalizeUrl(trimmed) || trimmed |
|
} |
|
|
|
function libraryIndexRelayUrls(extraRelayUrls: string[] = []): string[] { |
|
const base = LIBRARY_RELAY_URLS.map(normalizeLibraryRelayUrl).filter(Boolean) |
|
const extra = extraRelayUrls.map(normalizeLibraryRelayUrl).filter(Boolean) |
|
return [...new Set([...base, ...extra])] |
|
} |
|
|
|
export async function buildLibraryRelayUrls(userPubkey?: string): Promise<string[]> { |
|
const base = libraryIndexRelayUrls() |
|
const urls = await buildComprehensiveRelayList({ |
|
userPubkey, |
|
includeUserOwnRelays: true, |
|
includeFastReadRelays: false, |
|
includeSearchableRelays: false, |
|
includeFavoriteRelays: false, |
|
relayHints: base |
|
}) |
|
return libraryIndexRelayUrls([...urls]) |
|
} |
|
|
|
export async function fetchLibraryIndexEvents(relayUrls: string[]): Promise<Event[]> { |
|
const indexRelays = libraryIndexRelayUrls(relayUrls) |
|
if (indexRelays.length === 0) return [] |
|
|
|
const cached = await loadLibraryIndexCacheEvents() |
|
const filter: Filter = { kinds: [ExtendedKind.PUBLICATION], limit: INDEX_FETCH_LIMIT } |
|
const { wsRelays, httpRelays } = splitWsAndHttpRelays(indexRelays) |
|
|
|
const batches: Promise<Event[]>[] = [] |
|
if (wsRelays.length > 0) { |
|
batches.push( |
|
queryService.fetchEvents(wsRelays, [filter], QUERY_OPTS).catch((e) => { |
|
if (import.meta.env.DEV) { |
|
logger.warn('[Library] WS index fetch failed', { |
|
message: e instanceof Error ? e.message : String(e) |
|
}) |
|
} |
|
return [] as Event[] |
|
}) |
|
) |
|
} |
|
for (const httpRelay of httpRelays) { |
|
batches.push(fetchPaginatedFromHttpIndexRelay(httpRelay, filter)) |
|
} |
|
|
|
const settled = await Promise.allSettled(batches) |
|
const networkMerged = dedupeEventsById( |
|
settled.flatMap((r) => (r.status === 'fulfilled' ? r.value : [])) |
|
) |
|
const merged = dedupeEventsById([...cached, ...networkMerged]) |
|
const valid = filterValidIndexEvents(merged) |
|
void persistLibraryIndexCacheEvents(valid) |
|
if (import.meta.env.DEV) { |
|
logger.info('[Library] index fetch', { |
|
indexRelays: indexRelays.length, |
|
wsRelays: wsRelays.length, |
|
httpRelays: httpRelays.length, |
|
cachedCount: cached.length, |
|
networkCount: networkMerged.length, |
|
mergedCount: merged.length, |
|
validCount: valid.length |
|
}) |
|
} |
|
return valid |
|
} |
|
|
|
export function buildEngagementMapsFromEvents( |
|
labels: Event[], |
|
comments: Event[], |
|
highlights: Event[], |
|
targetAddresses?: Set<string>, |
|
targetEventIds?: Set<string> |
|
): PublicationEngagementMaps { |
|
const labelAddresses = new Set<string>() |
|
const labelEventIds = new Set<string>() |
|
const commentAddresses = new Set<string>() |
|
const highlightAddresses = new Set<string>() |
|
|
|
const addressMatches = (addr: string) => !targetAddresses || targetAddresses.has(addr) |
|
const eventIdMatches = (id: string) => !targetEventIds || targetEventIds.has(id.toLowerCase()) |
|
|
|
for (const ev of labels) { |
|
for (const tag of ev.tags) { |
|
if (tag[0] === 'a' && tag[1] && addressMatches(tag[1])) labelAddresses.add(tag[1]) |
|
if (tag[0] === 'e' && tag[1] && eventIdMatches(tag[1])) labelEventIds.add(tag[1].toLowerCase()) |
|
} |
|
} |
|
|
|
for (const ev of comments) { |
|
for (const tag of ev.tags) { |
|
if (tag[0] === 'A' && tag[1] && addressMatches(tag[1])) commentAddresses.add(tag[1]) |
|
} |
|
} |
|
|
|
for (const ev of highlights) { |
|
for (const tag of ev.tags) { |
|
if (tag[0] === 'a' && tag[1] && addressMatches(tag[1])) highlightAddresses.add(tag[1]) |
|
} |
|
} |
|
|
|
return { labelAddresses, labelEventIds, commentAddresses, highlightAddresses } |
|
} |
|
|
|
async function fetchHttpEngagementByAddresses( |
|
httpRelays: string[], |
|
kind: number, |
|
tagKey: '#a' | '#A', |
|
addressChunks: string[][] |
|
): Promise<Event[]> { |
|
if (httpRelays.length === 0 || addressChunks.length === 0) return [] |
|
const out: Event[] = [] |
|
const seen = new Set<string>() |
|
for (const relay of httpRelays) { |
|
for (const chunk of addressChunks) { |
|
if (chunk.length === 0) continue |
|
const filter = { |
|
kinds: [kind], |
|
[tagKey]: chunk, |
|
limit: Math.min(chunk.length * 10, INDEX_HTTP_PAGE_LIMIT) |
|
} as Filter |
|
const batch = await queryIndexRelay(relay, filter) |
|
for (const ev of batch) { |
|
if (seen.has(ev.id)) continue |
|
seen.add(ev.id) |
|
out.push(ev) |
|
} |
|
} |
|
} |
|
return out |
|
} |
|
|
|
export async function fetchPublicationEngagementMaps( |
|
relayUrls: string[], |
|
targetAddresses: Set<string>, |
|
targetEventIds: Set<string>, |
|
options?: { httpOnly?: boolean } |
|
): Promise<PublicationEngagementMaps> { |
|
if (relayUrls.length === 0 || targetAddresses.size === 0) { |
|
return { |
|
labelAddresses: new Set(), |
|
labelEventIds: new Set(), |
|
commentAddresses: new Set(), |
|
highlightAddresses: new Set() |
|
} |
|
} |
|
|
|
const addressChunks = chunkArray([...targetAddresses], ENGAGEMENT_ADDRESS_CHUNK) |
|
const eventIdChunks = chunkArray([...targetEventIds], ENGAGEMENT_EVENT_ID_CHUNK) |
|
const { wsRelays, httpRelays } = splitWsAndHttpRelays(relayUrls) |
|
const useWs = !options?.httpOnly && wsRelays.length > 0 |
|
|
|
const highlightFilters = addressChunks.map( |
|
(chunk): Filter => ({ kinds: [kinds.Highlights], '#a': chunk, limit: chunk.length * 12 }) |
|
) |
|
const labelAddressFilters = addressChunks.map( |
|
(chunk): Filter => ({ kinds: [ExtendedKind.LABEL], '#a': chunk, limit: chunk.length * 8 }) |
|
) |
|
const labelEventFilters = eventIdChunks.map( |
|
(chunk): Filter => ({ kinds: [ExtendedKind.LABEL], '#e': chunk, limit: chunk.length * 6 }) |
|
) |
|
const commentWsFilters = addressChunks.map( |
|
(chunk): Filter => ({ kinds: [ExtendedKind.COMMENT], '#A': chunk, limit: chunk.length * 12 }) |
|
) |
|
|
|
const highlightPromise = Promise.all([ |
|
useWs && highlightFilters.length > 0 |
|
? queryService.fetchEvents(wsRelays, highlightFilters, QUERY_OPTS) |
|
: Promise.resolve([] as Event[]), |
|
fetchHttpEngagementByAddresses(httpRelays, kinds.Highlights, '#a', addressChunks) |
|
]).then(([scoped, bulk]) => dedupeEventsById([...scoped, ...bulk])) |
|
|
|
const labelPromise = Promise.all([ |
|
useWs && labelAddressFilters.length > 0 |
|
? queryService.fetchEvents(wsRelays, labelAddressFilters, QUERY_OPTS) |
|
: Promise.resolve([] as Event[]), |
|
useWs && labelEventFilters.length > 0 |
|
? queryService.fetchEvents(wsRelays, labelEventFilters, QUERY_OPTS) |
|
: Promise.resolve([] as Event[]), |
|
fetchHttpEngagementByAddresses(httpRelays, ExtendedKind.LABEL, '#a', addressChunks) |
|
]).then(([byAddress, byEvent, bulk]) => dedupeEventsById([...byAddress, ...byEvent, ...bulk])) |
|
|
|
const commentPromise = Promise.all([ |
|
useWs && commentWsFilters.length > 0 |
|
? queryService.fetchEvents(wsRelays, commentWsFilters, QUERY_OPTS) |
|
: Promise.resolve([] as Event[]), |
|
fetchHttpEngagementByAddresses(httpRelays, ExtendedKind.COMMENT, '#A', addressChunks) |
|
]).then(([scoped, bulk]) => dedupeEventsById([...scoped, ...bulk])) |
|
|
|
const [highlights, labels, comments] = await Promise.all([ |
|
highlightPromise, |
|
labelPromise, |
|
commentPromise |
|
]) |
|
|
|
return buildEngagementMapsFromEvents( |
|
dedupeEventsById(labels), |
|
dedupeEventsById(comments), |
|
dedupeEventsById(highlights), |
|
targetAddresses, |
|
targetEventIds |
|
) |
|
} |
|
|
|
function addressHasEngagement( |
|
address: string, |
|
eventId: string | undefined, |
|
maps: PublicationEngagementMaps |
|
): { hasLabel: boolean; hasComment: boolean; hasHighlight: boolean } { |
|
const hasLabel = |
|
maps.labelAddresses.has(address) || |
|
(eventId ? maps.labelEventIds.has(eventId.toLowerCase()) : false) |
|
const hasComment = maps.commentAddresses.has(address) |
|
const hasHighlight = maps.highlightAddresses.has(address) |
|
return { hasLabel, hasComment, hasHighlight } |
|
} |
|
|
|
export function filterEngagedPublications( |
|
roots: Event[], |
|
indexByAddress: Map<string, Event>, |
|
engagement: PublicationEngagementMaps |
|
): LibraryPublicationEntry[] { |
|
const out: LibraryPublicationEntry[] = [] |
|
|
|
for (const root of roots) { |
|
const reachable = collectReachableAddressesCached(root, indexByAddress) |
|
const rootAddr = eventTagAddress(root) |
|
if (rootAddr) reachable.add(rootAddr) |
|
|
|
let hasLabel = false |
|
let hasComment = false |
|
let hasHighlight = false |
|
let engagementCount = 0 |
|
|
|
for (const addr of reachable) { |
|
const indexed = indexByAddress.get(addr) |
|
const flags = addressHasEngagement(addr, indexed?.id, engagement) |
|
if (flags.hasLabel) hasLabel = true |
|
if (flags.hasComment) hasComment = true |
|
if (flags.hasHighlight) hasHighlight = true |
|
if (flags.hasLabel || flags.hasComment || flags.hasHighlight) engagementCount++ |
|
} |
|
|
|
const rootFlags = addressHasEngagement(rootAddr ?? '', root.id, engagement) |
|
hasLabel = hasLabel || rootFlags.hasLabel |
|
hasComment = hasComment || rootFlags.hasComment |
|
hasHighlight = hasHighlight || rootFlags.hasHighlight |
|
|
|
if (hasLabel || hasComment || hasHighlight) { |
|
out.push({ |
|
event: root, |
|
hasLabel, |
|
hasComment, |
|
hasHighlight, |
|
engagementCount: Math.max(engagementCount, 1) |
|
}) |
|
} |
|
} |
|
|
|
return out |
|
} |
|
|
|
export function buildRecentPublicationEntries( |
|
roots: Event[], |
|
limit = LIBRARY_RECENT_FALLBACK_LIMIT |
|
): LibraryPublicationEntry[] { |
|
return [...roots] |
|
.sort((a, b) => b.created_at - a.created_at) |
|
.slice(0, limit) |
|
.map((event) => ({ |
|
event, |
|
hasLabel: false, |
|
hasComment: false, |
|
hasHighlight: false, |
|
engagementCount: 0 |
|
})) |
|
} |
|
|
|
/** Engaged publications first; when none match, show the newest top-level indexes. */ |
|
export function pickLibraryPublicationEntries( |
|
roots: Event[], |
|
indexByAddress: Map<string, Event>, |
|
engagement: PublicationEngagementMaps |
|
): LibraryPublicationEntry[] { |
|
const engaged = sortLibraryPublications(filterEngagedPublications(roots, indexByAddress, engagement)) |
|
if (engaged.length > 0) return engaged |
|
return buildRecentPublicationEntries(roots) |
|
} |
|
|
|
export function sortLibraryPublications(entries: LibraryPublicationEntry[]): LibraryPublicationEntry[] { |
|
return [...entries].sort((a, b) => { |
|
if (a.hasLabel !== b.hasLabel) return a.hasLabel ? -1 : 1 |
|
if (a.engagementCount !== b.engagementCount) return b.engagementCount - a.engagementCount |
|
return b.event.created_at - a.event.created_at |
|
}) |
|
} |
|
|
|
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<string, Event> |
|
): Map<string, Event> { |
|
const map = new Map<string, Event>() |
|
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<string, Event>, |
|
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<string, Event> |
|
): 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<string, Event>() |
|
|
|
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<LibraryPublicationEntry[]> { |
|
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<string, Event>() |
|
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 { |
|
const trimmed = query.trim() |
|
if (!trimmed) return null |
|
if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase() |
|
try { |
|
const decoded = nip19.decode(trimmed) |
|
if (decoded.type === 'npub') return decoded.data |
|
if (decoded.type === 'nprofile') return decoded.data.pubkey |
|
} catch { |
|
// not bech32 |
|
} |
|
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<string>() |
|
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<string>() |
|
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<Event[]>[] = [] |
|
|
|
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 = query.trim() |
|
if (!q) return entries |
|
|
|
const npub = tryNpubFromQuery(q) |
|
if (npub) { |
|
return entries.filter(({ event }) => event.pubkey.toLowerCase() === npub) |
|
} |
|
|
|
return entries.filter(({ event }) => publicationIndexMatchesSearchQuery(event, q)) |
|
} |
|
|
|
export function filterLibraryPublicationsByUser( |
|
entries: LibraryPublicationEntry[], |
|
userPubkey: string | null | undefined |
|
): LibraryPublicationEntry[] { |
|
if (!userPubkey) return entries |
|
const pk = userPubkey.toLowerCase() |
|
return entries.filter(({ event }) => { |
|
if (event.pubkey.toLowerCase() === pk) return true |
|
return event.tags.some((t) => t[0] === 'p' && t[1]?.toLowerCase() === pk) |
|
}) |
|
} |
|
|
|
function collectTargetAddressesFromIndexes( |
|
indexEvents: Event[], |
|
indexByAddress: Map<string, Event> |
|
): Set<string> { |
|
const addresses = new Set<string>() |
|
outer: for (const root of getTopLevelIndexEvents(indexEvents)) { |
|
for (const addr of collectReachableAddressesCached(root, indexByAddress)) { |
|
addresses.add(addr) |
|
if (addresses.size >= MAX_TARGET_ADDRESSES) break outer |
|
} |
|
const rootAddr = eventTagAddress(root) |
|
if (rootAddr) { |
|
addresses.add(rootAddr) |
|
if (addresses.size >= MAX_TARGET_ADDRESSES) break outer |
|
} |
|
} |
|
return addresses |
|
} |
|
|
|
async function buildEngagedFromCache( |
|
relayUrls: string[], |
|
indexEvents: Event[], |
|
indexByAddress: Map<string, Event>, |
|
engagement?: PublicationEngagementMaps |
|
): Promise<LibraryPublicationEntry[]> { |
|
const topLevel = getTopLevelIndexEvents(indexEvents) |
|
let maps = engagement |
|
if (!maps) { |
|
const targetAddresses = collectTargetAddressesFromIndexes(indexEvents, indexByAddress) |
|
const targetEventIds = collectPublicationIndexEventIds(indexEvents) |
|
maps = await fetchPublicationEngagementMaps(relayUrls, targetAddresses, targetEventIds) |
|
} |
|
return pickLibraryPublicationEntries(topLevel, indexByAddress, maps) |
|
} |
|
|
|
export async function loadLibraryPublicationIndex( |
|
relayUrls: string[], |
|
options?: { |
|
forceRefresh?: boolean |
|
/** Called as soon as kind-30040 indexes are loaded — before engagement (which can take minutes). */ |
|
onIndexesReady?: (snapshot: { |
|
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) { |
|
logger.info('[Library] load start', { relayCount: relayUrls.length, cached: sessionCache?.relayKey === key }) |
|
} |
|
|
|
if (!options?.forceRefresh && sessionCache?.relayKey === key) { |
|
const engaged = await buildEngagedFromCache( |
|
relayUrls, |
|
sessionCache.indexEvents, |
|
sessionCache.indexByAddress, |
|
sessionCache.engagement |
|
) |
|
if (import.meta.env.DEV) { |
|
logger.info('[Library] load from cache', { engaged: engaged.length }) |
|
} |
|
return { |
|
engaged, |
|
allIndexCount: sessionCache.indexEvents.length, |
|
topLevelCount: getTopLevelIndexEvents(sessionCache.indexEvents).length, |
|
indexEvents: sessionCache.indexEvents, |
|
engagement: sessionCache.engagement |
|
} |
|
} |
|
|
|
const indexEvents = await fetchLibraryIndexEvents(relayUrls) |
|
if (import.meta.env.DEV) { |
|
logger.info('[Library] indexes fetched', { validCount: indexEvents.length }) |
|
} |
|
|
|
const indexByAddress = buildIndexByAddress(indexEvents) |
|
let topLevel = getTopLevelIndexEvents(indexEvents) |
|
|
|
options?.onIndexesReady?.({ |
|
engaged: buildRecentPublicationEntries(topLevel), |
|
allIndexCount: indexEvents.length, |
|
topLevelCount: topLevel.length, |
|
indexEvents |
|
}) |
|
|
|
const topLevelForHydrate = topLevel |
|
await hydrateNestedIndexEvents(indexEvents, indexByAddress, relayUrls, { |
|
maxPasses: 1, |
|
maxMissingPerPass: HYDRATE_MISSING_CAP, |
|
scanRoots: topLevelForHydrate |
|
}) |
|
if (import.meta.env.DEV) { |
|
logger.info('[Library] nested hydrate done', { indexCount: indexEvents.length }) |
|
} |
|
|
|
topLevel = getTopLevelIndexEvents(indexEvents) |
|
const targetAddresses = collectTargetAddressesFromIndexes(indexEvents, indexByAddress) |
|
const targetEventIds = collectPublicationIndexEventIds(indexEvents) |
|
if (import.meta.env.DEV) { |
|
logger.info('[Library] fetching engagement', { |
|
targetAddresses: targetAddresses.size, |
|
targetEventIds: targetEventIds.size |
|
}) |
|
} |
|
|
|
let engagement: PublicationEngagementMaps |
|
try { |
|
engagement = await Promise.race([ |
|
fetchPublicationEngagementMaps(relayUrls, targetAddresses, targetEventIds, { |
|
httpOnly: true |
|
}), |
|
new Promise<PublicationEngagementMaps>((resolve) => { |
|
window.setTimeout( |
|
() => |
|
resolve({ |
|
labelAddresses: new Set(), |
|
labelEventIds: new Set(), |
|
commentAddresses: new Set(), |
|
highlightAddresses: new Set() |
|
}), |
|
ENGAGEMENT_FETCH_TIMEOUT_MS |
|
) |
|
}) |
|
]) |
|
} catch (e) { |
|
if (import.meta.env.DEV) { |
|
logger.warn('[Library] engagement fetch failed', { |
|
message: e instanceof Error ? e.message : String(e) |
|
}) |
|
} |
|
engagement = { |
|
labelAddresses: new Set(), |
|
labelEventIds: new Set(), |
|
commentAddresses: new Set(), |
|
highlightAddresses: new Set() |
|
} |
|
} |
|
if (import.meta.env.DEV) { |
|
logger.info('[Library] engagement maps built', { |
|
labels: engagement.labelAddresses.size + engagement.labelEventIds.size, |
|
comments: engagement.commentAddresses.size, |
|
highlights: engagement.highlightAddresses.size |
|
}) |
|
} |
|
|
|
sessionCache = { relayKey: key, indexEvents, indexByAddress, engagement } |
|
|
|
const engaged = pickLibraryPublicationEntries(topLevel, indexByAddress, engagement) |
|
|
|
if (import.meta.env.DEV) { |
|
logger.info('[Library] load done', { |
|
engaged: engaged.length, |
|
topLevel: topLevel.length, |
|
allIndexCount: indexEvents.length, |
|
recentFallback: engaged.length > 0 && engaged.every((e) => e.engagementCount === 0) |
|
}) |
|
} |
|
|
|
return { |
|
engaged, |
|
allIndexCount: indexEvents.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<void> { |
|
sessionCache = null |
|
clearLibrarySearchSessionCache() |
|
await clearLibraryIndexIdbCache() |
|
} |
|
|
|
/** |
|
* When opening a publication from Library, seed session cache and the publication events store |
|
* so offline re-read works even if the index lived only in the Library LRU store. |
|
*/ |
|
export function persistLibraryPublicationForReading(event: Event): void { |
|
if (event.kind !== ExtendedKind.PUBLICATION) return |
|
client.addEventToCache(event) |
|
void indexedDb.putReplaceableEvent(event).catch(() => {}) |
|
}
|
|
|