Browse Source

bug-fixes

imwald
Silberengel 1 week ago
parent
commit
cee78c00b2
  1. 13
      src/components/NoteOptions/useMenuActions.tsx
  2. 68
      src/hooks/useLibraryPublications.ts
  3. 229
      src/lib/library-publication-index.ts

13
src/components/NoteOptions/useMenuActions.tsx

@ -1205,10 +1205,6 @@ export function useMenuActions({ @@ -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({ @@ -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'),

68
src/hooks/useLibraryPublications.ts

@ -24,7 +24,6 @@ import type { Event } from 'nostr-tools' @@ -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) { @@ -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<LibraryPublicationEntry[]>([])
const [mineFilterComputing, setMineFilterComputing] = useState(false)
const mineIndexCacheRef = useRef<{
@ -159,42 +159,42 @@ export function useLibraryPublications(isActive: boolean) { @@ -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<never>((_, 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) {

229
src/lib/library-publication-index.ts

@ -50,7 +50,6 @@ const HYDRATE_MISSING_CAP = 64 @@ -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 = { @@ -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<string>
labelEventIds: Set<string>
@ -308,6 +313,60 @@ export async function buildLibraryRelayUrls( @@ -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<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 async function fetchLibraryIndexEvents(relayUrls: string[]): Promise<Event[]> {
const indexRelays = libraryIndexRelayUrls(relayUrls)
if (indexRelays.length === 0) return []
@ -526,11 +585,38 @@ async function fetchHttpEngagementByAddresses( @@ -526,11 +585,38 @@ async function fetchHttpEngagementByAddresses(
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?: { httpOnly?: boolean; viewerPubkey?: string | null }
options?: { viewerPubkey?: string | null }
): Promise<PublicationEngagementMaps> {
if (relayUrls.length === 0 || targetAddresses.size === 0) {
return emptyPublicationEngagementMaps()
@ -539,8 +625,15 @@ export async function fetchPublicationEngagementMaps( @@ -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( @@ -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( @@ -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( @@ -1587,17 +1705,28 @@ function collectTargetAddressesFromIndexes(
}
async function buildEngagedFromCache(
relayUrls: string[],
indexRelayUrls: string[],
indexEvents: Event[],
indexByAddress: Map<string, Event>,
engagement?: PublicationEngagementMaps
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)
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( @@ -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( @@ -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( @@ -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<PublicationEngagementMaps>((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( @@ -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 }

Loading…
Cancel
Save