diff --git a/index.html b/index.html index 8f7e2934..83f47904 100644 --- a/index.html +++ b/index.html @@ -39,7 +39,54 @@ -
+
+
+ +

Loading Jumble…

+
+ +
diff --git a/src/App.tsx b/src/App.tsx index 817e4e25..97dd79d5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import { ThemeProvider } from '@/providers/ThemeProvider' import { UserPreferencesProvider } from '@/providers/UserPreferencesProvider' import { UserTrustProvider } from '@/providers/UserTrustProvider' import { ZapProvider } from '@/providers/ZapProvider' +import StartupSessionBanner from '@/components/StartupSessionBanner' import { PageManager } from './PageManager' export default function App(): JSX.Element { @@ -31,34 +32,39 @@ export default function App(): JSX.Element { - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
diff --git a/src/components/StartupSessionBanner.tsx b/src/components/StartupSessionBanner.tsx new file mode 100644 index 00000000..470e8a2a --- /dev/null +++ b/src/components/StartupSessionBanner.tsx @@ -0,0 +1,47 @@ +import { useNostr } from '@/providers/NostrProvider' +import { cn } from '@/lib/utils' +import { Loader2 } from 'lucide-react' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +const SHOW_AFTER_MS = 400 + +/** + * Shown while the logged-in account’s relay list and replaceable events are being merged from IndexedDB + network. + * Debounced so fast sessions don’t flash the bar. + */ +export default function StartupSessionBanner() { + const { isAccountSessionHydrating } = useNostr() + const { t } = useTranslation() + const [visible, setVisible] = useState(false) + + useEffect(() => { + if (!isAccountSessionHydrating) { + setVisible(false) + return + } + const id = window.setTimeout(() => setVisible(true), SHOW_AFTER_MS) + return () => clearTimeout(id) + }, [isAccountSessionHydrating]) + + if (!visible) return null + + return ( +
+ + + {t('startupSessionHydrating', { + defaultValue: 'Syncing your relays and profile from the network…' + })} + +
+ ) +} diff --git a/src/main.tsx b/src/main.tsx index 7f68b316..7d22ed11 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -29,6 +29,7 @@ const SESSION_STORAGE_KEY = 'jumble:session' async function bootstrap() { // Always defined: fetch does not throw on 4xx/5xx, so non-OK responses must not leave this unset. window.__RUNTIME_CONFIG__ = {} + console.info('[jumble] Boot: opening storage and loading config…') await Promise.all([ storage.initAsync(), (async () => { @@ -42,6 +43,7 @@ async function bootstrap() { } })() ]) + console.info('[jumble] Boot: mounting React (UI shell will appear; Nostr session restores next)') // Mark session storage as used so it's visible in DevTools; VersionUpdateBanner and NotePage also use it. try { sessionStorage.setItem(SESSION_STORAGE_KEY, String(Date.now())) diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index 25426abf..fb5a30e4 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -10,7 +10,7 @@ import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import type { TNoteListRef } from '@/components/NoteList' import { TPageRef } from '@/types' -import { Compass, Info } from 'lucide-react' +import { Compass, Info, Loader2 } from 'lucide-react' import React, { Dispatch, forwardRef, @@ -83,7 +83,21 @@ const NoteListPage = forwardRef((_, ref) => { let content: React.ReactNode = null if (!isReady) { - content =
{t('loading...')}
+ content = ( +
+ +

+ {t('feedStarting', { + defaultValue: 'Starting feeds and relays… This can take a few seconds after login.' + })} +

+
+ ) } else if (feedInfo.feedType === 'following' && !pubkey) { content = (
diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index 4df2450f..66df1cdb 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -25,6 +25,7 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { id: DEFAULT_FAVORITE_RELAYS[0] }) const feedInfoRef = useRef(feedInfo) + const loggedWaitingForNostrInitRef = useRef(false) const switchFeed = useCallback(async ( feedType: TFeedType, @@ -148,8 +149,15 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { 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 1f1d7355..c56c81ed 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -43,7 +43,7 @@ import { Event, kinds, VerifiedEvent, validateEvent } from 'nostr-tools' import * as nip19 from 'nostr-tools/nip19' import * as nip49 from 'nostr-tools/nip49' import { NostrContext } from '@/providers/nostr-context' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { BunkerSigner } from './bunker.signer' @@ -142,9 +142,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const [userEmojiListEvent, setUserEmojiListEvent] = useState(null) const [rssFeedListEvent, setRssFeedListEvent] = useState(null) const [isInitialized, setIsInitialized] = useState(false) + const [isAccountSessionHydrating, setIsAccountSessionHydrating] = useState(false) + /** Bumps on each account hydration run so stale async completions cannot clear {@link isAccountSessionHydrating}. */ + const accountHydrationGenerationRef = useRef(0) useEffect(() => { const init = async () => { + logger.info('[NostrProvider] Restoring session (login / first account)…') if (hasNostrLoginHash()) { return await loginByNostrLoginHash() } @@ -155,9 +159,15 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { await loginWithAccountPointer(act) } - init().then(() => { - setIsInitialized(true) - }) + init() + .then(() => { + logger.info('[NostrProvider] Session restore finished; feeds and UI can initialize') + setIsInitialized(true) + }) + .catch((e) => { + logger.error('[NostrProvider] Session restore failed', { error: e }) + setIsInitialized(true) + }) const handleHashChange = () => { if (hasNostrLoginHash()) { @@ -172,7 +182,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } }, []) + /** Logged-out: run IndexedDB + NIP-66 prewarm once session gate opens (logged-in path includes this inside hydrate). */ useEffect(() => { + if (!isInitialized || account) return + void client.runSessionPrewarm({ pubkey: null }) + }, [isInitialized, account]) + + useEffect(() => { + let hydrationGenForThisRun = -1 const init = async () => { setRelayList(null) setProfile(null) @@ -184,9 +201,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { setBookmarkListEvent(null) setRssFeedListEvent(null) if (!account) { - return + accountHydrationGenerationRef.current += 1 + setIsAccountSessionHydrating(false) + return undefined } + hydrationGenForThisRun = accountHydrationGenerationRef.current += 1 + setIsAccountSessionHydrating(true) + logger.info('[NostrProvider] Account session hydrate: loading cache and relays…', { + pubkeySlice: account.pubkey.slice(0, 12), + hydrationGen: hydrationGenForThisRun + }) const controller = new AbortController() const storedNsec = storage.getAccountNsec(account.pubkey) if (storedNsec) { @@ -488,14 +513,31 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } } - client.initUserIndexFromFollowings(account.pubkey, controller.signal) + void client.runSessionPrewarm({ pubkey: account.pubkey, signal: controller.signal }) + logger.info('[NostrProvider] Account session hydrate: core relay/profile merge finished; client prewarm started (parallel)', { + pubkeySlice: account.pubkey.slice(0, 12) + }) return controller } const promise = init() + const finishHydration = () => { + if ( + hydrationGenForThisRun >= 0 && + accountHydrationGenerationRef.current === hydrationGenForThisRun + ) { + setIsAccountSessionHydrating(false) + } + } + promise.then(finishHydration).catch((e) => { + logger.error('[NostrProvider] Account session hydrate failed', { error: e }) + finishHydration() + }) return () => { - promise.then((controller) => { - controller?.abort() - }) + promise + .then((controller) => { + controller?.abort() + }) + .catch(() => {}) } }, [account]) @@ -1084,6 +1126,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { { successCount, sumLatencyMs } for preferring faster, proven relays when picking "random" relays. */ private sessionRelayPublishStats = new Map() + /** + * IndexedDB profile index + NIP-66 relay discovery run once per page session; followings prewarm runs when logged in. + * @see {@link runSessionPrewarm} + */ + private sessionPrewarmBaseCompleted = false + constructor() { super() this.pool = new SimplePool() @@ -138,20 +144,53 @@ class ClientService extends EventTarget { public static getInstance(): ClientService { if (!ClientService.instance) { ClientService.instance = new ClientService() - ClientService.instance.init() } return ClientService.instance } - async init() { - await indexedDb.iterateProfileEvents((profileEvent) => this.addUsernameToIndex(profileEvent)) - // Defer NIP-66 discovery so the first WebSocket slots go to login, relay list, and feed — not background search. - const runNip66 = () => this.fetchNip66RelayDiscovery().catch(() => {}) - if (typeof requestIdleCallback !== 'undefined') { - requestIdleCallback(() => runNip66(), { timeout: 8000 }) - } else { - setTimeout(runNip66, 2500) + private async prewarmProfileSearchIndexFromIdb(): Promise { + const t0 = typeof performance !== 'undefined' ? performance.now() : 0 + let profileRows = 0 + await indexedDb.iterateProfileEvents((profileEvent) => { + this.addUsernameToIndex(profileEvent) + profileRows += 1 + }) + logger.info('[client] Prewarm: profile @-mention index from IndexedDB done', { + profileRows, + ms: typeof performance !== 'undefined' ? Math.round(performance.now() - t0) : undefined + }) + } + + /** + * One-shot batch: local profile search index + NIP-66 relay discovery (once per session) + optional following-profile fetch (parallel). + * Call after Nostr session is ready so it does not compete with the first relay-list REQ. + */ + async runSessionPrewarm(options: { pubkey: string | null; signal?: AbortSignal }): Promise { + const signal = options.signal ?? new AbortController().signal + const t0 = typeof performance !== 'undefined' ? performance.now() : 0 + const tasks: Promise[] = [] + + if (!this.sessionPrewarmBaseCompleted) { + this.sessionPrewarmBaseCompleted = true + tasks.push(this.prewarmProfileSearchIndexFromIdb(), this.fetchNip66RelayDiscovery()) + } + if (options.pubkey) { + tasks.push(this.initUserIndexFromFollowings(options.pubkey, signal)) } + + if (tasks.length === 0) { + return + } + + logger.info('[client] Session prewarm batch started (parallel)', { + hasPubkey: !!options.pubkey, + taskCount: tasks.length + }) + const results = await Promise.allSettled(tasks) + logger.info('[client] Session prewarm batch finished', { + ms: typeof performance !== 'undefined' ? Math.round(performance.now() - t0) : undefined, + results: results.map((r) => r.status) + }) } // Update signer in query service when it changes @@ -174,10 +213,10 @@ class ClientService extends EventTarget { if (events.length > 0) { const capped = events.length > 2000 ? events.slice(0, 2000) : events nip66Service.loadFromEvents(capped) - logger.debug('NIP-66: loaded relay discovery events', { count: capped.length }) + logger.info('[client] Prewarm: NIP-66 relay discovery list updated', { count: capped.length }) } } catch (err) { - logger.debug('NIP-66: failed to fetch relay discovery', { err }) + logger.warn('[client] Prewarm: NIP-66 relay discovery fetch failed', { err }) } } @@ -1919,15 +1958,33 @@ class ClientService extends EventTarget { /** =========== Followings =========== */ // Moved to ReplaceableEventService - async initUserIndexFromFollowings(pubkey: string, signal: AbortSignal) { + /** Part of {@link runSessionPrewarm}; batches followings to limit relay load. */ + private async initUserIndexFromFollowings(pubkey: string, signal: AbortSignal) { const followings = await this.replaceableEventService.fetchFollowings(pubkey) + if (followings.length === 0) { + logger.info('[client] Prewarm: following profiles skipped (no followings)', { + pubkeySlice: pubkey.slice(0, 12) + }) + return + } + logger.info('[client] Prewarm: following profile fetch started', { + pubkeySlice: pubkey.slice(0, 12), + followingCount: followings.length + }) for (let i = 0; i * 20 < followings.length; i++) { - if (signal.aborted) return + if (signal.aborted) { + logger.info('[client] Prewarm: following profiles aborted', { pubkeySlice: pubkey.slice(0, 12) }) + return + } await Promise.all( - followings.slice(i * 20, (i + 1) * 20).map((pubkey) => this.fetchProfileEvent(pubkey)) + followings.slice(i * 20, (i + 1) * 20).map((pk) => this.fetchProfileEvent(pk)) ) await new Promise((resolve) => setTimeout(resolve, 1000)) } + logger.info('[client] Prewarm: following profile fetch finished', { + pubkeySlice: pubkey.slice(0, 12), + followingCount: followings.length + }) } /** =========== Profile =========== */