diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index f759dc52..75c7d337 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -1205,10 +1205,6 @@ export function useMenuActions({ if (isArticleType) { const isMarkdownFormat = event.kind === kinds.LongFormArticle || event.kind === ExtendedKind.NOSTR_SPECIFICATION - const isAsciidocFormat = - event.kind === ExtendedKind.WIKI_ARTICLE || - event.kind === ExtendedKind.PUBLICATION || - event.kind === ExtendedKind.PUBLICATION_CONTENT if (isMarkdownFormat) { advancedSubMenu.push({ @@ -1219,15 +1215,6 @@ export function useMenuActions({ } }) } - if (isAsciidocFormat) { - advancedSubMenu.push({ - label: t('Export as AsciiDoc'), - onClick: () => { - closeDrawer() - exportAsAsciidoc() - } - }) - } if (event.kind === ExtendedKind.PUBLICATION && publicationBroadcastSubMenu.length > 0) { advancedSubMenu.push({ label: t('Rebroadcast entire publication'), diff --git a/src/hooks/useLibraryPublications.ts b/src/hooks/useLibraryPublications.ts index 4a9e2cc1..4edd69e4 100644 --- a/src/hooks/useLibraryPublications.ts +++ b/src/hooks/useLibraryPublications.ts @@ -24,7 +24,6 @@ import type { Event } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' const SEARCH_DEBOUNCE_MS = 300 -const LOAD_TIMEOUT_MS = 120_000 const EMPTY_ENGAGEMENT: PublicationEngagementMaps = { labelAddresses: new Set(), @@ -74,6 +73,7 @@ export function useLibraryPublications(isActive: boolean) { const [myBooklistTargets, setMyBooklistTargets] = useState(EMPTY_BOOKLIST_TARGETS) const [booklistTargetsLoading, setBooklistTargetsLoading] = useState(false) const loadGenRef = useRef(0) + const indexesReadyGenRef = useRef(0) const [mineIndexEntries, setMineIndexEntries] = useState([]) const [mineFilterComputing, setMineFilterComputing] = useState(false) const mineIndexCacheRef = useRef<{ @@ -159,42 +159,42 @@ export function useLibraryPublications(isActive: boolean) { } try { const relays = await buildLibraryRelayUrls(pubkey || undefined, blockedRelays ?? []) - let timeoutId: number | undefined - const timeoutPromise = new Promise((_, reject) => { - timeoutId = window.setTimeout(() => reject(new Error('Library load timed out')), LOAD_TIMEOUT_MS) + indexesReadyGenRef.current = 0 + const result = await loadLibraryPublicationIndex(relays, { + forceRefresh, + viewerPubkey: pubkey || undefined, + onIndexesReady: (snapshot) => { + if (gen !== loadGenRef.current) return + indexesReadyGenRef.current = gen + setIndexEvents(snapshot.indexEvents) + setAllIndexCount(snapshot.allIndexCount) + setTopLevelCount(snapshot.topLevelCount) + applyDefaultFeedSlice(snapshot.indexEvents, EMPTY_ENGAGEMENT, 0) + setLoading(false) + setEngagementLoading(true) + } }) - try { - const result = await Promise.race([ - loadLibraryPublicationIndex(relays, { - forceRefresh, - viewerPubkey: pubkey || undefined, - onIndexesReady: (snapshot) => { - if (gen !== loadGenRef.current) return - setIndexEvents(snapshot.indexEvents) - setAllIndexCount(snapshot.allIndexCount) - setTopLevelCount(snapshot.topLevelCount) - applyDefaultFeedSlice(snapshot.indexEvents, EMPTY_ENGAGEMENT, 0) - setLoading(false) - setEngagementLoading(true) - } - }), - timeoutPromise - ]) - if (gen !== loadGenRef.current) return - setIndexEvents(result.indexEvents) - setEngagement(result.engagement) - setAllIndexCount(result.allIndexCount) - setTopLevelCount(result.topLevelCount) - applyDefaultFeedSlice(result.indexEvents, result.engagement, 0) - } finally { - if (timeoutId != null) window.clearTimeout(timeoutId) - } + if (gen !== loadGenRef.current) return + setIndexEvents(result.indexEvents) + setEngagement(result.engagement) + setAllIndexCount(result.allIndexCount) + setTopLevelCount(result.topLevelCount) + applyDefaultFeedSlice(result.indexEvents, result.engagement, 0) } catch (e) { if (gen !== loadGenRef.current) return - const message = e instanceof Error ? e.message : 'Failed to load library' - setError(message) - if (import.meta.env.DEV) { - logger.warn('[Library] page load failed', { message, gen }) + if (indexesReadyGenRef.current === gen) { + if (import.meta.env.DEV) { + logger.warn('[Library] engagement phase failed after indexes loaded', { + message: e instanceof Error ? e.message : String(e), + gen + }) + } + } else { + const message = e instanceof Error ? e.message : 'Failed to load library' + setError(message) + if (import.meta.env.DEV) { + logger.warn('[Library] page load failed', { message, gen }) + } } } finally { if (gen === loadGenRef.current) { diff --git a/src/lib/library-publication-index.ts b/src/lib/library-publication-index.ts index fb82c7dc..ce942632 100644 --- a/src/lib/library-publication-index.ts +++ b/src/lib/library-publication-index.ts @@ -50,7 +50,6 @@ 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 ENGAGEMENT_FETCH_TIMEOUT_MS = 25_000 const LIBRARY_SEARCH_READING_CACHE_LIMIT = 200 export const LIBRARY_RELAY_SEARCH_LIMIT = 100 const LIBRARY_RELAY_SEARCH_TIMEOUT_MS = 28_000 @@ -62,6 +61,12 @@ const QUERY_OPTS = { firstRelayResultGraceMs: false as const } +const ENGAGEMENT_QUERY_OPTS = { + globalTimeout: 45_000, + eoseTimeout: 8_000, + firstRelayResultGraceMs: false as const +} + export type PublicationEngagementMaps = { labelAddresses: Set labelEventIds: Set @@ -308,6 +313,60 @@ export async function buildLibraryRelayUrls( 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 async function fetchLibraryIndexEvents(relayUrls: string[]): Promise { const indexRelays = libraryIndexRelayUrls(relayUrls) if (indexRelays.length === 0) return [] @@ -526,11 +585,38 @@ async function fetchHttpEngagementByAddresses( 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?: { httpOnly?: boolean; viewerPubkey?: string | null } + options?: { viewerPubkey?: string | null } ): Promise { if (relayUrls.length === 0 || targetAddresses.size === 0) { return emptyPublicationEngagementMaps() @@ -539,8 +625,15 @@ export async function fetchPublicationEngagementMaps( const addressChunks = chunkArray([...targetAddresses], ENGAGEMENT_ADDRESS_CHUNK) const eventIdChunks = chunkArray([...targetEventIds], ENGAGEMENT_EVENT_ID_CHUNK) const { wsRelays, httpRelays } = splitWsAndHttpRelays(relayUrls) - /** Labels/comments/highlights often live on WS relays only — always query them when available. */ 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 }) @@ -575,53 +668,68 @@ export async function fetchPublicationEngagementMaps( const highlightPromise = Promise.all([ useWsEngagement && highlightFilters.length > 0 - ? queryService.fetchEvents(wsRelays, highlightFilters, QUERY_OPTS) + ? queryService.fetchEvents(wsRelays, highlightFilters, ENGAGEMENT_QUERY_OPTS) : Promise.resolve([] as Event[]), useWsEngagement && highlightEventFilters.length > 0 - ? queryService.fetchEvents(wsRelays, highlightEventFilters, QUERY_OPTS) + ? queryService.fetchEvents(wsRelays, highlightEventFilters, ENGAGEMENT_QUERY_OPTS) : Promise.resolve([] as Event[]), - fetchHttpEngagementByAddresses(httpRelays, kinds.Highlights, '#a', addressChunks) - ]).then(([scoped, byEvent, bulk]) => dedupeEventsById([...scoped, ...byEvent, ...bulk])) + 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, QUERY_OPTS) + ? queryService.fetchEvents(wsRelays, labelAddressFilters, ENGAGEMENT_QUERY_OPTS) : Promise.resolve([] as Event[]), useWsEngagement && labelEventFilters.length > 0 - ? queryService.fetchEvents(wsRelays, labelEventFilters, QUERY_OPTS) + ? queryService.fetchEvents(wsRelays, labelEventFilters, ENGAGEMENT_QUERY_OPTS) : Promise.resolve([] as Event[]), - fetchHttpEngagementByAddresses(httpRelays, ExtendedKind.LABEL, '#a', addressChunks) - ]).then(([byAddress, byEvent, bulk]) => dedupeEventsById([...byAddress, ...byEvent, ...bulk])) + 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, QUERY_OPTS) + ? queryService.fetchEvents(wsRelays, commentWsFilters, ENGAGEMENT_QUERY_OPTS) : Promise.resolve([] as Event[]), useWsEngagement && commentEventFilters.length > 0 - ? queryService.fetchEvents(wsRelays, commentEventFilters, QUERY_OPTS) + ? queryService.fetchEvents(wsRelays, commentEventFilters, ENGAGEMENT_QUERY_OPTS) : Promise.resolve([] as Event[]), - fetchHttpEngagementByAddresses(httpRelays, ExtendedKind.COMMENT, '#A', addressChunks) - ]).then(([scoped, byEvent, bulk]) => dedupeEventsById([...scoped, ...byEvent, ...bulk])) + 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, QUERY_OPTS) + ? queryService.fetchEvents(wsRelays, bookmarkAddressFilters, ENGAGEMENT_QUERY_OPTS) : Promise.resolve([] as Event[]), useWsEngagement && bookmarkEventFilters.length > 0 - ? queryService.fetchEvents(wsRelays, bookmarkEventFilters, QUERY_OPTS) + ? queryService.fetchEvents(wsRelays, bookmarkEventFilters, ENGAGEMENT_QUERY_OPTS) : Promise.resolve([] as Event[]), - fetchHttpEngagementByAddresses(httpRelays, kinds.BookmarkList, '#a', addressChunks) - ]).then(([byAddress, byEvent, bulk]) => dedupeEventsById([...byAddress, ...byEvent, ...bulk])) + 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, QUERY_OPTS) + ? queryService.fetchEvents(wsRelays, pinAddressFilters, ENGAGEMENT_QUERY_OPTS) : Promise.resolve([] as Event[]), useWsEngagement && pinEventFilters.length > 0 - ? queryService.fetchEvents(wsRelays, pinEventFilters, QUERY_OPTS) + ? queryService.fetchEvents(wsRelays, pinEventFilters, ENGAGEMENT_QUERY_OPTS) : Promise.resolve([] as Event[]), - fetchHttpEngagementByAddresses(httpRelays, PIN_LIST_KIND, '#a', addressChunks) - ]).then(([byAddress, byEvent, bulk]) => dedupeEventsById([...byAddress, ...byEvent, ...bulk])) + 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, @@ -1162,21 +1270,31 @@ function libraryEntriesFromRoots( /** Re-fetch engagement maps for the current library index snapshot (e.g. after booklist toggle). */ export async function refreshLibraryEngagement( - relayUrls: string[], + 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 engagement = await fetchPublicationEngagementMaps(relayUrls, targetAddresses, targetEventIds, { - httpOnly: true, - viewerPubkey - }) + 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) @@ -1587,17 +1705,28 @@ function collectTargetAddressesFromIndexes( } async function buildEngagedFromCache( - relayUrls: string[], + indexRelayUrls: string[], indexEvents: Event[], indexByAddress: Map, - engagement?: PublicationEngagementMaps + engagement?: PublicationEngagementMaps, + viewerPubkey?: string | null ): Promise { const topLevel = getTopLevelIndexEvents(indexEvents) let maps = engagement if (!maps) { const targetAddresses = collectTargetAddressesFromIndexes(indexEvents, indexByAddress) const targetEventIds = collectPublicationIndexEventIds(indexEvents) - maps = await fetchPublicationEngagementMaps(relayUrls, targetAddresses, targetEventIds) + const engagementRelayUrls = await buildLibraryEngagementRelayUrls( + viewerPubkey ?? undefined, + indexRelayUrls, + indexEvents + ) + maps = await fetchPublicationEngagementMaps( + engagementRelayUrls, + targetAddresses, + targetEventIds, + { viewerPubkey } + ) } return pickLibraryPublicationEntries(topLevel, indexByAddress, maps) } @@ -1635,14 +1764,19 @@ export async function loadLibraryPublicationIndex( sessionCache.indexByAddress ) const targetEventIds = collectPublicationIndexEventIds(sessionCache.indexEvents) + const engagementRelayUrls = await buildLibraryEngagementRelayUrls( + viewerPubkey ?? undefined, + relayUrls, + sessionCache.indexEvents + ) sessionCache = { ...sessionCache, viewerPubkey, engagement: await fetchPublicationEngagementMaps( - relayUrls, + engagementRelayUrls, targetAddresses, targetEventIds, - { httpOnly: true, viewerPubkey } + { viewerPubkey } ) } } @@ -1650,7 +1784,8 @@ export async function loadLibraryPublicationIndex( relayUrls, sessionCache.indexEvents, sessionCache.indexByAddress, - sessionCache.engagement + sessionCache.engagement, + viewerPubkey ) if (import.meta.env.DEV) { logger.info('[Library] load from cache', { engaged: engaged.length }) @@ -1701,15 +1836,17 @@ export async function loadLibraryPublicationIndex( let engagement: PublicationEngagementMaps try { - engagement = await Promise.race([ - fetchPublicationEngagementMaps(relayUrls, targetAddresses, targetEventIds, { - httpOnly: true, - viewerPubkey - }), - new Promise((resolve) => { - window.setTimeout(() => resolve(emptyPublicationEngagementMaps()), ENGAGEMENT_FETCH_TIMEOUT_MS) - }) - ]) + 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', { @@ -1719,11 +1856,7 @@ export async function loadLibraryPublicationIndex( engagement = emptyPublicationEngagementMaps() } if (import.meta.env.DEV) { - logger.info('[Library] engagement maps built', { - labels: engagement.labelAddresses.size + engagement.labelEventIds.size, - comments: engagement.commentAddresses.size, - highlights: engagement.highlightAddresses.size - }) + logger.info('[Library] engagement maps built', engagementMapsSizeSummary(engagement)) } sessionCache = { relayKey: key, viewerPubkey, indexEvents, indexByAddress, engagement }