From e2bad188f116b63bf68b308106bba33ac8fc7164 Mon Sep 17 00:00:00 2001 From: Thebora Kompanioni Date: Fri, 9 Dec 2022 10:14:23 +0100 Subject: [PATCH] refactor: speed up initial page load (#566) * refactor: prepare removing display request * refactor: Navbar from jsx to tsx * refactor: SettingsContext from jsx to tsx * refactor: use calculated balance in sats instead of totalBalance as string * refactor: remove totalBalance and availableBalance from BalanceSummary * refactor: simplify navbar states * refactor(WalletContext): ability to independently load data * refactor(JarSelectorModal): allow async confirm operations * refactor(Send): always fetch new jar destination address * refactor(WalletContext): return response data from reload functions * refactor(Send): speed up loading Send page * refactor(CreateFidelityBond): speed up waiting for fb utxo * refactor(MainWalletView): jsx to tsx * refactor(MainWalletView): speed up loading MainWalletView page * refactor(Jam): speed up loading Jam page * fix: prevent artifact on wallet with zero balance * refactor(Earn): speed up loading Earn page --- src/components/Earn.jsx | 23 +-- src/components/Jam.jsx | 71 +++---- src/components/JarSelectorModal.tsx | 23 ++- src/components/Jars.tsx | 15 +- src/components/MainWalletView.module.css | 20 +- ...{MainWalletView.jsx => MainWalletView.tsx} | 126 +++++++----- src/components/Send.jsx | 48 +++-- src/components/fb/CreateFidelityBond.jsx | 48 ++++- .../jar_details/JarDetailsOverlay.tsx | 80 ++++++-- src/context/WalletContext.tsx | 187 +++++++++++------- 10 files changed, 385 insertions(+), 256 deletions(-) rename src/components/{MainWalletView.jsx => MainWalletView.tsx} (61%) diff --git a/src/components/Earn.jsx b/src/components/Earn.jsx index 9b706bc86..24c15388d 100644 --- a/src/components/Earn.jsx +++ b/src/components/Earn.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Formik } from 'formik' import * as rb from 'react-bootstrap' import { useTranslation } from 'react-i18next' @@ -183,7 +183,9 @@ export default function Earn({ wallet }) { const [isWaitingMakerStop, setIsWaitingMakerStop] = useState(false) const [isShowReport, setIsShowReport] = useState(false) const [isShowOrderbook, setIsShowOrderbook] = useState(false) - const [fidelityBonds, setFidelityBonds] = useState([]) + const fidelityBonds = useMemo(() => { + return currentWalletInfo?.fidelityBondSummary.fbOutputs || [] + }, [currentWalletInfo]) const startMakerService = (ordertype, minsize, cjfee_a, cjfee_r) => { setIsSending(true) @@ -238,11 +240,7 @@ export default function Earn({ wallet }) { setIsLoading(true) const reloadingServiceInfo = reloadServiceInfo({ signal: abortCtrl.signal }) - const reloadingCurrentWalletInfo = reloadCurrentWalletInfo({ signal: abortCtrl.signal }).then((info) => { - if (!abortCtrl.signal.aborted) { - setFidelityBonds(info.fidelityBondSummary.fbOutputs) - } - }) + const reloadingCurrentWalletInfo = reloadCurrentWalletInfo.reloadUtxos({ signal: abortCtrl.signal }) Promise.all([reloadingServiceInfo, reloadingCurrentWalletInfo]) .catch((err) => { @@ -251,7 +249,7 @@ export default function Earn({ wallet }) { .finally(() => !abortCtrl.signal.aborted && setIsLoading(false)) return () => abortCtrl.abort() - }, [wallet, isSending, reloadServiceInfo, reloadCurrentWalletInfo]) + }, [isSending, reloadServiceInfo, reloadCurrentWalletInfo]) useEffect(() => { if (isSending) return @@ -283,14 +281,17 @@ export default function Earn({ wallet }) { new Promise((resolve) => { setTimeout(() => { - resolve(reloadCurrentWalletInfo({ signal: abortCtrl.signal })) + resolve(reloadCurrentWalletInfo.reloadUtxos({ signal: abortCtrl.signal })) }, delay) }) - .then((info) => setFidelityBonds(info.fidelityBondSummary.fbOutputs)) .catch((err) => { + if (abortCtrl.signal.aborted) return setAlert({ variant: 'danger', message: err.message }) }) - .finally(() => !abortCtrl.signal.aborted && setIsLoading(false)) + .finally(() => { + if (abortCtrl.signal.aborted) return + setIsLoading(false) + }) } const feeRelMin = 0.0 diff --git a/src/components/Jam.jsx b/src/components/Jam.jsx index 297563016..d87e123be 100644 --- a/src/components/Jam.jsx +++ b/src/components/Jam.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo } from 'react' +import { useState, useEffect, useMemo } from 'react' import * as rb from 'react-bootstrap' import { useTranslation } from 'react-i18next' import { Formik, useFormikContext } from 'formik' @@ -21,6 +21,23 @@ import styles from './Jam.module.css' const DEST_ADDRESS_COUNT_PROD = 3 const DEST_ADDRESS_COUNT_TEST = 1 +const getNewAddressesForTesting = (walletInfo, count, mixdepth = 0) => { + if (!walletInfo) { + throw new Error('Wallet info is not available.') + } + const externalBranch = walletInfo.data.display.walletinfo.accounts[mixdepth].branches.find((branch) => { + return branch.branch.split('\t')[0] === 'external addresses' + }) + + const newEntries = externalBranch.entries.filter((entry) => entry.status === 'new').slice(0, count) + + if (newEntries.length !== count) { + throw new Error(`Cannot find enough fresh addresses in mixdepth ${mixdepth}`) + } + + return newEntries.map((it) => it.address) +} + const addressValueKeys = (addressCount) => Array(addressCount) .fill('') @@ -66,27 +83,6 @@ export default function Jam({ wallet }) { [walletInfo] ) - // Returns one fresh address for each requested mixdepth. - const getNewAddresses = useCallback( - (count, mixdepth = 0) => { - if (!walletInfo) { - throw new Error('Wallet info is not available.') - } - const externalBranch = walletInfo.data.display.walletinfo.accounts[mixdepth].branches.find((branch) => { - return branch.branch.split('\t')[0] === 'external addresses' - }) - - const newEntries = externalBranch.entries.filter((entry) => entry.status === 'new').slice(0, count) - - if (newEntries.length !== count) { - throw new Error(`Cannot find enough fresh addresses in mixdepth ${mixdepth}`) - } - - return newEntries.map((it) => it.address) - }, - [walletInfo] - ) - const [useInsecureTestingSettings, setUseInsecureTestingSettings] = useState(false) const addressCount = useMemo( () => (useInsecureTestingSettings ? DEST_ADDRESS_COUNT_TEST : DEST_ADDRESS_COUNT_PROD), @@ -98,7 +94,7 @@ export default function Jam({ wallet }) { if (useInsecureTestingSettings) { try { // prefill with addresses marked as "new" - destinationAddresses = getNewAddresses(addressCount) + destinationAddresses = getNewAddressesForTesting(walletInfo, addressCount) } catch (e) { // on error initialize with empty addresses - form validation will do the rest destinationAddresses = Array(addressCount).fill('') @@ -106,7 +102,7 @@ export default function Jam({ wallet }) { } return destinationAddresses.reduce((obj, addr, index) => ({ ...obj, [`dest${index + 1}`]: addr }), {}) - }, [addressCount, useInsecureTestingSettings, getNewAddresses]) + }, [addressCount, useInsecureTestingSettings, walletInfo]) useEffect(() => { setAlert(null) @@ -119,7 +115,7 @@ export default function Jam({ wallet }) { setAlert({ variant: 'danger', message }) }) - const loadingWalletInfo = reloadCurrentWalletInfo({ signal: abortCtrl.signal }).catch((err) => { + const loadingWalletInfo = reloadCurrentWalletInfo.reloadUtxos({ signal: abortCtrl.signal }).catch((err) => { if (abortCtrl.signal.aborted) return const message = err.message || t('send.error_loading_wallet_failed') setAlert({ variant: 'danger', message }) @@ -133,7 +129,7 @@ export default function Jam({ wallet }) { return () => { abortCtrl.abort() } - }, [reloadServiceInfo, reloadCurrentWalletInfo, t]) + }, [collaborativeOperationRunning, reloadServiceInfo, reloadCurrentWalletInfo, t]) useEffect(() => { if (!serviceInfo) return @@ -149,24 +145,6 @@ export default function Jam({ wallet }) { } }, [serviceInfo]) - useEffect(() => { - // Due to polling, using `collaborativeOperationRunning` instead of - // `schedule` here, as a schedule object might still be present when - // the scheduler is actually not running anymore. Reload wallet data - // only when no collaborative operation is running anymore. - if (collaborativeOperationRunning) return - - setIsLoading(true) - const abortCtrl = new AbortController() - reloadCurrentWalletInfo({ signal: abortCtrl.signal }).finally(() => { - if (abortCtrl.signal.aborted) return - setIsLoading(false) - }) - return () => { - abortCtrl.abort() - } - }, [collaborativeOperationRunning, reloadCurrentWalletInfo]) - const startSchedule = async (values) => { if (isLoading || collaborativeOperationRunning) { return @@ -365,7 +343,10 @@ export default function Jam({ wallet }) { setUseInsecureTestingSettings(isToggled) if (isToggled) { try { - const newAddresses = getNewAddresses(DEST_ADDRESS_COUNT_TEST) + const newAddresses = getNewAddressesForTesting( + walletInfo, + DEST_ADDRESS_COUNT_TEST + ) newAddresses.forEach((newAddress, index) => { setFieldValue(`dest${index + 1}`, newAddress, true) }) diff --git a/src/components/JarSelectorModal.tsx b/src/components/JarSelectorModal.tsx index a6315e16e..b5c3bf607 100644 --- a/src/components/JarSelectorModal.tsx +++ b/src/components/JarSelectorModal.tsx @@ -14,7 +14,7 @@ interface JarSelectorModalProps { totalBalance: AmountSats disabledJar?: JarIndex onCancel: () => void - onConfirm: (jarIndex: JarIndex) => void + onConfirm: (jarIndex: JarIndex) => Promise } export default function JarSelectorModal({ @@ -28,7 +28,8 @@ export default function JarSelectorModal({ }: JarSelectorModalProps) { const { t } = useTranslation() - const [selectedJar, setSelectedJar] = useState(null) + const [isConfirming, setIsConfirming] = useState(false) + const [selectedJar, setSelectedJar] = useState() const sortedAccountBalances = useMemo(() => { if (!accountBalances) return [] @@ -36,15 +37,17 @@ export default function JarSelectorModal({ }, [accountBalances]) const cancel = () => { - setSelectedJar(null) + setSelectedJar(undefined) onCancel() } const confirm = () => { - if (selectedJar === null) return + if (selectedJar === undefined) return + setIsConfirming(true) onConfirm(selectedJar) - setSelectedJar(null) + .then(() => setSelectedJar(undefined)) + .finally(() => setIsConfirming(false)) } return ( @@ -88,8 +91,14 @@ export default function JarSelectorModal({ {t('modal.confirm_button_reject')} - - {t('modal.confirm_button_accept')} + + {isConfirming ? ( + <> + + + ) : ( + <>{t('modal.confirm_button_accept')} + )} diff --git a/src/components/Jars.tsx b/src/components/Jars.tsx index 19b2273e2..a362fb017 100644 --- a/src/components/Jars.tsx +++ b/src/components/Jars.tsx @@ -21,15 +21,16 @@ const Jars = ({ accountBalances, totalBalance, onClick }: JarsProps) => { return Object.values(accountBalances).sort((lhs, rhs) => lhs.accountIndex - rhs.accountIndex) }, [accountBalances]) - const jarsDescriptionPopover = ( - - {t('current_wallet.jars_title_popover')} - - ) - return (
- + + {t('current_wallet.jars_title_popover')} + + } + >
{t('current_wallet.jars_title')}
diff --git a/src/components/MainWalletView.module.css b/src/components/MainWalletView.module.css index 542b2aa77..d934284ce 100644 --- a/src/components/MainWalletView.module.css +++ b/src/components/MainWalletView.module.css @@ -1,33 +1,39 @@ -.wallet-header-title-placeholder { +.walletHeader { + display: flex; + flex-direction: column; + align-items: center; +} + +.walletHeader .titlePlaceholder { width: 5rem; margin-bottom: 0.5rem; } -.wallet-header-subtitle-placeholder { +.walletHeader .subtitlePlaceholder { width: 12rem; height: 1.8rem; margin-bottom: 0.45rem; } -.jars-placeholder { +.jarsPlaceholder { width: 100%; height: 3.5rem; } -.jars-divider-container { +.jarsDividerContainer { display: flex; justify-content: space-between; align-items: center; } -.jars-divider-line { +.jarsDividerContainer .dividerLine { margin: 0; width: 50%; flex-grow: 0; flex-shrink: 1; } -.jars-divider-button { +.jarsDividerContainer .dividerButton { display: flex; justify-content: center; align-items: center; @@ -43,7 +49,7 @@ height: 2rem; } -.send-receive-button { +.sendReceiveButton { padding: 0.25rem; font-weight: 500; border-color: rgba(222, 222, 222, 1) !important; diff --git a/src/components/MainWalletView.jsx b/src/components/MainWalletView.tsx similarity index 61% rename from src/components/MainWalletView.jsx rename to src/components/MainWalletView.tsx index 2827dc745..e661f2e75 100644 --- a/src/components/MainWalletView.jsx +++ b/src/components/MainWalletView.tsx @@ -3,8 +3,9 @@ import * as rb from 'react-bootstrap' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' import { useSettings, useSettingsDispatch } from '../context/SettingsContext' -import { useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/WalletContext' +import { CurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/WalletContext' import { walletDisplayName } from '../utils' +import * as Api from '../libs/JmWalletApi' import { routes } from '../constants/routes' import Balance from './Balance' import Sprite from './Sprite' @@ -13,35 +14,47 @@ import { JarDetailsOverlay } from './jar_details/JarDetailsOverlay' import { Jars } from './Jars' import styles from './MainWalletView.module.css' -const WalletHeader = ({ name, balance /*: AmountSats */, unit, showBalance, loading }) => { +interface WalletHeaderProps { + name: string + balance: Api.AmountSats + unit: Unit + showBalance: boolean +} + +const WalletHeader = ({ name, balance, unit, showBalance }: WalletHeaderProps) => { return ( -
- {loading && ( - - - - )} - {!loading &&

{walletDisplayName(name)}

} - {loading && ( - - - - )} - {!loading && ( -

- -

- )} +
+

{walletDisplayName(name)}

+

+ +

+
+ ) +} + +const WalletHeaderPlaceholder = () => { + return ( +
+ + + + + +
) } -export default function MainWalletView({ wallet }) { +interface MainWalletViewProps { + wallet: CurrentWallet +} + +export default function MainWalletView({ wallet }: MainWalletViewProps) { const { t } = useTranslation() const navigate = useNavigate() @@ -50,19 +63,16 @@ export default function MainWalletView({ wallet }) { const currentWalletInfo = useCurrentWalletInfo() const reloadCurrentWalletInfo = useReloadCurrentWalletInfo() - const [alert, setAlert] = useState(null) + const [alert, setAlert] = useState() const [isLoading, setIsLoading] = useState(true) const [showJars, setShowJars] = useState(false) - const jars = useMemo( - () => currentWalletInfo && currentWalletInfo.data.display.walletinfo.accounts, - [currentWalletInfo] - ) + const jars = useMemo(() => currentWalletInfo?.data.display.walletinfo.accounts, [currentWalletInfo]) const [selectedJarIndex, setSelectedJarIndex] = useState(0) const [isAccountOverlayShown, setIsAccountOverlayShown] = useState(false) - const onJarClicked = (jarIndex) => { + const onJarClicked = (jarIndex: JarIndex) => { if (jarIndex === 0) { const isEmpty = currentWalletInfo?.balanceSummary.accountBalances[jarIndex]?.calculatedTotalBalanceInSats === 0 @@ -79,18 +89,23 @@ export default function MainWalletView({ wallet }) { useEffect(() => { const abortCtrl = new AbortController() - setAlert(null) + setAlert(undefined) setIsLoading(true) - reloadCurrentWalletInfo({ signal: abortCtrl.signal }) + reloadCurrentWalletInfo + .reloadUtxos({ signal: abortCtrl.signal }) .catch((err) => { + if (abortCtrl.signal.aborted) return const message = err.message || t('current_wallet.error_loading_failed') !abortCtrl.signal.aborted && setAlert({ variant: 'danger', message }) }) - .finally(() => !abortCtrl.signal.aborted && setIsLoading(false)) + .finally(() => { + if (abortCtrl.signal.aborted) return + setIsLoading(false) + }) return () => abortCtrl.abort() - }, [wallet, reloadCurrentWalletInfo, t]) + }, [reloadCurrentWalletInfo, t]) return (
@@ -102,7 +117,7 @@ export default function MainWalletView({ wallet }) { )} - {jars && isAccountOverlayShown && ( + {currentWalletInfo && jars && isAccountOverlayShown && ( )} settingsDispatch({ showBalance: !settings.showBalance })} style={{ cursor: 'pointer' }}> - + {!currentWalletInfo || isLoading ? ( + <> + + + ) : ( + + )} @@ -129,7 +149,7 @@ export default function MainWalletView({ wallet }) {
@@ -143,7 +163,7 @@ export default function MainWalletView({ wallet }) { Depending on the mixdepth/account there will be different amounts available. */}
@@ -159,14 +179,14 @@ export default function MainWalletView({ wallet }) {
- {isLoading ? ( + {!currentWalletInfo || isLoading ? ( - + ) : ( )} @@ -176,12 +196,12 @@ export default function MainWalletView({ wallet }) { -
-
-
setShowJars((current) => !current)}> +
+
+
setShowJars((current) => !current)}>
-
+
diff --git a/src/components/Send.jsx b/src/components/Send.jsx index b45026f8a..868aff554 100644 --- a/src/components/Send.jsx +++ b/src/components/Send.jsx @@ -515,14 +515,15 @@ export default function Send({ wallet }) { const timer = setTimeout(() => { if (abortCtrl.signal.aborted) return - reloadCurrentWalletInfo({ signal: abortCtrl.signal }) - .then((data) => { + reloadCurrentWalletInfo + .reloadUtxos({ signal: abortCtrl.signal }) + .then((res) => { if (abortCtrl.signal.aborted) return - - const outputs = data.data.utxos.utxos.map((it) => it.utxo) + const outputs = res.utxos.map((it) => it.utxo) const utxosStillPresent = waitForUtxosToBeSpent.filter((it) => outputs.includes(it)) setWaitForUtxosToBeSpent([...utxosStillPresent]) }) + .catch((err) => { if (abortCtrl.signal.aborted) return @@ -560,7 +561,7 @@ export default function Send({ wallet }) { !abortCtrl.signal.aborted && setAlert({ variant: 'danger', message }) }) - const loadingWalletInfoAndUtxos = reloadCurrentWalletInfo({ signal: abortCtrl.signal }).catch((err) => { + const loadingWalletInfoAndUtxos = reloadCurrentWalletInfo.reloadUtxos({ signal: abortCtrl.signal }).catch((err) => { const message = err.message || t('send.error_loading_wallet_failed') !abortCtrl.signal.aborted && setAlert({ variant: 'danger', message }) }) @@ -922,22 +923,27 @@ export default function Send({ wallet }) { disabledJar={sourceJarIndex} onCancel={() => setDestinationJarPickerShown(false)} onConfirm={(selectedJar) => { - setDestinationJarPickerShown(false) - - const externalBranch = walletInfo.data.display.walletinfo.accounts[selectedJar].branches.find( - (branch) => { - return branch.branch.split('\t')[0] === 'external addresses' - } - ) - - const newEntry = externalBranch.entries.find((entry) => entry.status === 'new') - - if (newEntry) { - setDestination(newEntry.address) - setDestinationJar(selectedJar) - } else { - console.error(`Cannot find a new address in mixdepth ${selectedJar}`) - } + const abortCtrl = new AbortController() + return Api.getAddressNew({ + signal: abortCtrl.signal, + walletName: wallet.name, + token: wallet.token, + mixdepth: selectedJar, + }) + .then((res) => + res.ok ? res.json() : Api.Helper.throwError(res, t('receive.error_loading_address_failed')) + ) + .then((data) => { + if (abortCtrl.signal.aborted) return + setDestination(data.address) + setDestinationJar(selectedJar) + setDestinationJarPickerShown(false) + }) + .catch((err) => { + if (abortCtrl.signal.aborted) return + setAlert({ variant: 'danger', message: err.message }) + setDestinationJarPickerShown(false) + }) }} /> )} diff --git a/src/components/fb/CreateFidelityBond.jsx b/src/components/fb/CreateFidelityBond.jsx index ecddd5881..3589ba36c 100644 --- a/src/components/fb/CreateFidelityBond.jsx +++ b/src/components/fb/CreateFidelityBond.jsx @@ -59,6 +59,7 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon }, []) const reset = () => { + setIsLoading(false) setIsExpanded(false) setStep(steps.selectDate) setSelectedJar(null) @@ -71,6 +72,26 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon setUtxoIdsToBeSpent([]) } + useEffect(() => { + if (!isExpanded) { + reset() + } else { + setIsLoading(true) + const abortCtrl = new AbortController() + reloadCurrentWalletInfo + .reloadAll({ signal: abortCtrl.signal }) + .catch(() => { + if (abortCtrl.signal.aborted) return + setAlert({ variant: 'danger', message: t('earn.fidelity_bond.error_reloading_wallet') }) + }) + .finally(() => { + if (abortCtrl.signal.aborted) return + setIsLoading(false) + }) + return () => abortCtrl.abort() + } + }, [isExpanded, reloadCurrentWalletInfo, t]) + const freezeUtxos = (utxos) => { changeUtxoFreeze(utxos, true) } @@ -103,9 +124,9 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon ) Promise.all(freezeCalls) - .then((_) => reloadCurrentWalletInfo({ signal: abortCtrl.signal })) - .then((_) => setAlert(null)) - .then((_) => freeze && setFrozenUtxos([...frozenUtxos, ...utxosThatWereFrozen])) + .then((_) => reloadCurrentWalletInfo.reloadUtxos({ signal: abortCtrl.signal })) + .then(() => setAlert(null)) + .then(() => freeze && setFrozenUtxos([...frozenUtxos, ...utxosThatWereFrozen])) .catch((err) => { setAlert({ variant: 'danger', message: err.message, dismissible: true }) }) @@ -166,18 +187,20 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon const timer = setTimeout(() => { if (abortCtrl.signal.aborted) return - reloadCurrentWalletInfo({ signal: abortCtrl.signal }) - .then((walletInfo) => { + reloadCurrentWalletInfo + .reloadUtxos({ signal: abortCtrl.signal }) + .then((res) => { if (abortCtrl.signal.aborted) return - const allUtxoIds = walletInfo.data.utxos.utxos.map((utxo) => utxo.utxo) + const allUtxoIds = res.utxos.map((utxo) => utxo.utxo) const utxoIdsStillPresent = utxoIdsToBeSpent.filter((utxoId) => allUtxoIds.includes(utxoId)) if (utxoIdsStillPresent.length === 0) { // Note that two fidelity bonds with the same locktime will end up on the same address. // Therefore, this might not actually be the UTXO we just created. // Since we're using it only for displaying locktime and address, this should be fine though. - const fbUtxo = walletInfo.fidelityBondSummary.fbOutputs.find((utxo) => utxo.address === timelockedAddress) + const fbOutputs = res.utxos.filter((utxo) => fb.utxo.isFidelityBond(utxo)) + const fbUtxo = fbOutputs.find((utxo) => utxo.address === timelockedAddress) if (fbUtxo !== undefined) { setCreatedFidelityBondUtxo(fbUtxo) @@ -188,7 +211,7 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon setUtxoIdsToBeSpent([...utxoIdsStillPresent]) }) - .catch((err) => { + .catch(() => { if (abortCtrl.signal.aborted) return setUtxoIdsToBeSpent([]) @@ -207,6 +230,15 @@ const CreateFidelityBond = ({ otherFidelityBondExists, wallet, walletInfo, onDon const stepComponent = (currentStep) => { switch (currentStep) { case steps.selectDate: + if (isLoading) { + return ( +
+
+ ) + } + return ( void setTab: (tab: string) => void onHide: () => void + isInitializing: boolean initialTab: string } -interface JarDetailsOverlayProps { - jars: Account[] - initialJarIndex: JarIndex - walletInfo: WalletInfo - wallet: CurrentWallet - isShown: boolean - onHide: () => void -} - -const Header = ({ jar, nextJar, previousJar, setTab, onHide, initialTab }: HeaderProps) => { +const Header = ({ jar, nextJar, previousJar, setTab, onHide, initialTab, isInitializing }: HeaderProps) => { const { t } = useTranslation() const tabs = [ @@ -64,6 +56,20 @@ const Header = ({ jar, nextJar, previousJar, setTab, onHide, initialTab }: Heade nextJar()}> + {isInitializing && ( + <> + { +
@@ -84,20 +90,30 @@ const Header = ({ jar, nextJar, previousJar, setTab, onHide, initialTab }: Heade ) } +interface JarDetailsOverlayProps { + jars: Account[] + initialJarIndex: JarIndex + walletInfo: WalletInfo + wallet: CurrentWallet + isShown: boolean + onHide: () => void +} + const JarDetailsOverlay = (props: JarDetailsOverlayProps) => { const { t } = useTranslation() const settings = useSettings() const reloadCurrentWalletInfo = useReloadCurrentWalletInfo() const serviceInfo = useServiceInfo() - const [alert, setAlert] = useState(null) + const [alert, setAlert] = useState() const [jarIndex, setJarIndex] = useState(props.initialJarIndex) const [selectedTab, setSelectedTab] = useState(TABS.UTXOS) + const [isInitializing, setIsInitializing] = useState(true) const [isLoadingRefresh, setIsLoadingRefresh] = useState(false) const [isLoadingFreeze, setIsLoadingFreeze] = useState(false) const [isLoadingUnfreeze, setIsLoadingUnfreeze] = useState(false) const [selectedUtxoIds, setSelectedUtxoIds] = useState>([]) - const [detailUtxo, setDetailUtxo] = useState(null) + const [detailUtxo, setDetailUtxo] = useState() const jar = useMemo(() => props.jars[jarIndex], [props.jars, jarIndex]) const utxos = useMemo(() => props.walletInfo.utxosByJar[jarIndex] || [], [props.walletInfo, jarIndex]) @@ -118,6 +134,26 @@ const JarDetailsOverlay = (props: JarDetailsOverlayProps) => { [props.jars] ) + useEffect(() => { + if (!props.isShown) return + + const abortCtrl = new AbortController() + + setIsInitializing(true) + reloadCurrentWalletInfo + .reloadAll({ signal: abortCtrl.signal }) + .catch((err) => { + if (abortCtrl.signal.aborted) return + setAlert({ variant: 'danger', message: err.message, dismissible: true }) + }) + .finally(() => { + if (abortCtrl.signal.aborted) return + setIsInitializing(false) + }) + + return () => abortCtrl.abort() + }, [props.isShown, reloadCurrentWalletInfo]) + useEffect(() => setJarIndex(props.initialJarIndex), [props.initialJarIndex]) useEffect(() => { // reset selected utxos when switching jars @@ -154,16 +190,17 @@ const JarDetailsOverlay = (props: JarDetailsOverlayProps) => { const refreshUtxos = async () => { if (isLoadingFreeze || isLoadingUnfreeze || isLoadingRefresh) return - setAlert(null) + setAlert(undefined) setIsLoadingRefresh(true) const abortCtrl = new AbortController() - reloadCurrentWalletInfo({ signal: abortCtrl.signal }) + reloadCurrentWalletInfo + .reloadUtxos({ signal: abortCtrl.signal }) .catch((err) => { - setAlert({ variant: 'danger', message: err.message, dismissible: true }) + !abortCtrl.signal.aborted && setAlert({ variant: 'danger', message: err.message, dismissible: true }) }) - .finally(() => setIsLoadingRefresh(false)) + .finally(() => !abortCtrl.signal.aborted && setIsLoadingRefresh(false)) return () => abortCtrl.abort() } @@ -173,7 +210,7 @@ const JarDetailsOverlay = (props: JarDetailsOverlayProps) => { if (selectedUtxos.length <= 0) return - setAlert(null) + setAlert(undefined) freeze ? setIsLoadingFreeze(true) : setIsLoadingUnfreeze(true) const abortCtrl = new AbortController() @@ -192,7 +229,7 @@ const JarDetailsOverlay = (props: JarDetailsOverlayProps) => { ) Promise.all(freezeCalls) - .then((_) => reloadCurrentWalletInfo({ signal: abortCtrl.signal })) + .then((_) => reloadCurrentWalletInfo.reloadUtxos({ signal: abortCtrl.signal })) .catch((err) => { setAlert({ variant: 'danger', message: err.message, dismissible: true }) }) @@ -273,6 +310,7 @@ const JarDetailsOverlay = (props: JarDetailsOverlayProps) => { setTab={setSelectedTab} onHide={props.onHide} initialTab={selectedTab} + isInitializing={isInitializing} /> @@ -285,17 +323,17 @@ const JarDetailsOverlay = (props: JarDetailsOverlayProps) => { variant={alert.variant} dismissible={true} message={alert.message} - onClose={() => setAlert(null)} + onClose={() => setAlert(undefined)} /> )} {detailUtxo && ( setDetailUtxo(null)} + close={() => setDetailUtxo(undefined)} /> )} diff --git a/src/context/WalletContext.tsx b/src/context/WalletContext.tsx index b9e9c43eb..fd33e5407 100644 --- a/src/context/WalletContext.tsx +++ b/src/context/WalletContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useEffect, useCallback, useState, useContext, PropsWithChildren, useRef } from 'react' +import { createContext, useEffect, useCallback, useState, useContext, PropsWithChildren, useMemo } from 'react' import { getSession } from '../session' import * as fb from '../components/fb/utils' @@ -111,12 +111,16 @@ export interface WalletInfo { interface WalletContextEntry { currentWallet: CurrentWallet | null setCurrentWallet: React.Dispatch> - currentWalletInfo: WalletInfo | null - reloadCurrentWalletInfo: ({ signal }: { signal: AbortSignal }) => Promise + currentWalletInfo: WalletInfo | undefined + reloadCurrentWalletInfo: { + reloadAll: ({ signal }: { signal: AbortSignal }) => Promise + reloadUtxos: ({ signal }: { signal: AbortSignal }) => Promise + reloadDisplay: ({ signal }: { signal: AbortSignal }) => Promise + } } -const toAddressSummary = (data: CombinedRawWalletData): AddressSummary => { - const accounts = data.display.walletinfo.accounts +const toAddressSummary = (res: WalletDisplayResponse): AddressSummary => { + const accounts = res.walletinfo.accounts return accounts .flatMap((it) => it.branches) .flatMap((it) => it.entries) @@ -126,8 +130,8 @@ const toAddressSummary = (data: CombinedRawWalletData): AddressSummary => { }, {} as AddressSummary) } -const toFidelityBondSummary = (data: CombinedRawWalletData): FidenlityBondSummary => { - const fbOutputs = data.utxos.utxos.filter((utxo) => fb.utxo.isFidelityBond(utxo)) +const toFidelityBondSummary = (res: UtxosResponse): FidenlityBondSummary => { + const fbOutputs = res.utxos.filter((utxo) => fb.utxo.isFidelityBond(utxo)) return { fbOutputs, } @@ -145,26 +149,6 @@ const restoreWalletFromSession = (): CurrentWallet | null => { : null } -const loadWalletInfoData = async ({ - walletName, - token, - signal, -}: Api.WalletRequestContext & { signal: AbortSignal }): Promise => { - const loadingWallet = Api.getWalletDisplay({ walletName, token, signal }).then( - (res): Promise => (res.ok ? res.json() : Api.Helper.throwError(res)) - ) - - const loadingUtxos = Api.getWalletUtxos({ walletName, token, signal }).then( - (res): Promise<{ utxos: Utxos }> => (res.ok ? res.json() : Api.Helper.throwError(res)) - ) - - const data = await Promise.all([loadingWallet, loadingUtxos]) - return { - display: data[0], - utxos: data[1], - } -} - export const groupByJar = (utxos: Utxos): UtxosByJar => { return utxos.reduce((res, utxo) => { const { mixdepth } = utxo @@ -176,8 +160,8 @@ export const groupByJar = (utxos: Utxos): UtxosByJar => { const toWalletInfo = (data: CombinedRawWalletData): WalletInfo => { const balanceSummary = toBalanceSummary(data) - const addressSummary = toAddressSummary(data) - const fidelityBondSummary = toFidelityBondSummary(data) + const addressSummary = toAddressSummary(data.display) + const fidelityBondSummary = toFidelityBondSummary(data.utxos) const utxosByJar = groupByJar(data.utxos.utxos) return { @@ -189,70 +173,121 @@ const toWalletInfo = (data: CombinedRawWalletData): WalletInfo => { } } +const toCombinedRawData = (utxos: UtxosResponse, display: WalletDisplayResponse) => ({ utxos, display }) + const WalletProvider = ({ children }: PropsWithChildren) => { const [currentWallet, setCurrentWallet] = useState(restoreWalletFromSession()) - const [currentWalletInfo, setCurrentWalletInfo] = useState(null) - const fetchWalletInfoInProgress = useRef | null>(null) - const reloadCurrentWalletInfo = useCallback( + const [utxoResponse, setUtxoResponse] = useState() + const [displayResponse, setDisplayResponse] = useState() + + const fetchUtxos = useCallback( async ({ signal }: { signal: AbortSignal }) => { if (!currentWallet) { throw new Error('Cannot load wallet info: Wallet not present') - } else { - if (fetchWalletInfoInProgress.current !== null) { - try { - return await fetchWalletInfoInProgress.current - } catch (err: unknown) { - // If a previous wallet info request was in progress but failed, retry! - // This happens e.g. when the in-progress request was aborted. - if (!(err instanceof Error) || err.name !== 'AbortError') { - console.warn('Previous wallet info request resulted in an unexpected error. Retrying!', err) - } - } - } - - const { name: walletName, token } = currentWallet - const fetch = loadWalletInfoData({ walletName, token, signal }).then((data) => toWalletInfo(data)) - - fetchWalletInfoInProgress.current = fetch - - return fetch - .finally(() => { - fetchWalletInfoInProgress.current = null - }) - .then((walletInfo) => { - if (!signal.aborted) { - setCurrentWalletInfo(walletInfo) - } - return walletInfo - }) } + + const { name: walletName, token } = currentWallet + return await Api.getWalletUtxos({ walletName, token, signal }).then( + (res): Promise => (res.ok ? res.json() : Api.Helper.throwError(res)) + ) }, - [currentWallet, fetchWalletInfoInProgress] + [currentWallet] ) - useEffect(() => { - if (!currentWallet) { - setCurrentWalletInfo(null) - return - } + const fetchDisplay = useCallback( + async ({ signal }: { signal: AbortSignal }) => { + if (!currentWallet) { + throw new Error('Cannot load wallet info: Wallet not present') + } - const abortCtrl = new AbortController() + const { name: walletName, token } = currentWallet + return await Api.getWalletDisplay({ walletName, token, signal }).then( + (res): Promise => (res.ok ? res.json() : Api.Helper.throwError(res)) + ) + }, + [currentWallet] + ) - reloadCurrentWalletInfo({ signal: abortCtrl.signal }) - // If the auto-reloading on wallet change fails, the error can currently - // only be logged and cannot be displayed to the user satisfactorily. - // This might change in the future but is okay for now - components can - // always trigger a reload on demand and inform the user as they see fit. - .catch((err) => console.error(err)) + const reloadUtxos = useCallback( + async ({ signal }: { signal: AbortSignal }) => { + const response = await fetchUtxos({ signal }) + if (!signal.aborted) { + setUtxoResponse(response) + } + return response + }, + [fetchUtxos] + ) - return () => { - abortCtrl.abort() + const reloadDisplay = useCallback( + async ({ signal }: { signal: AbortSignal }) => { + const response = await fetchDisplay({ signal }) + if (!signal.aborted) { + setDisplayResponse(response) + } + return response + }, + [fetchDisplay] + ) + + const reloadAll = useCallback( + async ({ signal }: { signal: AbortSignal }): Promise => { + await Promise.all([reloadUtxos({ signal }), reloadDisplay({ signal })]) + }, + [reloadUtxos, reloadDisplay] + ) + + const combinedRawData = useMemo(() => { + if (!utxoResponse || !displayResponse) return + return toCombinedRawData(utxoResponse, displayResponse) + }, [utxoResponse, displayResponse]) + + const currentWalletInfo = useMemo(() => { + if (!combinedRawData) return + return toWalletInfo(combinedRawData) + }, [combinedRawData]) + + const reloadCurrentWalletInfo = useMemo( + () => ({ + reloadAll, + reloadUtxos, + reloadDisplay, + }), + [reloadAll, reloadUtxos, reloadDisplay] + ) + + useEffect(() => { + if (!currentWallet) { + setUtxoResponse(undefined) + setDisplayResponse(undefined) + } else { + const abortCtrl = new AbortController() + const signal = abortCtrl.signal + + reloadCurrentWalletInfo + .reloadAll({ signal }) + // If the auto-reloading on wallet change fails, the error can currently + // only be logged and cannot be displayed to the user satisfactorily. + // This might change in the future but is okay for now - components can + // always trigger a reload on demand and inform the user as they see fit. + .catch((err) => console.error(err)) + + return () => { + abortCtrl.abort() + } } }, [currentWallet, reloadCurrentWalletInfo]) return ( - + {children} )