diff --git a/src/components/RssFeedList/index.tsx b/src/components/RssFeedList/index.tsx index 6618981e..250c0dc7 100644 --- a/src/components/RssFeedList/index.tsx +++ b/src/components/RssFeedList/index.tsx @@ -11,6 +11,7 @@ import { RssUnifiedScopeSection } from './RssUnifiedScopeSection' import { canonicalizeRssArticleUrl, isClawstrDotComHttpUrl } from '@/lib/rss-article' import { addManualRssWebUrl, + discoverRssWebArticleUrlsFromLocalCaches, fetchDiscoveredWebUrlsFromRelays, loadManualRssWebUrls, loadPromotedRssThreadUrls, @@ -21,6 +22,7 @@ import { isHttpArticleUrl, isRssWebUnifiedClutterUrl, mergeDiscoveredRssWebUrls, + mergeManualRssWebUrlEntries, rssWebRowHasRealFeedItems, saveRssWebFeedScopePreference, saveRssWebHideUnifiedClutterPreference, @@ -669,14 +671,22 @@ export default function RssFeedList() { let cancelled = false void (async () => { try { - const discovered = await fetchDiscoveredWebUrlsFromRelays({ - accountPubkey: pubkey, - favoriteRelays: favoriteRelays ?? [], - blockedRelays: blockedRelays ?? [], + const local = await discoverRssWebArticleUrlsFromLocalCaches({ excludeClutterUrls: hideUnifiedClutter }) + let discovered: ManualRssWebUrlEntry[] = [] + try { + discovered = await fetchDiscoveredWebUrlsFromRelays({ + accountPubkey: pubkey, + favoriteRelays: favoriteRelays ?? [], + blockedRelays: blockedRelays ?? [], + excludeClutterUrls: hideUnifiedClutter + }) + } catch { + /* relay discovery is best-effort */ + } if (cancelled) return - setRelayDiscoveredUrls(discovered) + setRelayDiscoveredUrls(mergeManualRssWebUrlEntries(local, discovered)) const didMerge = await mergeDiscoveredRssWebUrls(discovered) if (didMerge && !cancelled) refreshManualWebUrls() } catch { @@ -1114,6 +1124,15 @@ export default function RssFeedList() { ? t('No URL-only items yet') : t('No RSS feed items available')}

+ {feedScope === 'urls' && + !searchQuery.trim() && + selectedFeeds.includes('all') && + timeFilter === 'all' && + rssScopeRows.length > 0 ? ( +

+ {t('RSS+Web url tab empty hint')} +

+ ) : null} ) : feedScope === 'urls' ? ( <> diff --git a/src/hooks/useFetchCalendarRsvps.tsx b/src/hooks/useFetchCalendarRsvps.tsx index 2582a15f..ea0beccd 100644 --- a/src/hooks/useFetchCalendarRsvps.tsx +++ b/src/hooks/useFetchCalendarRsvps.tsx @@ -73,18 +73,17 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { const userWrite = userWriteRelaysForQuery(relayList) void (async () => { - // Read order: IndexedDB first (offline + last session), then in-memory session, then relays. - let fromIdb: Event[] = [] - try { - fromIdb = await indexedDb.getCalendarRsvpEventsByParentCoordinate(coordinate) - } catch { - fromIdb = [] - } - if (cancelled) return - const fromSession = client.getSessionCalendarRsvpsForCalendarEvent(calendarEvent) - const mergedLocal = mergeRsvpList([...fromIdb, ...fromSession]) - setRsvps(mergedLocal) + setRsvps(mergeRsvpList(fromSession)) + + const idbP = indexedDb + .getCalendarRsvpEventsByParentCoordinate(coordinate) + .catch((): Event[] => []) + + void idbP.then((rows) => { + if (cancelled) return + setRsvps(mergeRsvpList([...rows, ...fromSession])) + }) const baseUrls = new Set([ ...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url), @@ -139,6 +138,7 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { ) if (cancelled) return const fromRelay = events ?? [] + const fromIdb = await idbP await Promise.allSettled( fromRelay.map((ev) => indexedDb.putCalendarRsvpEventRow(ev).catch(() => undefined)) ) diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index 673850e6..310626ab 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -12,6 +12,31 @@ import { kinds } from 'nostr-tools' import { useEffect, useState, useRef, useCallback } from 'react' import logger from '@/lib/logger' +function tryHydrateProfileFromSessionOnly(pubkey: string, skipCache: boolean): TProfile | null { + if (skipCache) return null + const pk = pubkey.toLowerCase() + const sessionEv = eventService.getSessionMetadataForPubkey(pk) + if (sessionEv) { + return getProfileFromEvent(sessionEv) + } + return null +} + +/** Single-flight IndexedDB kind-0 read (never blocks callers until they await the promise). */ +function profileFromIdbPromise(pubkey: string, skipCache: boolean): Promise { + if (skipCache) return Promise.resolve(null) + const pk = pubkey.toLowerCase() + return indexedDb + .getReplaceableEvent(pk, kinds.Metadata) + .then((idbEv) => { + if (idbEv && !shouldDropEventOnIngest(idbEv)) { + return getProfileFromEvent(idbEv) + } + return null + }) + .catch(() => null) +} + /** * Session LRU + IndexedDB kind 0 without ReplaceableEventService / batched DataLoader. * Used when the hook's fetch race times out or the batch path is slow while disk/session already has metadata. @@ -20,23 +45,9 @@ async function tryHydrateProfileFromLocalCaches( pubkey: string, skipCache: boolean ): Promise { - if (skipCache) return null - const pk = pubkey.toLowerCase() - - const sessionEv = eventService.getSessionMetadataForPubkey(pk) - if (sessionEv) { - return getProfileFromEvent(sessionEv) - } - - try { - const idbEv = await indexedDb.getReplaceableEvent(pk, kinds.Metadata) - if (idbEv && !shouldDropEventOnIngest(idbEv)) { - return getProfileFromEvent(idbEv) - } - } catch { - /* IDB not ready */ - } - return null + const fromSession = tryHydrateProfileFromSessionOnly(pubkey, skipCache) + if (fromSession) return fromSession + return profileFromIdbPromise(pubkey, skipCache) } // CRITICAL: Global deduplication - shared across ALL hook instances @@ -199,11 +210,12 @@ export function useFetchProfile(id?: string, skipCache = false) { // Create a new fetch promise with timeout protection const fetchPromise = (async (): Promise => { + let idbEarlyP: Promise | null = null try { globalFetchingPubkeys.add(pubkey) const startTime = Date.now() - const quick = await tryHydrateProfileFromLocalCaches(pubkey, skipCache) + const quick = tryHydrateProfileFromSessionOnly(pubkey, skipCache) if (quick) { logger.debug('[useFetchProfile] Profile from session/IndexedDB (fast path)', { pubkey: pubkey.substring(0, 8), @@ -212,6 +224,9 @@ export function useFetchProfile(id?: string, skipCache = false) { return quick } + /** Disk read runs in parallel with `fetchProfileEvent` — never block network on IDB. */ + idbEarlyP = profileFromIdbPromise(pubkey, skipCache) + // CRITICAL: Add timeout to prevent infinite hangs (must exceed batched metadata query globalTimeout) const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { @@ -263,7 +278,8 @@ export function useFetchProfile(id?: string, skipCache = false) { fetchTime: `${fetchTime}ms` }) } - const afterMiss = await tryHydrateProfileFromLocalCaches(pubkey, skipCache) + const afterMiss = + (idbEarlyP != null ? await idbEarlyP : null) ?? tryHydrateProfileFromSessionOnly(pubkey, skipCache) if (afterMiss) { logger.debug('[useFetchProfile] Profile from session/IndexedDB after network miss', { pubkey: pubkey.substring(0, 8), @@ -281,7 +297,9 @@ export function useFetchProfile(id?: string, skipCache = false) { }) // Set cooldown period after timeout to prevent cascade of duplicate fetches globalFetchCooldowns.set(pubkey, Date.now() + 10000) // 10 second cooldown - const fallback = await tryHydrateProfileFromLocalCaches(pubkey, skipCache) + const fallback = + tryHydrateProfileFromSessionOnly(pubkey, skipCache) ?? + (idbEarlyP != null ? await idbEarlyP : null) if (fallback) { logger.debug('[useFetchProfile] Profile from session/IndexedDB after fetch timeout', { pubkey: pubkey.substring(0, 8), diff --git a/src/hooks/useProfileTimeline.tsx b/src/hooks/useProfileTimeline.tsx index 97d408d5..79dc3dd5 100644 --- a/src/hooks/useProfileTimeline.tsx +++ b/src/hooks/useProfileTimeline.tsx @@ -243,103 +243,72 @@ export function useProfileTimeline({ const socialKinds = kinds.some(isSocialKindBlockedKind) const emptyAuthor = { read: [] as string[], write: [] as string[], httpRead: [] as string[], httpWrite: [] as string[] } const idbDocKinds = kinds.filter((k) => isDocumentRelayKind(k)) - /** - * Author NIP-65 read/write relays must feed the **first** REQ for every profile tab. Favorites-only - * misses most people’s kind-1 notes; we previously only prefetched relays for document tabs. - */ - let prefetchedAuthorRelays: typeof emptyAuthor = emptyAuthor - if (idbDocKinds.length > 0) { + let pkNorm: string | null = null + try { + pkNorm = normalizeHexPubkey(pubkey) + } catch { + pkNorm = null + } + + let hadSessionHits = false + if (pkNorm) { + const pkForDisk = pkNorm try { - const pkNorm = normalizeHexPubkey(pubkey) - const fromSession = eventService.listSessionEventsAuthoredBy(pkNorm, { - kinds: idbDocKinds, + const sessionKindList = idbDocKinds.length > 0 ? idbDocKinds : kinds + const fromSession = eventService.listSessionEventsAuthoredBy(pkForDisk, { + kinds: sessionKindList, limit }) + hadSessionHits = fromSession.length > 0 if (!cancelled) { for (const e of fromSession) { pool.set(e.id, e as Event) } if (fromSession.length) flushPool() } - const [authorRl, fromPubStore, fromArchive] = await Promise.all([ - client.fetchRelayList(pubkey).catch(() => ({ - read: [] as string[], - write: [] as string[], - httpRead: [] as string[], - httpWrite: [] as string[] - })), - indexedDb.getCachedPublicationStoreEventsForProfileAuthor(pkNorm, idbDocKinds, limit), - indexedDb.scanEventArchiveByAuthorPubkey(pkNorm, { - kinds: idbDocKinds, - maxRowsScanned: 18_000, - maxMatches: limit - }) - ]) - if (!cancelled) { - prefetchedAuthorRelays = authorRl - for (const e of fromPubStore) { - pool.set(e.id, e) - } - for (const e of fromArchive) { - pool.set(e.id, e) - } - const hadDisk = fromPubStore.length > 0 || fromArchive.length > 0 - if (hadDisk) flushPool() - else if (!isCacheFresh && !mem?.events?.length && fromSession.length === 0) { - setIsLoading(true) - } - } } catch { - if (!cancelled) { - prefetchedAuthorRelays = await client.fetchRelayList(pubkey).catch(() => emptyAuthor) - } - if (!cancelled && !isCacheFresh && !mem?.events?.length) { - setIsLoading(true) - } + /* ignore malformed pubkeys */ } - } else { - try { - const pkNorm = normalizeHexPubkey(pubkey) - const fromSession = eventService.listSessionEventsAuthoredBy(pkNorm, { kinds, limit }) - if (!cancelled) { - for (const e of fromSession) { - pool.set(e.id, e as Event) - } - if (fromSession.length) flushPool() - } - const [authorRl, fromArchiveSocial] = await Promise.all([ - client.fetchRelayList(pubkey).catch(() => emptyAuthor), - indexedDb.scanEventArchiveByAuthorPubkey(pkNorm, { - kinds, - maxRowsScanned: 16_000, - maxMatches: limit - }) - ]) - if (!cancelled) { - prefetchedAuthorRelays = authorRl - for (const e of fromArchiveSocial) { - pool.set(e.id, e) - } - if (fromArchiveSocial.length) flushPool() - else if (!isCacheFresh && !mem?.events?.length && fromSession.length === 0) { + + void (async () => { + try { + const idbKindsForScan = idbDocKinds.length > 0 ? idbDocKinds : kinds + const maxScan = idbDocKinds.length > 0 ? 18_000 : 16_000 + const pubStorePromise = + idbDocKinds.length > 0 + ? indexedDb.getCachedPublicationStoreEventsForProfileAuthor(pkForDisk, idbDocKinds, limit) + : Promise.resolve([] as Event[]) + const [fromPubStore, fromArchive] = await Promise.all([ + pubStorePromise, + indexedDb.scanEventArchiveByAuthorPubkey(pkForDisk, { + kinds: idbKindsForScan, + maxRowsScanned: maxScan, + maxMatches: limit + }) + ]) + if (cancelled) return + for (const e of fromPubStore) pool.set(e.id, e) + for (const e of fromArchive) pool.set(e.id, e) + const hadDisk = fromPubStore.length + fromArchive.length > 0 + if (hadDisk) flushPool() + else if (!isCacheFresh && !mem?.events?.length && !hadSessionHits) { setIsLoading(true) } + } catch { + /* best-effort */ } - } catch { - if (!cancelled) { - prefetchedAuthorRelays = await client.fetchRelayList(pubkey).catch(() => emptyAuthor) - } - if (!cancelled && !isCacheFresh && !mem?.events?.length) { - setIsLoading(true) - } - } + })() + } else if (!isCacheFresh && !mem?.events?.length) { + setIsLoading(true) } + const authorRelayPromise = client.fetchRelayList(pubkey).catch(() => emptyAuthor) + const provisionalFeedUrls = buildProfilePageReadRelayUrls( favoriteRelays, blockedRelays, - prefetchedAuthorRelays, + emptyAuthor, socialKinds, includeAuthorLocalRelays, kinds @@ -429,7 +398,7 @@ export function useProfileTimeline({ })() void (async () => { - const authorRl = prefetchedAuthorRelays + const authorRl = await authorRelayPromise if (cancelled) return const fullFeedUrls = buildProfilePageReadRelayUrls( favoriteRelays, diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 00e857e4..2c159d6c 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1675,6 +1675,8 @@ export default { URLs: "URLs", RSS: "RSS", "No URL-only items yet": "Noch keine reinen Artikel-URLs", + "RSS+Web url tab empty hint": + "Artikel aus deinen RSS-Feeds findest du unter dem Tab RSS. Hier erscheinen Artikel-URLs aus Nostr (Reaktionen, Kommentare, Lesezeichen auf Webseiten), wenn sie nicht schon vollständig als Feed-Einträge abgedeckt sind, sowie manuell hinzugefügte Links.", "Respond to this RSS entry": "Auf diesen RSS-Eintrag reagieren", "RSS read-only thread hint": "Nostr-Antworten, Zaps und Markierungen sind hier ausgeblendet. Damit fügst du den Artikel der URL-Liste hinzu und reagierst dort.", "RSS feed item label": "RSS", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 35a93dc6..b1a5643c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1695,6 +1695,8 @@ export default { URLs: "URLs", RSS: "RSS", "No URL-only items yet": "No URL-only items yet", + "RSS+Web url tab empty hint": + "Links from your RSS subscriptions appear under the RSS tab. This tab lists article URLs from Nostr (reactions, comments, bookmarks on web pages) when they are not only covered by feed items, and links you add manually.", "Respond to this RSS entry": "Respond to this RSS entry", "RSS read-only thread hint": "Nostr replies, zaps, and highlights are hidden here. Use this to add the article to your URL feed and respond there.", "RSS feed item label": "RSS", diff --git a/src/lib/rss-web-feed.ts b/src/lib/rss-web-feed.ts index 2136c7a8..04f5c710 100644 --- a/src/lib/rss-web-feed.ts +++ b/src/lib/rss-web-feed.ts @@ -14,7 +14,7 @@ import { } from '@/lib/rss-article' import logger from '@/lib/logger' import { isImage, isLocalNetworkUrl, isMedia, isVideo, normalizeUrl } from '@/lib/url' -import { queryService } from '@/services/client.service' +import { eventService, queryService } from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' import type { RssFeedItem } from '@/services/rss-feed.service' import { isWebOnlyFauxRssItem } from '@/services/rss-feed.service' @@ -479,6 +479,72 @@ function extractArticleUrlFromWebActivityEvent(evt: Event): string | undefined { return undefined } +function touchRssWebDiscoveryUrlFromEvent( + evt: Event, + excludeClutter: boolean, + latestByUrl: Map +): void { + const url = extractArticleUrlFromWebActivityEvent(evt) + if (!url) return + if (excludeClutter && isRssWebUnifiedClutterUrl(url)) return + const key = canonicalizeRssArticleUrl(url) + const prev = latestByUrl.get(key) ?? 0 + if (evt.created_at > prev) latestByUrl.set(key, evt.created_at) +} + +/** Merge manual / discovered URL lists; per URL keep the newest `addedAt`. */ +export function mergeManualRssWebUrlEntries(...parts: ManualRssWebUrlEntry[]): ManualRssWebUrlEntry[] { + const byUrl = new Map() + for (const list of parts) { + for (const e of list) { + const prev = byUrl.get(e.url) ?? 0 + if (e.addedAt > prev) byUrl.set(e.url, e.addedAt) + } + } + return [...byUrl.entries()].map(([url, addedAt]) => ({ url, addedAt })) +} + +/** + * Article URLs from session LRU + event archive (same kinds as relay discovery), so the URL tab is not + * empty when relays return nothing but the client already saw reactions / bookmarks / etc. + */ +export async function discoverRssWebArticleUrlsFromLocalCaches(options?: { + excludeClutterUrls?: boolean +}): Promise { + const excludeClutter = options?.excludeClutterUrls !== false + const sinceSec = Math.floor(Date.now() / 1000) - RSS_WEB_RELAY_DISCOVERY_SINCE_SEC + const latestByUrl = new Map() + + const sessionEv = eventService.listSessionEventsByKinds(RSS_WEB_RELAY_DISCOVERY_KINDS, { + since: sinceSec, + limit: 5000 + }) + for (const evt of sessionEv) { + touchRssWebDiscoveryUrlFromEvent(evt, excludeClutter, latestByUrl) + } + + try { + const archived = await indexedDb.scanEventArchiveByKinds({ + kinds: RSS_WEB_RELAY_DISCOVERY_KINDS, + since: sinceSec, + maxRowsScanned: 24_000, + maxMatches: 4000 + }) + for (const evt of archived) { + touchRssWebDiscoveryUrlFromEvent(evt, excludeClutter, latestByUrl) + } + } catch { + /* IDB unavailable */ + } + + const entries = [...latestByUrl.entries()].map(([url, addedAt]) => ({ url, addedAt })) + logger.info('[RssWebFeed] Local URL discovery finished', { + uniqueUrls: entries.length, + sessionHits: sessionEv.length + }) + return entries +} + /** * One REQ per kind, no `authors` filter: latest events from aggregated relays, grouped by canonical URL. */ @@ -503,14 +569,7 @@ export async function fetchDiscoveredWebUrlsFromRelays(options: { }) const latestByUrl = new Map() - const onEvent = (evt: Event) => { - const url = extractArticleUrlFromWebActivityEvent(evt) - if (!url) return - if (excludeClutter && isRssWebUnifiedClutterUrl(url)) return - const key = canonicalizeRssArticleUrl(url) - const prev = latestByUrl.get(key) ?? 0 - if (evt.created_at > prev) latestByUrl.set(key, evt.created_at) - } + const onEvent = (evt: Event) => touchRssWebDiscoveryUrlFromEvent(evt, excludeClutter, latestByUrl) await Promise.all( RSS_WEB_RELAY_DISCOVERY_KINDS.map(async (kind) => { diff --git a/src/pages/primary/CalendarPrimaryPage.tsx b/src/pages/primary/CalendarPrimaryPage.tsx index d28db4fb..7c89f797 100644 --- a/src/pages/primary/CalendarPrimaryPage.tsx +++ b/src/pages/primary/CalendarPrimaryPage.tsx @@ -176,7 +176,8 @@ const CalendarPrimaryPage = forwardRef(funct try { const { rangeStartMs, rangeEndExclusiveMs } = paddedMonthRange - const [fromIdb, fromArchive] = await Promise.all([ + + const idbP = Promise.all([ indexedDb.getCalendarEventsForOccurrenceWindow( rangeStartMs, rangeEndExclusiveMs, @@ -189,30 +190,50 @@ const CalendarPrimaryPage = forwardRef(funct 2500 ) ]) - if (cancelled) return - - const localBaseline = dedupeCalendarEventsPreferringOccurrenceRange( - [...fromIdb, ...fromArchive], - rangeStartMs, - rangeEndExclusiveMs - ) + .then(([fromIdb, fromArchive]) => + dedupeCalendarEventsPreferringOccurrenceRange( + [...fromIdb, ...fromArchive], + rangeStartMs, + rangeEndExclusiveMs + ) + ) + .catch((): NostrEvent[] => []) const fromSessionNow = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents( - dedupeCalendarEventsPreferringOccurrenceRange( - [...localBaseline, ...fromSessionNow], - rangeStartMs, - rangeEndExclusiveMs - ) + const sessionOnly = dedupeCalendarEventsPreferringOccurrenceRange( + fromSessionNow, + rangeStartMs, + rangeEndExclusiveMs ) - setLoading(false) + if (!cancelled) { + setRawEvents(sessionOnly) + setLoading(false) + } + + void idbP.then((localBaseline) => { + if (cancelled) return + const s2 = client.getSessionEventsMatchingSearch( + '', + SESSION_CALENDAR_MERGE_CAP, + [...CALENDAR_EVENT_KINDS] + ) + setRawEvents( + dedupeCalendarEventsPreferringOccurrenceRange( + [...localBaseline, ...s2], + rangeStartMs, + rangeEndExclusiveMs + ) + ) + }) if (!relayUrls.length) { - scheduleLateSessionMerge(localBaseline) + void idbP.then((lb) => { + if (!cancelled) scheduleLateSessionMerge(lb) + }) return } @@ -257,17 +278,18 @@ const CalendarPrimaryPage = forwardRef(funct ) ) - let batch: NostrEvent[] = [] - const fromFollowing: NostrEvent[] = [] - try { - const merged = await Promise.all([mainReq, ...chunkReqs]) - batch = merged[0] ?? [] - for (let i = 1; i < merged.length; i++) { - fromFollowing.push(...(merged[i] ?? [])) - } - } catch { - /* keep IndexedDB + session view; relays may be unreachable */ - } + const relayMergedP = Promise.all([mainReq, ...chunkReqs]) + .then((merged) => { + const batch = merged[0] ?? [] + const fromFollowing: NostrEvent[] = [] + for (let i = 1; i < merged.length; i++) { + fromFollowing.push(...(merged[i] ?? [])) + } + return { batch, fromFollowing } + }) + .catch(() => ({ batch: [] as NostrEvent[], fromFollowing: [] as NostrEvent[] })) + + const [{ batch, fromFollowing }, localBaseline] = await Promise.all([relayMergedP, idbP]) if (cancelled) return const fromSession = client.getSessionEventsMatchingSearch( diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index 23433011..ba32b87b 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -259,12 +259,14 @@ const SpellsPage = forwardRef(function SpellsPage( if (!cancelled) setFollowSetListEvents([]) return } - const events = await queryService.fetchEvents( - feedUrls, - { authors: [pubkey], kinds: [ExtendedKind.FOLLOW_SET], limit: 500 }, - { eoseTimeout: 2000, globalTimeout: 15000, firstRelayResultGraceMs: false } - ) - const tombstones = await indexedDb.getAllTombstones() + const [events, tombstones] = await Promise.all([ + queryService.fetchEvents( + feedUrls, + { authors: [pubkey], kinds: [ExtendedKind.FOLLOW_SET], limit: 500 }, + { eoseTimeout: 2000, globalTimeout: 15000, firstRelayResultGraceMs: false } + ), + indexedDb.getAllTombstones() + ]) if (!cancelled) { setFollowSetListEvents(dedupeFollowSetEventsByD(filterEventsExcludingTombstones(events, tombstones))) } @@ -349,12 +351,14 @@ const SpellsPage = forwardRef(function SpellsPage( if (manualBump) { spellCatalogLastManualKeyRef.current = spellCatalogManualRefreshKey } - const cachedSpells = await indexedDb.getSpellEvents() - if (cancelled) return - const shouldSyncFromRelays = manualBump || cachedSpells.length === 0 - if (!shouldSyncFromRelays) { - return + const idbSpellsP = indexedDb.getSpellEvents() + if (!manualBump) { + const cachedSpells = await idbSpellsP + if (cancelled) return + if (cachedSpells.length > 0) { + return + } } const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList), { diff --git a/src/pages/secondary/ProfileInteractionDiagramPage/index.tsx b/src/pages/secondary/ProfileInteractionDiagramPage/index.tsx index 58c9376a..2afb1797 100644 --- a/src/pages/secondary/ProfileInteractionDiagramPage/index.tsx +++ b/src/pages/secondary/ProfileInteractionDiagramPage/index.tsx @@ -55,16 +55,24 @@ function useProfileInteractionPartners(authorPubkey: string | undefined, refresh const kindsArr = [...INTERACTION_KINDS] const sessionEv = eventService.listSessionEventsAuthoredBy(pk, { kinds: kindsArr, limit: 900 }) setSessionEventCount(sessionEv.length) + setArchiveAuthorEvents(0) + const mergedSession = mergeEventsById([...sessionEv]) + setPartners(buildInteractionPartnerStats(mergedSession, pk)) - const idbEv = await indexedDb.scanEventArchiveByAuthorPubkey(pk, { - kinds: kindsArr, - maxRowsScanned: 14_000, - maxMatches: 450 - }) - setArchiveAuthorEvents(idbEv.length) - - const merged = mergeEventsById([...sessionEv, ...idbEv]) - setPartners(buildInteractionPartnerStats(merged, pk)) + void (async () => { + try { + const idbEv = await indexedDb.scanEventArchiveByAuthorPubkey(pk, { + kinds: kindsArr, + maxRowsScanned: 14_000, + maxMatches: 450 + }) + setArchiveAuthorEvents(idbEv.length) + const merged = mergeEventsById([...sessionEv, ...idbEv]) + setPartners(buildInteractionPartnerStats(merged, pk)) + } catch { + /* best-effort disk */ + } + })() } finally { setLoading(false) } diff --git a/src/services/mention-event-search.service.ts b/src/services/mention-event-search.service.ts index b489c8d9..56869dd3 100644 --- a/src/services/mention-event-search.service.ts +++ b/src/services/mention-event-search.service.ts @@ -82,29 +82,31 @@ async function searchCitationEventsForPickerInternal( } const idHex = tryParseCitationEventIdFromQuery(q) - if (idHex) { - const ev = await client.fetchEvent(idHex) - if (ev && kindsList.includes(ev.kind)) push(ev, false) - if (out.length >= limit) return out.slice(0, limit) - } for (const ev of eventService.getSessionCitationFieldSearch(q, limit)) { push(ev, false) if (out.length >= limit) return out.slice(0, limit) } - const fromArch = await indexedDb.getCachedAndArchivedCitationFieldSearch( - q, - limit - out.length, - kindsList, - { archiveScanMaxMs: 14_000 } - ) + const needAfterSession = limit - out.length + const [idEv, fromArch, relayUrls] = await Promise.all([ + idHex ? client.fetchEvent(idHex) : Promise.resolve(null), + needAfterSession > 0 + ? indexedDb.getCachedAndArchivedCitationFieldSearch(q, needAfterSession, kindsList, { + archiveScanMaxMs: 14_000 + }) + : Promise.resolve([] as NEvent[]), + buildCitationPickerSearchRelayUrls() + ]) + + if (idEv && kindsList.includes(idEv.kind)) push(idEv, false) + if (out.length >= limit) return out.slice(0, limit) + for (const ev of fromArch) { push(ev, false) if (out.length >= limit) return out.slice(0, limit) } - const relayUrls = await buildCitationPickerSearchRelayUrls() const need = limit - out.length if (need <= 0) return out.slice(0, limit) @@ -170,15 +172,16 @@ export async function searchEventsForPicker( fromSession.forEach(addUnique) if (out.length >= limit) return out.slice(0, limit) - const fromIdb = await indexedDb.getCachedEventsForSearch(q, limit - out.length, kindsList) + const need = limit - out.length + const [fromIdb, fromRelays] = await Promise.all([ + indexedDb.getCachedEventsForSearch(q, need, kindsList), + queryService.fetchEvents( + SEARCHABLE_RELAY_URLS, + { kinds: kindsList, search: q, limit: need }, + { eoseTimeout: 5000, globalTimeout: 8000 } + ) + ]) fromIdb.forEach(addUnique) - if (out.length >= limit) return out.slice(0, limit) - - const fromRelays = await queryService.fetchEvents( - SEARCHABLE_RELAY_URLS, - { kinds: kindsList, search: q, limit: limit - out.length }, - { eoseTimeout: 5000, globalTimeout: 8000 } - ) fromRelays.forEach(addUnique) return out.slice(0, limit) }