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.
2230 lines
74 KiB
2230 lines
74 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 { 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<string> |
|
labelEventIds: Set<string> |
|
labelValuesByAddress: Map<string, Set<string>> |
|
labelValuesByEventId: Map<string, Set<string>> |
|
booklistAddresses: Set<string> |
|
booklistEventIds: Set<string> |
|
myBooklistAddresses: Set<string> |
|
myBooklistEventIds: Set<string> |
|
myCommentAddresses: Set<string> |
|
myCommentEventIds: Set<string> |
|
myHighlightAddresses: Set<string> |
|
myHighlightEventIds: Set<string> |
|
commentAddresses: Set<string> |
|
commentEventIds: Set<string> |
|
highlightAddresses: Set<string> |
|
highlightEventIds: Set<string> |
|
bookmarkAddresses: Set<string> |
|
bookmarkEventIds: Set<string> |
|
pinAddresses: Set<string> |
|
pinEventIds: Set<string> |
|
} |
|
|
|
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<LibraryIndexLoadResult> |
|
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<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.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<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) { |
|
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<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 |
|
} |
|
|
|
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<string>, 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<Event[]> { |
|
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<Event[]> { |
|
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<Event[]> { |
|
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<Event[]> { |
|
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<string[]> { |
|
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<string>() |
|
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<string[]> { |
|
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<string, number> { |
|
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<string> |
|
): Promise<Event[]> { |
|
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<void>((resolve) => { |
|
setTimeout(resolve, 0) |
|
}) |
|
} |
|
} |
|
return out |
|
} |
|
|
|
async function mergeValidIndexBatch( |
|
existing: PublicationIndexMap, |
|
knownIds: Set<string>, |
|
incoming: Event[] |
|
): Promise<PublicationIndexMap> { |
|
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<Event[]> { |
|
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<string, Event[]>() |
|
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<string>, |
|
targetEventIds?: Set<string>, |
|
viewerPubkey?: string | null, |
|
bookmarkLists: Event[] = [], |
|
pinLists: Event[] = [] |
|
): PublicationEngagementMaps { |
|
const labelAddresses = new Set<string>() |
|
const labelEventIds = new Set<string>() |
|
const labelValuesByAddress = new Map<string, Set<string>>() |
|
const labelValuesByEventId = new Map<string, Set<string>>() |
|
const booklistAddresses = new Set<string>() |
|
const booklistEventIds = new Set<string>() |
|
const myBooklistAddresses = new Set<string>() |
|
const myBooklistEventIds = new Set<string>() |
|
const myCommentAddresses = new Set<string>() |
|
const myCommentEventIds = new Set<string>() |
|
const myHighlightAddresses = new Set<string>() |
|
const myHighlightEventIds = new Set<string>() |
|
const commentAddresses = new Set<string>() |
|
const commentEventIds = new Set<string>() |
|
const highlightAddresses = new Set<string>() |
|
const highlightEventIds = new Set<string>() |
|
const bookmarkAddresses = new Set<string>() |
|
const bookmarkEventIds = new Set<string>() |
|
const pinAddresses = new Set<string>() |
|
const pinEventIds = new Set<string>() |
|
|
|
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<string, Set<string>>, key: string, values: string[]) => { |
|
if (values.length === 0) return |
|
let set = map.get(key) |
|
if (!set) { |
|
set = new Set<string>() |
|
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<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 |
|
} |
|
|
|
async function fetchHttpEngagementByEventIds( |
|
httpRelays: string[], |
|
kind: number, |
|
eventIdChunks: string[][] |
|
): Promise<Event[]> { |
|
if (httpRelays.length === 0 || eventIdChunks.length === 0) return [] |
|
const out: Event[] = [] |
|
const seen = new Set<string>() |
|
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<string>, |
|
targetEventIds: Set<string>, |
|
options?: { viewerPubkey?: string | null } |
|
): Promise<PublicationEngagementMaps> { |
|
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<string> |
|
): 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<string, Event>, |
|
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<string>() |
|
|
|
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<string, Event>, |
|
engagement: PublicationEngagementMaps |
|
): LibraryPublicationEntry[] { |
|
return getTopLevelIndexEvents(roots) |
|
.map((root) => buildLibraryPublicationEntry(root, indexByAddress, engagement)) |
|
.filter((entry) => publicationEntryHasEngagement(entry)) |
|
} |
|
|
|
export function buildRecentPublicationEntries( |
|
roots: Event[], |
|
indexByAddress: Map<string, Event>, |
|
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<string, Event>, |
|
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<string>() |
|
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<string, Event>, |
|
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<string, Event>, |
|
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<string> |
|
myBooklistEventIds?: Set<string> |
|
} |
|
): 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<string> |
|
myBooklistEventIds?: Set<string> |
|
} |
|
|
|
/** Cheap membership test on a top-level index — no full {@link LibraryPublicationEntry} build. */ |
|
export function publicationRootBelongsToUser( |
|
root: Event, |
|
indexByAddress: Map<string, Event>, |
|
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<LibraryPublicationEntry[]> { |
|
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<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) => 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<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) { |
|
indexEvents = await loadLibraryIndexCacheEvents() |
|
} |
|
|
|
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 = 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<string> |
|
myBooklistEventIds?: Set<string> |
|
} |
|
): 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<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( |
|
indexRelayUrls: string[], |
|
indexEvents: Event[], |
|
indexByAddress: Map<string, Event>, |
|
engagement?: PublicationEngagementMaps, |
|
viewerPubkey?: string | null |
|
): Promise<LibraryPublicationEntry[]> { |
|
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<LibraryIndexLoadResult> { |
|
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<LibraryIndexLoadResult> { |
|
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<void> { |
|
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(() => {}) |
|
}
|
|
|