diff --git a/src/components/Profile/ProfileMediaFeed.tsx b/src/components/Profile/ProfileMediaFeed.tsx index 735ef1de..f07221dd 100644 --- a/src/components/Profile/ProfileMediaFeed.tsx +++ b/src/components/Profile/ProfileMediaFeed.tsx @@ -55,6 +55,16 @@ const ProfileMediaFeed = forwardRef(({ pubkey let cancelled = false setRefinedAuthorRelayUrls(null) void (async () => { + try { + const peeked = await client.peekRelayListFromStorage(pk) + if (!cancelled) { + setRefinedAuthorRelayUrls( + buildAuthorInboxOutboxRelayUrls(peeked, blockedRelays, includeAuthorLocalRelays) + ) + } + } catch { + /* keep provisionalAuthorRelayUrls */ + } const authorRl = await client.fetchRelayList(pk).catch(() => ({ read: [] as string[], write: [] as string[] diff --git a/src/components/Sidebar/SidebarCalendarWeekWidget.tsx b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx index 673c7b4b..f2067166 100644 --- a/src/components/Sidebar/SidebarCalendarWeekWidget.tsx +++ b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx @@ -58,7 +58,8 @@ export default function SidebarCalendarWeekWidget() { const [weekOffset, setWeekOffset] = useState(0) const [rawEvents, setRawEvents] = useState([]) - const [loading, setLoading] = useState(false) + /** True only until the first IndexedDB (+ session) snapshot for this week is applied — never while relay REQ runs. */ + const [loading, setLoading] = useState(true) const relayUrls = useMemo(() => { const base = getRelayUrlsWithFavoritesFastReadAndInbox( @@ -115,16 +116,19 @@ export default function SidebarCalendarWeekWidget() { indexedDb.getCalendarEventsForOccurrenceWindow(weekStartMs, weekEndExclusiveMs), indexedDb.getArchivedCalendarEventsOverlappingWindow(weekStartMs, weekEndExclusiveMs, 25_000, 400) ]) + if (cancelled) return + const localBaseline = dedupeCalendarEvents([...fromIdb, ...fromArchive]) + const sessionSnap = client.getSessionEventsMatchingSearch( + '', + SESSION_CALENDAR_MERGE_CAP, + [...CALENDAR_EVENT_KINDS] + ) + const mergedLocal = dedupeCalendarEvents([...localBaseline, ...sessionSnap]) + setRawEvents(mergedLocal) + setLoading(false) if (!relayUrls.length) { - if (cancelled) return - const fromSession = client.getSessionEventsMatchingSearch( - '', - SESSION_CALENDAR_MERGE_CAP, - [...CALENDAR_EVENT_KINDS] - ) - setRawEvents(dedupeCalendarEvents([...localBaseline, ...fromSession])) lateMergeTimer = window.setTimeout(() => { lateMergeTimer = null if (cancelled) return @@ -189,12 +193,14 @@ export default function SidebarCalendarWeekWidget() { } if (cancelled) return - const fromSession = client.getSessionEventsMatchingSearch( + const fromSessionAfterNet = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents(dedupeCalendarEvents([...batch, ...fromFollowing, ...fromSession, ...localBaseline])) + setRawEvents( + dedupeCalendarEvents([...batch, ...fromFollowing, ...fromSessionAfterNet, ...localBaseline]) + ) lateMergeTimer = window.setTimeout(() => { lateMergeTimer = null if (cancelled) return @@ -223,6 +229,7 @@ export default function SidebarCalendarWeekWidget() { } catch { setRawEvents([]) } + setLoading(false) } } finally { if (!cancelled) setLoading(false) diff --git a/src/constants.ts b/src/constants.ts index e8820eaa..9ffc904f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -144,6 +144,13 @@ export const EARLY_PUBLISH_SUCCESS_GRACE_MS = 1200 */ export const PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS = 20_000 +/** + * How long {@link ClientService.fetchRelayLists} waits on the network before returning an IndexedDB + default + * merge. Kept short so users without NIP-65 (or slow relays) get {@link PROFILE_FETCH_RELAY_URLS} immediately; + * {@link PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS} stays longer for publish / prioritize paths that wrap their own races. + */ +export const FETCH_RELAY_LIST_UI_TIMEOUT_MS = 2_500 + /** * {@link ClientService.prioritizePublishUrlListWithTimeout}: must exceed {@link PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS} * so one full `fetchRelayLists` budget can elapse before we fall back to “deduped order without inbox fetch”. diff --git a/src/hooks/useFetchRelayList.tsx b/src/hooks/useFetchRelayList.tsx index d935cacb..37f25f43 100644 --- a/src/hooks/useFetchRelayList.tsx +++ b/src/hooks/useFetchRelayList.tsx @@ -22,11 +22,17 @@ export function useFetchRelayList(pubkey?: string | null) { return } try { - // Use client.fetchRelayList which handles merging cache relays + const fromStorage = await client.peekRelayListFromStorage(pubkey) + setRelayList(fromStorage) const relayList = await client.fetchRelayList(pubkey) setRelayList(relayList) } catch (err) { logger.error('Failed to fetch relay list', { error: err, pubkey }) + try { + setRelayList(await client.peekRelayListFromStorage(pubkey)) + } catch { + /* keep last good state */ + } } finally { setIsFetching(false) } diff --git a/src/lib/private-relays.ts b/src/lib/private-relays.ts index f0a1bca8..c92347d6 100644 --- a/src/lib/private-relays.ts +++ b/src/lib/private-relays.ts @@ -8,8 +8,8 @@ import { ExtendedKind } from '@/constants' * @returns Promise - true if user has at least one private relay available */ export async function hasPrivateRelays(pubkey: string): Promise { - // Check for outbox relays (kind 10002) - const relayList = await client.fetchRelayList(pubkey) + // Check for outbox relays (kind 10002) — IndexedDB merge only; no network wait. + const relayList = await client.peekRelayListFromStorage(pubkey) if (relayList.write && relayList.write.length > 0) { return true } @@ -35,8 +35,8 @@ export async function hasPrivateRelays(pubkey: string): Promise { export async function getPrivateRelayUrls(pubkey: string): Promise { const relayUrls: string[] = [] - // Get outbox relays (kind 10002) - const relayList = await client.fetchRelayList(pubkey) + // Get outbox relays (kind 10002) — storage-first; cache rows below still augment. + const relayList = await client.peekRelayListFromStorage(pubkey) if (relayList.write) { relayUrls.push(...relayList.write) } diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts index dcf06462..9ac59195 100644 --- a/src/lib/relay-list-builder.ts +++ b/src/lib/relay-list-builder.ts @@ -132,69 +132,33 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio } } - // 4. Author's outboxes (write relays) - where they publish + // 4. Author's outboxes (write relays) - where they publish (IndexedDB + defaults; no network gate) if (authorPubkey) { try { - // Add timeout to prevent hanging - 2 seconds max - const relayListPromise = client.fetchRelayList(authorPubkey) - const timeoutPromise = new Promise((resolve) => { - setTimeout(() => { - logger.debug('[RelayListBuilder] fetchRelayList timeout for author', { - author: authorPubkey.substring(0, 8) - }) - resolve(null) - }, 2000) + const authorRelayList = await client.peekRelayListFromStorage(authorPubkey) + const authorOutboxes = [...(authorRelayList.write || []).slice(0, 10)] + authorOutboxes.forEach(addRelay) + const authorInboxes = [...(authorRelayList.read || []).slice(0, 10)] + authorInboxes.forEach(addRelay) + logger.debug('[RelayListBuilder] Added author relays', { + author: authorPubkey.substring(0, 8), + outboxes: authorOutboxes.length, + inboxes: authorInboxes.length }) - const authorRelayList = await Promise.race([relayListPromise, timeoutPromise]) - - if (authorRelayList) { - const authorOutboxes = [ - ...(authorRelayList.write || []).slice(0, 10) - ] - authorOutboxes.forEach(addRelay) - - const authorInboxes = [ - ...(authorRelayList.read || []).slice(0, 10) - ] - authorInboxes.forEach(addRelay) - - logger.debug('[RelayListBuilder] Added author relays', { - author: authorPubkey.substring(0, 8), - outboxes: authorOutboxes.length, - inboxes: authorInboxes.length - }) - } } catch (error) { - logger.debug('[RelayListBuilder] Failed to fetch author relay list', { error }) + logger.debug('[RelayListBuilder] Failed to read author relay list from storage', { error }) } } // 5. User's own relays (for profiles/metadata) if (includeUserOwnRelays && userPubkey) { try { - // Add timeout to prevent hanging - 2 seconds max - const relayListPromise = client.fetchRelayList(userPubkey) - const timeoutPromise = new Promise((resolve) => { - setTimeout(() => { - logger.debug('[RelayListBuilder] fetchRelayList timeout for user', { - user: userPubkey.substring(0, 8) - }) - resolve(null) - }, 2000) - }) - const userRelayList = await Promise.race([relayListPromise, timeoutPromise]) - - if (userRelayList) { - const userRead = [ - ...(userRelayList.read || []).slice(0, 10) - ] - const userWrite = [ - ...(userRelayList.write || []).slice(0, 10) - ] - userRead.forEach(addRelay) - userWrite.forEach(addRelay) - } - + const userRelayList = await client.peekRelayListFromStorage(userPubkey) + const userRead = [...(userRelayList.read || []).slice(0, 10)] + const userWrite = [...(userRelayList.write || []).slice(0, 10)] + userRead.forEach(addRelay) + userWrite.forEach(addRelay) + // Include local relays from kind 10432 if (includeLocalRelays) { const localRelays = await getCacheRelayUrls(userPubkey) @@ -217,8 +181,8 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio } logger.debug('[RelayListBuilder] Added user own relays', { - read: userRelayList ? (userRelayList.read || []).length : 0, - write: userRelayList ? (userRelayList.write || []).length : 0, + read: (userRelayList.read || []).length, + write: (userRelayList.write || []).length, local: includeLocalRelays ? (await getCacheRelayUrls(userPubkey)).length : 0, favorite: favoriteRelaysCount }) @@ -228,25 +192,9 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio } else if (userPubkey) { // Even if not including user's own relays, still include user's inboxes for reading try { - // Add timeout to prevent hanging - 2 seconds max - const relayListPromise = client.fetchRelayList(userPubkey) - const timeoutPromise = new Promise((resolve) => { - setTimeout(() => { - logger.debug('[RelayListBuilder] fetchRelayList timeout for user inboxes', { - user: userPubkey.substring(0, 8) - }) - resolve(null) - }, 2000) - }) - const userRelayList = await Promise.race([relayListPromise, timeoutPromise]) - - if (userRelayList) { - const userInboxes = [ - ...(userRelayList.read || []).slice(0, 10) - ] - userInboxes.forEach(addRelay) - } - + const userRelayList = await client.peekRelayListFromStorage(userPubkey) + ;[...(userRelayList.read || []).slice(0, 10)].forEach(addRelay) + // Include local relays from kind 10432 if enabled if (includeLocalRelays) { const localRelays = await getCacheRelayUrls(userPubkey) @@ -333,7 +281,6 @@ export function relayHintsFromEventTags(event: { tags: string[][] }): string[] { return [...out] } -const POLL_RESULTS_RELAY_TIMEOUT_MS = 2000 const POLL_RESULTS_MAX_RELAYS = 40 const POLL_RESULTS_NIP65_READ_SLICE = 16 @@ -383,20 +330,12 @@ export async function buildPollResultsReadRelayUrls(options: { pushLayer(relayHintsFromEventTags(pollEvent)) pushLayer(pollRelayUrls) - const raceRelayList = (pubkey: string) => { - const p = client.fetchRelayList(pubkey) - const t = new Promise((resolve) => - setTimeout(() => resolve(null), POLL_RESULTS_RELAY_TIMEOUT_MS) - ) - return Promise.race([p, t]) - } - let authorReadSlice: string[] = [] let viewerReadSlice: string[] = [] try { const [authorRl, viewerRl] = await Promise.all([ - pollEvent.pubkey ? raceRelayList(pollEvent.pubkey) : Promise.resolve(null), - viewerPubkey ? raceRelayList(viewerPubkey) : Promise.resolve(null) + pollEvent.pubkey ? client.peekRelayListFromStorage(pollEvent.pubkey) : Promise.resolve(null), + viewerPubkey ? client.peekRelayListFromStorage(viewerPubkey) : Promise.resolve(null) ]) if (authorRl?.read?.length) { authorReadSlice = authorRl.read.slice(0, POLL_RESULTS_NIP65_READ_SLICE) diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index 67e18242..7104560c 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -38,14 +38,16 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { } return extra }, [cacheRelayListEvent, httpRelayListEvent]) - const [relayUrls, setRelayUrls] = useState([]) - const [isReady, setIsReady] = useState(false) + /** Default relays immediately so feeds / sidebar REQ never wait on Nostr session restore. */ + const [relayUrls, setRelayUrls] = useState(() => + mergeRelayUrlLayers([getFavoritesFeedRelayUrls([], []), [buildWispTrendingNotesRelayUrl()]], []) + ) + const [isReady, setIsReady] = useState(true) const [feedInfo, setFeedInfo] = useState({ feedType: 'relay', id: DEFAULT_FAVORITE_RELAYS[0] }) const feedInfoRef = useRef(feedInfo) - const loggedWaitingForNostrInitRef = useRef(false) const switchFeed = useCallback(async ( feedType: TFeedType, @@ -56,7 +58,6 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { } = {} ) => { logger.debug('switchFeed called:', { feedType, options }) - setIsReady(false) if (feedType === 'relay') { const normalizedUrl = normalizeAnyRelayUrl(options.relay ?? '') const isRelayFeedUrl = @@ -142,16 +143,6 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { useEffect(() => { const init = async () => { logger.debug('FeedProvider init:', { isInitialized, pubkey, favoriteRelays: favoriteRelays.length, blockedRelays: blockedRelays.length }) - if (!isInitialized) { - if (!loggedWaitingForNostrInitRef.current) { - loggedWaitingForNostrInitRef.current = true - logger.info( - '[FeedProvider] Waiting for Nostr session restore before attaching feeds (home may show a loading state)' - ) - } - return - } - loggedWaitingForNostrInitRef.current = false // Wait for favoriteRelays to be initialized (should have at least default relays) // If favoriteRelays is empty, it might not be initialized yet, so wait diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index ff37a24d..3b7e20c9 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -343,6 +343,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } else { setRelayList(baseRelayList) } + } else if (!userForcedAccountNetworkHydrate) { + /** No NIP-65 / 10432 / 10243 in IDB — still set merged defaults immediately (never wait on network). */ + const quick = await client.peekRelayListFromStorage(account.pubkey) + setRelayList(quick) } if (!userForcedAccountNetworkHydrate) { if (storedProfileEvent) { diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 3b62beba..80fd3ec0 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -18,6 +18,7 @@ import { PUBLIC_MESSAGE_RSVP_PUBLISH_MAX_RELAYS, PUBLISH_PRIORITIZE_RELAY_ORDER_TIMEOUT_MS, PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS, + FETCH_RELAY_LIST_UI_TIMEOUT_MS, MULTI_RELAY_PUBLISH_ACK_CAP_MS, RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS, RELAY_POOL_CONNECTION_TIMEOUT_MS, @@ -3719,11 +3720,23 @@ class ClientService extends EventTarget { }) return relayList } catch (error) { - logger.error('[FetchRelayList] Fetch failed', { + logger.warn('[FetchRelayList] Fetch failed; using IndexedDB / defaults', { pubkey, error: error instanceof Error ? error.message : String(error) }) - throw error + try { + const [fallback] = await this.mergeRelayListsFromStoredOnly([pubkey]) + return fallback! + } catch { + return { + write: PROFILE_FETCH_RELAY_URLS, + read: PROFILE_FETCH_RELAY_URLS, + originalRelays: [], + httpRead: [], + httpWrite: [], + httpOriginalRelays: [] + } + } } finally { this.relayListRequestCache.delete(cacheKey) } @@ -3733,6 +3746,36 @@ class ClientService extends EventTarget { return requestPromise } + /** + * Merge relay list from IndexedDB only (no network). Same rules as a timed-out {@link fetchRelayLists}: + * defaults to {@link PROFILE_FETCH_RELAY_URLS} when kind 10002 is missing. + */ + async peekRelayListFromStorage(pubkey: string): Promise { + const [rl] = await this.mergeRelayListsFromStoredOnly([pubkey]) + return rl! + } + + private async mergeRelayListsFromStoredOnly(pubkeys: string[]): Promise { + const storedRelayEvents = await Promise.all( + pubkeys.map((pk) => indexedDb.getReplaceableEvent(pk, kinds.RelayList)) + ) + const storedCacheRelayEvents = await Promise.all( + pubkeys.map((pk) => indexedDb.getReplaceableEvent(pk, ExtendedKind.CACHE_RELAYS)) + ) + const storedHttpRelayEvents = await Promise.all( + pubkeys.map((pk) => indexedDb.getReplaceableEvent(pk, ExtendedKind.HTTP_RELAY_LIST)) + ) + return this.mergeRelayListsBundle( + pubkeys, + pubkeys.map(() => undefined), + pubkeys.map(() => undefined), + storedCacheRelayEvents.map((e) => e ?? undefined), + storedRelayEvents, + storedHttpRelayEvents, + storedCacheRelayEvents + ) + } + /** * Merge NIP-65 (10002), HTTP relay list (10243), and cache relays (10432) from network and/or IndexedDB. * Network arrays may be sparse/undefined per index; stored* always filled from IDB reads. @@ -3853,6 +3896,7 @@ class ClientService extends EventTarget { async fetchRelayLists(pubkeys: string[]): Promise { if (pubkeys.length === 0) return [] + try { const storedRelayEvents = await Promise.all( pubkeys.map((pubkey) => indexedDb.getReplaceableEvent(pubkey, kinds.RelayList)) ) @@ -3863,7 +3907,7 @@ class ClientService extends EventTarget { pubkeys.map((pubkey) => indexedDb.getReplaceableEvent(pubkey, ExtendedKind.HTTP_RELAY_LIST)) ) - const budgetMs = PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS + const budgetMs = FETCH_RELAY_LIST_UI_TIMEOUT_MS /** True only when *every* pubkey in this batch already has kind 10002 in IDB (not just you). */ const allHaveKind10002 = pubkeys.every((_, i) => storedRelayEvents[i] != null) @@ -3964,7 +4008,13 @@ class ClientService extends EventTarget { } const raced = await Promise.race([ - hydrateRelayListsFromNetwork(), + hydrateRelayListsFromNetwork().catch((err: unknown) => { + logger.warn('[FetchRelayLists] hydrateRelayListsFromNetwork failed', { + pubkeyCount: pubkeys.length, + error: err instanceof Error ? err.message : String(err) + }) + return null + }), new Promise((resolve) => setTimeout(() => resolve(null), budgetMs)) ]) if (raced != null) { @@ -3980,6 +4030,7 @@ class ClientService extends EventTarget { ) } + this.refreshRelayListsFromNetwork(pubkeys, storedRelayEvents) const now = Date.now() if (now - fetchRelayListBudgetWarnLastMs >= FETCH_RELAY_LIST_BUDGET_WARN_MIN_INTERVAL_MS) { fetchRelayListBudgetWarnLastMs = now @@ -3998,6 +4049,24 @@ class ClientService extends EventTarget { storedHttpRelayEvents, storedCacheRelayEvents ) + } catch (err: unknown) { + logger.warn('[FetchRelayLists] Unexpected failure; using IndexedDB / defaults', { + pubkeyCount: pubkeys.length, + error: err instanceof Error ? err.message : String(err) + }) + try { + return await this.mergeRelayListsFromStoredOnly(pubkeys) + } catch { + return pubkeys.map(() => ({ + write: PROFILE_FETCH_RELAY_URLS, + read: PROFILE_FETCH_RELAY_URLS, + originalRelays: [] as TMailboxRelay[], + httpRead: [] as string[], + httpWrite: [] as string[], + httpOriginalRelays: [] as TMailboxRelay[] + })) + } + } } async forceUpdateRelayListEvent(pubkey: string) {