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 { extractNip32LabelValues, isBooklistNip32Label } from '@/lib/nip32-label' import { queryIndexRelay, queryIndexRelayForLibrary, queryIndexRelayPublicationSearch } from '@/lib/index-relay-http' import { buildIndexByAddress, buildStructuralPublicationIndexMap, collectPublicationIndexEventIds, collectReachableAddressesCached, eventTagAddress, filterValidIndexEvents, getReferencedChild30040Addresses, getTopLevelIndexEvents, getTopLevelIndexEventsFromMap, hydrateNestedIndexEvents, mergePublicationIndexMaps, publicationIndexMapValues, type PublicationIndexMap } from '@/lib/publication-index' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { verifyEvent } from 'nostr-tools' import { isEventInPinList } from '@/lib/replaceable-list-latest' import { isRelayBlockedByUser } from '@/lib/relay-blocked' import { stripLocalNetworkRelaysForWssReq } from '@/lib/relay-list-sanitize' 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_WS_PAGE_LIMIT = 500 const INDEX_HTTP_PAGE_LIMIT = 100 /** Cursor pages per relay for library index bulk load (up to ~50k WS / ~10k HTTP rows each). */ const INDEX_MAX_PAGES_PER_RELAY = 100 /** verifyEvent batch size — yield between chunks so the main thread stays responsive. */ const INDEX_VERIFY_CHUNK = 80 const ENGAGEMENT_ADDRESS_CHUNK = 36 const ENGAGEMENT_EVENT_ID_CHUNK = 44 const MAX_TARGET_ADDRESSES = 480 const HYDRATE_MISSING_CAP = 64 export const LIBRARY_PAGE_SIZE = 120 /** @deprecated Use {@link LIBRARY_PAGE_SIZE} */ export const LIBRARY_RECENT_FALLBACK_LIMIT = LIBRARY_PAGE_SIZE const LIBRARY_SEARCH_READING_CACHE_LIMIT = 200 export const LIBRARY_RELAY_SEARCH_LIMIT = 100 const LIBRARY_RELAY_SEARCH_TIMEOUT_MS = 28_000 /** NIP-51 pin list (kind 10001). */ const PIN_LIST_KIND = 10001 /** Per-relay WS page fetch — one relay at a time avoids multi-relay onclose resolving after ~1s. */ const LIBRARY_INDEX_QUERY_OPTS = { globalTimeout: 45_000, eoseTimeout: 8_000, firstRelayResultGraceMs: false as const, foreground: true } as const /** First-page batch: unblock the library grid quickly. */ const LIBRARY_INDEX_FIRST_PAGE_OPTS = { globalTimeout: 12_000, eoseTimeout: 5_000, firstRelayResultGraceMs: false as const, foreground: true } as const const ENGAGEMENT_QUERY_OPTS = { globalTimeout: 45_000, eoseTimeout: 8_000, firstRelayResultGraceMs: false as const } export type PublicationEngagementMaps = { labelAddresses: Set labelEventIds: Set labelValuesByAddress: Map> labelValuesByEventId: Map> booklistAddresses: Set booklistEventIds: Set myBooklistAddresses: Set myBooklistEventIds: Set myCommentAddresses: Set myCommentEventIds: Set myHighlightAddresses: Set myHighlightEventIds: Set commentAddresses: Set commentEventIds: Set highlightAddresses: Set highlightEventIds: Set bookmarkAddresses: Set bookmarkEventIds: Set pinAddresses: Set pinEventIds: Set } export type LibraryPublicationEntry = { event: Event hasLabel: boolean /** NIP-32 `l` tag values from kind-1985 events (e.g. "booklist"), not `L` namespaces (e.g. "ugc"). */ labelNames: string[] hasBooklistLabel: boolean hasMyBooklistLabel: boolean hasMyComment: boolean hasMyHighlight: boolean hasComment: boolean hasHighlight: boolean hasBookmark: boolean hasPin: boolean engagementCount: number } type LibraryIndexCache = { relayKey: string viewerPubkey: string | null indexByAddress: PublicationIndexMap engagement: PublicationEngagementMaps } let sessionCache: LibraryIndexCache | null = null type LibraryIndexLoadSnapshot = { engaged: LibraryPublicationEntry[] allIndexCount: number topLevelCount: number indexEvents: Event[] } type LibraryIndexLoadResult = LibraryIndexLoadSnapshot & { engagement: PublicationEngagementMaps } type LibraryIndexLoadJob = { relayKey: string forceRefresh: boolean promise: Promise onIndexesReadyListeners: Array<(snapshot: LibraryIndexLoadSnapshot) => void> lastProgressIndex: PublicationIndexMap | null } let indexLoadJob: LibraryIndexLoadJob | null = null function indexEventsFromCache(cache: LibraryIndexCache): Event[] { return publicationIndexMapValues(cache.indexByAddress) } function emitIndexesReadySnapshot( listeners: Array<(snapshot: LibraryIndexLoadSnapshot) => void>, indexByAddress: PublicationIndexMap ) { if (listeners.length === 0) return const indexEvents = publicationIndexMapValues(indexByAddress) const topLevel = getTopLevelIndexEventsFromMap(indexByAddress) const snapshot: LibraryIndexLoadSnapshot = { engaged: buildRecentPublicationEntries(topLevel, indexByAddress, emptyPublicationEngagementMaps()), allIndexCount: indexEvents.length, topLevelCount: topLevel.length, indexEvents } for (const listener of listeners) { listener(snapshot) } } function registerIndexesReadyListener( job: LibraryIndexLoadJob, listener?: (snapshot: LibraryIndexLoadSnapshot) => void ) { if (!listener) return job.onIndexesReadyListeners.push(listener) if (job.lastProgressIndex) { emitIndexesReadySnapshot([listener], job.lastProgressIndex) } } 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.commentEventIds.size + engagement.highlightAddresses.size + engagement.highlightEventIds.size + engagement.booklistAddresses.size + engagement.booklistEventIds.size + engagement.bookmarkAddresses.size + engagement.bookmarkEventIds.size + engagement.pinAddresses.size + engagement.pinEventIds.size + engagement.myBooklistAddresses.size + engagement.myBooklistEventIds.size + engagement.myCommentAddresses.size + engagement.myCommentEventIds.size + engagement.myHighlightAddresses.size + engagement.myHighlightEventIds.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('|') } 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() for (const ev of events) { const prev = byId.get(ev.id) if (!prev) { byId.set(ev.id, ev) continue } const prevVerified = verifyEvent(prev) const nextVerified = verifyEvent(ev) if (nextVerified && !prevVerified) { byId.set(ev.id, ev) continue } if (prevVerified && !nextVerified) continue if (ev.created_at > prev.created_at) byId.set(ev.id, ev) } return [...byId.values()] } function chunkArray(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 } function isLibraryDeepIndexRelay(url: string): boolean { const key = canonicalRelaySessionKey(normalizeLibraryRelayUrl(url) || url) return key !== '' && LIBRARY_DEEP_INDEX_RELAY_KEYS.has(key) } function oldestCreatedAt(events: Event[]): number { let oldest = Number.MAX_SAFE_INTEGER for (const ev of events) { if (ev.created_at < oldest) oldest = ev.created_at } return oldest } function mergeIndexPageBatch(out: Event[], seen: Set, batch: Event[]): number { 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) } return oldest } async function fetchWsIndexFirstPage(wsRelay: string, filter: Filter): Promise { const pageFilter: Filter = { ...filter, limit: INDEX_WS_PAGE_LIMIT } try { return await queryService.fetchEvents([wsRelay], [pageFilter], LIBRARY_INDEX_FIRST_PAGE_OPTS) } catch (e) { if (import.meta.env.DEV) { logger.warn('[Library] WS index first page failed', { wsRelay, message: e instanceof Error ? e.message : String(e) }) } return [] } } async function fetchHttpIndexFirstPage(baseUrl: string, filter: Filter): Promise { const pageFilter: Filter = { ...filter, limit: INDEX_HTTP_PAGE_LIMIT } try { const pageResult = await queryIndexRelayForLibrary(baseUrl, pageFilter) return pageResult.events } catch (e) { if (import.meta.env.DEV) { logger.warn('[Library] HTTP index first page failed', { baseUrl, message: e instanceof Error ? e.message : String(e) }) } return [] } } async function fetchRemainingPagesFromWsIndexRelay( wsRelay: string, filter: Filter, firstPage: Event[] ): Promise { if (firstPage.length < INDEX_WS_PAGE_LIMIT) return [] const out: Event[] = [] const seen = new Set(firstPage.map((ev) => ev.id)) let until = oldestCreatedAt(firstPage) - 1 if (until < 0) return [] for (let page = 1; page < INDEX_MAX_PAGES_PER_RELAY; page++) { const pageFilter: Filter = { ...filter, limit: INDEX_WS_PAGE_LIMIT, until } let batch: Event[] = [] try { batch = await queryService.fetchEvents([wsRelay], [pageFilter], LIBRARY_INDEX_QUERY_OPTS) } catch (e) { if (import.meta.env.DEV) { logger.warn('[Library] WS index page failed', { wsRelay, page, message: e instanceof Error ? e.message : String(e) }) } break } if (batch.length === 0) break const oldest = mergeIndexPageBatch(out, seen, batch) if (batch.length < INDEX_WS_PAGE_LIMIT) break if (oldest === Number.MAX_SAFE_INTEGER) break until = oldest - 1 } return out } async function fetchRemainingPagesFromHttpIndexRelay( baseUrl: string, filter: Filter, firstPage: Event[] ): Promise { if (firstPage.length === 0) return [] const out: Event[] = [] const seen = new Set(firstPage.map((ev) => ev.id)) let until = oldestCreatedAt(firstPage) - 1 if (until < 0) return [] for (let page = 1; page < INDEX_MAX_PAGES_PER_RELAY; page++) { const pageFilter: Filter = { ...filter, limit: INDEX_HTTP_PAGE_LIMIT, 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 const oldest = mergeIndexPageBatch(out, seen, batch) 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 } const LIBRARY_DEEP_INDEX_RELAY_KEYS = new Set( LIBRARY_RELAY_URLS.map((url) => canonicalRelaySessionKey(normalizeLibraryRelayUrl(url) || url) ).filter(Boolean) ) function filterBlockedLibraryRelays(urls: string[], blockedRelays: readonly string[] = []): string[] { if (blockedRelays.length === 0) return urls return urls.filter((url) => !isRelayBlockedByUser(url, blockedRelays)) } function libraryIndexRelayUrls(extraRelayUrls: string[] = [], blockedRelays: readonly string[] = []): string[] { const base = filterBlockedLibraryRelays( LIBRARY_RELAY_URLS.map(normalizeLibraryRelayUrl).filter(Boolean), blockedRelays ) const extra = filterBlockedLibraryRelays( extraRelayUrls.map(normalizeLibraryRelayUrl).filter(Boolean), blockedRelays ) return [...new Set([...base, ...extra])] } export async function buildLibraryRelayUrls( userPubkey?: string, blockedRelays: string[] = [] ): Promise { const base = libraryIndexRelayUrls([], blockedRelays) const urls = await buildComprehensiveRelayList({ userPubkey, includeUserOwnRelays: true, includeFastReadRelays: false, includeSearchableRelays: false, includeFavoriteRelays: false, relayHints: base, blockedRelays }) return libraryIndexRelayUrls([...urls], blockedRelays) } /** Relay hints from kind-30040 `a` tags (section relay URLs). */ function collectPublicationRelayHints(indexEvents: Event[]): string[] { const hints = new Set() for (const ev of indexEvents) { for (const tag of ev.tags) { if (tag[0] !== 'a') continue const hint = tag[2]?.trim() if (!hint || !/^wss?:\/\//i.test(hint)) continue const normalized = normalizeUrl(hint) || hint if (normalized) hints.add(normalized) } } return [...hints] } /** * WS/social relays for labels, comments, highlights, bookmarks, and pins. * Index-only HTTP relays (mercury, document mirrors) do not carry engagement kinds. */ export async function buildLibraryEngagementRelayUrls( userPubkey: string | undefined, indexRelayHints: string[], indexEvents: Event[] = [], blockedRelays: readonly string[] = [] ): Promise { const pubHints = collectPublicationRelayHints(indexEvents) const urls = await buildComprehensiveRelayList({ userPubkey, relayHints: [...indexRelayHints, ...pubHints], includeUserOwnRelays: true, includeFastReadRelays: true, includeFavoriteRelays: true, includeProfileFetchRelays: true, includeSearchableRelays: false, includeViewerHttpIndexRelays: false, blockedRelays: [...blockedRelays] }) return filterBlockedLibraryRelays( urls.map((u) => normalizeLibraryRelayUrl(u) || u).filter(Boolean), blockedRelays ) } function engagementMapsSizeSummary(maps: PublicationEngagementMaps): Record { return { labels: maps.labelAddresses.size + maps.labelEventIds.size, comments: maps.commentAddresses.size + maps.commentEventIds.size, highlights: maps.highlightAddresses.size + maps.highlightEventIds.size, bookmarks: maps.bookmarkAddresses.size + maps.bookmarkEventIds.size, pins: maps.pinAddresses.size + maps.pinEventIds.size, booklists: maps.booklistAddresses.size + maps.booklistEventIds.size } } export type FetchLibraryIndexEventsOptions = { /** Called when IDB cache and each network batch are ready — unblocks the library grid early. */ onProgress?: (events: Event[]) => void } async function filterValidNewIndexEvents( incoming: Event[], knownIds: ReadonlySet ): Promise { const novel = incoming.filter((event) => !knownIds.has(event.id)) if (novel.length === 0) return [] const out: Event[] = [] for (let i = 0; i < novel.length; i += INDEX_VERIFY_CHUNK) { out.push(...filterValidIndexEvents(novel.slice(i, i + INDEX_VERIFY_CHUNK))) if (i + INDEX_VERIFY_CHUNK < novel.length) { await new Promise((resolve) => { setTimeout(resolve, 0) }) } } return out } async function mergeValidIndexBatch( existing: PublicationIndexMap, knownIds: Set, incoming: Event[] ): Promise { const newValid = await filterValidNewIndexEvents(incoming, knownIds) if (newValid.length === 0) return existing for (const event of newValid) knownIds.add(event.id) return mergePublicationIndexMaps(existing, newValid) } export async function fetchLibraryIndexEvents( relayUrls: string[], options?: FetchLibraryIndexEventsOptions ): Promise { const indexRelays = libraryIndexRelayUrls(relayUrls) if (indexRelays.length === 0) return [] const cached = await loadLibraryIndexCacheEvents() let indexMap = buildStructuralPublicationIndexMap(cached) let validMerged = publicationIndexMapValues(indexMap) const knownValidIds = new Set(validMerged.map((event) => event.id)) const emitProgress = () => { options?.onProgress?.(validMerged) } emitProgress() const filter: Filter = { kinds: [ExtendedKind.PUBLICATION], limit: INDEX_WS_PAGE_LIMIT } const { wsRelays: rawWsRelays, httpRelays } = splitWsAndHttpRelays(indexRelays) const wsRelays = stripLocalNetworkRelaysForWssReq(rawWsRelays) const firstPageByRelay = new Map() const firstPagePromises: Promise<{ relay: string; events: Event[] }>[] = [ ...wsRelays.map(async (wsRelay) => { const events = await fetchWsIndexFirstPage(wsRelay, filter) return { relay: wsRelay, events } }), ...httpRelays.map(async (httpRelay) => { const events = await fetchHttpIndexFirstPage(httpRelay, filter) return { relay: httpRelay, events } }) ] const firstSettled = await Promise.allSettled(firstPagePromises) for (const result of firstSettled) { if (result.status !== 'fulfilled') continue firstPageByRelay.set(result.value.relay, result.value.events) } const firstPageNetwork = dedupeEventsById( firstSettled.flatMap((r) => (r.status === 'fulfilled' ? r.value.events : [])) ) indexMap = await mergeValidIndexBatch(indexMap, knownValidIds, firstPageNetwork) validMerged = publicationIndexMapValues(indexMap) void persistLibraryIndexCacheEvents(validMerged) emitProgress() if (import.meta.env.DEV) { const perRelayFirstPageCounts = firstSettled.map((r) => r.status === 'fulfilled' ? { relay: r.value.relay, count: r.value.events.length } : { relay: 'unknown', count: 0 } ) logger.info('[Library] index first page', { indexRelays: indexRelays.length, wsRelays: wsRelays.length, httpRelays: httpRelays.length, strippedWsRelays: rawWsRelays.length - wsRelays.length, cachedCount: cached.length, firstPageCount: firstPageNetwork.length, mergedCount: validMerged.length, validCount: validMerged.length, topLevelCount: getTopLevelIndexEvents(validMerged).length, perRelayCounts: perRelayFirstPageCounts }) } const deepBatches: Promise<{ relay: string; events: Event[] }>[] = [] for (const wsRelay of wsRelays) { if (!isLibraryDeepIndexRelay(wsRelay)) continue deepBatches.push( fetchRemainingPagesFromWsIndexRelay(wsRelay, filter, firstPageByRelay.get(wsRelay) ?? []).then( (events) => ({ relay: wsRelay, events }) ) ) } for (const httpRelay of httpRelays) { if (!isLibraryDeepIndexRelay(httpRelay)) continue deepBatches.push( fetchRemainingPagesFromHttpIndexRelay( httpRelay, filter, firstPageByRelay.get(httpRelay) ?? [] ).then((events) => ({ relay: httpRelay, events })) ) } const deepSettled = await Promise.allSettled(deepBatches) const deepNetwork = dedupeEventsById( deepSettled.flatMap((r) => (r.status === 'fulfilled' ? r.value.events : [])) ) indexMap = await mergeValidIndexBatch(indexMap, knownValidIds, deepNetwork) validMerged = publicationIndexMapValues(indexMap) void persistLibraryIndexCacheEvents(validMerged) emitProgress() if (import.meta.env.DEV) { const perRelayDeepCounts = deepSettled.map((r) => r.status === 'fulfilled' ? { relay: r.value.relay, count: r.value.events.length } : { relay: 'unknown', count: 0 } ) logger.info('[Library] index fetch complete', { deepPageCount: deepNetwork.length, mergedCount: validMerged.length, validCount: validMerged.length, topLevelCount: getTopLevelIndexEvents(validMerged).length, perRelayDeepCounts }) } return validMerged } export function buildEngagementMapsFromEvents( labels: Event[], comments: Event[], highlights: Event[], targetAddresses?: Set, targetEventIds?: Set, viewerPubkey?: string | null, bookmarkLists: Event[] = [], pinLists: Event[] = [] ): PublicationEngagementMaps { const labelAddresses = new Set() const labelEventIds = new Set() const labelValuesByAddress = new Map>() const labelValuesByEventId = new Map>() const booklistAddresses = new Set() const booklistEventIds = new Set() const myBooklistAddresses = new Set() const myBooklistEventIds = new Set() const myCommentAddresses = new Set() const myCommentEventIds = new Set() const myHighlightAddresses = new Set() const myHighlightEventIds = new Set() const commentAddresses = new Set() const commentEventIds = new Set() const highlightAddresses = new Set() const highlightEventIds = new Set() const bookmarkAddresses = new Set() const bookmarkEventIds = new Set() const pinAddresses = new Set() const pinEventIds = new Set() const addressMatches = (addr: string) => !targetAddresses || targetAddresses.has(addr) const eventIdMatches = (id: string) => !targetEventIds || targetEventIds.has(id.toLowerCase()) const viewerPk = viewerPubkey?.trim().toLowerCase() const addLabelValues = (map: Map>, key: string, values: string[]) => { if (values.length === 0) return let set = map.get(key) if (!set) { set = new Set() map.set(key, set) } for (const value of values) set.add(value) } for (const ev of labels) { const labelValues = extractNip32LabelValues(ev.tags) const isBooklist = labelValues.some(isBooklistNip32Label) const isViewerLabel = !!viewerPk && ev.pubkey.toLowerCase() === viewerPk for (const tag of ev.tags) { if (tag[0] === 'a' && tag[1] && addressMatches(tag[1])) { labelAddresses.add(tag[1]) addLabelValues(labelValuesByAddress, tag[1], labelValues) if (isBooklist) { booklistAddresses.add(tag[1]) if (isViewerLabel) myBooklistAddresses.add(tag[1]) } } if (tag[0] === 'e' && tag[1] && eventIdMatches(tag[1])) { const eventId = tag[1].toLowerCase() labelEventIds.add(eventId) addLabelValues(labelValuesByEventId, eventId, labelValues) if (isBooklist) { booklistEventIds.add(eventId) if (isViewerLabel) myBooklistEventIds.add(eventId) } } } } for (const ev of comments) { const isViewerEvent = !!viewerPk && ev.pubkey.toLowerCase() === viewerPk for (const tag of ev.tags) { if (tag[0] === 'A' && tag[1] && addressMatches(tag[1])) { commentAddresses.add(tag[1]) if (isViewerEvent) myCommentAddresses.add(tag[1]) } if (tag[0] === 'e' && tag[1] && eventIdMatches(tag[1])) { commentEventIds.add(tag[1].toLowerCase()) if (isViewerEvent) myCommentEventIds.add(tag[1].toLowerCase()) } } } for (const ev of highlights) { const isViewerEvent = !!viewerPk && ev.pubkey.toLowerCase() === viewerPk for (const tag of ev.tags) { if (tag[0] === 'a' && tag[1] && addressMatches(tag[1])) { highlightAddresses.add(tag[1]) if (isViewerEvent) myHighlightAddresses.add(tag[1]) } if (tag[0] === 'e' && tag[1] && eventIdMatches(tag[1])) { highlightEventIds.add(tag[1].toLowerCase()) if (isViewerEvent) myHighlightEventIds.add(tag[1].toLowerCase()) } } } for (const ev of bookmarkLists) { for (const tag of ev.tags) { if (tag[0] === 'a' && tag[1] && addressMatches(tag[1])) { bookmarkAddresses.add(tag[1]) } if (tag[0] === 'e' && tag[1] && eventIdMatches(tag[1])) { bookmarkEventIds.add(tag[1].toLowerCase()) } } } for (const ev of pinLists) { for (const tag of ev.tags) { if (tag[0] === 'a' && tag[1] && addressMatches(tag[1])) { pinAddresses.add(tag[1]) } if (tag[0] === 'e' && tag[1] && eventIdMatches(tag[1])) { pinEventIds.add(tag[1].toLowerCase()) } } } return { labelAddresses, labelEventIds, labelValuesByAddress, labelValuesByEventId, booklistAddresses, booklistEventIds, myBooklistAddresses, myBooklistEventIds, myCommentAddresses, myCommentEventIds, myHighlightAddresses, myHighlightEventIds, commentAddresses, commentEventIds, highlightAddresses, highlightEventIds, bookmarkAddresses, bookmarkEventIds, pinAddresses, pinEventIds } } async function fetchHttpEngagementByAddresses( httpRelays: string[], kind: number, tagKey: '#a' | '#A', addressChunks: string[][] ): Promise { if (httpRelays.length === 0 || addressChunks.length === 0) return [] const out: Event[] = [] const seen = new Set() 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 } async function fetchHttpEngagementByEventIds( httpRelays: string[], kind: number, eventIdChunks: string[][] ): Promise { if (httpRelays.length === 0 || eventIdChunks.length === 0) return [] const out: Event[] = [] const seen = new Set() for (const relay of httpRelays) { for (const chunk of eventIdChunks) { if (chunk.length === 0) continue const filter = { kinds: [kind], '#e': 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, targetEventIds: Set, options?: { viewerPubkey?: string | null } ): Promise { if (relayUrls.length === 0 || targetAddresses.size === 0) { return emptyPublicationEngagementMaps() } const addressChunks = chunkArray([...targetAddresses], ENGAGEMENT_ADDRESS_CHUNK) const eventIdChunks = chunkArray([...targetEventIds], ENGAGEMENT_EVENT_ID_CHUNK) const { wsRelays, httpRelays } = splitWsAndHttpRelays(relayUrls) const useWsEngagement = wsRelays.length > 0 if (import.meta.env.DEV) { logger.info('[Library] engagement relay split', { wsRelays: wsRelays.length, httpRelays: httpRelays.length, targetAddresses: targetAddresses.size, targetEventIds: targetEventIds.size }) } 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 commentEventFilters = eventIdChunks.map( (chunk): Filter => ({ kinds: [ExtendedKind.COMMENT], '#e': chunk, limit: chunk.length * 12 }) ) const highlightEventFilters = eventIdChunks.map( (chunk): Filter => ({ kinds: [kinds.Highlights], '#e': chunk, limit: chunk.length * 12 }) ) const bookmarkAddressFilters = addressChunks.map( (chunk): Filter => ({ kinds: [kinds.BookmarkList], '#a': chunk, limit: chunk.length * 8 }) ) const bookmarkEventFilters = eventIdChunks.map( (chunk): Filter => ({ kinds: [kinds.BookmarkList], '#e': chunk, limit: chunk.length * 8 }) ) const pinAddressFilters = addressChunks.map( (chunk): Filter => ({ kinds: [PIN_LIST_KIND], '#a': chunk, limit: chunk.length * 8 }) ) const pinEventFilters = eventIdChunks.map( (chunk): Filter => ({ kinds: [PIN_LIST_KIND], '#e': chunk, limit: chunk.length * 8 }) ) const highlightPromise = Promise.all([ useWsEngagement && highlightFilters.length > 0 ? queryService.fetchEvents(wsRelays, highlightFilters, ENGAGEMENT_QUERY_OPTS) : Promise.resolve([] as Event[]), useWsEngagement && highlightEventFilters.length > 0 ? queryService.fetchEvents(wsRelays, highlightEventFilters, ENGAGEMENT_QUERY_OPTS) : Promise.resolve([] as Event[]), fetchHttpEngagementByAddresses(httpRelays, kinds.Highlights, '#a', addressChunks), fetchHttpEngagementByEventIds(httpRelays, kinds.Highlights, eventIdChunks) ]).then(([scoped, byEvent, bulkAddress, bulkEvent]) => dedupeEventsById([...scoped, ...byEvent, ...bulkAddress, ...bulkEvent]) ) const labelPromise = Promise.all([ useWsEngagement && labelAddressFilters.length > 0 ? queryService.fetchEvents(wsRelays, labelAddressFilters, ENGAGEMENT_QUERY_OPTS) : Promise.resolve([] as Event[]), useWsEngagement && labelEventFilters.length > 0 ? queryService.fetchEvents(wsRelays, labelEventFilters, ENGAGEMENT_QUERY_OPTS) : Promise.resolve([] as Event[]), fetchHttpEngagementByAddresses(httpRelays, ExtendedKind.LABEL, '#a', addressChunks), fetchHttpEngagementByEventIds(httpRelays, ExtendedKind.LABEL, eventIdChunks) ]).then(([byAddress, byEvent, bulkAddress, bulkEvent]) => dedupeEventsById([...byAddress, ...byEvent, ...bulkAddress, ...bulkEvent]) ) const commentPromise = Promise.all([ useWsEngagement && commentWsFilters.length > 0 ? queryService.fetchEvents(wsRelays, commentWsFilters, ENGAGEMENT_QUERY_OPTS) : Promise.resolve([] as Event[]), useWsEngagement && commentEventFilters.length > 0 ? queryService.fetchEvents(wsRelays, commentEventFilters, ENGAGEMENT_QUERY_OPTS) : Promise.resolve([] as Event[]), fetchHttpEngagementByAddresses(httpRelays, ExtendedKind.COMMENT, '#A', addressChunks), fetchHttpEngagementByEventIds(httpRelays, ExtendedKind.COMMENT, eventIdChunks) ]).then(([scoped, byEvent, bulkAddress, bulkEvent]) => dedupeEventsById([...scoped, ...byEvent, ...bulkAddress, ...bulkEvent]) ) const bookmarkPromise = Promise.all([ useWsEngagement && bookmarkAddressFilters.length > 0 ? queryService.fetchEvents(wsRelays, bookmarkAddressFilters, ENGAGEMENT_QUERY_OPTS) : Promise.resolve([] as Event[]), useWsEngagement && bookmarkEventFilters.length > 0 ? queryService.fetchEvents(wsRelays, bookmarkEventFilters, ENGAGEMENT_QUERY_OPTS) : Promise.resolve([] as Event[]), fetchHttpEngagementByAddresses(httpRelays, kinds.BookmarkList, '#a', addressChunks), fetchHttpEngagementByEventIds(httpRelays, kinds.BookmarkList, eventIdChunks) ]).then(([byAddress, byEvent, bulkAddress, bulkEvent]) => dedupeEventsById([...byAddress, ...byEvent, ...bulkAddress, ...bulkEvent]) ) const pinPromise = Promise.all([ useWsEngagement && pinAddressFilters.length > 0 ? queryService.fetchEvents(wsRelays, pinAddressFilters, ENGAGEMENT_QUERY_OPTS) : Promise.resolve([] as Event[]), useWsEngagement && pinEventFilters.length > 0 ? queryService.fetchEvents(wsRelays, pinEventFilters, ENGAGEMENT_QUERY_OPTS) : Promise.resolve([] as Event[]), fetchHttpEngagementByAddresses(httpRelays, PIN_LIST_KIND, '#a', addressChunks), fetchHttpEngagementByEventIds(httpRelays, PIN_LIST_KIND, eventIdChunks) ]).then(([byAddress, byEvent, bulkAddress, bulkEvent]) => dedupeEventsById([...byAddress, ...byEvent, ...bulkAddress, ...bulkEvent]) ) const [highlights, labels, comments, bookmarkLists, pinLists] = await Promise.all([ highlightPromise, labelPromise, commentPromise, bookmarkPromise, pinPromise ]) return buildEngagementMapsFromEvents( dedupeEventsById(labels), dedupeEventsById(comments), dedupeEventsById(highlights), targetAddresses, targetEventIds, options?.viewerPubkey, dedupeEventsById(bookmarkLists), dedupeEventsById(pinLists) ) } function addressHasEngagement( address: string, eventId: string | undefined, maps: PublicationEngagementMaps ): { hasLabel: boolean; hasComment: boolean; hasHighlight: boolean } { const idLower = eventId?.toLowerCase() const hasLabel = maps.labelAddresses.has(address) || (idLower ? maps.labelEventIds.has(idLower) : false) const hasComment = maps.commentAddresses.has(address) || (idLower ? maps.commentEventIds.has(idLower) : false) const hasHighlight = maps.highlightAddresses.has(address) || (idLower ? maps.highlightEventIds.has(idLower) : false) return { hasLabel, hasComment, hasHighlight } } function collectBookmarkPinFlagsForTarget( address: string, eventId: string | undefined, maps: PublicationEngagementMaps ): { hasBookmark: boolean; hasPin: boolean } { const idLower = eventId?.toLowerCase() return { hasBookmark: maps.bookmarkAddresses.has(address) || (idLower ? maps.bookmarkEventIds.has(idLower) : false), hasPin: maps.pinAddresses.has(address) || (idLower ? maps.pinEventIds.has(idLower) : false) } } function targetHasPublicationEngagement( flags: { hasLabel: boolean; hasComment: boolean; hasHighlight: boolean }, booklistFlags: { hasBooklistLabel: boolean }, bookmarkPinFlags: { hasBookmark: boolean; hasPin: boolean } ): boolean { return ( flags.hasLabel || flags.hasComment || flags.hasHighlight || booklistFlags.hasBooklistLabel || bookmarkPinFlags.hasBookmark || bookmarkPinFlags.hasPin ) } /** True when a library row has any engagement signal from any author. */ export function publicationEntryHasEngagement(entry: LibraryPublicationEntry): boolean { return ( entry.hasLabel || entry.hasBooklistLabel || entry.hasComment || entry.hasHighlight || entry.hasBookmark || entry.hasPin ) } function collectLabelNamesForTarget( address: string, eventId: string | undefined, maps: PublicationEngagementMaps, out: Set ): void { const byAddress = maps.labelValuesByAddress.get(address) if (byAddress) { for (const value of byAddress) { if (!isBooklistNip32Label(value)) out.add(value) } } if (eventId) { const byEventId = maps.labelValuesByEventId.get(eventId.toLowerCase()) if (byEventId) { for (const value of byEventId) { if (!isBooklistNip32Label(value)) out.add(value) } } } } function collectBooklistFlagsForTarget( address: string, eventId: string | undefined, maps: PublicationEngagementMaps ): { hasBooklistLabel: boolean; hasMyBooklistLabel: boolean } { const hasBooklistLabel = maps.booklistAddresses.has(address) || (eventId ? maps.booklistEventIds.has(eventId.toLowerCase()) : false) const hasMyBooklistLabel = maps.myBooklistAddresses.has(address) || (eventId ? maps.myBooklistEventIds.has(eventId.toLowerCase()) : false) return { hasBooklistLabel, hasMyBooklistLabel } } function collectMyEngagementFlagsForTarget( address: string, eventId: string | undefined, maps: PublicationEngagementMaps ): { hasMyComment: boolean; hasMyHighlight: boolean } { const hasMyComment = maps.myCommentAddresses.has(address) || (eventId ? maps.myCommentEventIds.has(eventId.toLowerCase()) : false) const hasMyHighlight = maps.myHighlightAddresses.has(address) || (eventId ? maps.myHighlightEventIds.has(eventId.toLowerCase()) : false) return { hasMyComment, hasMyHighlight } } /** Build one library row with engagement/booklist flags for a top-level kind-30040 root. */ export function buildLibraryPublicationEntry( root: Event, indexByAddress: Map, engagement: PublicationEngagementMaps ): LibraryPublicationEntry { const reachable = collectReachableAddressesCached(root, indexByAddress) const rootAddr = eventTagAddress(root) if (rootAddr) reachable.add(rootAddr) let hasLabel = false let hasComment = false let hasHighlight = false let hasBookmark = false let hasPin = false let hasBooklistLabel = false let hasMyBooklistLabel = false let hasMyComment = false let hasMyHighlight = false let engagementCount = 0 const labelNames = new Set() for (const addr of reachable) { const indexed = indexByAddress.get(addr) const flags = addressHasEngagement(addr, indexed?.id, engagement) const booklistFlags = collectBooklistFlagsForTarget(addr, indexed?.id, engagement) const bookmarkPinFlags = collectBookmarkPinFlagsForTarget(addr, indexed?.id, engagement) const myFlags = collectMyEngagementFlagsForTarget(addr, indexed?.id, engagement) if (flags.hasLabel) { hasLabel = true collectLabelNamesForTarget(addr, indexed?.id, engagement, labelNames) } if (booklistFlags.hasBooklistLabel) hasBooklistLabel = true if (booklistFlags.hasMyBooklistLabel) hasMyBooklistLabel = true if (myFlags.hasMyComment) hasMyComment = true if (myFlags.hasMyHighlight) hasMyHighlight = true if (flags.hasComment) hasComment = true if (flags.hasHighlight) hasHighlight = true if (bookmarkPinFlags.hasBookmark) hasBookmark = true if (bookmarkPinFlags.hasPin) hasPin = true if (targetHasPublicationEngagement(flags, booklistFlags, bookmarkPinFlags)) engagementCount++ } const rootFlags = addressHasEngagement(rootAddr ?? '', root.id, engagement) const rootBooklistFlags = collectBooklistFlagsForTarget(rootAddr ?? '', root.id, engagement) const rootBookmarkPinFlags = collectBookmarkPinFlagsForTarget(rootAddr ?? '', root.id, engagement) const rootMyFlags = collectMyEngagementFlagsForTarget(rootAddr ?? '', root.id, engagement) hasLabel = hasLabel || rootFlags.hasLabel hasComment = hasComment || rootFlags.hasComment hasHighlight = hasHighlight || rootFlags.hasHighlight hasBookmark = hasBookmark || rootBookmarkPinFlags.hasBookmark hasPin = hasPin || rootBookmarkPinFlags.hasPin hasBooklistLabel = hasBooklistLabel || rootBooklistFlags.hasBooklistLabel hasMyBooklistLabel = hasMyBooklistLabel || rootBooklistFlags.hasMyBooklistLabel hasMyComment = hasMyComment || rootMyFlags.hasMyComment hasMyHighlight = hasMyHighlight || rootMyFlags.hasMyHighlight if (rootFlags.hasLabel) { collectLabelNamesForTarget(rootAddr ?? '', root.id, engagement, labelNames) } return { event: root, hasLabel, labelNames: [...labelNames].sort((a, b) => a.localeCompare(b)), hasBooklistLabel, hasMyBooklistLabel, hasMyComment, hasMyHighlight, hasComment, hasHighlight, hasBookmark, hasPin, engagementCount } } export function libraryPublicationEntriesFromIndex( indexEvents: Event[], engagement: PublicationEngagementMaps ): LibraryPublicationEntry[] { const indexByAddress = buildIndexByAddress(indexEvents) return getTopLevelIndexEvents(indexEvents).map((root) => buildLibraryPublicationEntry(root, indexByAddress, engagement) ) } export function filterEngagedPublications( roots: Event[], indexByAddress: Map, engagement: PublicationEngagementMaps ): LibraryPublicationEntry[] { return getTopLevelIndexEvents(roots) .map((root) => buildLibraryPublicationEntry(root, indexByAddress, engagement)) .filter((entry) => publicationEntryHasEngagement(entry)) } export function buildRecentPublicationEntries( roots: Event[], indexByAddress: Map, engagement: PublicationEngagementMaps, limit = LIBRARY_PAGE_SIZE ): LibraryPublicationEntry[] { return [...getTopLevelIndexEvents(roots)] .sort((a, b) => b.created_at - a.created_at) .slice(0, limit) .map((event) => buildLibraryPublicationEntry(event, indexByAddress, engagement)) } /** Full default-feed order: engaged publications first, then newest top-level indexes. */ export function computeLibraryFeedRootOrder( roots: Event[], indexByAddress: Map, engagement: PublicationEngagementMaps ): Event[] { const topLevel = getTopLevelIndexEvents(roots) const engagedRoots: Event[] = [] const restRoots: Event[] = [] for (const root of topLevel) { const entry = buildLibraryPublicationEntry(root, indexByAddress, engagement) if (publicationEntryHasEngagement(entry)) { engagedRoots.push(root) } else { restRoots.push(root) } } const sortedEngaged = sortLibraryPublications( engagedRoots.map((root) => buildLibraryPublicationEntry(root, indexByAddress, engagement)) ).map((entry) => entry.event) restRoots.sort((a, b) => b.created_at - a.created_at) const seen = new Set() const ordered: Event[] = [] for (const root of [...sortedEngaged, ...restRoots]) { const dedupeKey = eventTagAddress(root) ?? root.id if (seen.has(dedupeKey)) continue seen.add(dedupeKey) ordered.push(root) } return ordered } /** Entries for default library feed from page 0 through {@link pageIndexInclusive} (inclusive). */ export function libraryFeedEntriesThroughPage( orderedRoots: Event[], indexByAddress: Map, engagement: PublicationEngagementMaps, pageIndexInclusive: number, pageSize = LIBRARY_PAGE_SIZE ): LibraryPublicationEntry[] { const end = Math.min(orderedRoots.length, (pageIndexInclusive + 1) * pageSize) return orderedRoots .slice(0, end) .map((root) => buildLibraryPublicationEntry(root, indexByAddress, engagement)) } export function libraryDefaultFeedSlice( indexEvents: Event[], engagement: PublicationEngagementMaps, pageIndexInclusive: number ): { entries: LibraryPublicationEntry[] totalCount: number hasMore: boolean } { if (indexEvents.length === 0) { return { entries: [], totalCount: 0, hasMore: false } } const indexByAddress = buildIndexByAddress(indexEvents) const ordered = computeLibraryFeedRootOrder(indexEvents, indexByAddress, engagement) const entries = libraryFeedEntriesThroughPage( ordered, indexByAddress, engagement, pageIndexInclusive ) return { entries, totalCount: ordered.length, hasMore: entries.length < ordered.length } } /** First page of the default library feed (engaged first, then recent). */ export function pickLibraryPublicationEntries( roots: Event[], indexByAddress: Map, engagement: PublicationEngagementMaps ): LibraryPublicationEntry[] { const ordered = computeLibraryFeedRootOrder(roots, indexByAddress, engagement) return libraryFeedEntriesThroughPage(ordered, indexByAddress, engagement, 0) } export function sortLibraryPublications(entries: LibraryPublicationEntry[]): LibraryPublicationEntry[] { return [...entries].sort((a, b) => { const aEngaged = publicationEntryHasEngagement(a) const bEngaged = publicationEntryHasEngagement(b) if (aEngaged !== bEngaged) return aEngaged ? -1 : 1 if (a.engagementCount !== b.engagementCount) return b.engagementCount - a.engagementCount return b.event.created_at - a.event.created_at }) } const EMPTY_ENGAGEMENT = emptyPublicationEngagementMaps() function emptyPublicationEngagementMaps(): PublicationEngagementMaps { return { labelAddresses: new Set(), labelEventIds: new Set(), labelValuesByAddress: new Map(), labelValuesByEventId: new Map(), booklistAddresses: new Set(), booklistEventIds: new Set(), myBooklistAddresses: new Set(), myBooklistEventIds: new Set(), myCommentAddresses: new Set(), myCommentEventIds: new Set(), myHighlightAddresses: new Set(), myHighlightEventIds: new Set(), commentAddresses: new Set(), commentEventIds: new Set(), highlightAddresses: new Set(), highlightEventIds: new Set(), bookmarkAddresses: new Set(), bookmarkEventIds: new Set(), pinAddresses: new Set(), pinEventIds: new Set() } } function isEventInBookmarkList(bookmarkList: Event, event: Event): boolean { const isReplaceable = isReplaceableEvent(event.kind) const eventKey = isReplaceable ? getReplaceableCoordinateFromEvent(event) : event.id return bookmarkList.tags.some((tag) => isReplaceable ? tag[0] === 'a' && tag[1] === eventKey : tag[0] === 'e' && tag[1] === eventKey ) } export function publicationEntryBelongsToUser( entry: LibraryPublicationEntry, opts: { userPubkey: string bookmarkListEvent?: Event | null pinListEvent?: Event | null myBooklistAddresses?: Set myBooklistEventIds?: Set } ): boolean { const { event } = entry const pk = opts.userPubkey.toLowerCase() const rootAddr = eventTagAddress(event) if (event.pubkey.toLowerCase() === pk) return true if (event.tags.some((t) => t[0] === 'p' && t[1]?.toLowerCase() === pk)) return true if (entry.hasMyBooklistLabel || entry.hasMyComment || entry.hasMyHighlight) return true if (rootAddr && opts.myBooklistAddresses?.has(rootAddr)) return true if (opts.myBooklistEventIds?.has(event.id.toLowerCase())) return true if (opts.bookmarkListEvent && isEventInBookmarkList(opts.bookmarkListEvent, event)) return true if (opts.pinListEvent && isEventInPinList(opts.pinListEvent, event)) return true return false } export type LibraryMineFilterOpts = { bookmarkListEvent?: Event | null pinListEvent?: Event | null myBooklistAddresses?: Set myBooklistEventIds?: Set } /** Cheap membership test on a top-level index — no full {@link LibraryPublicationEntry} build. */ export function publicationRootBelongsToUser( root: Event, indexByAddress: Map, engagement: PublicationEngagementMaps, userPubkey: string, opts?: LibraryMineFilterOpts ): boolean { const pk = userPubkey.toLowerCase() if (root.pubkey.toLowerCase() === pk) return true if (root.tags.some((t) => t[0] === 'p' && t[1]?.toLowerCase() === pk)) return true const rootAddr = eventTagAddress(root) if (rootAddr && opts?.myBooklistAddresses?.has(rootAddr)) return true if (opts?.myBooklistEventIds?.has(root.id.toLowerCase())) return true if (opts?.bookmarkListEvent && isEventInBookmarkList(opts.bookmarkListEvent, root)) return true if (opts?.pinListEvent && isEventInPinList(opts.pinListEvent, root)) return true const reachable = collectReachableAddressesCached(root, indexByAddress) if (rootAddr) reachable.add(rootAddr) for (const addr of reachable) { const indexed = indexByAddress.get(addr) const eventId = indexed?.id ?? (addr === rootAddr ? root.id : undefined) if (collectBooklistFlagsForTarget(addr, eventId, engagement).hasMyBooklistLabel) return true const myFlags = collectMyEngagementFlagsForTarget(addr, eventId, engagement) if (myFlags.hasMyComment || myFlags.hasMyHighlight) return true } return false } const MINE_FILTER_BATCH_SIZE = 40 /** Build library rows only for publications belonging to the viewer (fast path for “My publications”). */ export function libraryPublicationEntriesForUserFromIndex( indexEvents: Event[], engagement: PublicationEngagementMaps, userPubkey: string, opts?: LibraryMineFilterOpts ): LibraryPublicationEntry[] { if (!userPubkey) return [] const indexByAddress = buildIndexByAddress(indexEvents) const out: LibraryPublicationEntry[] = [] for (const root of getTopLevelIndexEvents(indexEvents)) { if (!publicationRootBelongsToUser(root, indexByAddress, engagement, userPubkey, opts)) continue out.push(buildLibraryPublicationEntry(root, indexByAddress, engagement)) } return sortLibraryPublications(out) } /** Yields between root batches so the UI stays responsive on large indexes. */ export function libraryPublicationEntriesForUserFromIndexAsync( indexEvents: Event[], engagement: PublicationEngagementMaps, userPubkey: string, opts?: LibraryMineFilterOpts, signal?: { cancelled: boolean } ): Promise { if (!userPubkey) return Promise.resolve([]) const indexByAddress = buildIndexByAddress(indexEvents) const roots = getTopLevelIndexEvents(indexEvents) const out: LibraryPublicationEntry[] = [] let i = 0 return new Promise((resolve) => { const step = () => { if (signal?.cancelled) return const end = Math.min(i + MINE_FILTER_BATCH_SIZE, roots.length) for (; i < end; i++) { const root = roots[i] if (!publicationRootBelongsToUser(root, indexByAddress, engagement, userPubkey, opts)) continue out.push(buildLibraryPublicationEntry(root, indexByAddress, engagement)) } if (signal?.cancelled) return if (i < roots.length) { requestAnimationFrame(step) } else { resolve(sortLibraryPublications(out)) } } requestAnimationFrame(step) }) } /** 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) => buildLibraryPublicationEntry(root, indexByAddress, engagement)) } /** Re-fetch engagement maps for the current library index snapshot (e.g. after booklist toggle). */ export async function refreshLibraryEngagement( indexRelayUrls: string[], indexEvents: Event[], viewerPubkey?: string | null ): Promise<{ engagement: PublicationEngagementMaps; engaged: LibraryPublicationEntry[] }> { const indexByAddress = buildIndexByAddress(indexEvents) const targetAddresses = collectTargetAddressesFromIndexes(indexEvents, indexByAddress) const targetEventIds = collectPublicationIndexEventIds(indexEvents) const engagementRelayUrls = await buildLibraryEngagementRelayUrls( viewerPubkey ?? undefined, indexRelayUrls, indexEvents ) const engagement = await fetchPublicationEngagementMaps( engagementRelayUrls, targetAddresses, targetEventIds, { viewerPubkey } ) const topLevel = getTopLevelIndexEvents(indexEvents) if (sessionCache) { sessionCache = { ...sessionCache, engagement, viewerPubkey: viewerPubkey ?? null } } if (import.meta.env.DEV) { logger.info('[Library] engagement refreshed', engagementMapsSizeSummary(engagement)) } return { engagement, engaged: pickLibraryPublicationEntries(topLevel, indexByAddress, engagement) } } /** 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) { indexEvents = await loadLibraryIndexCacheEvents() } 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 { 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() 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 = publicationIndexMapValues( mergePublicationIndexMaps(buildStructuralPublicationIndexMap(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, opts?: { bookmarkListEvent?: Event | null pinListEvent?: Event | null myBooklistAddresses?: Set myBooklistEventIds?: Set } ): LibraryPublicationEntry[] { if (!userPubkey) return entries return entries.filter((entry) => publicationEntryBelongsToUser(entry, { userPubkey, bookmarkListEvent: opts?.bookmarkListEvent, pinListEvent: opts?.pinListEvent, myBooklistAddresses: opts?.myBooklistAddresses, myBooklistEventIds: opts?.myBooklistEventIds }) ) } function collectTargetAddressesFromIndexes( indexEvents: Event[], indexByAddress: Map ): Set { const addresses = new Set() 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( indexRelayUrls: string[], indexEvents: Event[], indexByAddress: Map, engagement?: PublicationEngagementMaps, viewerPubkey?: string | null ): Promise { const topLevel = getTopLevelIndexEvents(indexEvents) let maps = engagement if (!maps) { const targetAddresses = collectTargetAddressesFromIndexes(indexEvents, indexByAddress) const targetEventIds = collectPublicationIndexEventIds(indexEvents) const engagementRelayUrls = await buildLibraryEngagementRelayUrls( viewerPubkey ?? undefined, indexRelayUrls, indexEvents ) maps = await fetchPublicationEngagementMaps( engagementRelayUrls, targetAddresses, targetEventIds, { viewerPubkey } ) } return pickLibraryPublicationEntries(topLevel, indexByAddress, maps) } export async function loadLibraryPublicationIndex( relayUrls: string[], options?: { forceRefresh?: boolean viewerPubkey?: string | null /** Called as soon as kind-30040 indexes are loaded — before engagement (which can take minutes). */ onIndexesReady?: (snapshot: LibraryIndexLoadSnapshot) => void } ): Promise { const relayKey = relaySetKey(relayUrls) const forceRefresh = options?.forceRefresh ?? false if (!forceRefresh && indexLoadJob?.relayKey === relayKey && !indexLoadJob.forceRefresh) { registerIndexesReadyListener(indexLoadJob, options?.onIndexesReady) if (import.meta.env.DEV) { logger.info('[Library] load joined in-flight', { relayCount: relayUrls.length, hasProgress: indexLoadJob.lastProgressIndex != null }) } return indexLoadJob.promise } const job: LibraryIndexLoadJob = { relayKey, forceRefresh, onIndexesReadyListeners: [], lastProgressIndex: null, promise: Promise.resolve(null as unknown as LibraryIndexLoadResult) } registerIndexesReadyListener(job, options?.onIndexesReady) indexLoadJob = job job.promise = runLibraryPublicationIndexLoad(relayUrls, options, job).finally(() => { if (indexLoadJob === job) indexLoadJob = null }) return job.promise } async function runLibraryPublicationIndexLoad( relayUrls: string[], options: { forceRefresh?: boolean viewerPubkey?: string | null onIndexesReady?: (snapshot: LibraryIndexLoadSnapshot) => void } | undefined, job: LibraryIndexLoadJob ): Promise { const key = job.relayKey const viewerPubkey = options?.viewerPubkey ?? null if (import.meta.env.DEV) { logger.info('[Library] load start', { relayCount: relayUrls.length, cached: sessionCache?.relayKey === key }) } const emitIndexesReady = (indexByAddress: PublicationIndexMap) => { job.lastProgressIndex = indexByAddress emitIndexesReadySnapshot(job.onIndexesReadyListeners, indexByAddress) } if (!options?.forceRefresh && sessionCache?.relayKey === key) { const cachedIndexEvents = indexEventsFromCache(sessionCache) if (sessionCache.viewerPubkey !== viewerPubkey) { const targetAddresses = collectTargetAddressesFromIndexes( cachedIndexEvents, sessionCache.indexByAddress ) const targetEventIds = collectPublicationIndexEventIds(cachedIndexEvents) const engagementRelayUrls = await buildLibraryEngagementRelayUrls( viewerPubkey ?? undefined, relayUrls, cachedIndexEvents ) sessionCache = { ...sessionCache, viewerPubkey, engagement: await fetchPublicationEngagementMaps( engagementRelayUrls, targetAddresses, targetEventIds, { viewerPubkey } ) } } const engaged = await buildEngagedFromCache( relayUrls, cachedIndexEvents, sessionCache.indexByAddress, sessionCache.engagement, viewerPubkey ) if (import.meta.env.DEV) { logger.info('[Library] load from cache', { engaged: engaged.length }) } emitIndexesReady(sessionCache.indexByAddress) return { engaged, allIndexCount: cachedIndexEvents.length, topLevelCount: getTopLevelIndexEventsFromMap(sessionCache.indexByAddress).length, indexEvents: cachedIndexEvents, engagement: sessionCache.engagement } } let indexByAddress = buildStructuralPublicationIndexMap( await fetchLibraryIndexEvents(relayUrls, { onProgress: (events) => emitIndexesReady(buildStructuralPublicationIndexMap(events)) }) ) let indexEvents = publicationIndexMapValues(indexByAddress) if (import.meta.env.DEV) { logger.info('[Library] indexes fetched', { validCount: indexEvents.length }) } let topLevel = getTopLevelIndexEventsFromMap(indexByAddress) emitIndexesReady(indexByAddress) const topLevelForHydrate = topLevel await hydrateNestedIndexEvents(indexByAddress, relayUrls, { maxPasses: 1, maxMissingPerPass: HYDRATE_MISSING_CAP, scanRoots: topLevelForHydrate }) indexEvents = publicationIndexMapValues(indexByAddress) void persistLibraryIndexCacheEvents(indexEvents) if (import.meta.env.DEV) { logger.info('[Library] nested hydrate done', { indexCount: indexEvents.length }) } topLevel = getTopLevelIndexEventsFromMap(indexByAddress) 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 { const engagementRelayUrls = await buildLibraryEngagementRelayUrls( viewerPubkey ?? undefined, relayUrls, indexEvents ) engagement = await fetchPublicationEngagementMaps( engagementRelayUrls, targetAddresses, targetEventIds, { viewerPubkey } ) } catch (e) { if (import.meta.env.DEV) { logger.warn('[Library] engagement fetch failed', { message: e instanceof Error ? e.message : String(e) }) } engagement = emptyPublicationEngagementMaps() } if (import.meta.env.DEV) { logger.info('[Library] engagement maps built', engagementMapsSizeSummary(engagement)) } sessionCache = { relayKey: key, viewerPubkey, 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 indexLoadJob = null clearLibrarySearchSessionCache() } /** Clears Library tab session + IDB index cache only (publication reading cache is unchanged). */ export async function clearAllLibraryIndexCaches(): Promise { sessionCache = null indexLoadJob = 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(() => {}) }