Browse Source

make startup more recognizable

imwald
Silberengel 1 month ago
parent
commit
00e9dc3fa9
  1. 49
      index.html
  2. 6
      src/App.tsx
  3. 47
      src/components/StartupSessionBanner.tsx
  4. 2
      src/main.tsx
  5. 18
      src/pages/primary/NoteListPage/index.tsx
  6. 8
      src/providers/FeedProvider.tsx
  7. 53
      src/providers/NostrProvider/index.tsx
  8. 2
      src/providers/nostr-context.tsx
  9. 85
      src/services/client.service.ts

49
index.html

@ -39,7 +39,54 @@
<meta name="twitter:image" content="https://github.com/CodyTseng/jumble/blob/master/resources/og-image.png?raw=true" /> <meta name="twitter:image" content="https://github.com/CodyTseng/jumble/blob/master/resources/og-image.png?raw=true" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root">
<div
id="jumble-boot-splash"
style="
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
font-family: system-ui, sans-serif;
font-size: 0.95rem;
color: #737373;
background: #fafafa;
"
>
<div
style="
width: 2rem;
height: 2rem;
border: 3px solid #e5e5e5;
border-top-color: #404040;
border-radius: 50%;
animation: jumble-spin 0.7s linear infinite;
"
aria-hidden="true"
></div>
<p style="margin: 0; max-width: 18rem; text-align: center">Loading Jumble…</p>
</div>
<style>
@keyframes jumble-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-color-scheme: dark) {
#jumble-boot-splash {
color: #a3a3a3;
background: #171717;
}
#jumble-boot-splash div[aria-hidden] {
border-color: #404040;
border-top-color: #d4d4d4;
}
}
</style>
</div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

6
src/App.tsx

@ -21,6 +21,7 @@ import { ThemeProvider } from '@/providers/ThemeProvider'
import { UserPreferencesProvider } from '@/providers/UserPreferencesProvider' import { UserPreferencesProvider } from '@/providers/UserPreferencesProvider'
import { UserTrustProvider } from '@/providers/UserTrustProvider' import { UserTrustProvider } from '@/providers/UserTrustProvider'
import { ZapProvider } from '@/providers/ZapProvider' import { ZapProvider } from '@/providers/ZapProvider'
import StartupSessionBanner from '@/components/StartupSessionBanner'
import { PageManager } from './PageManager' import { PageManager } from './PageManager'
export default function App(): JSX.Element { export default function App(): JSX.Element {
@ -31,6 +32,9 @@ export default function App(): JSX.Element {
<ScreenSizeProvider> <ScreenSizeProvider>
<DeletedEventProvider> <DeletedEventProvider>
<NostrProvider> <NostrProvider>
<div className="flex min-h-[100dvh] flex-col">
<StartupSessionBanner />
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<ZapProvider> <ZapProvider>
<FavoriteRelaysProvider> <FavoriteRelaysProvider>
<FollowListProvider> <FollowListProvider>
@ -59,6 +63,8 @@ export default function App(): JSX.Element {
</FollowListProvider> </FollowListProvider>
</FavoriteRelaysProvider> </FavoriteRelaysProvider>
</ZapProvider> </ZapProvider>
</div>
</div>
</NostrProvider> </NostrProvider>
</DeletedEventProvider> </DeletedEventProvider>
</ScreenSizeProvider> </ScreenSizeProvider>

47
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 accounts relay list and replaceable events are being merged from IndexedDB + network.
* Debounced so fast sessions dont 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 (
<div
role="status"
aria-live="polite"
aria-busy="true"
className={cn(
'flex w-full shrink-0 items-center justify-center gap-2 border-b border-border',
'bg-background px-3 py-2 text-center text-sm text-muted-foreground'
)}
>
<Loader2 className="size-4 shrink-0 animate-spin" aria-hidden />
<span>
{t('startupSessionHydrating', {
defaultValue: 'Syncing your relays and profile from the network…'
})}
</span>
</div>
)
}

2
src/main.tsx

@ -29,6 +29,7 @@ const SESSION_STORAGE_KEY = 'jumble:session'
async function bootstrap() { async function bootstrap() {
// Always defined: fetch does not throw on 4xx/5xx, so non-OK responses must not leave this unset. // Always defined: fetch does not throw on 4xx/5xx, so non-OK responses must not leave this unset.
window.__RUNTIME_CONFIG__ = {} window.__RUNTIME_CONFIG__ = {}
console.info('[jumble] Boot: opening storage and loading config…')
await Promise.all([ await Promise.all([
storage.initAsync(), storage.initAsync(),
(async () => { (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. // Mark session storage as used so it's visible in DevTools; VersionUpdateBanner and NotePage also use it.
try { try {
sessionStorage.setItem(SESSION_STORAGE_KEY, String(Date.now())) sessionStorage.setItem(SESSION_STORAGE_KEY, String(Date.now()))

18
src/pages/primary/NoteListPage/index.tsx

@ -10,7 +10,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import type { TNoteListRef } from '@/components/NoteList' import type { TNoteListRef } from '@/components/NoteList'
import { TPageRef } from '@/types' import { TPageRef } from '@/types'
import { Compass, Info } from 'lucide-react' import { Compass, Info, Loader2 } from 'lucide-react'
import React, { import React, {
Dispatch, Dispatch,
forwardRef, forwardRef,
@ -83,7 +83,21 @@ const NoteListPage = forwardRef<TPageRef>((_, ref) => {
let content: React.ReactNode = null let content: React.ReactNode = null
if (!isReady) { if (!isReady) {
content = <div className="text-center text-sm text-muted-foreground">{t('loading...')}</div> content = (
<div
className="flex min-h-[40vh] flex-col items-center justify-center gap-3 px-4 text-center"
role="status"
aria-live="polite"
aria-busy="true"
>
<Loader2 className="size-8 animate-spin text-muted-foreground" aria-hidden />
<p className="text-sm text-muted-foreground">
{t('feedStarting', {
defaultValue: 'Starting feeds and relays… This can take a few seconds after login.'
})}
</p>
</div>
)
} else if (feedInfo.feedType === 'following' && !pubkey) { } else if (feedInfo.feedType === 'following' && !pubkey) {
content = ( content = (
<div className="flex justify-center w-full"> <div className="flex justify-center w-full">

8
src/providers/FeedProvider.tsx

@ -25,6 +25,7 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
id: DEFAULT_FAVORITE_RELAYS[0] id: DEFAULT_FAVORITE_RELAYS[0]
}) })
const feedInfoRef = useRef<TFeedInfo>(feedInfo) const feedInfoRef = useRef<TFeedInfo>(feedInfo)
const loggedWaitingForNostrInitRef = useRef(false)
const switchFeed = useCallback(async ( const switchFeed = useCallback(async (
feedType: TFeedType, feedType: TFeedType,
@ -148,8 +149,15 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
const init = async () => { const init = async () => {
logger.debug('FeedProvider init:', { isInitialized, pubkey, favoriteRelays: favoriteRelays.length, blockedRelays: blockedRelays.length }) logger.debug('FeedProvider init:', { isInitialized, pubkey, favoriteRelays: favoriteRelays.length, blockedRelays: blockedRelays.length })
if (!isInitialized) { 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 return
} }
loggedWaitingForNostrInitRef.current = false
// Wait for favoriteRelays to be initialized (should have at least default relays) // Wait for favoriteRelays to be initialized (should have at least default relays)
// If favoriteRelays is empty, it might not be initialized yet, so wait // If favoriteRelays is empty, it might not be initialized yet, so wait

53
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 nip19 from 'nostr-tools/nip19'
import * as nip49 from 'nostr-tools/nip49' import * as nip49 from 'nostr-tools/nip49'
import { NostrContext } from '@/providers/nostr-context' import { NostrContext } from '@/providers/nostr-context'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { BunkerSigner } from './bunker.signer' import { BunkerSigner } from './bunker.signer'
@ -142,9 +142,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [userEmojiListEvent, setUserEmojiListEvent] = useState<Event | null>(null) const [userEmojiListEvent, setUserEmojiListEvent] = useState<Event | null>(null)
const [rssFeedListEvent, setRssFeedListEvent] = useState<Event | null>(null) const [rssFeedListEvent, setRssFeedListEvent] = useState<Event | null>(null)
const [isInitialized, setIsInitialized] = useState(false) 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(() => { useEffect(() => {
const init = async () => { const init = async () => {
logger.info('[NostrProvider] Restoring session (login / first account)…')
if (hasNostrLoginHash()) { if (hasNostrLoginHash()) {
return await loginByNostrLoginHash() return await loginByNostrLoginHash()
} }
@ -155,7 +159,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
await loginWithAccountPointer(act) await loginWithAccountPointer(act)
} }
init().then(() => { 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) setIsInitialized(true)
}) })
@ -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(() => { useEffect(() => {
if (!isInitialized || account) return
void client.runSessionPrewarm({ pubkey: null })
}, [isInitialized, account])
useEffect(() => {
let hydrationGenForThisRun = -1
const init = async () => { const init = async () => {
setRelayList(null) setRelayList(null)
setProfile(null) setProfile(null)
@ -184,9 +201,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setBookmarkListEvent(null) setBookmarkListEvent(null)
setRssFeedListEvent(null) setRssFeedListEvent(null)
if (!account) { 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 controller = new AbortController()
const storedNsec = storage.getAccountNsec(account.pubkey) const storedNsec = storage.getAccountNsec(account.pubkey)
if (storedNsec) { 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 return controller
} }
const promise = init() 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 () => { return () => {
promise.then((controller) => { promise
.then((controller) => {
controller?.abort() controller?.abort()
}) })
.catch(() => {})
} }
}, [account]) }, [account])
@ -1084,6 +1126,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
<NostrContext.Provider <NostrContext.Provider
value={{ value={{
isInitialized, isInitialized,
isAccountSessionHydrating,
pubkey: account?.pubkey ?? null, pubkey: account?.pubkey ?? null,
profile, profile,
profileEvent, profileEvent,

2
src/providers/nostr-context.tsx

@ -14,6 +14,8 @@ import { createContext, useContext } from 'react'
export type TNostrContext = { export type TNostrContext = {
isInitialized: boolean isInitialized: boolean
/** True while replaceable events + relay list are loading from cache/relays after login or account switch. */
isAccountSessionHydrating: boolean
pubkey: string | null pubkey: string | null
profile: TProfile | null profile: TProfile | null
profileEvent: Event | null profileEvent: Event | null

85
src/services/client.service.ts

@ -120,6 +120,12 @@ class ClientService extends EventTarget {
/** Session-only: relay URL -> { successCount, sumLatencyMs } for preferring faster, proven relays when picking "random" relays. */ /** Session-only: relay URL -> { successCount, sumLatencyMs } for preferring faster, proven relays when picking "random" relays. */
private sessionRelayPublishStats = new Map<string, { successCount: number; sumLatencyMs: number }>() private sessionRelayPublishStats = new Map<string, { successCount: number; sumLatencyMs: number }>()
/**
* 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() { constructor() {
super() super()
this.pool = new SimplePool() this.pool = new SimplePool()
@ -138,20 +144,53 @@ class ClientService extends EventTarget {
public static getInstance(): ClientService { public static getInstance(): ClientService {
if (!ClientService.instance) { if (!ClientService.instance) {
ClientService.instance = new ClientService() ClientService.instance = new ClientService()
ClientService.instance.init()
} }
return ClientService.instance return ClientService.instance
} }
async init() { private async prewarmProfileSearchIndexFromIdb(): Promise<void> {
await indexedDb.iterateProfileEvents((profileEvent) => this.addUsernameToIndex(profileEvent)) const t0 = typeof performance !== 'undefined' ? performance.now() : 0
// Defer NIP-66 discovery so the first WebSocket slots go to login, relay list, and feed — not background search. let profileRows = 0
const runNip66 = () => this.fetchNip66RelayDiscovery().catch(() => {}) await indexedDb.iterateProfileEvents((profileEvent) => {
if (typeof requestIdleCallback !== 'undefined') { this.addUsernameToIndex(profileEvent)
requestIdleCallback(() => runNip66(), { timeout: 8000 }) profileRows += 1
} else { })
setTimeout(runNip66, 2500) 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<void> {
const signal = options.signal ?? new AbortController().signal
const t0 = typeof performance !== 'undefined' ? performance.now() : 0
const tasks: Promise<unknown>[] = []
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 // Update signer in query service when it changes
@ -174,10 +213,10 @@ class ClientService extends EventTarget {
if (events.length > 0) { if (events.length > 0) {
const capped = events.length > 2000 ? events.slice(0, 2000) : events const capped = events.length > 2000 ? events.slice(0, 2000) : events
nip66Service.loadFromEvents(capped) 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) { } 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 =========== */ /** =========== Followings =========== */
// Moved to ReplaceableEventService // 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) 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++) { 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( 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)) await new Promise((resolve) => setTimeout(resolve, 1000))
} }
logger.info('[client] Prewarm: following profile fetch finished', {
pubkeySlice: pubkey.slice(0, 12),
followingCount: followings.length
})
} }
/** =========== Profile =========== */ /** =========== Profile =========== */

Loading…
Cancel
Save