import { useState, useEffect, useCallback } from 'react' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faSpinner } from '@fortawesome/free-solid-svg-icons' import { EventStoreProvider, AccountsProvider, Hooks } from 'applesauce-react' import { EventStore } from 'applesauce-core' import { AccountManager, Accounts } from 'applesauce-accounts' import { registerCommonAccountTypes } from 'applesauce-accounts/accounts' import { RelayPool } from 'applesauce-relay' import { NostrConnectSigner } from 'applesauce-signers' import { getDefaultBunkerPermissions } from './services/nostrConnect' import { createAddressLoader } from 'applesauce-loaders/loaders' import Debug from './components/Debug' import Bookmarks from './components/Bookmarks' import RouteDebug from './components/RouteDebug' import Toast from './components/Toast' import { useToast } from './hooks/useToast' import { useOnlineStatus } from './hooks/useOnlineStatus' import { RELAYS } from './config/relays' import { SkeletonThemeProvider } from './components/Skeletons' import { DebugBus } from './utils/debugBus' import { Bookmark } from './types/bookmarks' import { bookmarkController } from './services/bookmarkController' import { contactsController } from './services/contactsController' import { highlightsController } from './services/highlightsController' import { writingsController } from './services/writingsController' import { readingProgressController } from './services/readingProgressController' // import { fetchNostrverseHighlights } from './services/nostrverseService' import { nostrverseHighlightsController } from './services/nostrverseHighlightsController' import { nostrverseWritingsController } from './services/nostrverseWritingsController' import { readsController } from './services/readsController' const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR || 'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew' // AppRoutes component that has access to hooks function AppRoutes({ relayPool, eventStore, showToast }: { relayPool: RelayPool eventStore: EventStore | null showToast: (message: string) => void }) { const accountManager = Hooks.useAccountManager() const activeAccount = Hooks.useActiveAccount() // Centralized bookmark state (fed by controller) const [bookmarks, setBookmarks] = useState([]) const [bookmarksLoading, setBookmarksLoading] = useState(false) // Centralized contacts state (fed by controller) const [contacts, setContacts] = useState>(new Set()) const [contactsLoading, setContactsLoading] = useState(false) // Subscribe to bookmark controller useEffect(() => { const unsubBookmarks = bookmarkController.onBookmarks((bookmarks) => { setBookmarks(bookmarks) }) const unsubLoading = bookmarkController.onLoading((loading) => { setBookmarksLoading(loading) }) return () => { unsubBookmarks() unsubLoading() } }, []) // Subscribe to contacts controller useEffect(() => { const unsubContacts = contactsController.onContacts((contacts) => { setContacts(contacts) }) const unsubLoading = contactsController.onLoading((loading) => { setContactsLoading(loading) }) return () => { unsubContacts() unsubLoading() } }, []) // Auto-load bookmarks, contacts, and highlights when account is ready (on login or page mount) useEffect(() => { if (activeAccount && relayPool) { const pubkey = (activeAccount as { pubkey?: string }).pubkey // Load bookmarks if (bookmarks.length === 0 && !bookmarksLoading) { bookmarkController.start({ relayPool, activeAccount, accountManager }) } // Load contacts if (pubkey && contacts.size === 0 && !contactsLoading) { contactsController.start({ relayPool, pubkey }) } // Load highlights (controller manages its own state) if (pubkey && eventStore && !highlightsController.isLoadedFor(pubkey)) { highlightsController.start({ relayPool, eventStore, pubkey }) } // Load writings (controller manages its own state) if (pubkey && eventStore && !writingsController.isLoadedFor(pubkey)) { writingsController.start({ relayPool, eventStore, pubkey }) } // Load reading progress (controller manages its own state) if (pubkey && eventStore && !readingProgressController.isLoadedFor(pubkey)) { readingProgressController.start({ relayPool, eventStore, pubkey }) } // Load reads (controller manages its own state) if (pubkey && eventStore && !readsController.isLoadedFor(pubkey)) { readsController.start({ relayPool, eventStore, pubkey }) } // Start centralized nostrverse highlights controller (non-blocking) if (eventStore) { nostrverseHighlightsController.start({ relayPool, eventStore }) nostrverseWritingsController.start({ relayPool, eventStore }) } } }, [activeAccount, relayPool, eventStore, bookmarks.length, bookmarksLoading, contacts.size, contactsLoading, accountManager]) // Ensure nostrverse controllers run even when logged out useEffect(() => { if (relayPool && eventStore) { nostrverseHighlightsController.start({ relayPool, eventStore }) nostrverseWritingsController.start({ relayPool, eventStore }) } }, [relayPool, eventStore]) // Manual refresh (for sidebar button) const handleRefreshBookmarks = useCallback(async () => { if (!relayPool || !activeAccount) { return } bookmarkController.reset() await bookmarkController.start({ relayPool, activeAccount, accountManager }) }, [relayPool, activeAccount, accountManager]) const handleLogout = () => { accountManager.clearActive() bookmarkController.reset() // Clear bookmarks via controller contactsController.reset() // Clear contacts via controller highlightsController.reset() // Clear highlights via controller readingProgressController.reset() // Clear reading progress via controller showToast('Logged out successfully') } return ( } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> ) } function App() { const [eventStore, setEventStore] = useState(null) const [accountManager, setAccountManager] = useState(null) const [relayPool, setRelayPool] = useState(null) const { toastMessage, toastType, showToast, clearToast } = useToast() const isOnline = useOnlineStatus() useEffect(() => { const initializeApp = async () => { // Initialize event store, account manager, and relay pool const store = new EventStore() const accounts = new AccountManager() // Disable request queueing globally - makes all operations instant // Queue causes requests to wait for user interaction which blocks batch operations accounts.disableQueue = true // Register common account types (needed for deserialization) registerCommonAccountTypes(accounts) // Create relay pool and set it up BEFORE loading accounts // NostrConnectAccount.fromJSON needs this to restore the signer const pool = new RelayPool() // Wire the signer to use this pool; make publish non-blocking so callers don't // wait for every relay send to finish. Responses still resolve the pending request. NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool) NostrConnectSigner.publishMethod = (relays: string[], event: unknown) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result: any = pool.publish(relays, event as any) // eslint-disable-next-line @typescript-eslint/no-explicit-any if (result && typeof (result as any).subscribe === 'function') { // Subscribe to the observable but ignore completion/errors (fire-and-forget) // eslint-disable-next-line @typescript-eslint/no-explicit-any try { (result as any).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ } } // Return an already-resolved promise so upstream await finishes immediately return Promise.resolve() } // Create a relay group for better event deduplication and management pool.group(RELAYS) // Load persisted accounts from localStorage try { const accountsJson = localStorage.getItem('accounts') const json = JSON.parse(accountsJson || '[]') await accounts.fromJSON(json) // Load active account from storage const activeId = localStorage.getItem('active') if (activeId) { const account = accounts.getAccount(activeId) if (account) { accounts.setActive(activeId) } else { console.warn('[bunker] ⚠️ Active ID found but account not in list') } } else { // No active account ID in localStorage } } catch (err) { console.error('[bunker] ❌ Failed to load accounts from storage:', err) } // Subscribe to accounts changes and persist to localStorage const accountsSub = accounts.accounts$.subscribe(() => { localStorage.setItem('accounts', JSON.stringify(accounts.toJSON())) }) // Subscribe to active account changes and persist to localStorage const activeSub = accounts.active$.subscribe((account) => { if (account) { localStorage.setItem('active', account.id) } else { localStorage.removeItem('active') } }) // Reconnect bunker signers when active account changes // Keep track of which accounts we've already reconnected to avoid double-connecting const reconnectedAccounts = new Set() const bunkerReconnectSub = accounts.active$.subscribe(async (account) => { if (account && account.type === 'nostr-connect') { const nostrConnectAccount = account as Accounts.NostrConnectAccount // Disable applesauce account queueing so decrypt requests aren't serialized behind earlier ops try { if (!(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue) { (nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue = true } } catch (err) { // Ignore queue disable errors } // Note: for Amber bunker, the remote signer pubkey is the user's pubkey. This is expected. // Skip if we've already reconnected this account if (reconnectedAccounts.has(account.id)) { return } try { // For restored signers, ensure they have the pool's subscription methods // The signer was created in fromJSON without pool context, so we need to recreate it const signerData = nostrConnectAccount.toJSON().signer // Add bunker's relays to the pool BEFORE recreating the signer // This ensures the pool has all relays when the signer sets up its methods const bunkerRelays = signerData.relays || [] const existingRelayUrls = new Set(Array.from(pool.relays.keys())) const newBunkerRelays = bunkerRelays.filter(url => !existingRelayUrls.has(url)) if (newBunkerRelays.length > 0) { pool.group(newBunkerRelays) } else { // Bunker relays already in pool } const recreatedSigner = new NostrConnectSigner({ relays: signerData.relays, pubkey: nostrConnectAccount.pubkey, remote: signerData.remote, signer: nostrConnectAccount.signer.signer, // Use the existing SimpleSigner pool: pool }) // Ensure local relays are included for NIP-46 request/response traffic (e.g., Amber bunker) try { const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS])) recreatedSigner.relays = mergedRelays } catch (err) { console.warn('[bunker] failed to merge signer relays', err) } // Replace the signer on the account nostrConnectAccount.signer = recreatedSigner // Debug: log publish/subscription calls made by signer (decrypt/sign requests) // IMPORTANT: bind originals to preserve `this` context used internally by the signer const originalPublish = (recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod.bind(recreatedSigner) ;(recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod = (relays: string[], event: unknown) => { try { let method: string | undefined const content = (event as { content?: unknown })?.content if (typeof content === 'string') { try { const parsed = JSON.parse(content) as { method?: string; id?: unknown } method = parsed?.method } catch (err) { console.warn('[bunker] failed to parse event content', err) } } const summary = { relays, kind: (event as { kind?: number })?.kind, method, // include tags array for debugging (NIP-46 expects method tag) tags: (event as { tags?: unknown })?.tags, contentLength: typeof content === 'string' ? content.length : undefined } try { DebugBus.info('bunker', 'publish', summary) } catch (err) { console.warn('[bunker] failed to log to DebugBus', err) } } catch (err) { console.warn('[bunker] failed to log publish summary', err) } // Fire-and-forget publish: trigger the publish but do not return the // Observable/Promise to upstream to avoid their awaiting of completion. const result = originalPublish(relays, event) if (result && typeof (result as { subscribe?: unknown }).subscribe === 'function') { // Subscribe to the observable but ignore completion/errors (fire-and-forget) try { (result as { subscribe: (h: { complete?: () => void; error?: (e: unknown) => void }) => unknown }).subscribe({ complete: () => { /* noop */ }, error: () => { /* noop */ } }) } catch { /* ignore */ } } // If it's a Promise, simply ignore it (no await) so it resolves in the background. // Return a benign object so callers that probe for a "subscribe" property // (e.g., applesauce makeRequest) won't throw on `"subscribe" in result`. return {} as unknown as never } const originalSubscribe = (recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod.bind(recreatedSigner) ;(recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod = (relays: string[], filters: unknown[]) => { try { try { DebugBus.info('bunker', 'subscribe', { relays, filters }) } catch (err) { console.warn('[bunker] failed to log subscribe to DebugBus', err) } } catch (err) { console.warn('[bunker] failed to log subscribe summary', err) } return originalSubscribe(relays, filters) } // Just ensure the signer is listening for responses - don't call connect() again // The fromBunkerURI already connected with permissions during login if (!nostrConnectAccount.signer.listening) { await nostrConnectAccount.signer.open() } else { // Signer already listening } // Attempt a guarded reconnect to ensure Amber authorizes decrypt operations try { if (nostrConnectAccount.signer.remote && !reconnectedAccounts.has(account.id)) { const permissions = getDefaultBunkerPermissions() await nostrConnectAccount.signer.connect(undefined, permissions) } } catch (e) { console.warn('[bunker] ⚠️ Guarded connect() failed:', e) } // Give the subscription a moment to fully establish before allowing decrypt operations // This ensures the signer is ready to handle and receive responses await new Promise(resolve => setTimeout(resolve, 100)) // Fire-and-forget: probe decrypt path to verify Amber responds to NIP-46 decrypt try { const withTimeout = async (p: Promise, ms = 10000): Promise => { return await Promise.race([ p, new Promise((_, rej) => setTimeout(() => rej(new Error(`probe timeout after ${ms}ms`)), ms)), ]) } setTimeout(async () => { const self = nostrConnectAccount.pubkey // Try a roundtrip so the bunker can respond successfully try { await withTimeout(nostrConnectAccount.signer.nip44!.encrypt(self, 'probe-nip44')) await withTimeout(nostrConnectAccount.signer.nip44!.decrypt(self, '')) } catch (_err) { // Ignore probe errors } try { await withTimeout(nostrConnectAccount.signer.nip04!.encrypt(self, 'probe-nip04')) await withTimeout(nostrConnectAccount.signer.nip04!.decrypt(self, '')) } catch (_err) { // Ignore probe errors } }, 0) } catch (_err) { // Ignore signer setup errors } // The bunker remembers the permissions from the initial connection nostrConnectAccount.signer.isConnected = true // Mark this account as reconnected reconnectedAccounts.add(account.id) } catch (error) { console.error('[bunker] ❌ Failed to open signer:', error) } } }) // Keep all relay connections alive indefinitely by creating a persistent subscription // This prevents disconnection when no other subscriptions are active // Create a minimal subscription that never completes to keep connections alive const keepAliveSub = pool.subscription(RELAYS, { kinds: [0], limit: 0 }).subscribe({ next: () => {}, // No-op, we don't care about events error: (err) => console.warn('Keep-alive subscription error:', err) }) // Store subscription for cleanup ;(pool as unknown as { _keepAliveSubscription: typeof keepAliveSub })._keepAliveSubscription = keepAliveSub // Attach address/replaceable loaders so ProfileModel can fetch profiles const addressLoader = createAddressLoader(pool, { eventStore: store, lookupRelays: RELAYS }) store.addressableLoader = addressLoader store.replaceableLoader = addressLoader setEventStore(store) setAccountManager(accounts) setRelayPool(pool) // Cleanup function return () => { accountsSub.unsubscribe() activeSub.unsubscribe() bunkerReconnectSub.unsubscribe() // Clean up keep-alive subscription if it exists const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } } if (poolWithSub._keepAliveSubscription) { poolWithSub._keepAliveSubscription.unsubscribe() } } } let cleanup: (() => void) | undefined initializeApp().then((fn) => { cleanup = fn }) return () => { if (cleanup) cleanup() } }, [isOnline, showToast]) // Monitor online/offline status useEffect(() => { if (!isOnline) { showToast('You are offline. Some features may be limited.') } }, [isOnline, showToast]) // Listen for service worker updates useEffect(() => { const handleSWUpdate = () => { showToast('New version available! Refresh to update.') } window.addEventListener('sw-update-available', handleSWUpdate) return () => { window.removeEventListener('sw-update-available', handleSWUpdate) } }, [showToast]) if (!eventStore || !accountManager || !relayPool) { return (
) } return (
{toastMessage && ( )}
) } export default App