diff --git a/neode-ui/src/views/Home.vue b/neode-ui/src/views/Home.vue index c41229a4..b1e68109 100644 --- a/neode-ui/src/views/Home.vue +++ b/neode-ui/src/views/Home.vue @@ -522,8 +522,10 @@ const cloudStorageDisplay = computed(() => cloudStorageUsed.value !== null ? for const cloudFolderDisplay = computed(() => cloudFolderCount.value !== null ? String(cloudFolderCount.value) : '...') onMounted(async () => { - try { const usage = await fileBrowserClient.getUsage(); cloudStorageUsed.value = usage.totalSize; cloudFolderCount.value = usage.folderCount } catch { /* not running */ } + // Paint last-known wallet figures BEFORE any network round-trip. + hydrateWalletSnapshot() loadSystemStats(); systemStatsInterval = setInterval(loadSystemStats, 10000); checkUpdateStatus(); loadWeb5Status() + try { const usage = await fileBrowserClient.getUsage(); cloudStorageUsed.value = usage.totalSize; cloudFolderCount.value = usage.folderCount } catch { /* not running */ } // Poll wallet balances/transactions like Web5.vue does — without this a // pending on-chain receive (or a fresh instant payment) only shows up // after a manual wallet action or a remount. @@ -583,25 +585,78 @@ function ecashToWalletTransaction(tx: EcashTransaction): WalletTransaction { } } +// Last-known wallet snapshot, hydrated before ANY network round-trip so the +// card paints real figures instantly (app-launch-speed doctrine: over the +// mesh every serialized RPC costs a full RTT — never make the user watch it). +const WALLET_SNAPSHOT_KEY = 'archy-wallet-snapshot-v1' + +function hydrateWalletSnapshot() { + try { + const raw = localStorage.getItem(WALLET_SNAPSHOT_KEY) + if (!raw) return + const s = JSON.parse(raw) + walletOnchain.value = s.onchain ?? 0 + walletLightning.value = s.lightning ?? 0 + walletEcash.value = s.ecash ?? 0 + walletFedimint.value = s.fedimint ?? 0 + walletArk.value = s.ark ?? 0 + walletConnected.value = s.connected === true + if (Array.isArray(s.transactions)) walletTransactions.value = s.transactions + } catch { /* corrupt/absent snapshot — fresh load fills in */ } +} + +function persistWalletSnapshot() { + try { + localStorage.setItem(WALLET_SNAPSHOT_KEY, JSON.stringify({ + onchain: walletOnchain.value, + lightning: walletLightning.value, + ecash: walletEcash.value, + fedimint: walletFedimint.value, + ark: walletArk.value, + connected: walletConnected.value, + // Enough for the Transactions modal's first paint; refresh replaces it. + transactions: walletTransactions.value.slice(0, 50), + })) + } catch { /* storage full — snapshot is best-effort */ } +} + async function loadWeb5Status() { // A transient RPC timeout must NOT flash the balance to 0 ("wallet says 0 when // there is a balance"). On failure keep the last-known value — the refs start - // at 0, so only the very first load before any success shows 0. - try { const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }); walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true } catch { walletConnected.value = false } - try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }); walletEcash.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ } - try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 }); walletFedimint.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ } - try { const res = await rpcClient.call<{ spendable_sats: number }>({ method: 'wallet.ark-balance', timeout: 5000 }); walletArk.value = res.spendable_sats ?? 0 } catch { /* keep last-known balance */ } + // from the persisted snapshot, so 0 only ever shows on a genuinely fresh node. + // + // All seven calls are independent — fire them TOGETHER. Serialized, this + // block cost 7 × (mesh RTT + backend time); parallel it costs one slowest + // call, which is what makes the card feel like an app launch. + const balances = Promise.allSettled([ + rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }) + .then(res => { walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true }) + .catch(() => { walletConnected.value = false }), + rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }) + .then(res => { walletEcash.value = res.balance_sats ?? 0 }).catch(() => { /* keep last-known */ }), + rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 }) + .then(res => { walletFedimint.value = res.balance_sats ?? 0 }).catch(() => { /* keep last-known */ }), + rpcClient.call<{ spendable_sats: number }>({ method: 'wallet.ark-balance', timeout: 5000 }) + .then(res => { walletArk.value = res.spendable_sats ?? 0 }).catch(() => { /* keep last-known */ }), + ]) // Merge LND transactions with ecash/Fedimint history (wallet.ecash-history - // already unifies both) — previously only LND transactions were fetched - // here, so any Cashu or Fedimint receive (e.g. a TollGate payment) never - // appeared in the Transactions modal even though the balance included it. - let lndTxs: WalletTransaction[] = [] - try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); lndTxs = (res.transactions || []).map(tx => ({ ...tx, kind: 'onchain' as const })) } catch { /* keep last-known transactions */ } - let lightningTxs: WalletTransaction[] = [] - try { const res = await rpcClient.call<{ transactions: WalletTransaction[] }>({ method: 'lnd.lightning-history', timeout: 5000 }); lightningTxs = res.transactions || [] } catch { /* keep last-known transactions */ } - let ecashTxs: WalletTransaction[] = [] - try { const res = await rpcClient.call<{ transactions: EcashTransaction[] }>({ method: 'wallet.ecash-history', timeout: 5000 }); ecashTxs = (res.transactions || []).map(ecashToWalletTransaction) } catch { /* keep last-known transactions */ } - walletTransactions.value = [...lndTxs, ...lightningTxs, ...ecashTxs].sort((a, b) => b.time_stamp - a.time_stamp) + // already unifies both) so Cashu/Fedimint receives appear in the modal. + const histories = Promise.allSettled([ + rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }) + .then(res => (res.transactions || []).map(tx => ({ ...tx, kind: 'onchain' as const }))).catch(() => [] as WalletTransaction[]), + rpcClient.call<{ transactions: WalletTransaction[] }>({ method: 'lnd.lightning-history', timeout: 5000 }) + .then(res => res.transactions || []).catch(() => [] as WalletTransaction[]), + rpcClient.call<{ transactions: EcashTransaction[] }>({ method: 'wallet.ecash-history', timeout: 5000 }) + .then(res => (res.transactions || []).map(ecashToWalletTransaction)).catch(() => [] as WalletTransaction[]), + ]).then((results) => { + const merged = results.flatMap(r => (r.status === 'fulfilled' ? r.value : [])) + // Keep last-known list when every history call failed this round. + if (merged.length > 0 || results.some(r => r.status === 'fulfilled')) { + walletTransactions.value = merged.sort((a, b) => b.time_stamp - a.time_stamp) + } + }) + await Promise.allSettled([balances, histories]) + persistWalletSnapshot() } // System stats diff --git a/neode-ui/src/views/PeerFiles.vue b/neode-ui/src/views/PeerFiles.vue index 99f2124e..7255c317 100644 --- a/neode-ui/src/views/PeerFiles.vue +++ b/neode-ui/src/views/PeerFiles.vue @@ -794,15 +794,19 @@ function goBack() { onMounted(async () => { if (props.peerId) { - // Find the peer by onion address - try { - const result = await rpcClient.federationListNodes() - const peers = result?.nodes ?? [] - currentPeer.value = peers.find((p: PeerNode) => p.onion === props.peerId) || null - } catch { - // Continue with just the onion address - } - await Promise.all([loadCatalog(), loadOwned()]) + // The peer-name lookup is cosmetic — the catalog only needs the onion we + // already have. Serialized, it added a full mesh round-trip before the + // files even started loading. + await Promise.all([ + rpcClient.federationListNodes() + .then((result) => { + const peers = result?.nodes ?? [] + currentPeer.value = peers.find((p: PeerNode) => p.onion === props.peerId) || null + }) + .catch(() => { /* continue with just the onion address */ }), + loadCatalog(), + loadOwned(), + ]) } else { loading.value = false } diff --git a/neode-ui/src/views/web5/Web5ConnectedNodes.vue b/neode-ui/src/views/web5/Web5ConnectedNodes.vue index bb57e5aa..bdb64866 100644 --- a/neode-ui/src/views/web5/Web5ConnectedNodes.vue +++ b/neode-ui/src/views/web5/Web5ConnectedNodes.vue @@ -350,13 +350,17 @@ async function loadPeers() { const hadPeers = peers.value.length > 0 || observers.value.length > 0 loadingPeers.value = true try { - const res = await rpcClient.listPeers() + // Independent RPCs — fetched together (serialized they stacked two full + // mesh round-trips before anything rendered). + const [res, fedSettled] = await Promise.all([ + rpcClient.listPeers(), + rpcClient.federationListNodes().catch(() => null), + ]) const peerList = res.peers || [] const observerList: Peer[] = [] try { - const fedRes = await rpcClient.federationListNodes() - const fedNodes = fedRes.nodes || [] + const fedNodes = fedSettled?.nodes || [] for (const n of fedNodes) { if (!n.onion || n.trust_level === 'untrusted') { continue