From 54a9ffd04c158902e3abc4d64ba9f7b03fa21eab Mon Sep 17 00:00:00 2001 From: sibilla merlo Date: Wed, 4 Mar 2026 16:46:03 +0100 Subject: [PATCH 01/24] frontend: add config context and fetch config once on startup - Introduce ConfigProvider and ConfigContext and wrap app with provider - Fetch config once when the app loads and expose config + setConfig via useConfig - Refactor config consumers to read from context instead of calling getConfig per component --- frontends/web/src/api/config.ts | 22 +++++ .../src/components/banners/offline-error.tsx | 11 +-- .../web/src/components/status/status.tsx | 27 +++--- .../src/components/terms/bitrefill-terms.tsx | 10 +-- .../src/components/terms/bitsurance-terms.tsx | 3 +- .../components/terms/btcdirect-otc-terms.tsx | 10 +-- .../src/components/terms/btcdirect-terms.tsx | 10 +-- .../src/components/terms/moonpay-terms.tsx | 3 +- .../web/src/components/terms/pocket-terms.tsx | 3 +- frontends/web/src/contexts/AppProvider.tsx | 26 +++--- frontends/web/src/contexts/ConfigContext.tsx | 11 +++ frontends/web/src/contexts/ConfigProvider.tsx | 44 ++++++++++ .../web/src/contexts/DarkmodeProvider.tsx | 87 +++++++++---------- frontends/web/src/contexts/RatesProvider.tsx | 34 ++++---- .../web/src/contexts/WCWeb3WalletProvider.tsx | 7 +- frontends/web/src/contexts/providers.tsx | 33 +++---- frontends/web/src/hooks/bitsurance.ts | 18 ++-- frontends/web/src/i18n/config.ts | 2 +- .../src/routes/account/send/coin-control.tsx | 11 ++- .../routes/account/send/feetargets.test.tsx | 20 ++++- .../src/routes/account/send/feetargets.tsx | 8 +- .../web/src/routes/bitsurance/widget.tsx | 6 +- frontends/web/src/routes/market/bitrefill.tsx | 8 +- frontends/web/src/routes/market/btcdirect.tsx | 7 +- frontends/web/src/routes/market/market.tsx | 10 +-- frontends/web/src/routes/market/moonpay.tsx | 6 +- frontends/web/src/routes/market/pocket.tsx | 6 +- .../src/routes/settings/advanced-settings.tsx | 17 ++-- .../custom-gap-limit-setting.tsx | 8 +- .../advanced-settings/enable-auth-setting.tsx | 3 +- .../enable-coin-control-setting.tsx | 3 +- .../enable-custom-fees-toggle-setting.tsx | 3 +- .../restart-in-testnet-setting.tsx | 7 +- .../advanced-settings/tor-proxy-dialog.tsx | 3 +- .../dialogs/enableRememberWalletDialog.tsx | 9 +- .../manage-accounts/watchonlySetting.tsx | 7 +- .../src/routes/settings/electrum-servers.tsx | 25 +++--- frontends/web/src/utils/config.ts | 42 ++++----- 38 files changed, 326 insertions(+), 244 deletions(-) create mode 100644 frontends/web/src/api/config.ts create mode 100644 frontends/web/src/contexts/ConfigContext.tsx create mode 100644 frontends/web/src/contexts/ConfigProvider.tsx diff --git a/frontends/web/src/api/config.ts b/frontends/web/src/api/config.ts new file mode 100644 index 0000000000..652efa1db1 --- /dev/null +++ b/frontends/web/src/api/config.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { apiGet, apiPost } from '@/utils/request'; + +export type TConfig = { + readonly backend: Readonly>; + readonly frontend: Readonly>; +}; + +/** + * Fetch current config from the backend. + */ +export const getConfig = (): Promise> => { + return apiGet('config'); +}; + +/** + * Post a config object to the backend. + */ +export const setConfig = (config: Partial): Promise => { + return apiPost('config', config); +}; diff --git a/frontends/web/src/components/banners/offline-error.tsx b/frontends/web/src/components/banners/offline-error.tsx index 5ea8e713f6..55933d5485 100644 --- a/frontends/web/src/components/banners/offline-error.tsx +++ b/frontends/web/src/components/banners/offline-error.tsx @@ -1,9 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 -import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Message } from '../message/message'; -import { getConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import style from './offline-errors.module.css'; type Props = { @@ -13,13 +12,9 @@ type Props = { export const OfflineError = ({ error, }: Props) => { - const { t } = useTranslation(); - const [usesProxy, setUsesProxy] = useState(); - - useEffect(() => { - getConfig().then(({ backend }) => setUsesProxy(backend.proxy.useProxy)); - }, []); + const { config } = useConfig(); + const usesProxy = (config?.backend?.proxy as { useProxy?: boolean } | undefined)?.useProxy; // Status: offline error const offlineErrorTextLines: string[] = []; diff --git a/frontends/web/src/components/status/status.tsx b/frontends/web/src/components/status/status.tsx index 5ece304d78..591e480adc 100644 --- a/frontends/web/src/components/status/status.tsx +++ b/frontends/web/src/components/status/status.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 -import { ReactNode, useCallback, useEffect, useState } from 'react'; -import { getConfig, setConfig } from '@/utils/config'; +import { ReactNode } from 'react'; +import { useConfig } from '@/contexts/ConfigProvider'; import { CloseXDark, CloseXWhite } from '@/components/icon'; import { useDarkmode } from '@/hooks/darkmode'; import { Message } from '@/components/message/message'; @@ -28,21 +28,15 @@ export const Status = ({ className = '', children, }: TProps) => { - // note: dismissible can be falsy i.e. empty string '' - const [show, setShow] = useState(dismissibleKey ? false : true); - + const { config, setConfig } = useConfig(); const { isDarkMode } = useDarkmode(); - const checkConfig = useCallback(async () => { - if (dismissibleKey) { - const config = await getConfig(); - setShow(!config ? true : !config.frontend[dismissibleKey]); - } - }, [dismissibleKey]); - - useEffect(() => { - checkConfig(); - }, [checkConfig]); + // note: dismissibleKey can be falsy i.e. empty string '' + const show = hidden + ? false + : dismissibleKey + ? !config?.frontend?.[dismissibleKey] + : true; const dismiss = async () => { if (!dismissibleKey) { @@ -53,10 +47,9 @@ export const Status = ({ [dismissibleKey]: true, } }); - setShow(false); }; - if (hidden || !show) { + if (!show) { return null; } diff --git a/frontends/web/src/components/terms/bitrefill-terms.tsx b/frontends/web/src/components/terms/bitrefill-terms.tsx index ee5b9d6a7b..fa57b614bc 100644 --- a/frontends/web/src/components/terms/bitrefill-terms.tsx +++ b/frontends/web/src/components/terms/bitrefill-terms.tsx @@ -6,7 +6,7 @@ import { TAccount } from '@/api/account'; import { isBitcoinOnly } from '@/routes/account/utils'; import { getBitrefillHelpLink, getBitrefillLimitsLink } from '@/routes/market/bitrefill-guide'; import { Button, Checkbox } from '@/components/forms'; -import { setConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { A } from '../anchor/anchor'; import style from './terms.module.css'; @@ -32,12 +32,12 @@ export const getBitrefillPrivacyLink = () => { return 'https://www.bitrefill.com/privacy/?hl=' + hl; }; -const handleSkipDisclaimer = (e: ChangeEvent) => { - setConfig({ frontend: { skipBitrefillWidgetDisclaimer: e.target.checked } }); -}; - export const BitrefillTerms = ({ account, onAgreedTerms }: TProps) => { const { t } = useTranslation(); + const { setConfig } = useConfig(); + const handleSkipDisclaimer = (e: ChangeEvent) => { + setConfig({ frontend: { skipBitrefillWidgetDisclaimer: e.target.checked } }); + }; const isBitcoin = isBitcoinOnly(account.coinCode); return ( diff --git a/frontends/web/src/components/terms/bitsurance-terms.tsx b/frontends/web/src/components/terms/bitsurance-terms.tsx index e5f7458aa6..a763ad81ab 100644 --- a/frontends/web/src/components/terms/bitsurance-terms.tsx +++ b/frontends/web/src/components/terms/bitsurance-terms.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { ChangeEvent } from 'react'; import { Button, Checkbox } from '@/components/forms'; -import { setConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { A } from '@/components/anchor/anchor'; import style from './terms.module.css'; import { i18n } from '@/i18n/i18n'; @@ -14,6 +14,7 @@ type TProps = { export const BitsuranceTerms = ({ onAgreedTerms }: TProps) => { const { t } = useTranslation(); + const { setConfig } = useConfig(); const handleSkipDisclaimer = (e: ChangeEvent) => { setConfig({ frontend: { skipBitsuranceDisclaimer: e.target.checked } }); }; diff --git a/frontends/web/src/components/terms/btcdirect-otc-terms.tsx b/frontends/web/src/components/terms/btcdirect-otc-terms.tsx index 9631ff4be3..ec014a8d2b 100644 --- a/frontends/web/src/components/terms/btcdirect-otc-terms.tsx +++ b/frontends/web/src/components/terms/btcdirect-otc-terms.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { ChangeEvent } from 'react'; import { Button, Checkbox } from '@/components/forms'; -import { setConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { i18n } from '@/i18n/i18n'; import { A } from '../anchor/anchor'; import style from './terms.module.css'; @@ -27,12 +27,12 @@ export const getBTCDirectPrivacyLink = () => { } }; -const handleSkipDisclaimer = (e: ChangeEvent) => { - setConfig({ frontend: { skipBTCDirectOTCDisclaimer: e.target.checked } }); -}; - export const BTCDirectOTCTerms = ({ onContinue }: TProps) => { const { t } = useTranslation(); + const { setConfig } = useConfig(); + const handleSkipDisclaimer = (e: ChangeEvent) => { + setConfig({ frontend: { skipBTCDirectOTCDisclaimer: e.target.checked } }); + }; return (
diff --git a/frontends/web/src/components/terms/btcdirect-terms.tsx b/frontends/web/src/components/terms/btcdirect-terms.tsx index e8c485e4b4..fcda15d5a9 100644 --- a/frontends/web/src/components/terms/btcdirect-terms.tsx +++ b/frontends/web/src/components/terms/btcdirect-terms.tsx @@ -4,7 +4,7 @@ import { ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { isBitcoinOnly } from '@/routes/account/utils'; import { Button, Checkbox } from '@/components/forms'; -import { setConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { TAccount } from '@/api/account'; import { A } from '@/components/anchor/anchor'; import { getBTCDirectAboutUsLink } from '@/routes/market/components/infocontent'; @@ -16,12 +16,12 @@ type TProps = { onAgreedTerms: () => void; }; -const handleSkipDisclaimer = (e: ChangeEvent) => { - setConfig({ frontend: { skipBTCDirectWidgetDisclaimer: e.target.checked } }); -}; - export const BTCDirectTerms = ({ account, onAgreedTerms }: TProps) => { const { t } = useTranslation(); + const { setConfig } = useConfig(); + const handleSkipDisclaimer = (e: ChangeEvent) => { + setConfig({ frontend: { skipBTCDirectWidgetDisclaimer: e.target.checked } }); + }; const isBitcoin = isBitcoinOnly(account.coinCode); diff --git a/frontends/web/src/components/terms/moonpay-terms.tsx b/frontends/web/src/components/terms/moonpay-terms.tsx index 76ab340740..a59fa5a222 100644 --- a/frontends/web/src/components/terms/moonpay-terms.tsx +++ b/frontends/web/src/components/terms/moonpay-terms.tsx @@ -4,7 +4,7 @@ import { ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { isBitcoinOnly } from '@/routes/account/utils'; import { Button, Checkbox } from '@/components/forms'; -import { setConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { TAccount } from '@/api/account'; import { Col, Colgroup, Table, Tbody, Td, Th, Thead, Tr } from '@/components/table/table'; import { A } from '@/components/anchor/anchor'; @@ -17,6 +17,7 @@ type TProps = { export const MoonpayTerms = ({ account, onAgreedTerms }: TProps) => { const { t } = useTranslation(); + const { setConfig } = useConfig(); const handleSkipDisclaimer = (e: ChangeEvent) => { setConfig({ frontend: { skipMoonpayDisclaimer: e.target.checked } }); diff --git a/frontends/web/src/components/terms/pocket-terms.tsx b/frontends/web/src/components/terms/pocket-terms.tsx index 418cc0251f..bbeac02238 100644 --- a/frontends/web/src/components/terms/pocket-terms.tsx +++ b/frontends/web/src/components/terms/pocket-terms.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { ChangeEvent } from 'react'; import { Button, Checkbox } from '@/components/forms'; -import { setConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { A } from '@/components/anchor/anchor'; import style from './terms.module.css'; import { SimpleMarkup } from '@/utils/markup'; @@ -14,6 +14,7 @@ type TProps = { export const PocketTerms = ({ onAgreedTerms }: TProps) => { const { t } = useTranslation(); + const { setConfig } = useConfig(); const handleSkipDisclaimer = (e: ChangeEvent) => { setConfig({ frontend: { skipPocketDisclaimer: e.target.checked } }); }; diff --git a/frontends/web/src/contexts/AppProvider.tsx b/frontends/web/src/contexts/AppProvider.tsx index 945048db20..1a9120ab19 100644 --- a/frontends/web/src/contexts/AppProvider.tsx +++ b/frontends/web/src/contexts/AppProvider.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { ReactNode, useEffect, useState } from 'react'; -import { getConfig, setConfig } from '@/utils/config'; +import { useConfig } from './ConfigProvider'; import { AppContext } from './AppContext'; import { useLoad } from '@/hooks/api'; import { useDefault } from '@/hooks/default'; @@ -19,6 +19,7 @@ type TProps = { }; export const AppProvider = ({ children }: TProps) => { + const { config, setConfig } = useConfig(); const nativeLocale = i18nextFormat(useDefault(useLoad(getNativeLocale), 'de-CH')); const isTesting = useDefault(useLoad(getTesting), false); const isOnline = useSync(getOnline, subscribeOnline); @@ -62,19 +63,18 @@ export const AppProvider = ({ children }: TProps) => { }, [activeSidebar, isMobile, orientation]); useEffect(() => { - getConfig().then(({ frontend }) => { - if (frontend) { - if (frontend.guideShown !== undefined) { - setGuideShown(frontend.guideShown); - } - if (frontend.hideAmounts !== undefined) { - setHideAmounts(frontend.hideAmounts); - } - } else { - setGuideShown(true); + const frontend = config?.frontend; + if (frontend && typeof frontend === 'object') { + if (frontend.guideShown !== undefined) { + setGuideShown(Boolean(frontend.guideShown)); } - }); - }, []); + if (frontend.hideAmounts !== undefined) { + setHideAmounts(Boolean(frontend.hideAmounts)); + } + } else { + setGuideShown(true); + } + }, [config]); return ( ) => Promise; +}; + +export const ConfigContext = createContext(undefined); diff --git a/frontends/web/src/contexts/ConfigProvider.tsx b/frontends/web/src/contexts/ConfigProvider.tsx new file mode 100644 index 0000000000..8843c04633 --- /dev/null +++ b/frontends/web/src/contexts/ConfigProvider.tsx @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { ReactNode, useCallback, useContext, useEffect, useState } from 'react'; +import { getConfig, setConfig as setConfigAPI } from '@/utils/config'; +import type { TConfig } from '@/api/config'; +import { ConfigContext, TConfigContext } from './ConfigContext'; + +type TProps = { + children: ReactNode; +}; + +export const ConfigProvider = ({ children }: TProps) => { + const [config, setConfigState] = useState(undefined); + + useEffect(() => { + getConfig().then(setConfigState).catch(console.error); + }, []); + + const setConfig = useCallback((object: Partial) => { + return setConfigAPI(object).then(nextConfig => { + setConfigState(nextConfig); + return nextConfig; + }); + }, []); + + const value: TConfigContext = { + config, + setConfig + }; + + return ( + + {children} + + ); +}; + +export const useConfig = (): TConfigContext => { + const context = useContext(ConfigContext); + if (!context) { + throw new Error('useConfig must be used within ConfigProvider'); + } + return context; +}; diff --git a/frontends/web/src/contexts/DarkmodeProvider.tsx b/frontends/web/src/contexts/DarkmodeProvider.tsx index e967b274e4..84fdebf10a 100644 --- a/frontends/web/src/contexts/DarkmodeProvider.tsx +++ b/frontends/web/src/contexts/DarkmodeProvider.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { useState, useEffect, ReactNode, useCallback } from 'react'; -import { getConfig, setConfig } from '@/utils/config'; +import { useConfig } from './ConfigProvider'; import { setDarkTheme, detectDarkTheme } from '@/api/darktheme'; import { runningInAndroid } from '@/utils/env'; import { useMediaQuery } from '@/hooks/mediaquery'; @@ -12,6 +12,7 @@ type TProps = { }; export const DarkModeProvider = ({ children }: TProps) => { + const { config, setConfig } = useConfig(); const [isDarkMode, setIsDarkMode] = useState(false); const androidPrefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); @@ -27,22 +28,19 @@ export const DarkModeProvider = ({ children }: TProps) => { }, [isDarkMode]); useEffect(() => { - getConfig() - .then(config => { - // use config if it exists - if (!!config.frontend && 'darkmode' in config.frontend) { - setIsDarkMode(config.frontend.darkmode); - return; - } - // else use mode from OS - if (runningInAndroid()) { - setIsDarkMode(androidPrefersDarkMode); - } else { - detectDarkTheme().then(setIsDarkMode); - } - }) - .catch(console.error); - }, [androidPrefersDarkMode]); + if (config === undefined) { + return; + } + if (config.frontend.darkmode !== undefined) { + setIsDarkMode(config.frontend.darkmode); + return; + } + if (runningInAndroid()) { + setIsDarkMode(androidPrefersDarkMode); + } else { + detectDarkTheme().then(setIsDarkMode); + } + }, [androidPrefersDarkMode, config]); useEffect(() => { setAppTheme(); @@ -50,33 +48,34 @@ export const DarkModeProvider = ({ children }: TProps) => { const toggleDarkmode = (darkmode: boolean) => { setIsDarkMode(darkmode); - getConfig() - .then(async config => { - let preferredDarkMode; - if (runningInAndroid()) { - preferredDarkMode = androidPrefersDarkMode; - } else { - preferredDarkMode = await detectDarkTheme(); - } - if (preferredDarkMode === darkmode) { - // remove darkmode from config, so it use the same mode as the OS - const { darkmode, ...frontend } = config.frontend; - setConfig({ - frontend: { - ...frontend, - darkmode: undefined, - }, - }); - } else { - // darkmode is different from OS, save to config - setConfig({ - frontend: { - ...config.frontend, - darkmode, - } - }); - } - }); + if (!config) { + return; + } + (async () => { + let preferredDarkMode; + if (runningInAndroid()) { + preferredDarkMode = androidPrefersDarkMode; + } else { + preferredDarkMode = await detectDarkTheme(); + } + if (preferredDarkMode === darkmode) { + // Remove darkmode from config, so it uses the same mode as the OS. + const { darkmode: _, ...frontend } = config.frontend; + setConfig({ + frontend: { + ...frontend, + darkmode: undefined, + }, + }); + } else { + setConfig({ + frontend: { + ...config.frontend, + darkmode, + } + }); + } + })(); }; return ( diff --git a/frontends/web/src/contexts/RatesProvider.tsx b/frontends/web/src/contexts/RatesProvider.tsx index 2a613c6fb0..d461409d6a 100644 --- a/frontends/web/src/contexts/RatesProvider.tsx +++ b/frontends/web/src/contexts/RatesProvider.tsx @@ -1,10 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 -import { ReactNode, useEffect, useState } from 'react'; +import { ReactNode, useCallback, useEffect, useState } from 'react'; import { RatesContext } from './RatesContext'; import { Fiat } from '@/api/account'; import { BtcUnit, setBtcUnit as setBackendBtcUnit } from '@/api/coins'; -import { getConfig, setConfig } from '@/utils/config'; +import { useConfig } from './ConfigProvider'; import { reinitializeAccounts } from '@/api/backend'; import { equal } from '@/utils/equal'; @@ -13,26 +13,28 @@ type TProps = { }; export const RatesProvider = ({ children }: TProps) => { + const { config, setConfig } = useConfig(); const [defaultCurrency, setDefaultCurrency] = useState('USD'); const [activeCurrencies, setActiveCurrencies] = useState(['USD', 'EUR', 'CHF']); const [btcUnit, setBtcUnit] = useState('default'); - useEffect(() => { - updateRatesConfig(); - }, []); - - const updateRatesConfig = async () => { - const appConf = await getConfig(); - - if (appConf.backend?.mainFiat) { - setDefaultCurrency(appConf.backend.mainFiat); + const updateRatesConfig = useCallback((): Promise => { + if (config === undefined) { + return Promise.resolve(); } - - if (appConf.backend?.fiatList && appConf.backend?.btcUnit) { - setActiveCurrencies(appConf.backend.fiatList); - setBtcUnit(appConf.backend.btcUnit); + if (config.backend?.mainFiat) { + setDefaultCurrency(config.backend.mainFiat as Fiat); } - }; + if (config.backend?.fiatList && config.backend?.btcUnit) { + setActiveCurrencies(config.backend.fiatList as Fiat[]); + setBtcUnit(config.backend.btcUnit as BtcUnit); + } + return Promise.resolve(); + }, [config]); + + useEffect(() => { + updateRatesConfig(); + }, [updateRatesConfig]); const rotateDefaultCurrency = async () => { const index = activeCurrencies.indexOf(defaultCurrency); diff --git a/frontends/web/src/contexts/WCWeb3WalletProvider.tsx b/frontends/web/src/contexts/WCWeb3WalletProvider.tsx index 7da53e69c7..78ef04030b 100644 --- a/frontends/web/src/contexts/WCWeb3WalletProvider.tsx +++ b/frontends/web/src/contexts/WCWeb3WalletProvider.tsx @@ -5,19 +5,18 @@ import { useTranslation } from 'react-i18next'; import { WCWeb3WalletContext } from './WCWeb3WalletContext'; import { IWalletKit } from '@reown/walletkit'; import { getTopicFromURI, pairingHasEverBeenRejected } from '@/utils/walletconnect'; -import { useLoad } from '@/hooks/api'; -import { getConfig, setConfig } from '@/utils/config'; +import { useConfig } from './ConfigProvider'; type TProps = { children: ReactNode; }; export const WCWeb3WalletProvider = ({ children }: TProps) => { + const { config, setConfig } = useConfig(); const { t } = useTranslation(); const [web3wallet, setWeb3wallet] = useState(); const [isWalletInitialized, setIsWalletInitialized] = useState(false); - const config = useLoad(getConfig); - const hasUsedWC = config && config.frontend && config.frontend.hasUsedWalletConnect; + const hasUsedWC = config?.frontend?.hasUsedWalletConnect; const initializeWeb3Wallet = async () => { try { diff --git a/frontends/web/src/contexts/providers.tsx b/frontends/web/src/contexts/providers.tsx index d2e453076a..a1aa04f850 100644 --- a/frontends/web/src/contexts/providers.tsx +++ b/frontends/web/src/contexts/providers.tsx @@ -8,6 +8,7 @@ import { BackNavigationProvider } from './BackNavigationContext'; import { WCWeb3WalletProvider } from './WCWeb3WalletProvider'; import { RatesProvider } from './RatesProvider'; import { LocalizationProvider } from './localization-provider'; +import { ConfigProvider } from './ConfigProvider'; type Props = { children: ReactNode; @@ -15,20 +16,22 @@ type Props = { export const Providers = ({ children }: Props) => { return ( - - - - - - - - {children} - - - - - - - + + + + + + + + + {children} + + + + + + + + ); }; diff --git a/frontends/web/src/hooks/bitsurance.ts b/frontends/web/src/hooks/bitsurance.ts index 70e0e96334..d0e02b604d 100644 --- a/frontends/web/src/hooks/bitsurance.ts +++ b/frontends/web/src/hooks/bitsurance.ts @@ -5,7 +5,8 @@ import { useTranslation } from 'react-i18next'; import * as accountApi from '@/api/account'; import { bitsuranceLookup } from '@/api/bitsurance'; import { alertUser } from '@/components/alert/Alert'; -import { getConfig, setConfig } from '@/utils/config'; +import { getConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { getScriptName } from '@/routes/account/utils'; /** @@ -45,6 +46,7 @@ export const useBitsurance = ( account?: accountApi.TAccount, ) => { const { t } = useTranslation(); + const { setConfig } = useConfig(); const [insured, setInsured] = useState(false); const [uncoveredFunds, setUncoveredFunds] = useState([]); @@ -74,15 +76,15 @@ export const useBitsurance = ( return; } - // we fetch the config after the lookup as it could have changed. - const config = await getConfig(); - let cancelledAccounts: string[] = config.frontend.bitsuranceNotifyCancellation; + // We fetch the config after the lookup as it could have changed. + const freshConfig = await getConfig(); + let cancelledAccounts: string[] = (freshConfig.frontend?.bitsuranceNotifyCancellation as string[] | undefined) ?? []; if (cancelledAccounts?.includes(code)) { alertUser(t('account.insuranceExpired')); - // remove the pending notification from the frontend settings. - config.frontend.bitsuranceNotifyCancellation = cancelledAccounts.filter(accountCode => accountCode !== code); - setConfig(config); + const filtered = cancelledAccounts.filter(accountCode => accountCode !== code); + // Remove the pending notification from the frontend settings. + setConfig({ frontend: { bitsuranceNotifyCancellation: filtered } }); } const bitsuranceAccount = insuredAccounts.bitsuranceAccounts[0]; @@ -93,7 +95,7 @@ export const useBitsurance = ( } setInsured(false); - }, [t, account, code, checkUncoveredUTXOs]); + }, [t, account, code, checkUncoveredUTXOs, setConfig]); useEffect(() => { maybeCheckBitsuranceStatus(); diff --git a/frontends/web/src/i18n/config.ts b/frontends/web/src/i18n/config.ts index a1892f8e5a..24ef931230 100644 --- a/frontends/web/src/i18n/config.ts +++ b/frontends/web/src/i18n/config.ts @@ -13,7 +13,7 @@ export const languageFromConfig: LanguageDetectorAsyncModule = { detect: (cb) => { getConfig().then(({ backend }) => { if (backend && backend.userLanguage) { - cb(backend.userLanguage); + cb(backend.userLanguage as string); return; } getNativeLocale().then(locale => { diff --git a/frontends/web/src/routes/account/send/coin-control.tsx b/frontends/web/src/routes/account/send/coin-control.tsx index f9fe840ccc..efc5df1774 100644 --- a/frontends/web/src/routes/account/send/coin-control.tsx +++ b/frontends/web/src/routes/account/send/coin-control.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { TAccount } from '@/api/account'; -import { getConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { Button } from '@/components/forms'; import { TSelectedUTXOs, UTXOs } from './utxos'; import { isBitcoinBased } from '../utils'; @@ -21,17 +21,16 @@ export const CoinControl = ({ onCoinControlDialogActiveChange, }: TProps) => { const { t } = useTranslation(); + const { config } = useConfig(); const [coinControlEnabled, setCoinControlEnabled] = useState(false); const [showUTXODialog, setShowUTXODialog] = useState(false); useEffect(() => { - if (isBitcoinBased(account.coinCode)) { - getConfig().then(config => { - setCoinControlEnabled(!!(config.frontend || {}).coinControl); - }); + if (isBitcoinBased(account.coinCode) && config !== undefined) { + setCoinControlEnabled(!!(config.frontend || {}).coinControl); } - }, [account.coinCode]); + }, [account.coinCode, config]); // Notify parent whenever dialog visibility changes useEffect(() => { diff --git a/frontends/web/src/routes/account/send/feetargets.test.tsx b/frontends/web/src/routes/account/send/feetargets.test.tsx index 5c64f8604d..0fe1485363 100644 --- a/frontends/web/src/routes/account/send/feetargets.test.tsx +++ b/frontends/web/src/routes/account/send/feetargets.test.tsx @@ -10,15 +10,21 @@ vi.mock('@/i18n/i18n'); vi.mock('@/utils/env', () => ({ runningInIOS: vi.fn(() => false), })); +vi.mock('@/contexts/ConfigProvider', () => ({ + useConfig: vi.fn(() => ({ + config: { frontend: { expertFee: false } }, + setConfig: vi.fn(), + })), +})); import { act, fireEvent, render, waitFor } from '@testing-library/react'; import { FeeTargets } from './feetargets'; import { apiGet } from '@/utils/request'; import { runningInIOS } from '@/utils/env'; +import { useConfig } from '@/contexts/ConfigProvider'; -import * as utilsConfig from '@/utils/config'; -const getConfig = vi.spyOn(utilsConfig, 'getConfig'); const mockRunningInIOS = vi.mocked(runningInIOS); +const mockUseConfig = vi.mocked(useConfig); vi.mock('@/hooks/mediaquery', () => ({ useMediaQuery: vi.fn().mockReturnValue(true), @@ -29,10 +35,13 @@ describe('routes/account/send/feetargets', () => { beforeEach(() => { vi.clearAllMocks(); mockRunningInIOS.mockReturnValue(false); + mockUseConfig.mockReturnValue({ + config: { frontend: { expertFee: false } }, + setConfig: vi.fn(), + }); }); it('should call onFeeTargetChange with default', () => new Promise(async done => { - getConfig.mockReturnValue(Promise.resolve({ frontend: { expertFee: false } })); const apiGetMock = (apiGet as Mock).mockResolvedValue({ defaultFeeTarget: 'normal', feeTargets: [ @@ -60,7 +69,10 @@ describe('routes/account/send/feetargets', () => { it('normalizes custom fee values from iOS decimal input', async () => { mockRunningInIOS.mockReturnValue(true); - getConfig.mockReturnValue(Promise.resolve({ frontend: { expertFee: true } })); + mockUseConfig.mockReturnValue({ + config: { frontend: { expertFee: true } }, + setConfig: vi.fn(), + }); const apiGetMock = (apiGet as Mock).mockResolvedValue({ defaultFeeTarget: 'custom', feeTargets: [], diff --git a/frontends/web/src/routes/account/send/feetargets.tsx b/frontends/web/src/routes/account/send/feetargets.tsx index 4114183b7c..862f582021 100644 --- a/frontends/web/src/routes/account/send/feetargets.tsx +++ b/frontends/web/src/routes/account/send/feetargets.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; import { RatesContext } from '@/contexts/RatesContext'; import { useLoad } from '@/hooks/api'; import * as accountApi from '@/api/account'; -import { getConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { Input, NumberInput } from '@/components/forms'; import { Message } from '@/components/message/message'; import { customFeeUnit, getCoinCode, isEthereumBased } from '@/routes/account/utils'; @@ -42,8 +42,8 @@ export const FeeTargets = ({ error }: Props) => { const { t } = useTranslation(); + const { config } = useConfig(); const { defaultCurrency } = useContext(RatesContext); - const config = useLoad(getConfig); const [feeTarget, setFeeTarget] = useState(); const [options, setOptions] = useState(null); const [noFeeTargets, setNoFeeTargets] = useState(false); @@ -63,7 +63,7 @@ export const FeeTargets = ({ if (!config || !feeTargets) { return; } - const withCustomFee = config.frontend.expertFee || feeTargets.feeTargets.length === 0; + const withCustomFee = config.frontend?.expertFee || feeTargets.feeTargets.length === 0; const options = feeTargets.feeTargets.map(({ code, feeRateInfo }) => ({ value: code, label: t(`send.feeTarget.label.${code}`) + (withCustomFee && feeRateInfo ? ` (${feeRateInfo})` : ''), @@ -126,7 +126,7 @@ export const FeeTargets = ({ } const feetargetInfo = feeTargets?.feeTargets.find(({ code }) => code === option.value); - const withCustomFee = config.frontend.expertFee || feeTargets?.feeTargets.length === 0; + const withCustomFee = config?.frontend?.expertFee || feeTargets?.feeTargets.length === 0; if (withCustomFee && feetargetInfo) { return ( <> diff --git a/frontends/web/src/routes/bitsurance/widget.tsx b/frontends/web/src/routes/bitsurance/widget.tsx index f16c616577..f66c0ca433 100644 --- a/frontends/web/src/routes/bitsurance/widget.tsx +++ b/frontends/web/src/routes/bitsurance/widget.tsx @@ -4,7 +4,7 @@ import { useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { RequestAddressV0Message, MessageVersion, parseMessage, serializeMessage, V0MessageType } from 'request-address'; -import { getConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { ScriptType, signBTCMessageUnusedAddress } from '@/api/account'; import { getInfo } from '@/api/account'; import { Header } from '@/components/layout'; @@ -26,13 +26,13 @@ type TProps = { export const BitsuranceWidget = ({ code }: TProps) => { const navigate = useNavigate(); const { t } = useTranslation(); + const { config } = useConfig(); const iframeURL = useLoad(getBitsuranceURL); - const config = useLoad(getConfig); const accountInfo = useLoad(getInfo(code)); const { containerRef, height, iframeLoaded, iframeRef, onIframeLoad } = useVendorIframeResizeHeight(); - const { agreedTerms, setAgreedTerms } = useVendorTerms(!!config?.frontend?.skipBitsuranceDisclaimer); + const { agreedTerms, setAgreedTerms } = useVendorTerms(Boolean((config?.frontend as { skipBitsuranceDisclaimer?: unknown } | undefined)?.skipBitsuranceDisclaimer)); const signingRef = useRef(false); useEffect(() => { diff --git a/frontends/web/src/routes/market/bitrefill.tsx b/frontends/web/src/routes/market/bitrefill.tsx index 48cc26a64b..bdd3db888c 100644 --- a/frontends/web/src/routes/market/bitrefill.tsx +++ b/frontends/web/src/routes/market/bitrefill.tsx @@ -9,7 +9,7 @@ import { MarketGuide } from './guide'; import { AccountCode, TAccount, proposeTx, sendTx, TTxInput, TTxProposalResult } from '@/api/account'; import { findAccount, isBitcoinOnly } from '@/routes/account/utils'; import { useDarkmode } from '@/hooks/darkmode'; -import { getConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { i18n } from '@/i18n/i18n'; import { alertUser } from '@/components/alert/Alert'; import { parseExternalBtcAmount } from '@/api/coins'; @@ -45,17 +45,15 @@ export const Bitrefill = ({ region, }: TProps) => { const { t } = useTranslation(); + const { config } = useConfig(); const { isDarkMode } = useDarkmode(); const { isDevServers } = useContext(AppContext); const account = findAccount(accounts, code); const fetchBitrefillInfo = useCallback(() => getBitrefillInfo('spend', code), [code]); const bitrefillInfo = useAccountSynced(code, fetchBitrefillInfo); - - - const config = useLoad(getConfig); const { containerRef, height, iframeLoaded, iframeRef, onIframeLoad } = useVendorIframeResizeHeight(); - const { agreedTerms, setAgreedTerms } = useVendorTerms(!!config?.frontend?.skipBitrefillWidgetDisclaimer); + const { agreedTerms, setAgreedTerms } = useVendorTerms(Boolean((config?.frontend as { skipBitrefillWidgetDisclaimer?: unknown } | undefined)?.skipBitrefillWidgetDisclaimer)); const [pendingPayment, setPendingPayment] = useState(false); const [verifyPaymentRequest, setVerifyPaymentRequest] = useState(false); diff --git a/frontends/web/src/routes/market/btcdirect.tsx b/frontends/web/src/routes/market/btcdirect.tsx index 17faa42508..db8467ccb3 100644 --- a/frontends/web/src/routes/market/btcdirect.tsx +++ b/frontends/web/src/routes/market/btcdirect.tsx @@ -11,7 +11,7 @@ import { useLoad } from '@/hooks/api'; import { useAccountSynced } from '@/hooks/account'; import { useDarkmode } from '@/hooks/darkmode'; import { UseDisableBackButton } from '@/hooks/backbutton'; -import { getConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { getURLOrigin } from '@/utils/url'; import { Header } from '@/components/layout'; import { MobileHeader } from '../settings/components/mobile-header'; @@ -45,6 +45,7 @@ export const BTCDirect = ({ code, }: TProps) => { const { i18n, t } = useTranslation(); + const { config } = useConfig(); const { isDevServers } = useContext(AppContext); const { isDarkMode } = useDarkmode(); const navigate = useNavigate(); @@ -54,11 +55,9 @@ export const BTCDirect = ({ const [blocking, setBlocking] = useState(false); - const config = useLoad(getConfig); - const account = findAccount(accounts, code); const { containerRef, height, iframeLoaded, iframeRef, onIframeLoad } = useVendorIframeResizeHeight(); - const { agreedTerms, setAgreedTerms } = useVendorTerms(!!config?.frontend?.skipBTCDirectWidgetDisclaimer); + const { agreedTerms, setAgreedTerms } = useVendorTerms(Boolean((config?.frontend as { skipBTCDirectWidgetDisclaimer?: unknown } | undefined)?.skipBTCDirectWidgetDisclaimer)); const handlePaymentRequest = useCallback(async (event: MessageEvent) => { const { diff --git a/frontends/web/src/routes/market/market.tsx b/frontends/web/src/routes/market/market.tsx index 9153eb3647..0abc67d14f 100644 --- a/frontends/web/src/routes/market/market.tsx +++ b/frontends/web/src/routes/market/market.tsx @@ -24,7 +24,7 @@ import { InfoButton } from '@/components/infobutton/infobutton'; import { MarketTab } from './components/markettab'; import { Deals } from './components/deals'; import { getNativeLocale } from '@/api/nativelocale'; -import { getConfig, setConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { CountrySelect, TOption } from './components/countryselect'; import { getBTCDirectOTCLink, getPocketOTCLink, InfoContent, TInfoContentProps } from './components/infocontent'; import { GroupedAccountSelector } from '@/components/groupedaccountselector/groupedaccountselector'; @@ -42,6 +42,7 @@ export const Market = ({ code, }: TProps) => { const { t } = useTranslation(); + const { config, setConfig } = useConfig(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); @@ -54,7 +55,6 @@ export const Market = ({ const regionCodes = useLoad(marketAPI.getMarketRegionCodes); const nativeLocale = useLoad(getNativeLocale); - const config = useLoad(getConfig); const swapStatus = useLoad(getSwapStatus, [accounts]); const hasOnlyBTCAccounts = accounts.every(({ coinCode }) => isBitcoinOnly(coinCode)); @@ -93,13 +93,13 @@ export const Market = ({ setRegions(regions); // if user had selected no region before, do not pre-select any. - if (config.frontend.selectedExchangeRegion === '') { + if (config.frontend?.selectedExchangeRegion === '') { return; } - if (config.frontend.selectedExchangeRegion) { + if (config.frontend?.selectedExchangeRegion) { // pre-select config region - setSelectedRegion(config.frontend.selectedExchangeRegion); + setSelectedRegion(String(config.frontend.selectedExchangeRegion)); return; } diff --git a/frontends/web/src/routes/market/moonpay.tsx b/frontends/web/src/routes/market/moonpay.tsx index 330d4d5f1c..46da9b2f15 100644 --- a/frontends/web/src/routes/market/moonpay.tsx +++ b/frontends/web/src/routes/market/moonpay.tsx @@ -5,7 +5,7 @@ import { useLoad } from '@/hooks/api'; import { useDarkmode } from '@/hooks/darkmode'; import { UseDisableBackButton } from '@/hooks/backbutton'; import { AccountCode, TAccount } from '@/api/account'; -import { getConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { getMoonpayBuyInfo } from '@/api/market'; import { MarketGuide } from './guide'; import { Header } from '@/components/layout'; @@ -24,14 +24,14 @@ type TProps = { export const Moonpay = ({ accounts, code }: TProps) => { const { t } = useTranslation(); + const { config } = useConfig(); const { isDarkMode } = useDarkmode(); - const config = useLoad(getConfig); const moonpay = useLoad(getMoonpayBuyInfo(code)); const account = findAccount(accounts, code); const { containerRef, height, iframeLoaded, onIframeLoad } = useVendorIframeResizeHeight(); - const { agreedTerms, setAgreedTerms } = useVendorTerms(!!config?.frontend?.skipMoonpayDisclaimer); + const { agreedTerms, setAgreedTerms } = useVendorTerms(Boolean((config?.frontend as { skipMoonpayDisclaimer?: unknown } | undefined)?.skipMoonpayDisclaimer)); if (!account || !config) { return null; diff --git a/frontends/web/src/routes/market/pocket.tsx b/frontends/web/src/routes/market/pocket.tsx index 4c64140512..a169ffaa50 100644 --- a/frontends/web/src/routes/market/pocket.tsx +++ b/frontends/web/src/routes/market/pocket.tsx @@ -4,7 +4,7 @@ import { useEffect, useState, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { RequestAddressV0Message, MessageVersion, parseMessage, serializeMessage, V0MessageType, PaymentRequestV0Message } from 'request-address'; -import { getConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { Dialog } from '@/components/dialog/dialog'; import { confirmation } from '@/components/confirm/Confirm'; import { verifyAddress, getPocketURL, TMarketAction } from '@/api/market'; @@ -36,6 +36,7 @@ export const Pocket = ({ code, }: TProps) => { const { t } = useTranslation(); + const { config } = useConfig(); const navigate = useNavigate(); // Pocket sell only works if the FW supports payment requests @@ -45,11 +46,10 @@ export const Pocket = ({ const [blocking, setBlocking] = useState(false); const [verifying, setVerifying] = useState(false); - const config = useLoad(getConfig); const accountInfo = useLoad(getInfo(code)); const { containerRef, height, iframeLoaded, iframeRef, onIframeLoad } = useVendorIframeResizeHeight(); - const { agreedTerms, setAgreedTerms } = useVendorTerms(!!config?.frontend?.skipPocketDisclaimer); + const { agreedTerms, setAgreedTerms } = useVendorTerms(Boolean((config?.frontend as { skipPocketDisclaimer?: unknown } | undefined)?.skipPocketDisclaimer)); const signingRef = useRef(false); const pocketInfo = useAccountSynced(code, useCallback(() => getPocketURL(action), [action])); diff --git a/frontends/web/src/routes/settings/advanced-settings.tsx b/frontends/web/src/routes/settings/advanced-settings.tsx index 2482cf13bd..24d9e2839b 100644 --- a/frontends/web/src/routes/settings/advanced-settings.tsx +++ b/frontends/web/src/routes/settings/advanced-settings.tsx @@ -1,8 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useLoad } from '@/hooks/api'; import { Main, Header, GuideWrapper, GuidedContent } from '@/components/layout'; import { View, ViewContent } from '@/components/view/view'; import { WithSettingsTabs } from './components/tabs'; @@ -15,7 +13,7 @@ import { UnlockSoftwareKeystore } from './components/advanced-settings/unlock-so import { RestartInTestnetSetting } from './components/advanced-settings/restart-in-testnet-setting'; import { ExportLogSetting } from './components/advanced-settings/export-log-setting'; import { CustomGapLimitSettings } from './components/advanced-settings/custom-gap-limit-setting'; -import { getConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { MobileHeader } from './components/mobile-header'; import { Guide } from '@/components/guide/guide'; import { Entry } from '@/components/guide/entry'; @@ -48,16 +46,11 @@ export type TConfig = { export const AdvancedSettings = ({ devices, hasAccounts }: TPagePropsWithSettingsTabs) => { const { t } = useTranslation(); - const fetchedConfig = useLoad(getConfig) as TConfig; - const [config, setConfig] = useState(); + const { config, setConfig } = useConfig(); - const frontendConfig = config?.frontend; - const backendConfig = config?.backend; - const proxyConfig = config?.backend?.proxy; - - useEffect(() => { - setConfig(fetchedConfig); - }, [fetchedConfig]); + const frontendConfig = config?.frontend as TFrontendConfig | undefined; + const backendConfig = config?.backend as TBackendConfig | undefined; + const proxyConfig = config?.backend?.proxy as TProxyConfig | undefined; const deviceIDs = Object.keys(devices); diff --git a/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx index 824f29f544..0538c3fbaf 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx @@ -5,18 +5,20 @@ import { useTranslation } from 'react-i18next'; import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; import { Dialog, DialogButtons } from '@/components/dialog/dialog'; import { Button, Input } from '@/components/forms'; -import { setConfig } from '@/utils/config'; -import type { TBackendConfig, TConfig } from '@/routes/settings/advanced-settings'; +import { useConfig } from '@/contexts/ConfigProvider'; +import type { TConfig as ApiTConfig } from '@/api/config'; +import type { TBackendConfig } from '@/routes/settings/advanced-settings'; import { Message } from '@/components/message/message'; import { useMediaQuery } from '@/hooks/mediaquery'; type TProps = { backendConfig?: TBackendConfig; - onChangeConfig: (config: TConfig) => void; + onChangeConfig: (config: ApiTConfig) => void; }; export const CustomGapLimitSettings = ({ backendConfig, onChangeConfig }: TProps) => { const { t } = useTranslation(); + const { setConfig } = useConfig(); const [showDialog, setShowDialog] = useState(false); const isMobile = useMediaQuery('(max-width: 768px)'); diff --git a/frontends/web/src/routes/settings/components/advanced-settings/enable-auth-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/enable-auth-setting.tsx index 634f264184..147488bf0d 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/enable-auth-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/enable-auth-setting.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; import { Toggle } from '@/components/toggle/toggle'; import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; import { TBackendConfig, TConfig } from '@/routes/settings/advanced-settings'; -import { setConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { onAuthSettingChanged, TAuthEventObject, subscribeAuth, forceAuth } from '@/api/backend'; import { runningInAndroid, runningInIOS } from '@/utils/env'; @@ -16,6 +16,7 @@ type TProps = { export const EnableAuthSetting = ({ backendConfig, onChangeConfig }: TProps) => { const { t } = useTranslation(); + const { setConfig } = useConfig(); const handleToggleAuth = async (e: ChangeEvent) => { // Before updating the config we need the user to authenticate. diff --git a/frontends/web/src/routes/settings/components/advanced-settings/enable-coin-control-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/enable-coin-control-setting.tsx index 4936ed5659..661897b52e 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/enable-coin-control-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/enable-coin-control-setting.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; import { Toggle } from '@/components/toggle/toggle'; import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; import { TConfig, TFrontendConfig } from '@/routes/settings/advanced-settings'; -import { setConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; type TProps = { frontendConfig?: TFrontendConfig; @@ -14,6 +14,7 @@ type TProps = { export const EnableCoinControlSetting = ({ frontendConfig, onChangeConfig }: TProps) => { const { t } = useTranslation(); + const { setConfig } = useConfig(); const handleToggleFee = async (e: ChangeEvent) => { const config = await setConfig({ diff --git a/frontends/web/src/routes/settings/components/advanced-settings/enable-custom-fees-toggle-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/enable-custom-fees-toggle-setting.tsx index 4ac519ce7e..63ff79f525 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/enable-custom-fees-toggle-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/enable-custom-fees-toggle-setting.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; import { Toggle } from '@/components/toggle/toggle'; import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; import { TConfig, TFrontendConfig } from '@/routes/settings/advanced-settings'; -import { setConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; type TProps = { frontendConfig?: TFrontendConfig; @@ -14,6 +14,7 @@ type TProps = { export const EnableCustomFeesToggleSetting = ({ frontendConfig, onChangeConfig }: TProps) => { const { t } = useTranslation(); + const { setConfig } = useConfig(); const handleToggleFee = async (e: ChangeEvent) => { const config = await setConfig({ diff --git a/frontends/web/src/routes/settings/components/advanced-settings/restart-in-testnet-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/restart-in-testnet-setting.tsx index 36c691d711..32ac964cad 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/restart-in-testnet-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/restart-in-testnet-setting.tsx @@ -2,20 +2,21 @@ import { Dispatch, useContext, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import type { TConfig } from '@/routes/settings/advanced-settings'; +import type { TConfig as ApiTConfig } from '@/api/config'; import { AppContext } from '@/contexts/AppContext'; +import { useConfig } from '@/contexts/ConfigProvider'; import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; import { View, ViewButtons, ViewHeader } from '@/components/view/view'; import { Button } from '@/components/forms'; -import { setConfig } from '@/utils/config'; import { UseBackButton } from '@/hooks/backbutton'; type TProps = { - onChangeConfig: Dispatch; + onChangeConfig: Dispatch; }; export const RestartInTestnetSetting = ({ onChangeConfig }: TProps) => { const { t } = useTranslation(); + const { setConfig } = useConfig(); const [showRestartMessage, setShowRestartMessage] = useState(false); const { isTesting } = useContext(AppContext); diff --git a/frontends/web/src/routes/settings/components/advanced-settings/tor-proxy-dialog.tsx b/frontends/web/src/routes/settings/components/advanced-settings/tor-proxy-dialog.tsx index 97720d7739..ff35e116de 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/tor-proxy-dialog.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/tor-proxy-dialog.tsx @@ -6,7 +6,7 @@ import { useMediaQuery } from '@/hooks/mediaquery'; import { Dialog, DialogButtons } from '@/components/dialog/dialog'; import { Toggle } from '@/components/toggle/toggle'; import { Button, Input } from '@/components/forms'; -import { setConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { socksProxyCheck } from '@/api/backend'; import { alertUser } from '@/components/alert/Alert'; import { TConfig, TProxyConfig } from '@/routes/settings/advanced-settings'; @@ -20,6 +20,7 @@ type TProps = { }; export const TorProxyDialog = ({ open, proxyConfig, onCloseDialog, onChangeConfig, handleShowRestartMessage }: TProps) => { + const { setConfig } = useConfig(); const [proxyAddress, setProxyAddress] = useState(); const { t } = useTranslation(); const isMobile = useMediaQuery('(max-width: 768px)'); diff --git a/frontends/web/src/routes/settings/components/manage-accounts/dialogs/enableRememberWalletDialog.tsx b/frontends/web/src/routes/settings/components/manage-accounts/dialogs/enableRememberWalletDialog.tsx index 39dac89a84..7b27009ee2 100644 --- a/frontends/web/src/routes/settings/components/manage-accounts/dialogs/enableRememberWalletDialog.tsx +++ b/frontends/web/src/routes/settings/components/manage-accounts/dialogs/enableRememberWalletDialog.tsx @@ -4,8 +4,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Dialog, DialogButtons } from '@/components/dialog/dialog'; import { Button, Checkbox } from '@/components/forms'; -import { useLoad } from '@/hooks/api'; -import { getConfig, setConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; type Props = { open: boolean; @@ -13,13 +12,13 @@ type Props = { }; export const EnableRememberWalletDialog = ({ open, onClose }: Props) => { const { t } = useTranslation(); - const config = useLoad(getConfig); + const { config, setConfig } = useConfig(); const [checked, setChecked] = useState(false); const [shouldNotShowDialog, setShouldNotShowDialog] = useState(false); useEffect(() => { - if (config && config.frontend) { - setShouldNotShowDialog(config.frontend.hideEnableRememberWalletDialog); + if (config !== undefined && config.frontend) { + setShouldNotShowDialog(!!config.frontend.hideEnableRememberWalletDialog); } }, [config]); diff --git a/frontends/web/src/routes/settings/components/manage-accounts/watchonlySetting.tsx b/frontends/web/src/routes/settings/components/manage-accounts/watchonlySetting.tsx index 6d0d4b82dd..6c695c43d5 100644 --- a/frontends/web/src/routes/settings/components/manage-accounts/watchonlySetting.tsx +++ b/frontends/web/src/routes/settings/components/manage-accounts/watchonlySetting.tsx @@ -5,8 +5,7 @@ import { useTranslation } from 'react-i18next'; import { Toggle } from '@/components/toggle/toggle'; import * as backendAPI from '@/api/backend'; import * as accountAPI from '@/api/account'; -import { useLoad } from '@/hooks/api'; -import { getConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { Label } from '@/components/forms'; import { EnableRememberWalletDialog } from '@/routes/settings/components/manage-accounts/dialogs/enableRememberWalletDialog'; import { DisableRememberWalletDialog } from '@/routes/settings/components/manage-accounts/dialogs/disableRememberWalletDialog'; @@ -18,14 +17,14 @@ type Props = { export const WatchonlySetting = ({ keystore }: Props) => { const { t } = useTranslation(); + const { config } = useConfig(); const [disabled, setDisabled] = useState(false); const [watchonly, setWatchonly] = useState(); const [warningDialogOpen, setWarningDialogOpen] = useState(false); const [walletRememberedDialogOpen, setWalletRememberedDialogOpen] = useState(false); - const config = useLoad(getConfig); useEffect(() => { - if (config) { + if (config !== undefined) { setWatchonly(keystore.watchonly); } }, [config, keystore]); diff --git a/frontends/web/src/routes/settings/electrum-servers.tsx b/frontends/web/src/routes/settings/electrum-servers.tsx index f3f70d98e2..d3cc27bd2c 100644 --- a/frontends/web/src/routes/settings/electrum-servers.tsx +++ b/frontends/web/src/routes/settings/electrum-servers.tsx @@ -1,12 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 -import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { TElectrumServer } from '@/api/node'; import { ElectrumAddServer } from './electrum-add-server'; import { ElectrumServer } from './electrum-server'; import { getDefaultConfig } from '@/api/backend'; -import { getConfig, setConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { confirmation } from '@/components/confirm/Confirm'; import { Button } from '@/components/forms'; import style from './electrum.module.css'; @@ -19,21 +18,23 @@ export const ElectrumServers = ({ coin }: Props) => { const { t } = useTranslation(); - const [config, setConfigState] = useState(); - const loadConfig = () => { - getConfig().then(setConfigState); - }; - useEffect(loadConfig, []); + const { config, setConfig } = useConfig(); + if (config === undefined) { return null; } - const electrumServers: TElectrumServer[] = config.backend[coin].electrumServers; + const backendCoin = config.backend?.[coin] as { electrumServers?: TElectrumServer[] } | undefined; + const electrumServers: TElectrumServer[] = backendCoin?.electrumServers ?? []; const save = async (newElectrumServers: TElectrumServer[]) => { - const currentConfig = await getConfig(); - currentConfig.backend[coin].electrumServers = newElectrumServers; - await setConfig(currentConfig); - setConfigState(currentConfig); + await setConfig({ + backend: { + [coin]: { + ...(backendCoin && typeof backendCoin === 'object' ? backendCoin : {}), + electrumServers: newElectrumServers + } + } + }); }; const onAdd = (server: TElectrumServer) => { diff --git a/frontends/web/src/utils/config.ts b/frontends/web/src/utils/config.ts index 71e2cb2afc..b00a10f4ab 100644 --- a/frontends/web/src/utils/config.ts +++ b/frontends/web/src/utils/config.ts @@ -1,40 +1,42 @@ // SPDX-License-Identifier: Apache-2.0 -import { apiGet, apiPost } from '@/utils/request'; +import { getConfig as apiGetConfig, setConfig as apiSetConfig, type TConfig } from '@/api/config'; -type TConfig = { - backend?: unknown; - frontend?: unknown; -}; +let pendingConfig: Partial = {}; -let pendingConfig: TConfig = {}; +const normalizeConfig = (raw?: Partial): TConfig => { + const backend = (raw?.backend && typeof raw.backend === 'object' + ? { ...raw.backend } + : {}) as Readonly>; + const frontend = (raw?.frontend && typeof raw.frontend === 'object' + ? { ...raw.frontend } + : {}) as Readonly>; + return { backend, frontend }; +}; /** - * get current configs - * i.e. await getConfig() - * returns a promise with backend and frontend configs + * Fetch current config from the backend. */ -export const getConfig = (): Promise => { - return apiGet('config'); +export const getConfig = (): Promise => { + return apiGetConfig().then(normalizeConfig); }; /** - * expects an object with a backend or frontend config - * i.e. await setConfig({ frontend: { language }}) - * returns a promise and passes the new config + * Merge partial config with current, POST to backend, return new config. + * Returns the locally merged config object. It does not refetch the config from the backend. */ -export const setConfig = (object: TConfig) => { +export const setConfig = (object: Partial): Promise => { return getConfig() - .then((currentConfig = {}) => { - const nextConfig = Object.assign(currentConfig, { + .then((currentConfig) => { + const nextConfig = { backend: Object.assign({}, currentConfig.backend, pendingConfig.backend, object.backend), frontend: Object.assign({}, currentConfig.frontend, pendingConfig.frontend, object.frontend) - }); + }; pendingConfig = nextConfig; - return apiPost('config', nextConfig) + return apiSetConfig(nextConfig) .then(() => { pendingConfig = {}; - return nextConfig; + return nextConfig as TConfig; }); }); }; From 5cd1bf784487588454b6d93debe46f0d81bf469a Mon Sep 17 00:00:00 2001 From: Sibilla Date: Mon, 13 Apr 2026 13:29:31 +0200 Subject: [PATCH 02/24] frontend: keep guide hidden by default on first run Stop auto-opening the guide when no frontend config is present. This prevents the guide overlay from blocking interactions on fresh starts. --- frontends/web/src/contexts/AppProvider.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontends/web/src/contexts/AppProvider.tsx b/frontends/web/src/contexts/AppProvider.tsx index 1a9120ab19..f4839590cd 100644 --- a/frontends/web/src/contexts/AppProvider.tsx +++ b/frontends/web/src/contexts/AppProvider.tsx @@ -71,8 +71,6 @@ export const AppProvider = ({ children }: TProps) => { if (frontend.hideAmounts !== undefined) { setHideAmounts(Boolean(frontend.hideAmounts)); } - } else { - setGuideShown(true); } }, [config]); From 2992d26a1e4ac7d8eb078dc57761994c26564496 Mon Sep 17 00:00:00 2001 From: Sibilla Date: Wed, 8 Apr 2026 18:15:25 +0200 Subject: [PATCH 03/24] frontend: type frontend config keys in TConfig - Add TFrontendConfig with known frontend settings flags and keep index signature - Update consumers to use typed config.frontend keys and drop casts/guards --- frontends/web/src/api/config.ts | 21 ++++++++++++++++++- frontends/web/src/contexts/AppProvider.tsx | 2 +- frontends/web/src/hooks/bitsurance.ts | 2 +- .../web/src/routes/bitsurance/widget.tsx | 2 +- frontends/web/src/routes/market/bitrefill.tsx | 2 +- frontends/web/src/routes/market/btcdirect.tsx | 2 +- frontends/web/src/routes/market/moonpay.tsx | 2 +- frontends/web/src/routes/market/pocket.tsx | 2 +- 8 files changed, 27 insertions(+), 8 deletions(-) diff --git a/frontends/web/src/api/config.ts b/frontends/web/src/api/config.ts index 652efa1db1..badffacf31 100644 --- a/frontends/web/src/api/config.ts +++ b/frontends/web/src/api/config.ts @@ -2,9 +2,28 @@ import { apiGet, apiPost } from '@/utils/request'; +export type TFrontendConfig = Readonly<{ + guideShown?: boolean; + hideAmounts?: boolean; + darkmode?: boolean; + expertFee?: boolean; + coinControl?: boolean; + selectedExchangeRegion?: string; + hideEnableRememberWalletDialog?: boolean; + hasUsedWalletConnect?: boolean; + bitsuranceNotifyCancellation?: string[]; + + skipBitrefillWidgetDisclaimer?: boolean; + skipBTCDirectWidgetDisclaimer?: boolean; + skipBTCDirectOTCDisclaimer?: boolean; + skipMoonpayDisclaimer?: boolean; + skipPocketDisclaimer?: boolean; + skipBitsuranceDisclaimer?: boolean; +} & Record>; + export type TConfig = { readonly backend: Readonly>; - readonly frontend: Readonly>; + readonly frontend: TFrontendConfig; }; /** diff --git a/frontends/web/src/contexts/AppProvider.tsx b/frontends/web/src/contexts/AppProvider.tsx index f4839590cd..e8b7687fab 100644 --- a/frontends/web/src/contexts/AppProvider.tsx +++ b/frontends/web/src/contexts/AppProvider.tsx @@ -64,7 +64,7 @@ export const AppProvider = ({ children }: TProps) => { useEffect(() => { const frontend = config?.frontend; - if (frontend && typeof frontend === 'object') { + if (frontend) { if (frontend.guideShown !== undefined) { setGuideShown(Boolean(frontend.guideShown)); } diff --git a/frontends/web/src/hooks/bitsurance.ts b/frontends/web/src/hooks/bitsurance.ts index d0e02b604d..af3f9d41bf 100644 --- a/frontends/web/src/hooks/bitsurance.ts +++ b/frontends/web/src/hooks/bitsurance.ts @@ -78,7 +78,7 @@ export const useBitsurance = ( // We fetch the config after the lookup as it could have changed. const freshConfig = await getConfig(); - let cancelledAccounts: string[] = (freshConfig.frontend?.bitsuranceNotifyCancellation as string[] | undefined) ?? []; + let cancelledAccounts: string[] = freshConfig.frontend?.bitsuranceNotifyCancellation ?? []; if (cancelledAccounts?.includes(code)) { alertUser(t('account.insuranceExpired')); diff --git a/frontends/web/src/routes/bitsurance/widget.tsx b/frontends/web/src/routes/bitsurance/widget.tsx index f66c0ca433..5297aa2cd5 100644 --- a/frontends/web/src/routes/bitsurance/widget.tsx +++ b/frontends/web/src/routes/bitsurance/widget.tsx @@ -32,7 +32,7 @@ export const BitsuranceWidget = ({ code }: TProps) => { const accountInfo = useLoad(getInfo(code)); const { containerRef, height, iframeLoaded, iframeRef, onIframeLoad } = useVendorIframeResizeHeight(); - const { agreedTerms, setAgreedTerms } = useVendorTerms(Boolean((config?.frontend as { skipBitsuranceDisclaimer?: unknown } | undefined)?.skipBitsuranceDisclaimer)); + const { agreedTerms, setAgreedTerms } = useVendorTerms(Boolean(config?.frontend.skipBitsuranceDisclaimer)); const signingRef = useRef(false); useEffect(() => { diff --git a/frontends/web/src/routes/market/bitrefill.tsx b/frontends/web/src/routes/market/bitrefill.tsx index bdd3db888c..f60498c709 100644 --- a/frontends/web/src/routes/market/bitrefill.tsx +++ b/frontends/web/src/routes/market/bitrefill.tsx @@ -53,7 +53,7 @@ export const Bitrefill = ({ const fetchBitrefillInfo = useCallback(() => getBitrefillInfo('spend', code), [code]); const bitrefillInfo = useAccountSynced(code, fetchBitrefillInfo); const { containerRef, height, iframeLoaded, iframeRef, onIframeLoad } = useVendorIframeResizeHeight(); - const { agreedTerms, setAgreedTerms } = useVendorTerms(Boolean((config?.frontend as { skipBitrefillWidgetDisclaimer?: unknown } | undefined)?.skipBitrefillWidgetDisclaimer)); + const { agreedTerms, setAgreedTerms } = useVendorTerms(Boolean(config?.frontend.skipBitrefillWidgetDisclaimer)); const [pendingPayment, setPendingPayment] = useState(false); const [verifyPaymentRequest, setVerifyPaymentRequest] = useState(false); diff --git a/frontends/web/src/routes/market/btcdirect.tsx b/frontends/web/src/routes/market/btcdirect.tsx index db8467ccb3..78314a34fd 100644 --- a/frontends/web/src/routes/market/btcdirect.tsx +++ b/frontends/web/src/routes/market/btcdirect.tsx @@ -57,7 +57,7 @@ export const BTCDirect = ({ const account = findAccount(accounts, code); const { containerRef, height, iframeLoaded, iframeRef, onIframeLoad } = useVendorIframeResizeHeight(); - const { agreedTerms, setAgreedTerms } = useVendorTerms(Boolean((config?.frontend as { skipBTCDirectWidgetDisclaimer?: unknown } | undefined)?.skipBTCDirectWidgetDisclaimer)); + const { agreedTerms, setAgreedTerms } = useVendorTerms(Boolean(config?.frontend.skipBTCDirectWidgetDisclaimer)); const handlePaymentRequest = useCallback(async (event: MessageEvent) => { const { diff --git a/frontends/web/src/routes/market/moonpay.tsx b/frontends/web/src/routes/market/moonpay.tsx index 46da9b2f15..b09cbb26ef 100644 --- a/frontends/web/src/routes/market/moonpay.tsx +++ b/frontends/web/src/routes/market/moonpay.tsx @@ -31,7 +31,7 @@ export const Moonpay = ({ accounts, code }: TProps) => { const account = findAccount(accounts, code); const { containerRef, height, iframeLoaded, onIframeLoad } = useVendorIframeResizeHeight(); - const { agreedTerms, setAgreedTerms } = useVendorTerms(Boolean((config?.frontend as { skipMoonpayDisclaimer?: unknown } | undefined)?.skipMoonpayDisclaimer)); + const { agreedTerms, setAgreedTerms } = useVendorTerms(Boolean(config?.frontend.skipMoonpayDisclaimer)); if (!account || !config) { return null; diff --git a/frontends/web/src/routes/market/pocket.tsx b/frontends/web/src/routes/market/pocket.tsx index a169ffaa50..3047b7952d 100644 --- a/frontends/web/src/routes/market/pocket.tsx +++ b/frontends/web/src/routes/market/pocket.tsx @@ -49,7 +49,7 @@ export const Pocket = ({ const accountInfo = useLoad(getInfo(code)); const { containerRef, height, iframeLoaded, iframeRef, onIframeLoad } = useVendorIframeResizeHeight(); - const { agreedTerms, setAgreedTerms } = useVendorTerms(Boolean((config?.frontend as { skipPocketDisclaimer?: unknown } | undefined)?.skipPocketDisclaimer)); + const { agreedTerms, setAgreedTerms } = useVendorTerms(Boolean(config?.frontend.skipPocketDisclaimer)); const signingRef = useRef(false); const pocketInfo = useAccountSynced(code, useCallback(() => getPocketURL(action), [action])); From 6f8a26024d8a61759d605d5ac187d9c47e22dffb Mon Sep 17 00:00:00 2001 From: Sibilla Date: Wed, 8 Apr 2026 18:35:15 +0200 Subject: [PATCH 04/24] frontend: type backend config and add typed config updates - Add TBackendConfig and related types to api/config and use them in TConfig - Introduce TConfigUpdate so setConfig can accept partial backend/frontend updates - Update utils/config and ConfigProvider/Context to use TConfigUpdate merge + pendingConfig - Drop remaining backend casts in offline-error, electrum-servers, and RatesProvider --- frontends/web/src/api/config.ts | 51 ++++++++++++++++++- .../src/components/banners/offline-error.tsx | 2 +- frontends/web/src/contexts/ConfigContext.tsx | 4 +- frontends/web/src/contexts/ConfigProvider.tsx | 4 +- frontends/web/src/contexts/RatesProvider.tsx | 10 ++-- .../routes/account/send/feetargets.test.tsx | 16 ++++-- .../market/components/markettab.test.tsx | 9 +++- .../web/src/routes/market/swap/swap.test.tsx | 3 +- .../custom-gap-limit-setting.tsx | 4 +- .../src/routes/settings/electrum-servers.tsx | 5 +- frontends/web/src/utils/config.ts | 12 ++--- 11 files changed, 91 insertions(+), 29 deletions(-) diff --git a/frontends/web/src/api/config.ts b/frontends/web/src/api/config.ts index badffacf31..bb5736a333 100644 --- a/frontends/web/src/api/config.ts +++ b/frontends/web/src/api/config.ts @@ -1,6 +1,50 @@ // SPDX-License-Identifier: Apache-2.0 import { apiGet, apiPost } from '@/utils/request'; +import type { Fiat } from '@/api/account'; +import type { BtcUnit } from '@/api/coins'; + +export type TElectrumServerInfo = Readonly<{ + server: string; + tls: boolean; + pemCert: string; +}>; + +export type TBtcCoinConfig = Readonly<{ + electrumServers: TElectrumServerInfo[]; +}>; + +export type TEthCoinConfig = Readonly<{ + activeERC20Tokens: string[]; +} & Record>; + +export type TProxyConfig = Readonly<{ + useProxy: boolean; + proxyAddress: string; +}>; + +export type TBackendConfig = Readonly<{ + proxy: TProxyConfig; + bitcoinActive: boolean; + litecoinActive: boolean; + ethereumActive: boolean; + authentication: boolean; + btc: TBtcCoinConfig; + tbtc: TBtcCoinConfig; + rbtc: TBtcCoinConfig; + ltc: TBtcCoinConfig; + tltc: TBtcCoinConfig; + eth: TEthCoinConfig; + teth: Record; + reth: Record; + fiatList: Fiat[]; + mainFiat: Fiat; + userLanguage: string; + btcUnit: BtcUnit; + startInTestnet: boolean; + gapLimitReceive: number; + gapLimitChange: number; +} & Record>; export type TFrontendConfig = Readonly<{ guideShown?: boolean; @@ -22,10 +66,15 @@ export type TFrontendConfig = Readonly<{ } & Record>; export type TConfig = { - readonly backend: Readonly>; + readonly backend: TBackendConfig; readonly frontend: TFrontendConfig; }; +export type TConfigUpdate = { + backend?: (Omit, 'userLanguage'> & { userLanguage?: string | null }); + frontend?: Partial & Record; +}; + /** * Fetch current config from the backend. */ diff --git a/frontends/web/src/components/banners/offline-error.tsx b/frontends/web/src/components/banners/offline-error.tsx index 55933d5485..8a8791354f 100644 --- a/frontends/web/src/components/banners/offline-error.tsx +++ b/frontends/web/src/components/banners/offline-error.tsx @@ -14,7 +14,7 @@ export const OfflineError = ({ }: Props) => { const { t } = useTranslation(); const { config } = useConfig(); - const usesProxy = (config?.backend?.proxy as { useProxy?: boolean } | undefined)?.useProxy; + const usesProxy = config?.backend.proxy.useProxy; // Status: offline error const offlineErrorTextLines: string[] = []; diff --git a/frontends/web/src/contexts/ConfigContext.tsx b/frontends/web/src/contexts/ConfigContext.tsx index 66a601b858..f34660cde0 100644 --- a/frontends/web/src/contexts/ConfigContext.tsx +++ b/frontends/web/src/contexts/ConfigContext.tsx @@ -1,11 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 import { createContext } from 'react'; -import type { TConfig } from '@/api/config'; +import type { TConfig, TConfigUpdate } from '@/api/config'; export type TConfigContext = { config: TConfig | undefined; - setConfig: (object: Partial) => Promise; + setConfig: (object: TConfigUpdate) => Promise; }; export const ConfigContext = createContext(undefined); diff --git a/frontends/web/src/contexts/ConfigProvider.tsx b/frontends/web/src/contexts/ConfigProvider.tsx index 8843c04633..f400f5d4bf 100644 --- a/frontends/web/src/contexts/ConfigProvider.tsx +++ b/frontends/web/src/contexts/ConfigProvider.tsx @@ -2,7 +2,7 @@ import { ReactNode, useCallback, useContext, useEffect, useState } from 'react'; import { getConfig, setConfig as setConfigAPI } from '@/utils/config'; -import type { TConfig } from '@/api/config'; +import type { TConfig, TConfigUpdate } from '@/api/config'; import { ConfigContext, TConfigContext } from './ConfigContext'; type TProps = { @@ -16,7 +16,7 @@ export const ConfigProvider = ({ children }: TProps) => { getConfig().then(setConfigState).catch(console.error); }, []); - const setConfig = useCallback((object: Partial) => { + const setConfig = useCallback((object: TConfigUpdate) => { return setConfigAPI(object).then(nextConfig => { setConfigState(nextConfig); return nextConfig; diff --git a/frontends/web/src/contexts/RatesProvider.tsx b/frontends/web/src/contexts/RatesProvider.tsx index d461409d6a..db1661103e 100644 --- a/frontends/web/src/contexts/RatesProvider.tsx +++ b/frontends/web/src/contexts/RatesProvider.tsx @@ -22,13 +22,9 @@ export const RatesProvider = ({ children }: TProps) => { if (config === undefined) { return Promise.resolve(); } - if (config.backend?.mainFiat) { - setDefaultCurrency(config.backend.mainFiat as Fiat); - } - if (config.backend?.fiatList && config.backend?.btcUnit) { - setActiveCurrencies(config.backend.fiatList as Fiat[]); - setBtcUnit(config.backend.btcUnit as BtcUnit); - } + setDefaultCurrency(config.backend.mainFiat); + setActiveCurrencies(config.backend.fiatList); + setBtcUnit(config.backend.btcUnit); return Promise.resolve(); }, [config]); diff --git a/frontends/web/src/routes/account/send/feetargets.test.tsx b/frontends/web/src/routes/account/send/feetargets.test.tsx index 0fe1485363..0c064aa970 100644 --- a/frontends/web/src/routes/account/send/feetargets.test.tsx +++ b/frontends/web/src/routes/account/send/feetargets.test.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import '../../../../__mocks__/i18n'; +import type { TConfig } from '@/api/config'; import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; vi.mock('@/utils/request', () => ({ @@ -12,7 +13,10 @@ vi.mock('@/utils/env', () => ({ })); vi.mock('@/contexts/ConfigProvider', () => ({ useConfig: vi.fn(() => ({ - config: { frontend: { expertFee: false } }, + config: { + backend: {} as TConfig['backend'], + frontend: { expertFee: false }, + }, setConfig: vi.fn(), })), })); @@ -36,7 +40,10 @@ describe('routes/account/send/feetargets', () => { vi.clearAllMocks(); mockRunningInIOS.mockReturnValue(false); mockUseConfig.mockReturnValue({ - config: { frontend: { expertFee: false } }, + config: { + backend: {} as TConfig['backend'], + frontend: { expertFee: false }, + }, setConfig: vi.fn(), }); }); @@ -70,7 +77,10 @@ describe('routes/account/send/feetargets', () => { it('normalizes custom fee values from iOS decimal input', async () => { mockRunningInIOS.mockReturnValue(true); mockUseConfig.mockReturnValue({ - config: { frontend: { expertFee: true } }, + config: { + backend: {} as TConfig['backend'], + frontend: { expertFee: true }, + }, setConfig: vi.fn(), }); const apiGetMock = (apiGet as Mock).mockResolvedValue({ diff --git a/frontends/web/src/routes/market/components/markettab.test.tsx b/frontends/web/src/routes/market/components/markettab.test.tsx index 166bbb4f48..093a1f9234 100644 --- a/frontends/web/src/routes/market/components/markettab.test.tsx +++ b/frontends/web/src/routes/market/components/markettab.test.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import '../../../../__mocks__/i18n'; +import type { TConfig } from '@/api/config'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -17,11 +18,15 @@ const mockedGetConfig = vi.mocked(getConfig); describe('routes/market/components/markettab', () => { beforeEach(() => { - mockedGetConfig.mockResolvedValue({ frontend: {} }); + mockedGetConfig.mockResolvedValue({ + backend: {} as TConfig['backend'], + frontend: {}, + }); }); it('shows the new badge on swap when enabled', async () => { mockedGetConfig.mockResolvedValue({ + backend: {} as TConfig['backend'], frontend: { hasSeenOtcMarketTab: true, hasSeenSwapMarketTab: false, @@ -41,6 +46,7 @@ describe('routes/market/components/markettab', () => { it('hides the new badge on swap when disabled', async () => { mockedGetConfig.mockResolvedValue({ + backend: {} as TConfig['backend'], frontend: { hasSeenOtcMarketTab: true, hasSeenSwapMarketTab: true, @@ -82,6 +88,7 @@ describe('routes/market/components/markettab', () => { it('shows the new badge on otc when enabled', async () => { mockedGetConfig.mockResolvedValue({ + backend: {} as TConfig['backend'], frontend: { hasSeenOtcMarketTab: false, hasSeenSwapMarketTab: true, diff --git a/frontends/web/src/routes/market/swap/swap.test.tsx b/frontends/web/src/routes/market/swap/swap.test.tsx index 3646931772..65cbfe91b8 100644 --- a/frontends/web/src/routes/market/swap/swap.test.tsx +++ b/frontends/web/src/routes/market/swap/swap.test.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import '../../../../__mocks__/i18n'; +import type { TConfig } from '@/api/config'; import type { ReactNode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import '@testing-library/jest-dom'; @@ -219,7 +220,7 @@ describe('routes/market/swap', () => { }); vi.mocked(config.getConfig).mockResolvedValue({ frontend: {}, - backend: {}, + backend: {} as TConfig['backend'], }); }); diff --git a/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx index 0538c3fbaf..a22dc1b165 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx @@ -41,8 +41,8 @@ export const CustomGapLimitSettings = ({ backendConfig, onChangeConfig }: TProps const config = await setConfig({ backend: { ...backendConfig, - gapLimitReceive, - gapLimitChange, + gapLimitReceive: Number(gapLimitReceive), + gapLimitChange: Number(gapLimitChange), }, }); onChangeConfig(config); diff --git a/frontends/web/src/routes/settings/electrum-servers.tsx b/frontends/web/src/routes/settings/electrum-servers.tsx index d3cc27bd2c..bb9997cae5 100644 --- a/frontends/web/src/routes/settings/electrum-servers.tsx +++ b/frontends/web/src/routes/settings/electrum-servers.tsx @@ -23,14 +23,13 @@ export const ElectrumServers = ({ if (config === undefined) { return null; } - const backendCoin = config.backend?.[coin] as { electrumServers?: TElectrumServer[] } | undefined; - const electrumServers: TElectrumServer[] = backendCoin?.electrumServers ?? []; + const electrumServers: TElectrumServer[] = config.backend[coin].electrumServers; const save = async (newElectrumServers: TElectrumServer[]) => { await setConfig({ backend: { [coin]: { - ...(backendCoin && typeof backendCoin === 'object' ? backendCoin : {}), + ...config.backend[coin], electrumServers: newElectrumServers } } diff --git a/frontends/web/src/utils/config.ts b/frontends/web/src/utils/config.ts index b00a10f4ab..93739886fc 100644 --- a/frontends/web/src/utils/config.ts +++ b/frontends/web/src/utils/config.ts @@ -1,16 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 -import { getConfig as apiGetConfig, setConfig as apiSetConfig, type TConfig } from '@/api/config'; +import { getConfig as apiGetConfig, setConfig as apiSetConfig, type TConfig, type TConfigUpdate } from '@/api/config'; -let pendingConfig: Partial = {}; +let pendingConfig: TConfigUpdate = {}; const normalizeConfig = (raw?: Partial): TConfig => { const backend = (raw?.backend && typeof raw.backend === 'object' ? { ...raw.backend } - : {}) as Readonly>; + : {}) as TConfig['backend']; const frontend = (raw?.frontend && typeof raw.frontend === 'object' ? { ...raw.frontend } - : {}) as Readonly>; + : {}) as TConfig['frontend']; return { backend, frontend }; }; @@ -25,13 +25,13 @@ export const getConfig = (): Promise => { * Merge partial config with current, POST to backend, return new config. * Returns the locally merged config object. It does not refetch the config from the backend. */ -export const setConfig = (object: Partial): Promise => { +export const setConfig = (object: TConfigUpdate): Promise => { return getConfig() .then((currentConfig) => { const nextConfig = { backend: Object.assign({}, currentConfig.backend, pendingConfig.backend, object.backend), frontend: Object.assign({}, currentConfig.frontend, pendingConfig.frontend, object.frontend) - }; + } as TConfig; pendingConfig = nextConfig; return apiSetConfig(nextConfig) .then(() => { From 4d19d28e4cda331e0cba5378ba02333352d83597 Mon Sep 17 00:00:00 2001 From: Sibilla Date: Thu, 14 May 2026 10:36:17 +0200 Subject: [PATCH 05/24] frontend: complete TConfig typing Type known frontend/backend config keys in api/config, remove duplicate advanced-settings types and unsafe casts, tighten TConfigUpdate and normalization, and add mockConfig for unit tests. --- frontends/web/src/api/config.ts | 96 ++++++++++++++----- .../src/components/new-badge/new-badge.tsx | 8 +- .../web/src/components/status/status.tsx | 3 +- .../routes/account/send/feetargets.test.tsx | 17 +--- .../market/components/markettab.test.tsx | 22 ++--- .../web/src/routes/market/swap/swap.test.tsx | 7 +- .../src/routes/settings/advanced-settings.tsx | 37 +------ .../custom-gap-limit-setting.tsx | 5 +- .../advanced-settings/enable-auth-setting.tsx | 8 +- .../enable-coin-control-setting.tsx | 10 +- .../enable-custom-fees-toggle-setting.tsx | 10 +- .../enable-tor-proxy-setting.tsx | 6 +- .../restart-in-testnet-setting.tsx | 6 +- .../advanced-settings/tor-proxy-dialog.tsx | 13 ++- .../src/routes/settings/electrum-servers.tsx | 3 +- frontends/web/src/test/mock-config.ts | 12 +++ frontends/web/src/utils/config.ts | 39 +++++--- 17 files changed, 163 insertions(+), 139 deletions(-) create mode 100644 frontends/web/src/test/mock-config.ts diff --git a/frontends/web/src/api/config.ts b/frontends/web/src/api/config.ts index bb5736a333..6bad5edec2 100644 --- a/frontends/web/src/api/config.ts +++ b/frontends/web/src/api/config.ts @@ -14,15 +14,71 @@ export type TBtcCoinConfig = Readonly<{ electrumServers: TElectrumServerInfo[]; }>; +/** BTC-based coin keys in backend config (see backend/config/config.go). */ +export type TBtcCoinConfigKey = 'btc' | 'tbtc' | 'ltc' | 'tltc'; + export type TEthCoinConfig = Readonly<{ activeERC20Tokens: string[]; -} & Record>; +}>; export type TProxyConfig = Readonly<{ useProxy: boolean; proxyAddress: string; }>; +/** Keys used by NewBadge to mark UI elements as seen. */ +export type TFrontendBadgeConfigKey = + | 'hasSeenMarketplaceNudge' + | 'hasSeenSwapMarketTab' + | 'hasSeenOtcMarketTab'; + +/** Dynamic frontend keys written when dismissing Status banners. */ +export type TDynamicDismissibleFrontendKey = + | `update-${string}` + | `banner-backup-${string}` + | `banner-${string}-${string}`; + +/** Known static frontend keys written when dismissing Status banners. */ +export type TKnownDismissibleFrontendConfigKey = + | 'walletConnectDisclaimerDismissed' + | 'skipTestingWarning' + | 'mobile-data-warning'; + +export type TDismissibleFrontendConfigKey = + | TKnownDismissibleFrontendConfigKey + | TDynamicDismissibleFrontendKey; + +export type TFrontendConfig = Readonly<{ + guideShown?: boolean; + hideAmounts?: boolean; + darkmode?: boolean; + expertFee?: boolean; + coinControl?: boolean; + selectedExchangeRegion?: string; + hideEnableRememberWalletDialog?: boolean; + hasUsedWalletConnect?: boolean; + bitsuranceNotifyCancellation?: string[]; + + hasSeenMarketplaceNudge?: boolean; + hasSeenSwapMarketTab?: boolean; + hasSeenOtcMarketTab?: boolean; + + skipBitrefillWidgetDisclaimer?: boolean; + skipBTCDirectWidgetDisclaimer?: boolean; + skipBTCDirectOTCDisclaimer?: boolean; + skipMoonpayDisclaimer?: boolean; + skipPocketDisclaimer?: boolean; + skipPocketOTCDisclaimer?: boolean; + skipBitsuranceDisclaimer?: boolean; + skipSwapkitDisclaimer?: boolean; + + walletConnectDisclaimerDismissed?: boolean; + skipTestingWarning?: boolean; + 'mobile-data-warning'?: boolean; +}> & Readonly<{ + [key in TDynamicDismissibleFrontendKey]?: boolean; +}>; + export type TBackendConfig = Readonly<{ proxy: TProxyConfig; bitcoinActive: boolean; @@ -44,39 +100,29 @@ export type TBackendConfig = Readonly<{ startInTestnet: boolean; gapLimitReceive: number; gapLimitChange: number; -} & Record>; - -export type TFrontendConfig = Readonly<{ - guideShown?: boolean; - hideAmounts?: boolean; - darkmode?: boolean; - expertFee?: boolean; - coinControl?: boolean; - selectedExchangeRegion?: string; - hideEnableRememberWalletDialog?: boolean; - hasUsedWalletConnect?: boolean; - bitsuranceNotifyCancellation?: string[]; - - skipBitrefillWidgetDisclaimer?: boolean; - skipBTCDirectWidgetDisclaimer?: boolean; - skipBTCDirectOTCDisclaimer?: boolean; - skipMoonpayDisclaimer?: boolean; - skipPocketDisclaimer?: boolean; - skipBitsuranceDisclaimer?: boolean; -} & Record>; +}>; export type TConfig = { readonly backend: TBackendConfig; readonly frontend: TFrontendConfig; }; +/** Partial backend config for updates; null clears userLanguage (see i18n.ts). */ +export type TBackendConfigUpdate = + Omit, 'userLanguage'> & { + userLanguage?: string | null; + }; + +export type TFrontendConfigUpdate = Partial; + export type TConfigUpdate = { - backend?: (Omit, 'userLanguage'> & { userLanguage?: string | null }); - frontend?: Partial & Record; + backend?: TBackendConfigUpdate; + frontend?: TFrontendConfigUpdate; }; /** - * Fetch current config from the backend. + * Fetch raw config from the backend. Keys may be missing; use getConfig from + * @/utils/config for a normalized TConfig. */ export const getConfig = (): Promise> => { return apiGet('config'); @@ -85,6 +131,6 @@ export const getConfig = (): Promise> => { /** * Post a config object to the backend. */ -export const setConfig = (config: Partial): Promise => { +export const setConfig = (config: TConfig): Promise => { return apiPost('config', config); }; diff --git a/frontends/web/src/components/new-badge/new-badge.tsx b/frontends/web/src/components/new-badge/new-badge.tsx index 33c40bb108..dddda8d14a 100644 --- a/frontends/web/src/components/new-badge/new-badge.tsx +++ b/frontends/web/src/components/new-badge/new-badge.tsx @@ -2,15 +2,14 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import type { TFrontendBadgeConfigKey } from '@/api/config'; import { Badge } from '@/components/badge/badge'; import { useLoad } from '@/hooks/api'; import { getConfig, setConfig } from '@/utils/config'; -type TConfigKey = 'hasSeenMarketplaceNudge' | 'hasSeenSwapMarketTab' | 'hasSeenOtcMarketTab'; - type TProps = { className?: string; - configKey: TConfigKey; + configKey: TFrontendBadgeConfigKey; hideOnPathPrefix?: string; markAsSeen?: boolean; pathname?: string; @@ -43,8 +42,7 @@ export const NewBadge = ({ if (!config) { return; } - const frontendConfig = config.frontend as Record | undefined; - const hasSeenBadge = Boolean(frontendConfig?.[configKey]); + const hasSeenBadge = Boolean(config.frontend?.[configKey]); setShowBadge(currentShowBadge => ( currentShowBadge === false ? false : !hasSeenBadge )); diff --git a/frontends/web/src/components/status/status.tsx b/frontends/web/src/components/status/status.tsx index 591e480adc..865fb59b7e 100644 --- a/frontends/web/src/components/status/status.tsx +++ b/frontends/web/src/components/status/status.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { ReactNode } from 'react'; +import type { TDismissibleFrontendConfigKey } from '@/api/config'; import { useConfig } from '@/contexts/ConfigProvider'; import { CloseXDark, CloseXWhite } from '@/components/icon'; import { useDarkmode } from '@/hooks/darkmode'; @@ -15,7 +16,7 @@ type TProps = { // used as keyName in the config if dismissing the status should be persisted, so it is not // shown again. Use an empty string if it should be dismissible without storing it in the // config, so the status will be shown again the next time. - dismissibleKey: string; + dismissibleKey: TDismissibleFrontendConfigKey | ''; className?: string; children: ReactNode; }; diff --git a/frontends/web/src/routes/account/send/feetargets.test.tsx b/frontends/web/src/routes/account/send/feetargets.test.tsx index 0c064aa970..cc13a30126 100644 --- a/frontends/web/src/routes/account/send/feetargets.test.tsx +++ b/frontends/web/src/routes/account/send/feetargets.test.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import '../../../../__mocks__/i18n'; -import type { TConfig } from '@/api/config'; +import { mockConfig } from '@/test/mock-config'; import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; vi.mock('@/utils/request', () => ({ @@ -13,10 +13,7 @@ vi.mock('@/utils/env', () => ({ })); vi.mock('@/contexts/ConfigProvider', () => ({ useConfig: vi.fn(() => ({ - config: { - backend: {} as TConfig['backend'], - frontend: { expertFee: false }, - }, + config: mockConfig({ frontend: { expertFee: false } }), setConfig: vi.fn(), })), })); @@ -40,10 +37,7 @@ describe('routes/account/send/feetargets', () => { vi.clearAllMocks(); mockRunningInIOS.mockReturnValue(false); mockUseConfig.mockReturnValue({ - config: { - backend: {} as TConfig['backend'], - frontend: { expertFee: false }, - }, + config: mockConfig({ frontend: { expertFee: false } }), setConfig: vi.fn(), }); }); @@ -77,10 +71,7 @@ describe('routes/account/send/feetargets', () => { it('normalizes custom fee values from iOS decimal input', async () => { mockRunningInIOS.mockReturnValue(true); mockUseConfig.mockReturnValue({ - config: { - backend: {} as TConfig['backend'], - frontend: { expertFee: true }, - }, + config: mockConfig({ frontend: { expertFee: true } }), setConfig: vi.fn(), }); const apiGetMock = (apiGet as Mock).mockResolvedValue({ diff --git a/frontends/web/src/routes/market/components/markettab.test.tsx b/frontends/web/src/routes/market/components/markettab.test.tsx index 093a1f9234..54337437ab 100644 --- a/frontends/web/src/routes/market/components/markettab.test.tsx +++ b/frontends/web/src/routes/market/components/markettab.test.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import '../../../../__mocks__/i18n'; -import type { TConfig } from '@/api/config'; +import { mockConfig } from '@/test/mock-config'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -18,20 +18,16 @@ const mockedGetConfig = vi.mocked(getConfig); describe('routes/market/components/markettab', () => { beforeEach(() => { - mockedGetConfig.mockResolvedValue({ - backend: {} as TConfig['backend'], - frontend: {}, - }); + mockedGetConfig.mockResolvedValue(mockConfig()); }); it('shows the new badge on swap when enabled', async () => { - mockedGetConfig.mockResolvedValue({ - backend: {} as TConfig['backend'], + mockedGetConfig.mockResolvedValue(mockConfig({ frontend: { hasSeenOtcMarketTab: true, hasSeenSwapMarketTab: false, } - }); + })); render( { }); it('hides the new badge on swap when disabled', async () => { - mockedGetConfig.mockResolvedValue({ - backend: {} as TConfig['backend'], + mockedGetConfig.mockResolvedValue(mockConfig({ frontend: { hasSeenOtcMarketTab: true, hasSeenSwapMarketTab: true, } - }); + })); render( { }); it('shows the new badge on otc when enabled', async () => { - mockedGetConfig.mockResolvedValue({ - backend: {} as TConfig['backend'], + mockedGetConfig.mockResolvedValue(mockConfig({ frontend: { hasSeenOtcMarketTab: false, hasSeenSwapMarketTab: true, } - }); + })); render( { }], }, }); - vi.mocked(config.getConfig).mockResolvedValue({ - frontend: {}, - backend: {} as TConfig['backend'], - }); + vi.mocked(config.getConfig).mockResolvedValue(mockConfig()); }); it('renders grouped provider label after quote fetch', async () => { diff --git a/frontends/web/src/routes/settings/advanced-settings.tsx b/frontends/web/src/routes/settings/advanced-settings.tsx index 24d9e2839b..16aee93217 100644 --- a/frontends/web/src/routes/settings/advanced-settings.tsx +++ b/frontends/web/src/routes/settings/advanced-settings.tsx @@ -21,37 +21,10 @@ import { EnableAuthSetting } from './components/advanced-settings/enable-auth-se import { ContentWrapper } from '@/components/contentwrapper/contentwrapper'; import { GlobalBanners } from '@/components/banners'; -export type TProxyConfig = { - proxyAddress: string; - useProxy: boolean; -}; - -export type TFrontendConfig = { - expertFee?: boolean; - coinControl?: boolean; -}; - -export type TBackendConfig = { - proxy?: TProxyConfig; - authentication?: boolean; - startInTestnet?: boolean; - gapLimitReceive?: number; - gapLimitChange?: number; -}; - -export type TConfig = { - backend?: TBackendConfig; - frontend?: TFrontendConfig; -}; - export const AdvancedSettings = ({ devices, hasAccounts }: TPagePropsWithSettingsTabs) => { const { t } = useTranslation(); const { config, setConfig } = useConfig(); - const frontendConfig = config?.frontend as TFrontendConfig | undefined; - const backendConfig = config?.backend as TBackendConfig | undefined; - const proxyConfig = config?.backend?.proxy as TProxyConfig | undefined; - const deviceIDs = Object.keys(devices); return ( @@ -76,12 +49,12 @@ export const AdvancedSettings = ({ devices, hasAccounts }: TPagePropsWithSetting hideMobileMenu hasAccounts={hasAccounts} > - - - - + + + + - + diff --git a/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx index a22dc1b165..95036b9f81 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx @@ -6,14 +6,13 @@ import { SettingsItem } from '@/routes/settings/components/settingsItem/settings import { Dialog, DialogButtons } from '@/components/dialog/dialog'; import { Button, Input } from '@/components/forms'; import { useConfig } from '@/contexts/ConfigProvider'; -import type { TConfig as ApiTConfig } from '@/api/config'; -import type { TBackendConfig } from '@/routes/settings/advanced-settings'; +import type { TBackendConfig, TConfig } from '@/api/config'; import { Message } from '@/components/message/message'; import { useMediaQuery } from '@/hooks/mediaquery'; type TProps = { backendConfig?: TBackendConfig; - onChangeConfig: (config: ApiTConfig) => void; + onChangeConfig: (config: TConfig) => void; }; export const CustomGapLimitSettings = ({ backendConfig, onChangeConfig }: TProps) => { diff --git a/frontends/web/src/routes/settings/components/advanced-settings/enable-auth-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/enable-auth-setting.tsx index 147488bf0d..d1674734ce 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/enable-auth-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/enable-auth-setting.tsx @@ -1,17 +1,17 @@ // SPDX-License-Identifier: Apache-2.0 -import { ChangeEvent, Dispatch } from 'react'; +import { ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; +import type { TBackendConfig, TConfig } from '@/api/config'; import { Toggle } from '@/components/toggle/toggle'; import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; -import { TBackendConfig, TConfig } from '@/routes/settings/advanced-settings'; import { useConfig } from '@/contexts/ConfigProvider'; import { onAuthSettingChanged, TAuthEventObject, subscribeAuth, forceAuth } from '@/api/backend'; import { runningInAndroid, runningInIOS } from '@/utils/env'; type TProps = { backendConfig?: TBackendConfig; - onChangeConfig: Dispatch; + onChangeConfig: (config: TConfig) => void; }; export const EnableAuthSetting = ({ backendConfig, onChangeConfig }: TProps) => { @@ -41,7 +41,7 @@ export const EnableAuthSetting = ({ backendConfig, onChangeConfig }: TProps) => const updateConfig = async (auth: boolean) => { const config = await setConfig({ backend: { authentication: auth }, - }) as TConfig; + }); onAuthSettingChanged(); onChangeConfig(config); }; diff --git a/frontends/web/src/routes/settings/components/advanced-settings/enable-coin-control-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/enable-coin-control-setting.tsx index 661897b52e..45c56f4102 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/enable-coin-control-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/enable-coin-control-setting.tsx @@ -1,15 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 -import { ChangeEvent, Dispatch } from 'react'; +import { ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; +import type { TConfig, TFrontendConfig } from '@/api/config'; import { Toggle } from '@/components/toggle/toggle'; import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; -import { TConfig, TFrontendConfig } from '@/routes/settings/advanced-settings'; import { useConfig } from '@/contexts/ConfigProvider'; type TProps = { frontendConfig?: TFrontendConfig; - onChangeConfig: Dispatch; + onChangeConfig: (config: TConfig) => void; }; export const EnableCoinControlSetting = ({ frontendConfig, onChangeConfig }: TProps) => { @@ -19,9 +19,9 @@ export const EnableCoinControlSetting = ({ frontendConfig, onChangeConfig }: TPr const handleToggleFee = async (e: ChangeEvent) => { const config = await setConfig({ frontend: { - 'coinControl': e.target.checked + coinControl: e.target.checked }, - }) as TConfig; + }); onChangeConfig(config); }; diff --git a/frontends/web/src/routes/settings/components/advanced-settings/enable-custom-fees-toggle-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/enable-custom-fees-toggle-setting.tsx index 63ff79f525..e32f460d45 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/enable-custom-fees-toggle-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/enable-custom-fees-toggle-setting.tsx @@ -1,15 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 -import { ChangeEvent, Dispatch } from 'react'; +import { ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; +import type { TConfig, TFrontendConfig } from '@/api/config'; import { Toggle } from '@/components/toggle/toggle'; import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; -import { TConfig, TFrontendConfig } from '@/routes/settings/advanced-settings'; import { useConfig } from '@/contexts/ConfigProvider'; type TProps = { frontendConfig?: TFrontendConfig; - onChangeConfig: Dispatch; + onChangeConfig: (config: TConfig) => void; }; export const EnableCustomFeesToggleSetting = ({ frontendConfig, onChangeConfig }: TProps) => { @@ -19,9 +19,9 @@ export const EnableCustomFeesToggleSetting = ({ frontendConfig, onChangeConfig } const handleToggleFee = async (e: ChangeEvent) => { const config = await setConfig({ frontend: { - 'expertFee': e.target.checked + expertFee: e.target.checked }, - }) as TConfig; + }); onChangeConfig(config); }; diff --git a/frontends/web/src/routes/settings/components/advanced-settings/enable-tor-proxy-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/enable-tor-proxy-setting.tsx index 997d725209..f058f9f8fe 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/enable-tor-proxy-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/enable-tor-proxy-setting.tsx @@ -1,8 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 -import { Dispatch, useState } from 'react'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import type { TProxyConfig } from '@/routes/settings/advanced-settings'; +import type { TConfig, TProxyConfig } from '@/api/config'; import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; import { TorProxyDialog } from './tor-proxy-dialog'; import { Message } from '@/components/message/message'; @@ -10,7 +10,7 @@ import { runningInIOS } from '@/utils/env'; import styles from './enable-tor-proxy-setting.module.css'; type TProps = { proxyConfig?: TProxyConfig; - onChangeConfig: Dispatch; + onChangeConfig: (config: TConfig) => void; }; export const EnableTorProxySetting = ({ proxyConfig, onChangeConfig }: TProps) => { diff --git a/frontends/web/src/routes/settings/components/advanced-settings/restart-in-testnet-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/restart-in-testnet-setting.tsx index 32ac964cad..b53dd10142 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/restart-in-testnet-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/restart-in-testnet-setting.tsx @@ -1,8 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 -import { Dispatch, useContext, useState } from 'react'; +import { useContext, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import type { TConfig as ApiTConfig } from '@/api/config'; +import type { TConfig } from '@/api/config'; import { AppContext } from '@/contexts/AppContext'; import { useConfig } from '@/contexts/ConfigProvider'; import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; @@ -11,7 +11,7 @@ import { Button } from '@/components/forms'; import { UseBackButton } from '@/hooks/backbutton'; type TProps = { - onChangeConfig: Dispatch; + onChangeConfig: (config: TConfig) => void; }; export const RestartInTestnetSetting = ({ onChangeConfig }: TProps) => { diff --git a/frontends/web/src/routes/settings/components/advanced-settings/tor-proxy-dialog.tsx b/frontends/web/src/routes/settings/components/advanced-settings/tor-proxy-dialog.tsx index ff35e116de..dff1232b95 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/tor-proxy-dialog.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/tor-proxy-dialog.tsx @@ -1,7 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 import { useTranslation } from 'react-i18next'; -import { Dispatch, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; +import type { TConfig, TProxyConfig } from '@/api/config'; import { useMediaQuery } from '@/hooks/mediaquery'; import { Dialog, DialogButtons } from '@/components/dialog/dialog'; import { Toggle } from '@/components/toggle/toggle'; @@ -9,14 +10,13 @@ import { Button, Input } from '@/components/forms'; import { useConfig } from '@/contexts/ConfigProvider'; import { socksProxyCheck } from '@/api/backend'; import { alertUser } from '@/components/alert/Alert'; -import { TConfig, TProxyConfig } from '@/routes/settings/advanced-settings'; type TProps = { open: boolean; proxyConfig?: TProxyConfig; onCloseDialog: () => void; - onChangeConfig: (config: any) => void; - handleShowRestartMessage: Dispatch; + onChangeConfig: (config: TConfig) => void; + handleShowRestartMessage: (show: boolean) => void; }; export const TorProxyDialog = ({ open, proxyConfig, onCloseDialog, onChangeConfig, handleShowRestartMessage }: TProps) => { @@ -36,8 +36,7 @@ export const TorProxyDialog = ({ open, proxyConfig, onCloseDialog, onChangeConfi if (!proxyConfig || proxyAddress === undefined) { return; } - const proxy = proxyConfig; - proxy.proxyAddress = proxyAddress.trim(); + const proxy = { ...proxyConfig, proxyAddress: proxyAddress.trim() }; const result = await socksProxyCheck(proxy.proxyAddress); const { success, errorMessage } = result; @@ -52,7 +51,7 @@ export const TorProxyDialog = ({ open, proxyConfig, onCloseDialog, onChangeConfi const setProxyConfig = async (proxyConfig: TProxyConfig) => { const config = await setConfig({ backend: { proxy: proxyConfig }, - }) as TConfig; + }); setProxyAddress(proxyConfig.proxyAddress); onChangeConfig(config); handleShowRestartMessage(true); diff --git a/frontends/web/src/routes/settings/electrum-servers.tsx b/frontends/web/src/routes/settings/electrum-servers.tsx index bb9997cae5..7200796208 100644 --- a/frontends/web/src/routes/settings/electrum-servers.tsx +++ b/frontends/web/src/routes/settings/electrum-servers.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { useTranslation } from 'react-i18next'; +import type { TBtcCoinConfigKey } from '@/api/config'; import type { TElectrumServer } from '@/api/node'; import { ElectrumAddServer } from './electrum-add-server'; import { ElectrumServer } from './electrum-server'; @@ -11,7 +12,7 @@ import { Button } from '@/components/forms'; import style from './electrum.module.css'; type Props = { - coin: 'btc' | 'tbtc' | 'ltc' | 'tltc'; + coin: TBtcCoinConfigKey; }; export const ElectrumServers = ({ diff --git a/frontends/web/src/test/mock-config.ts b/frontends/web/src/test/mock-config.ts new file mode 100644 index 0000000000..4826efc06a --- /dev/null +++ b/frontends/web/src/test/mock-config.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { TConfig } from '@/api/config'; + +const emptyBackendConfig = (): TConfig['backend'] => ({} as TConfig['backend']); + +const emptyFrontendConfig = (): TConfig['frontend'] => ({}); + +export const mockConfig = (overrides: Partial = {}): TConfig => ({ + backend: { ...emptyBackendConfig(), ...overrides.backend }, + frontend: { ...emptyFrontendConfig(), ...overrides.frontend }, +}); diff --git a/frontends/web/src/utils/config.ts b/frontends/web/src/utils/config.ts index 93739886fc..a9ee38dcaa 100644 --- a/frontends/web/src/utils/config.ts +++ b/frontends/web/src/utils/config.ts @@ -4,15 +4,18 @@ import { getConfig as apiGetConfig, setConfig as apiSetConfig, type TConfig, typ let pendingConfig: TConfigUpdate = {}; -const normalizeConfig = (raw?: Partial): TConfig => { - const backend = (raw?.backend && typeof raw.backend === 'object' - ? { ...raw.backend } - : {}) as TConfig['backend']; - const frontend = (raw?.frontend && typeof raw.frontend === 'object' +export const emptyBackendConfig = (): TConfig['backend'] => ({} as TConfig['backend']); + +export const emptyFrontendConfig = (): TConfig['frontend'] => ({}); + +const normalizeConfig = (raw?: Partial): TConfig => ({ + backend: raw?.backend && typeof raw.backend === 'object' + ? { ...emptyBackendConfig(), ...raw.backend } + : emptyBackendConfig(), + frontend: raw?.frontend && typeof raw.frontend === 'object' ? { ...raw.frontend } - : {}) as TConfig['frontend']; - return { backend, frontend }; -}; + : emptyFrontendConfig(), +}); /** * Fetch current config from the backend. @@ -28,15 +31,25 @@ export const getConfig = (): Promise => { export const setConfig = (object: TConfigUpdate): Promise => { return getConfig() .then((currentConfig) => { - const nextConfig = { - backend: Object.assign({}, currentConfig.backend, pendingConfig.backend, object.backend), - frontend: Object.assign({}, currentConfig.frontend, pendingConfig.frontend, object.frontend) - } as TConfig; + const nextConfig: TConfig = { + backend: Object.assign( + {}, + currentConfig.backend, + pendingConfig.backend, + object.backend, + ) as TConfig['backend'], + frontend: Object.assign( + {}, + currentConfig.frontend, + pendingConfig.frontend, + object.frontend, + ), + }; pendingConfig = nextConfig; return apiSetConfig(nextConfig) .then(() => { pendingConfig = {}; - return nextConfig as TConfig; + return nextConfig; }); }); }; From 7642796b7b1050931d53376f1e015dda4cf355a2 Mon Sep 17 00:00:00 2001 From: Sibilla Date: Thu, 14 May 2026 17:31:55 +0200 Subject: [PATCH 06/24] frontend: migrate NewBadge to useConfig this avoids calling getConfig/setConfig directly and redundant config fetches --- .../src/components/new-badge/new-badge.tsx | 7 +- .../market/components/markettab.test.tsx | 68 +++++++++++-------- 2 files changed, 43 insertions(+), 32 deletions(-) diff --git a/frontends/web/src/components/new-badge/new-badge.tsx b/frontends/web/src/components/new-badge/new-badge.tsx index dddda8d14a..edc7deb4aa 100644 --- a/frontends/web/src/components/new-badge/new-badge.tsx +++ b/frontends/web/src/components/new-badge/new-badge.tsx @@ -4,8 +4,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { TFrontendBadgeConfigKey } from '@/api/config'; import { Badge } from '@/components/badge/badge'; -import { useLoad } from '@/hooks/api'; -import { getConfig, setConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; type TProps = { className?: string; @@ -27,7 +26,7 @@ export const NewBadge = ({ type = 'new', }: TProps) => { const { t } = useTranslation(); - const config = useLoad(getConfig); + const { config, setConfig } = useConfig(); const [showBadge, setShowBadge] = useState(undefined); const persistAsSeen = useCallback(() => { @@ -36,7 +35,7 @@ export const NewBadge = ({ } setShowBadge(false); setConfig({ frontend: { [configKey]: true } }); - }, [configKey, showBadge]); + }, [configKey, setConfig, showBadge]); useEffect(() => { if (!config) { diff --git a/frontends/web/src/routes/market/components/markettab.test.tsx b/frontends/web/src/routes/market/components/markettab.test.tsx index 54337437ab..1bae507fcb 100644 --- a/frontends/web/src/routes/market/components/markettab.test.tsx +++ b/frontends/web/src/routes/market/components/markettab.test.tsx @@ -3,31 +3,40 @@ import '../../../../__mocks__/i18n'; import { mockConfig } from '@/test/mock-config'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import { MarketTab } from './markettab'; -import { getConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; -vi.mock('@/utils/config', () => ({ - getConfig: vi.fn(), - setConfig: vi.fn(), +vi.mock('@/contexts/ConfigProvider', () => ({ + useConfig: vi.fn(() => ({ + config: mockConfig(), + setConfig: vi.fn(), + })), })); -const mockedGetConfig = vi.mocked(getConfig); +const mockUseConfig = vi.mocked(useConfig); describe('routes/market/components/markettab', () => { beforeEach(() => { - mockedGetConfig.mockResolvedValue(mockConfig()); + vi.clearAllMocks(); + mockUseConfig.mockReturnValue({ + config: mockConfig(), + setConfig: vi.fn(), + }); }); it('shows the new badge on swap when enabled', async () => { - mockedGetConfig.mockResolvedValue(mockConfig({ - frontend: { - hasSeenOtcMarketTab: true, - hasSeenSwapMarketTab: false, - } - })); + mockUseConfig.mockReturnValue({ + config: mockConfig({ + frontend: { + hasSeenOtcMarketTab: true, + hasSeenSwapMarketTab: false, + } + }), + setConfig: vi.fn(), + }); render( { }); it('hides the new badge on swap when disabled', async () => { - mockedGetConfig.mockResolvedValue(mockConfig({ - frontend: { - hasSeenOtcMarketTab: true, - hasSeenSwapMarketTab: true, - } - })); + mockUseConfig.mockReturnValue({ + config: mockConfig({ + frontend: { + hasSeenOtcMarketTab: true, + hasSeenSwapMarketTab: true, + } + }), + setConfig: vi.fn(), + }); render( { />, ); - await waitFor(() => { - expect(mockedGetConfig).toHaveBeenCalled(); - }); expect(screen.queryByTestId('swap-new-badge')).not.toBeInTheDocument(); }); @@ -82,12 +91,15 @@ describe('routes/market/components/markettab', () => { }); it('shows the new badge on otc when enabled', async () => { - mockedGetConfig.mockResolvedValue(mockConfig({ - frontend: { - hasSeenOtcMarketTab: false, - hasSeenSwapMarketTab: true, - } - })); + mockUseConfig.mockReturnValue({ + config: mockConfig({ + frontend: { + hasSeenOtcMarketTab: false, + hasSeenSwapMarketTab: true, + } + }), + setConfig: vi.fn(), + }); render( Date: Thu, 14 May 2026 17:45:40 +0200 Subject: [PATCH 07/24] frontend: remove duplicate onChangeConfig writes in advanced settings Drop the onChangeConfig callback chain; children already update config via useConfig(). --- .../web/src/routes/settings/advanced-settings.tsx | 14 +++++++------- .../advanced-settings/custom-gap-limit-setting.tsx | 8 +++----- .../advanced-settings/enable-auth-setting.tsx | 8 +++----- .../enable-coin-control-setting.tsx | 8 +++----- .../enable-custom-fees-toggle-setting.tsx | 8 +++----- .../advanced-settings/enable-tor-proxy-setting.tsx | 6 ++---- .../restart-in-testnet-setting.tsx | 13 +++---------- .../advanced-settings/tor-proxy-dialog.tsx | 8 +++----- 8 files changed, 27 insertions(+), 46 deletions(-) diff --git a/frontends/web/src/routes/settings/advanced-settings.tsx b/frontends/web/src/routes/settings/advanced-settings.tsx index 16aee93217..3858b984bb 100644 --- a/frontends/web/src/routes/settings/advanced-settings.tsx +++ b/frontends/web/src/routes/settings/advanced-settings.tsx @@ -23,7 +23,7 @@ import { GlobalBanners } from '@/components/banners'; export const AdvancedSettings = ({ devices, hasAccounts }: TPagePropsWithSettingsTabs) => { const { t } = useTranslation(); - const { config, setConfig } = useConfig(); + const { config } = useConfig(); const deviceIDs = Object.keys(devices); @@ -49,12 +49,12 @@ export const AdvancedSettings = ({ devices, hasAccounts }: TPagePropsWithSetting hideMobileMenu hasAccounts={hasAccounts} > - - - - - - + + + + + + diff --git a/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx index 95036b9f81..74a0b0854f 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx @@ -6,16 +6,15 @@ import { SettingsItem } from '@/routes/settings/components/settingsItem/settings import { Dialog, DialogButtons } from '@/components/dialog/dialog'; import { Button, Input } from '@/components/forms'; import { useConfig } from '@/contexts/ConfigProvider'; -import type { TBackendConfig, TConfig } from '@/api/config'; +import type { TBackendConfig } from '@/api/config'; import { Message } from '@/components/message/message'; import { useMediaQuery } from '@/hooks/mediaquery'; type TProps = { backendConfig?: TBackendConfig; - onChangeConfig: (config: TConfig) => void; }; -export const CustomGapLimitSettings = ({ backendConfig, onChangeConfig }: TProps) => { +export const CustomGapLimitSettings = ({ backendConfig }: TProps) => { const { t } = useTranslation(); const { setConfig } = useConfig(); const [showDialog, setShowDialog] = useState(false); @@ -37,14 +36,13 @@ export const CustomGapLimitSettings = ({ backendConfig, onChangeConfig }: TProps }, [backendConfig]); const handleSave = async () => { - const config = await setConfig({ + await setConfig({ backend: { ...backendConfig, gapLimitReceive: Number(gapLimitReceive), gapLimitChange: Number(gapLimitChange), }, }); - onChangeConfig(config); setShowDialog(false); }; diff --git a/frontends/web/src/routes/settings/components/advanced-settings/enable-auth-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/enable-auth-setting.tsx index d1674734ce..9d1d1bbbb2 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/enable-auth-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/enable-auth-setting.tsx @@ -2,7 +2,7 @@ import { ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import type { TBackendConfig, TConfig } from '@/api/config'; +import type { TBackendConfig } from '@/api/config'; import { Toggle } from '@/components/toggle/toggle'; import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; import { useConfig } from '@/contexts/ConfigProvider'; @@ -11,10 +11,9 @@ import { runningInAndroid, runningInIOS } from '@/utils/env'; type TProps = { backendConfig?: TBackendConfig; - onChangeConfig: (config: TConfig) => void; }; -export const EnableAuthSetting = ({ backendConfig, onChangeConfig }: TProps) => { +export const EnableAuthSetting = ({ backendConfig }: TProps) => { const { t } = useTranslation(); const { setConfig } = useConfig(); @@ -39,11 +38,10 @@ export const EnableAuthSetting = ({ backendConfig, onChangeConfig }: TProps) => }; const updateConfig = async (auth: boolean) => { - const config = await setConfig({ + await setConfig({ backend: { authentication: auth }, }); onAuthSettingChanged(); - onChangeConfig(config); }; if (!runningInAndroid() && !runningInIOS()) { diff --git a/frontends/web/src/routes/settings/components/advanced-settings/enable-coin-control-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/enable-coin-control-setting.tsx index 45c56f4102..27d4c494a0 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/enable-coin-control-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/enable-coin-control-setting.tsx @@ -2,27 +2,25 @@ import { ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import type { TConfig, TFrontendConfig } from '@/api/config'; +import type { TFrontendConfig } from '@/api/config'; import { Toggle } from '@/components/toggle/toggle'; import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; import { useConfig } from '@/contexts/ConfigProvider'; type TProps = { frontendConfig?: TFrontendConfig; - onChangeConfig: (config: TConfig) => void; }; -export const EnableCoinControlSetting = ({ frontendConfig, onChangeConfig }: TProps) => { +export const EnableCoinControlSetting = ({ frontendConfig }: TProps) => { const { t } = useTranslation(); const { setConfig } = useConfig(); const handleToggleFee = async (e: ChangeEvent) => { - const config = await setConfig({ + await setConfig({ frontend: { coinControl: e.target.checked }, }); - onChangeConfig(config); }; return ( diff --git a/frontends/web/src/routes/settings/components/advanced-settings/enable-custom-fees-toggle-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/enable-custom-fees-toggle-setting.tsx index e32f460d45..183f4b7b3c 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/enable-custom-fees-toggle-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/enable-custom-fees-toggle-setting.tsx @@ -2,27 +2,25 @@ import { ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import type { TConfig, TFrontendConfig } from '@/api/config'; +import type { TFrontendConfig } from '@/api/config'; import { Toggle } from '@/components/toggle/toggle'; import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; import { useConfig } from '@/contexts/ConfigProvider'; type TProps = { frontendConfig?: TFrontendConfig; - onChangeConfig: (config: TConfig) => void; }; -export const EnableCustomFeesToggleSetting = ({ frontendConfig, onChangeConfig }: TProps) => { +export const EnableCustomFeesToggleSetting = ({ frontendConfig }: TProps) => { const { t } = useTranslation(); const { setConfig } = useConfig(); const handleToggleFee = async (e: ChangeEvent) => { - const config = await setConfig({ + await setConfig({ frontend: { expertFee: e.target.checked }, }); - onChangeConfig(config); }; return ( diff --git a/frontends/web/src/routes/settings/components/advanced-settings/enable-tor-proxy-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/enable-tor-proxy-setting.tsx index f058f9f8fe..4351b832cb 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/enable-tor-proxy-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/enable-tor-proxy-setting.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import type { TConfig, TProxyConfig } from '@/api/config'; +import type { TProxyConfig } from '@/api/config'; import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; import { TorProxyDialog } from './tor-proxy-dialog'; import { Message } from '@/components/message/message'; @@ -10,10 +10,9 @@ import { runningInIOS } from '@/utils/env'; import styles from './enable-tor-proxy-setting.module.css'; type TProps = { proxyConfig?: TProxyConfig; - onChangeConfig: (config: TConfig) => void; }; -export const EnableTorProxySetting = ({ proxyConfig, onChangeConfig }: TProps) => { +export const EnableTorProxySetting = ({ proxyConfig }: TProps) => { const { t } = useTranslation(); const [showTorProxyDialog, setShowTorProxyDialog] = useState(false); const [showRestartMessage, setShowRestartMessage] = useState(false); @@ -48,7 +47,6 @@ export const EnableTorProxySetting = ({ proxyConfig, onChangeConfig }: TProps) = open={showTorProxyDialog} proxyConfig={proxyConfig} onCloseDialog={() => setShowTorProxyDialog(false)} - onChangeConfig={onChangeConfig} handleShowRestartMessage={setShowRestartMessage} /> diff --git a/frontends/web/src/routes/settings/components/advanced-settings/restart-in-testnet-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/restart-in-testnet-setting.tsx index b53dd10142..8f47e236ce 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/restart-in-testnet-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/restart-in-testnet-setting.tsx @@ -2,7 +2,6 @@ import { useContext, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import type { TConfig } from '@/api/config'; import { AppContext } from '@/contexts/AppContext'; import { useConfig } from '@/contexts/ConfigProvider'; import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; @@ -10,11 +9,7 @@ import { View, ViewButtons, ViewHeader } from '@/components/view/view'; import { Button } from '@/components/forms'; import { UseBackButton } from '@/hooks/backbutton'; -type TProps = { - onChangeConfig: (config: TConfig) => void; -}; - -export const RestartInTestnetSetting = ({ onChangeConfig }: TProps) => { +export const RestartInTestnetSetting = () => { const { t } = useTranslation(); const { setConfig } = useConfig(); const [showRestartMessage, setShowRestartMessage] = useState(false); @@ -22,23 +17,21 @@ export const RestartInTestnetSetting = ({ onChangeConfig }: TProps) => { const handleRestart = async () => { setShowRestartMessage(true); - const config = await setConfig({ + await setConfig({ backend: { startInTestnet: !isTesting }, }); - onChangeConfig(config); }; const handleReset = async () => { setShowRestartMessage(false); if (!isTesting) { - const config = await setConfig({ + await setConfig({ backend: { startInTestnet: false }, }); - onChangeConfig(config); } }; diff --git a/frontends/web/src/routes/settings/components/advanced-settings/tor-proxy-dialog.tsx b/frontends/web/src/routes/settings/components/advanced-settings/tor-proxy-dialog.tsx index dff1232b95..f97bdea378 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/tor-proxy-dialog.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/tor-proxy-dialog.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'; import { useEffect, useState } from 'react'; -import type { TConfig, TProxyConfig } from '@/api/config'; +import type { TProxyConfig } from '@/api/config'; import { useMediaQuery } from '@/hooks/mediaquery'; import { Dialog, DialogButtons } from '@/components/dialog/dialog'; import { Toggle } from '@/components/toggle/toggle'; @@ -15,11 +15,10 @@ type TProps = { open: boolean; proxyConfig?: TProxyConfig; onCloseDialog: () => void; - onChangeConfig: (config: TConfig) => void; handleShowRestartMessage: (show: boolean) => void; }; -export const TorProxyDialog = ({ open, proxyConfig, onCloseDialog, onChangeConfig, handleShowRestartMessage }: TProps) => { +export const TorProxyDialog = ({ open, proxyConfig, onCloseDialog, handleShowRestartMessage }: TProps) => { const { setConfig } = useConfig(); const [proxyAddress, setProxyAddress] = useState(); const { t } = useTranslation(); @@ -49,11 +48,10 @@ export const TorProxyDialog = ({ open, proxyConfig, onCloseDialog, onChangeConfi }; const setProxyConfig = async (proxyConfig: TProxyConfig) => { - const config = await setConfig({ + await setConfig({ backend: { proxy: proxyConfig }, }); setProxyAddress(proxyConfig.proxyAddress); - onChangeConfig(config); handleShowRestartMessage(true); }; From 06fcb733511b3a3e75eb5f5fb8d927640733b4e8 Mon Sep 17 00:00:00 2001 From: Sibilla Date: Fri, 15 May 2026 16:59:50 +0200 Subject: [PATCH 08/24] frontend: await bitsurance setConfig when clearing cancellation flag --- frontends/web/src/hooks/bitsurance.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontends/web/src/hooks/bitsurance.ts b/frontends/web/src/hooks/bitsurance.ts index af3f9d41bf..4a640b944c 100644 --- a/frontends/web/src/hooks/bitsurance.ts +++ b/frontends/web/src/hooks/bitsurance.ts @@ -80,11 +80,14 @@ export const useBitsurance = ( const freshConfig = await getConfig(); let cancelledAccounts: string[] = freshConfig.frontend?.bitsuranceNotifyCancellation ?? []; - if (cancelledAccounts?.includes(code)) { + if (cancelledAccounts.includes(code)) { alertUser(t('account.insuranceExpired')); const filtered = cancelledAccounts.filter(accountCode => accountCode !== code); - // Remove the pending notification from the frontend settings. - setConfig({ frontend: { bitsuranceNotifyCancellation: filtered } }); + try { + await setConfig({ frontend: { bitsuranceNotifyCancellation: filtered } }); + } catch (error) { + console.error(error); + } } const bitsuranceAccount = insuredAccounts.bitsuranceAccounts[0]; From 1e34610cc3129b341792631165d9dab93bd909ae Mon Sep 17 00:00:00 2001 From: Sibilla Date: Fri, 15 May 2026 17:02:51 +0200 Subject: [PATCH 09/24] frontend: address minor nits - Simplify darkmode unset spread in DarkmodeProvider - Remove redundant userLanguage cast in i18n detector after truthiness guard. - Align feetargets expertFee access with context nullability (direct frontend read in effect, optional config in render). - Simplify remember-wallet dialog config check to optional chaining. --- frontends/web/src/contexts/DarkmodeProvider.tsx | 3 +-- frontends/web/src/i18n/config.ts | 2 +- frontends/web/src/routes/account/send/feetargets.tsx | 4 ++-- .../manage-accounts/dialogs/enableRememberWalletDialog.tsx | 4 +--- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/frontends/web/src/contexts/DarkmodeProvider.tsx b/frontends/web/src/contexts/DarkmodeProvider.tsx index 84fdebf10a..cb3fccdd30 100644 --- a/frontends/web/src/contexts/DarkmodeProvider.tsx +++ b/frontends/web/src/contexts/DarkmodeProvider.tsx @@ -60,10 +60,9 @@ export const DarkModeProvider = ({ children }: TProps) => { } if (preferredDarkMode === darkmode) { // Remove darkmode from config, so it uses the same mode as the OS. - const { darkmode: _, ...frontend } = config.frontend; setConfig({ frontend: { - ...frontend, + ...config.frontend, darkmode: undefined, }, }); diff --git a/frontends/web/src/i18n/config.ts b/frontends/web/src/i18n/config.ts index 24ef931230..a1892f8e5a 100644 --- a/frontends/web/src/i18n/config.ts +++ b/frontends/web/src/i18n/config.ts @@ -13,7 +13,7 @@ export const languageFromConfig: LanguageDetectorAsyncModule = { detect: (cb) => { getConfig().then(({ backend }) => { if (backend && backend.userLanguage) { - cb(backend.userLanguage as string); + cb(backend.userLanguage); return; } getNativeLocale().then(locale => { diff --git a/frontends/web/src/routes/account/send/feetargets.tsx b/frontends/web/src/routes/account/send/feetargets.tsx index 862f582021..28b724e849 100644 --- a/frontends/web/src/routes/account/send/feetargets.tsx +++ b/frontends/web/src/routes/account/send/feetargets.tsx @@ -63,7 +63,7 @@ export const FeeTargets = ({ if (!config || !feeTargets) { return; } - const withCustomFee = config.frontend?.expertFee || feeTargets.feeTargets.length === 0; + const withCustomFee = config.frontend.expertFee || feeTargets.feeTargets.length === 0; const options = feeTargets.feeTargets.map(({ code, feeRateInfo }) => ({ value: code, label: t(`send.feeTarget.label.${code}`) + (withCustomFee && feeRateInfo ? ` (${feeRateInfo})` : ''), @@ -126,7 +126,7 @@ export const FeeTargets = ({ } const feetargetInfo = feeTargets?.feeTargets.find(({ code }) => code === option.value); - const withCustomFee = config?.frontend?.expertFee || feeTargets?.feeTargets.length === 0; + const withCustomFee = config?.frontend.expertFee || feeTargets?.feeTargets.length === 0; if (withCustomFee && feetargetInfo) { return ( <> diff --git a/frontends/web/src/routes/settings/components/manage-accounts/dialogs/enableRememberWalletDialog.tsx b/frontends/web/src/routes/settings/components/manage-accounts/dialogs/enableRememberWalletDialog.tsx index 7b27009ee2..deef5fe123 100644 --- a/frontends/web/src/routes/settings/components/manage-accounts/dialogs/enableRememberWalletDialog.tsx +++ b/frontends/web/src/routes/settings/components/manage-accounts/dialogs/enableRememberWalletDialog.tsx @@ -17,9 +17,7 @@ export const EnableRememberWalletDialog = ({ open, onClose }: Props) => { const [shouldNotShowDialog, setShouldNotShowDialog] = useState(false); useEffect(() => { - if (config !== undefined && config.frontend) { - setShouldNotShowDialog(!!config.frontend.hideEnableRememberWalletDialog); - } + setShouldNotShowDialog(!!config?.frontend.hideEnableRememberWalletDialog); }, [config]); if (shouldNotShowDialog) { From 21682d850686735c0516138201578ee4d047ed58 Mon Sep 17 00:00:00 2001 From: Sibilla Date: Tue, 19 May 2026 16:43:33 +0200 Subject: [PATCH 10/24] fix: remove unused import --- frontends/web/src/routes/market/bitrefill.tsx | 1 - frontends/web/src/routes/market/btcdirect.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/frontends/web/src/routes/market/bitrefill.tsx b/frontends/web/src/routes/market/bitrefill.tsx index f60498c709..b68e2f536c 100644 --- a/frontends/web/src/routes/market/bitrefill.tsx +++ b/frontends/web/src/routes/market/bitrefill.tsx @@ -13,7 +13,6 @@ import { useConfig } from '@/contexts/ConfigProvider'; import { i18n } from '@/i18n/i18n'; import { alertUser } from '@/components/alert/Alert'; import { parseExternalBtcAmount } from '@/api/coins'; -import { useLoad } from '@/hooks/api'; import { BitrefillTerms, localeMapping } from '@/components/terms/bitrefill-terms'; import { getBitrefillInfo } from '@/api/market'; import { getURLOrigin } from '@/utils/url'; diff --git a/frontends/web/src/routes/market/btcdirect.tsx b/frontends/web/src/routes/market/btcdirect.tsx index 78314a34fd..e4970b9e1e 100644 --- a/frontends/web/src/routes/market/btcdirect.tsx +++ b/frontends/web/src/routes/market/btcdirect.tsx @@ -7,7 +7,6 @@ import { getBTCDirectInfo, TMarketAction } from '@/api/market'; import { parseExternalBtcAmount } from '@/api/coins'; import { AppContext } from '@/contexts/AppContext'; import { AccountCode, TAccount, proposeTx, sendTx, TTxInput } from '@/api/account'; -import { useLoad } from '@/hooks/api'; import { useAccountSynced } from '@/hooks/account'; import { useDarkmode } from '@/hooks/darkmode'; import { UseDisableBackButton } from '@/hooks/backbutton'; From 24e614652c4c4050993b1f93ab06f511b8f1baaa Mon Sep 17 00:00:00 2001 From: Sibilla Date: Wed, 20 May 2026 11:47:08 +0200 Subject: [PATCH 11/24] fix: export only config types that are imported in other files --- frontends/web/src/api/config.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontends/web/src/api/config.ts b/frontends/web/src/api/config.ts index 6bad5edec2..4c10725b01 100644 --- a/frontends/web/src/api/config.ts +++ b/frontends/web/src/api/config.ts @@ -4,20 +4,20 @@ import { apiGet, apiPost } from '@/utils/request'; import type { Fiat } from '@/api/account'; import type { BtcUnit } from '@/api/coins'; -export type TElectrumServerInfo = Readonly<{ +type TElectrumServerInfo = Readonly<{ server: string; tls: boolean; pemCert: string; }>; -export type TBtcCoinConfig = Readonly<{ +type TBtcCoinConfig = Readonly<{ electrumServers: TElectrumServerInfo[]; }>; /** BTC-based coin keys in backend config (see backend/config/config.go). */ export type TBtcCoinConfigKey = 'btc' | 'tbtc' | 'ltc' | 'tltc'; -export type TEthCoinConfig = Readonly<{ +type TEthCoinConfig = Readonly<{ activeERC20Tokens: string[]; }>; @@ -33,13 +33,13 @@ export type TFrontendBadgeConfigKey = | 'hasSeenOtcMarketTab'; /** Dynamic frontend keys written when dismissing Status banners. */ -export type TDynamicDismissibleFrontendKey = +type TDynamicDismissibleFrontendKey = | `update-${string}` | `banner-backup-${string}` | `banner-${string}-${string}`; /** Known static frontend keys written when dismissing Status banners. */ -export type TKnownDismissibleFrontendConfigKey = +type TKnownDismissibleFrontendConfigKey = | 'walletConnectDisclaimerDismissed' | 'skipTestingWarning' | 'mobile-data-warning'; @@ -108,12 +108,12 @@ export type TConfig = { }; /** Partial backend config for updates; null clears userLanguage (see i18n.ts). */ -export type TBackendConfigUpdate = +type TBackendConfigUpdate = Omit, 'userLanguage'> & { userLanguage?: string | null; }; -export type TFrontendConfigUpdate = Partial; +type TFrontendConfigUpdate = Partial; export type TConfigUpdate = { backend?: TBackendConfigUpdate; From fd60ec87fb4d9af05072a85b3e10672dd5d3d306 Mon Sep 17 00:00:00 2001 From: Sibilla Date: Wed, 20 May 2026 11:50:04 +0200 Subject: [PATCH 12/24] nit: add comment for deprecated coisn to match backend --- frontends/web/src/api/config.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontends/web/src/api/config.ts b/frontends/web/src/api/config.ts index 4c10725b01..9c1e894a0d 100644 --- a/frontends/web/src/api/config.ts +++ b/frontends/web/src/api/config.ts @@ -17,6 +17,9 @@ type TBtcCoinConfig = Readonly<{ /** BTC-based coin keys in backend config (see backend/config/config.go). */ export type TBtcCoinConfigKey = 'btc' | 'tbtc' | 'ltc' | 'tltc'; +// Mirrors backend/config/config.go ethCoinConfig: DeprecatedActiveERC20Tokens (JSON key +// "activeERC20Tokens"). Deprecated — ERC20 activation is per-account in accounts config; kept +// for migration / compatibility with persisted app config. type TEthCoinConfig = Readonly<{ activeERC20Tokens: string[]; }>; @@ -81,6 +84,11 @@ export type TFrontendConfig = Readonly<{ export type TBackendConfig = Readonly<{ proxy: TProxyConfig; + /** + * Deprecated global coin activation flags (backend/config/config.go: DeprecatedBitcoinActive, + * DeprecatedLitecoinActive, DeprecatedEthereumActive). Coins are configured per account now; + * kept for migration / compatibility with persisted app config. + */ bitcoinActive: boolean; litecoinActive: boolean; ethereumActive: boolean; From c5b65e0eb3cd1c1470b818882053a5f67d23280c Mon Sep 17 00:00:00 2001 From: Sibilla Date: Wed, 20 May 2026 12:40:42 +0200 Subject: [PATCH 13/24] fix: drop redundant frontend/backend guards after config loads type getConfig with required top-level keys; guard only undefined config (and nested fields like proxy) instead of optional frontend/backend objects. --- frontends/web/src/api/config.ts | 12 +++++++----- .../src/components/banners/offline-error.tsx | 2 +- .../web/src/components/new-badge/new-badge.tsx | 2 +- frontends/web/src/components/status/status.tsx | 2 +- frontends/web/src/contexts/AppProvider.tsx | 17 +++++++++-------- .../web/src/contexts/WCWeb3WalletProvider.tsx | 2 +- .../src/routes/account/send/coin-control.tsx | 5 +++-- .../web/src/routes/account/send/feetargets.tsx | 5 ++++- frontends/web/src/routes/bitsurance/widget.tsx | 2 +- frontends/web/src/routes/market/bitrefill.tsx | 2 +- frontends/web/src/routes/market/btcdirect.tsx | 2 +- frontends/web/src/routes/market/market.tsx | 8 ++++---- frontends/web/src/routes/market/moonpay.tsx | 2 +- frontends/web/src/routes/market/pocket.tsx | 2 +- frontends/web/src/routes/market/swap/swap.tsx | 2 +- .../src/routes/settings/advanced-settings.tsx | 14 +++++++++----- .../dialogs/enableRememberWalletDialog.tsx | 6 +++++- .../manage-accounts/watchonlySetting.tsx | 5 +++-- frontends/web/src/utils/config.ts | 10 +++------- 19 files changed, 57 insertions(+), 45 deletions(-) diff --git a/frontends/web/src/api/config.ts b/frontends/web/src/api/config.ts index 9c1e894a0d..b975e81cc7 100644 --- a/frontends/web/src/api/config.ts +++ b/frontends/web/src/api/config.ts @@ -129,12 +129,14 @@ export type TConfigUpdate = { }; /** - * Fetch raw config from the backend. Keys may be missing; use getConfig from - * @/utils/config for a normalized TConfig. + * Fetch raw config from the backend. Response always includes `backend` and + * `frontend` objects (see handlers.getAppConfig). Nested backend fields may be + * incomplete; use getConfig from @/utils/config for a normalized TConfig. */ -export const getConfig = (): Promise> => { - return apiGet('config'); -}; +export const getConfig = () => + apiGet('config') as Promise< + Readonly<{ backend: Partial; frontend: TFrontendConfig }> + >; /** * Post a config object to the backend. diff --git a/frontends/web/src/components/banners/offline-error.tsx b/frontends/web/src/components/banners/offline-error.tsx index 8a8791354f..1e02716dcd 100644 --- a/frontends/web/src/components/banners/offline-error.tsx +++ b/frontends/web/src/components/banners/offline-error.tsx @@ -14,7 +14,7 @@ export const OfflineError = ({ }: Props) => { const { t } = useTranslation(); const { config } = useConfig(); - const usesProxy = config?.backend.proxy.useProxy; + const usesProxy = config ? Boolean(config.backend.proxy?.useProxy) : false; // Status: offline error const offlineErrorTextLines: string[] = []; diff --git a/frontends/web/src/components/new-badge/new-badge.tsx b/frontends/web/src/components/new-badge/new-badge.tsx index edc7deb4aa..fdc14b8f4e 100644 --- a/frontends/web/src/components/new-badge/new-badge.tsx +++ b/frontends/web/src/components/new-badge/new-badge.tsx @@ -41,7 +41,7 @@ export const NewBadge = ({ if (!config) { return; } - const hasSeenBadge = Boolean(config.frontend?.[configKey]); + const hasSeenBadge = Boolean(config.frontend[configKey]); setShowBadge(currentShowBadge => ( currentShowBadge === false ? false : !hasSeenBadge )); diff --git a/frontends/web/src/components/status/status.tsx b/frontends/web/src/components/status/status.tsx index 865fb59b7e..81e1ab923c 100644 --- a/frontends/web/src/components/status/status.tsx +++ b/frontends/web/src/components/status/status.tsx @@ -36,7 +36,7 @@ export const Status = ({ const show = hidden ? false : dismissibleKey - ? !config?.frontend?.[dismissibleKey] + ? (config ? !config.frontend[dismissibleKey] : true) : true; const dismiss = async () => { diff --git a/frontends/web/src/contexts/AppProvider.tsx b/frontends/web/src/contexts/AppProvider.tsx index e8b7687fab..8bedef4b61 100644 --- a/frontends/web/src/contexts/AppProvider.tsx +++ b/frontends/web/src/contexts/AppProvider.tsx @@ -63,14 +63,15 @@ export const AppProvider = ({ children }: TProps) => { }, [activeSidebar, isMobile, orientation]); useEffect(() => { - const frontend = config?.frontend; - if (frontend) { - if (frontend.guideShown !== undefined) { - setGuideShown(Boolean(frontend.guideShown)); - } - if (frontend.hideAmounts !== undefined) { - setHideAmounts(Boolean(frontend.hideAmounts)); - } + if (!config) { + return; + } + const { frontend } = config; + if (frontend.guideShown !== undefined) { + setGuideShown(Boolean(frontend.guideShown)); + } + if (frontend.hideAmounts !== undefined) { + setHideAmounts(Boolean(frontend.hideAmounts)); } }, [config]); diff --git a/frontends/web/src/contexts/WCWeb3WalletProvider.tsx b/frontends/web/src/contexts/WCWeb3WalletProvider.tsx index 78ef04030b..5c567516c6 100644 --- a/frontends/web/src/contexts/WCWeb3WalletProvider.tsx +++ b/frontends/web/src/contexts/WCWeb3WalletProvider.tsx @@ -16,7 +16,7 @@ export const WCWeb3WalletProvider = ({ children }: TProps) => { const { t } = useTranslation(); const [web3wallet, setWeb3wallet] = useState(); const [isWalletInitialized, setIsWalletInitialized] = useState(false); - const hasUsedWC = config?.frontend?.hasUsedWalletConnect; + const hasUsedWC = config ? Boolean(config.frontend.hasUsedWalletConnect) : false; const initializeWeb3Wallet = async () => { try { diff --git a/frontends/web/src/routes/account/send/coin-control.tsx b/frontends/web/src/routes/account/send/coin-control.tsx index efc5df1774..c433188d9c 100644 --- a/frontends/web/src/routes/account/send/coin-control.tsx +++ b/frontends/web/src/routes/account/send/coin-control.tsx @@ -27,9 +27,10 @@ export const CoinControl = ({ const [showUTXODialog, setShowUTXODialog] = useState(false); useEffect(() => { - if (isBitcoinBased(account.coinCode) && config !== undefined) { - setCoinControlEnabled(!!(config.frontend || {}).coinControl); + if (!isBitcoinBased(account.coinCode) || !config) { + return; } + setCoinControlEnabled(!!config.frontend.coinControl); }, [account.coinCode, config]); // Notify parent whenever dialog visibility changes diff --git a/frontends/web/src/routes/account/send/feetargets.tsx b/frontends/web/src/routes/account/send/feetargets.tsx index 28b724e849..aa229bd0b3 100644 --- a/frontends/web/src/routes/account/send/feetargets.tsx +++ b/frontends/web/src/routes/account/send/feetargets.tsx @@ -125,8 +125,11 @@ export const FeeTargets = ({ return null; } + if (!config) { + return null; + } const feetargetInfo = feeTargets?.feeTargets.find(({ code }) => code === option.value); - const withCustomFee = config?.frontend.expertFee || feeTargets?.feeTargets.length === 0; + const withCustomFee = config.frontend.expertFee || feeTargets?.feeTargets.length === 0; if (withCustomFee && feetargetInfo) { return ( <> diff --git a/frontends/web/src/routes/bitsurance/widget.tsx b/frontends/web/src/routes/bitsurance/widget.tsx index 5297aa2cd5..7672c5da6f 100644 --- a/frontends/web/src/routes/bitsurance/widget.tsx +++ b/frontends/web/src/routes/bitsurance/widget.tsx @@ -32,7 +32,7 @@ export const BitsuranceWidget = ({ code }: TProps) => { const accountInfo = useLoad(getInfo(code)); const { containerRef, height, iframeLoaded, iframeRef, onIframeLoad } = useVendorIframeResizeHeight(); - const { agreedTerms, setAgreedTerms } = useVendorTerms(Boolean(config?.frontend.skipBitsuranceDisclaimer)); + const { agreedTerms, setAgreedTerms } = useVendorTerms(config ? Boolean(config.frontend.skipBitsuranceDisclaimer) : false); const signingRef = useRef(false); useEffect(() => { diff --git a/frontends/web/src/routes/market/bitrefill.tsx b/frontends/web/src/routes/market/bitrefill.tsx index b68e2f536c..e8e463c616 100644 --- a/frontends/web/src/routes/market/bitrefill.tsx +++ b/frontends/web/src/routes/market/bitrefill.tsx @@ -52,7 +52,7 @@ export const Bitrefill = ({ const fetchBitrefillInfo = useCallback(() => getBitrefillInfo('spend', code), [code]); const bitrefillInfo = useAccountSynced(code, fetchBitrefillInfo); const { containerRef, height, iframeLoaded, iframeRef, onIframeLoad } = useVendorIframeResizeHeight(); - const { agreedTerms, setAgreedTerms } = useVendorTerms(Boolean(config?.frontend.skipBitrefillWidgetDisclaimer)); + const { agreedTerms, setAgreedTerms } = useVendorTerms(config ? Boolean(config.frontend.skipBitrefillWidgetDisclaimer) : false); const [pendingPayment, setPendingPayment] = useState(false); const [verifyPaymentRequest, setVerifyPaymentRequest] = useState(false); diff --git a/frontends/web/src/routes/market/btcdirect.tsx b/frontends/web/src/routes/market/btcdirect.tsx index e4970b9e1e..0ac05d8768 100644 --- a/frontends/web/src/routes/market/btcdirect.tsx +++ b/frontends/web/src/routes/market/btcdirect.tsx @@ -56,7 +56,7 @@ export const BTCDirect = ({ const account = findAccount(accounts, code); const { containerRef, height, iframeLoaded, iframeRef, onIframeLoad } = useVendorIframeResizeHeight(); - const { agreedTerms, setAgreedTerms } = useVendorTerms(Boolean(config?.frontend.skipBTCDirectWidgetDisclaimer)); + const { agreedTerms, setAgreedTerms } = useVendorTerms(config ? Boolean(config.frontend.skipBTCDirectWidgetDisclaimer) : false); const handlePaymentRequest = useCallback(async (event: MessageEvent) => { const { diff --git a/frontends/web/src/routes/market/market.tsx b/frontends/web/src/routes/market/market.tsx index 0abc67d14f..5facd76de3 100644 --- a/frontends/web/src/routes/market/market.tsx +++ b/frontends/web/src/routes/market/market.tsx @@ -63,11 +63,11 @@ export const Market = ({ const { agreedTerms: agreedBTCDirectOTCTerms, - } = useVendorTerms(!!config?.frontend?.skipBitsuranceDisclaimer); + } = useVendorTerms(config ? !!config.frontend.skipBitsuranceDisclaimer : false); const { agreedTerms: agreedPocketOTCTerms, - } = useVendorTerms(!!config?.frontend?.skipPocketOTCDisclaimer); + } = useVendorTerms(config ? !!config.frontend.skipPocketOTCDisclaimer : false); // keep account list in sync and ensure a valid selected account. useEffect(() => { @@ -93,11 +93,11 @@ export const Market = ({ setRegions(regions); // if user had selected no region before, do not pre-select any. - if (config.frontend?.selectedExchangeRegion === '') { + if (config.frontend.selectedExchangeRegion === '') { return; } - if (config.frontend?.selectedExchangeRegion) { + if (config.frontend.selectedExchangeRegion) { // pre-select config region setSelectedRegion(String(config.frontend.selectedExchangeRegion)); return; diff --git a/frontends/web/src/routes/market/moonpay.tsx b/frontends/web/src/routes/market/moonpay.tsx index b09cbb26ef..8484ef8fef 100644 --- a/frontends/web/src/routes/market/moonpay.tsx +++ b/frontends/web/src/routes/market/moonpay.tsx @@ -31,7 +31,7 @@ export const Moonpay = ({ accounts, code }: TProps) => { const account = findAccount(accounts, code); const { containerRef, height, iframeLoaded, onIframeLoad } = useVendorIframeResizeHeight(); - const { agreedTerms, setAgreedTerms } = useVendorTerms(Boolean(config?.frontend.skipMoonpayDisclaimer)); + const { agreedTerms, setAgreedTerms } = useVendorTerms(config ? Boolean(config.frontend.skipMoonpayDisclaimer) : false); if (!account || !config) { return null; diff --git a/frontends/web/src/routes/market/pocket.tsx b/frontends/web/src/routes/market/pocket.tsx index 3047b7952d..b3d5c9fb34 100644 --- a/frontends/web/src/routes/market/pocket.tsx +++ b/frontends/web/src/routes/market/pocket.tsx @@ -49,7 +49,7 @@ export const Pocket = ({ const accountInfo = useLoad(getInfo(code)); const { containerRef, height, iframeLoaded, iframeRef, onIframeLoad } = useVendorIframeResizeHeight(); - const { agreedTerms, setAgreedTerms } = useVendorTerms(Boolean(config?.frontend.skipPocketDisclaimer)); + const { agreedTerms, setAgreedTerms } = useVendorTerms(config ? Boolean(config.frontend.skipPocketDisclaimer) : false); const signingRef = useRef(false); const pocketInfo = useAccountSynced(code, useCallback(() => getPocketURL(action), [action])); diff --git a/frontends/web/src/routes/market/swap/swap.tsx b/frontends/web/src/routes/market/swap/swap.tsx index 7545630693..229beb7548 100644 --- a/frontends/web/src/routes/market/swap/swap.tsx +++ b/frontends/web/src/routes/market/swap/swap.tsx @@ -160,7 +160,7 @@ export const Swap = ({ ); const config = useLoad(getConfig); - const { agreedTerms, setAgreedTerms } = useVendorTerms(!!config?.frontend?.skipSwapkitDisclaimer); + const { agreedTerms, setAgreedTerms } = useVendorTerms(config ? !!config.frontend.skipSwapkitDisclaimer : false); const isSameCoinAccount = ( candidate: TSwapAccount, diff --git a/frontends/web/src/routes/settings/advanced-settings.tsx b/frontends/web/src/routes/settings/advanced-settings.tsx index 3858b984bb..0ee7475e2e 100644 --- a/frontends/web/src/routes/settings/advanced-settings.tsx +++ b/frontends/web/src/routes/settings/advanced-settings.tsx @@ -27,6 +27,10 @@ export const AdvancedSettings = ({ devices, hasAccounts }: TPagePropsWithSetting const deviceIDs = Object.keys(devices); + if (config === undefined) { + return null; + } + return ( @@ -49,12 +53,12 @@ export const AdvancedSettings = ({ devices, hasAccounts }: TPagePropsWithSetting hideMobileMenu hasAccounts={hasAccounts} > - - - - + + + + - + diff --git a/frontends/web/src/routes/settings/components/manage-accounts/dialogs/enableRememberWalletDialog.tsx b/frontends/web/src/routes/settings/components/manage-accounts/dialogs/enableRememberWalletDialog.tsx index deef5fe123..4ba72dfbfe 100644 --- a/frontends/web/src/routes/settings/components/manage-accounts/dialogs/enableRememberWalletDialog.tsx +++ b/frontends/web/src/routes/settings/components/manage-accounts/dialogs/enableRememberWalletDialog.tsx @@ -17,7 +17,11 @@ export const EnableRememberWalletDialog = ({ open, onClose }: Props) => { const [shouldNotShowDialog, setShouldNotShowDialog] = useState(false); useEffect(() => { - setShouldNotShowDialog(!!config?.frontend.hideEnableRememberWalletDialog); + if (!config) { + setShouldNotShowDialog(false); + return; + } + setShouldNotShowDialog(!!config.frontend.hideEnableRememberWalletDialog); }, [config]); if (shouldNotShowDialog) { diff --git a/frontends/web/src/routes/settings/components/manage-accounts/watchonlySetting.tsx b/frontends/web/src/routes/settings/components/manage-accounts/watchonlySetting.tsx index 6c695c43d5..122729c950 100644 --- a/frontends/web/src/routes/settings/components/manage-accounts/watchonlySetting.tsx +++ b/frontends/web/src/routes/settings/components/manage-accounts/watchonlySetting.tsx @@ -24,9 +24,10 @@ export const WatchonlySetting = ({ keystore }: Props) => { const [walletRememberedDialogOpen, setWalletRememberedDialogOpen] = useState(false); useEffect(() => { - if (config !== undefined) { - setWatchonly(keystore.watchonly); + if (!config) { + return; } + setWatchonly(keystore.watchonly); }, [config, keystore]); const toggleWatchonly = async () => { diff --git a/frontends/web/src/utils/config.ts b/frontends/web/src/utils/config.ts index a9ee38dcaa..e8c8c26a5f 100644 --- a/frontends/web/src/utils/config.ts +++ b/frontends/web/src/utils/config.ts @@ -8,13 +8,9 @@ export const emptyBackendConfig = (): TConfig['backend'] => ({} as TConfig['back export const emptyFrontendConfig = (): TConfig['frontend'] => ({}); -const normalizeConfig = (raw?: Partial): TConfig => ({ - backend: raw?.backend && typeof raw.backend === 'object' - ? { ...emptyBackendConfig(), ...raw.backend } - : emptyBackendConfig(), - frontend: raw?.frontend && typeof raw.frontend === 'object' - ? { ...raw.frontend } - : emptyFrontendConfig(), +const normalizeConfig = (raw: Awaited>): TConfig => ({ + backend: { ...emptyBackendConfig(), ...raw.backend }, + frontend: { ...raw.frontend }, }); /** From 4a1496f25ee3c2661e9353ad41a7fb20ea491f8e Mon Sep 17 00:00:00 2001 From: Sibilla Date: Wed, 20 May 2026 12:46:56 +0200 Subject: [PATCH 14/24] fix: drop redundant Boolean() on boolean config fields --- frontends/web/src/components/banners/offline-error.tsx | 2 +- frontends/web/src/components/new-badge/new-badge.tsx | 2 +- frontends/web/src/contexts/AppProvider.tsx | 4 ++-- frontends/web/src/contexts/WCWeb3WalletProvider.tsx | 2 +- frontends/web/src/routes/account/send/coin-control.tsx | 2 +- frontends/web/src/routes/bitsurance/widget.tsx | 2 +- frontends/web/src/routes/market/bitrefill.tsx | 2 +- frontends/web/src/routes/market/btcdirect.tsx | 2 +- frontends/web/src/routes/market/market.tsx | 4 ++-- frontends/web/src/routes/market/moonpay.tsx | 2 +- frontends/web/src/routes/market/pocket.tsx | 2 +- frontends/web/src/routes/market/swap/swap.tsx | 2 +- .../manage-accounts/dialogs/enableRememberWalletDialog.tsx | 2 +- 13 files changed, 15 insertions(+), 15 deletions(-) diff --git a/frontends/web/src/components/banners/offline-error.tsx b/frontends/web/src/components/banners/offline-error.tsx index 1e02716dcd..781c984fa5 100644 --- a/frontends/web/src/components/banners/offline-error.tsx +++ b/frontends/web/src/components/banners/offline-error.tsx @@ -14,7 +14,7 @@ export const OfflineError = ({ }: Props) => { const { t } = useTranslation(); const { config } = useConfig(); - const usesProxy = config ? Boolean(config.backend.proxy?.useProxy) : false; + const usesProxy = config?.backend.proxy?.useProxy ?? false; // Status: offline error const offlineErrorTextLines: string[] = []; diff --git a/frontends/web/src/components/new-badge/new-badge.tsx b/frontends/web/src/components/new-badge/new-badge.tsx index fdc14b8f4e..606fcd4af9 100644 --- a/frontends/web/src/components/new-badge/new-badge.tsx +++ b/frontends/web/src/components/new-badge/new-badge.tsx @@ -41,7 +41,7 @@ export const NewBadge = ({ if (!config) { return; } - const hasSeenBadge = Boolean(config.frontend[configKey]); + const hasSeenBadge = config.frontend[configKey]; setShowBadge(currentShowBadge => ( currentShowBadge === false ? false : !hasSeenBadge )); diff --git a/frontends/web/src/contexts/AppProvider.tsx b/frontends/web/src/contexts/AppProvider.tsx index 8bedef4b61..2831931e84 100644 --- a/frontends/web/src/contexts/AppProvider.tsx +++ b/frontends/web/src/contexts/AppProvider.tsx @@ -68,10 +68,10 @@ export const AppProvider = ({ children }: TProps) => { } const { frontend } = config; if (frontend.guideShown !== undefined) { - setGuideShown(Boolean(frontend.guideShown)); + setGuideShown(frontend.guideShown); } if (frontend.hideAmounts !== undefined) { - setHideAmounts(Boolean(frontend.hideAmounts)); + setHideAmounts(frontend.hideAmounts); } }, [config]); diff --git a/frontends/web/src/contexts/WCWeb3WalletProvider.tsx b/frontends/web/src/contexts/WCWeb3WalletProvider.tsx index 5c567516c6..8e0c858d66 100644 --- a/frontends/web/src/contexts/WCWeb3WalletProvider.tsx +++ b/frontends/web/src/contexts/WCWeb3WalletProvider.tsx @@ -16,7 +16,7 @@ export const WCWeb3WalletProvider = ({ children }: TProps) => { const { t } = useTranslation(); const [web3wallet, setWeb3wallet] = useState(); const [isWalletInitialized, setIsWalletInitialized] = useState(false); - const hasUsedWC = config ? Boolean(config.frontend.hasUsedWalletConnect) : false; + const hasUsedWC = config?.frontend.hasUsedWalletConnect ?? false; const initializeWeb3Wallet = async () => { try { diff --git a/frontends/web/src/routes/account/send/coin-control.tsx b/frontends/web/src/routes/account/send/coin-control.tsx index c433188d9c..a06f8ce9df 100644 --- a/frontends/web/src/routes/account/send/coin-control.tsx +++ b/frontends/web/src/routes/account/send/coin-control.tsx @@ -30,7 +30,7 @@ export const CoinControl = ({ if (!isBitcoinBased(account.coinCode) || !config) { return; } - setCoinControlEnabled(!!config.frontend.coinControl); + setCoinControlEnabled(config.frontend.coinControl ?? false); }, [account.coinCode, config]); // Notify parent whenever dialog visibility changes diff --git a/frontends/web/src/routes/bitsurance/widget.tsx b/frontends/web/src/routes/bitsurance/widget.tsx index 7672c5da6f..c0dbf46bbd 100644 --- a/frontends/web/src/routes/bitsurance/widget.tsx +++ b/frontends/web/src/routes/bitsurance/widget.tsx @@ -32,7 +32,7 @@ export const BitsuranceWidget = ({ code }: TProps) => { const accountInfo = useLoad(getInfo(code)); const { containerRef, height, iframeLoaded, iframeRef, onIframeLoad } = useVendorIframeResizeHeight(); - const { agreedTerms, setAgreedTerms } = useVendorTerms(config ? Boolean(config.frontend.skipBitsuranceDisclaimer) : false); + const { agreedTerms, setAgreedTerms } = useVendorTerms(config?.frontend.skipBitsuranceDisclaimer ?? false); const signingRef = useRef(false); useEffect(() => { diff --git a/frontends/web/src/routes/market/bitrefill.tsx b/frontends/web/src/routes/market/bitrefill.tsx index e8e463c616..8a2d52b45f 100644 --- a/frontends/web/src/routes/market/bitrefill.tsx +++ b/frontends/web/src/routes/market/bitrefill.tsx @@ -52,7 +52,7 @@ export const Bitrefill = ({ const fetchBitrefillInfo = useCallback(() => getBitrefillInfo('spend', code), [code]); const bitrefillInfo = useAccountSynced(code, fetchBitrefillInfo); const { containerRef, height, iframeLoaded, iframeRef, onIframeLoad } = useVendorIframeResizeHeight(); - const { agreedTerms, setAgreedTerms } = useVendorTerms(config ? Boolean(config.frontend.skipBitrefillWidgetDisclaimer) : false); + const { agreedTerms, setAgreedTerms } = useVendorTerms(config?.frontend.skipBitrefillWidgetDisclaimer ?? false); const [pendingPayment, setPendingPayment] = useState(false); const [verifyPaymentRequest, setVerifyPaymentRequest] = useState(false); diff --git a/frontends/web/src/routes/market/btcdirect.tsx b/frontends/web/src/routes/market/btcdirect.tsx index 0ac05d8768..fff31bdf69 100644 --- a/frontends/web/src/routes/market/btcdirect.tsx +++ b/frontends/web/src/routes/market/btcdirect.tsx @@ -56,7 +56,7 @@ export const BTCDirect = ({ const account = findAccount(accounts, code); const { containerRef, height, iframeLoaded, iframeRef, onIframeLoad } = useVendorIframeResizeHeight(); - const { agreedTerms, setAgreedTerms } = useVendorTerms(config ? Boolean(config.frontend.skipBTCDirectWidgetDisclaimer) : false); + const { agreedTerms, setAgreedTerms } = useVendorTerms(config?.frontend.skipBTCDirectWidgetDisclaimer ?? false); const handlePaymentRequest = useCallback(async (event: MessageEvent) => { const { diff --git a/frontends/web/src/routes/market/market.tsx b/frontends/web/src/routes/market/market.tsx index 5facd76de3..921261ac84 100644 --- a/frontends/web/src/routes/market/market.tsx +++ b/frontends/web/src/routes/market/market.tsx @@ -63,11 +63,11 @@ export const Market = ({ const { agreedTerms: agreedBTCDirectOTCTerms, - } = useVendorTerms(config ? !!config.frontend.skipBitsuranceDisclaimer : false); + } = useVendorTerms(config?.frontend.skipBitsuranceDisclaimer ?? false); const { agreedTerms: agreedPocketOTCTerms, - } = useVendorTerms(config ? !!config.frontend.skipPocketOTCDisclaimer : false); + } = useVendorTerms(config?.frontend.skipPocketOTCDisclaimer ?? false); // keep account list in sync and ensure a valid selected account. useEffect(() => { diff --git a/frontends/web/src/routes/market/moonpay.tsx b/frontends/web/src/routes/market/moonpay.tsx index 8484ef8fef..c001f85f8c 100644 --- a/frontends/web/src/routes/market/moonpay.tsx +++ b/frontends/web/src/routes/market/moonpay.tsx @@ -31,7 +31,7 @@ export const Moonpay = ({ accounts, code }: TProps) => { const account = findAccount(accounts, code); const { containerRef, height, iframeLoaded, onIframeLoad } = useVendorIframeResizeHeight(); - const { agreedTerms, setAgreedTerms } = useVendorTerms(config ? Boolean(config.frontend.skipMoonpayDisclaimer) : false); + const { agreedTerms, setAgreedTerms } = useVendorTerms(config?.frontend.skipMoonpayDisclaimer ?? false); if (!account || !config) { return null; diff --git a/frontends/web/src/routes/market/pocket.tsx b/frontends/web/src/routes/market/pocket.tsx index b3d5c9fb34..e870039410 100644 --- a/frontends/web/src/routes/market/pocket.tsx +++ b/frontends/web/src/routes/market/pocket.tsx @@ -49,7 +49,7 @@ export const Pocket = ({ const accountInfo = useLoad(getInfo(code)); const { containerRef, height, iframeLoaded, iframeRef, onIframeLoad } = useVendorIframeResizeHeight(); - const { agreedTerms, setAgreedTerms } = useVendorTerms(config ? Boolean(config.frontend.skipPocketDisclaimer) : false); + const { agreedTerms, setAgreedTerms } = useVendorTerms(config?.frontend.skipPocketDisclaimer ?? false); const signingRef = useRef(false); const pocketInfo = useAccountSynced(code, useCallback(() => getPocketURL(action), [action])); diff --git a/frontends/web/src/routes/market/swap/swap.tsx b/frontends/web/src/routes/market/swap/swap.tsx index 229beb7548..d79545f656 100644 --- a/frontends/web/src/routes/market/swap/swap.tsx +++ b/frontends/web/src/routes/market/swap/swap.tsx @@ -160,7 +160,7 @@ export const Swap = ({ ); const config = useLoad(getConfig); - const { agreedTerms, setAgreedTerms } = useVendorTerms(config ? !!config.frontend.skipSwapkitDisclaimer : false); + const { agreedTerms, setAgreedTerms } = useVendorTerms(config?.frontend.skipSwapkitDisclaimer ?? false); const isSameCoinAccount = ( candidate: TSwapAccount, diff --git a/frontends/web/src/routes/settings/components/manage-accounts/dialogs/enableRememberWalletDialog.tsx b/frontends/web/src/routes/settings/components/manage-accounts/dialogs/enableRememberWalletDialog.tsx index 4ba72dfbfe..4e72b66beb 100644 --- a/frontends/web/src/routes/settings/components/manage-accounts/dialogs/enableRememberWalletDialog.tsx +++ b/frontends/web/src/routes/settings/components/manage-accounts/dialogs/enableRememberWalletDialog.tsx @@ -21,7 +21,7 @@ export const EnableRememberWalletDialog = ({ open, onClose }: Props) => { setShouldNotShowDialog(false); return; } - setShouldNotShowDialog(!!config.frontend.hideEnableRememberWalletDialog); + setShouldNotShowDialog(config.frontend.hideEnableRememberWalletDialog ?? false); }, [config]); if (shouldNotShowDialog) { From b5dddcf6bda00125e1d66211f20b003e1a500602 Mon Sep 17 00:00:00 2001 From: Sibilla Date: Wed, 20 May 2026 17:17:58 +0200 Subject: [PATCH 15/24] frontend: use useConfig in swap component --- .../web/src/routes/market/swap/swap.test.tsx | 22 +++++++++++-------- frontends/web/src/routes/market/swap/swap.tsx | 4 ++-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/frontends/web/src/routes/market/swap/swap.test.tsx b/frontends/web/src/routes/market/swap/swap.test.tsx index 23d47abed3..86ed1ac50a 100644 --- a/frontends/web/src/routes/market/swap/swap.test.tsx +++ b/frontends/web/src/routes/market/swap/swap.test.tsx @@ -97,13 +97,12 @@ vi.mock('@/api/swap', async (importOriginal) => { signSwap: vi.fn(), }; }); -vi.mock('@/utils/config', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getConfig: vi.fn(), - }; -}); +vi.mock('@/contexts/ConfigProvider', () => ({ + useConfig: vi.fn(() => ({ + config: mockConfig(), + setConfig: vi.fn(), + })), +})); import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -111,10 +110,12 @@ import { MemoryRouter } from 'react-router-dom'; import * as accountApi from '@/api/account'; import * as coinsApi from '@/api/coins'; import * as swapApi from '@/api/swap'; -import * as config from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { RatesContext } from '@/contexts/RatesContext'; import { Swap } from './swap'; +const mockUseConfig = vi.mocked(useConfig); + const sellAccount: accountApi.TAccount = { keystore: { connected: true, @@ -218,7 +219,10 @@ describe('routes/market/swap', () => { }], }, }); - vi.mocked(config.getConfig).mockResolvedValue(mockConfig()); + mockUseConfig.mockReturnValue({ + config: mockConfig(), + setConfig: vi.fn(), + }); }); it('renders grouped provider label after quote fetch', async () => { diff --git a/frontends/web/src/routes/market/swap/swap.tsx b/frontends/web/src/routes/market/swap/swap.tsx index d79545f656..0e5ab57186 100644 --- a/frontends/web/src/routes/market/swap/swap.tsx +++ b/frontends/web/src/routes/market/swap/swap.tsx @@ -45,7 +45,7 @@ import { SwapServiceSelector } from './components/swap-service-selector'; import { ConfirmSwap } from './components/swap-confirm'; import { SwapResult } from './components/swap-result'; import { RatesContext } from '@/contexts/RatesContext'; -import { getConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { useVendorTerms } from '@/hooks/vendor-iframe-terms'; import { SwapkitTerms } from '@/components/terms/swapkit-terms'; import { Skeleton } from '@/components/skeleton/skeleton'; @@ -159,7 +159,7 @@ export const Swap = ({ [btcUnit, sellAccount], ); - const config = useLoad(getConfig); + const { config } = useConfig(); const { agreedTerms, setAgreedTerms } = useVendorTerms(config?.frontend.skipSwapkitDisclaimer ?? false); const isSameCoinAccount = ( From 9c56d5dff559ff7b7f6092dca38c080e0d7a0556 Mon Sep 17 00:00:00 2001 From: Sibilla Date: Wed, 20 May 2026 17:20:01 +0200 Subject: [PATCH 16/24] fix: move TConfigUpdate types to utils/config --- frontends/web/src/api/config.ts | 13 ------------- frontends/web/src/contexts/ConfigContext.tsx | 3 ++- frontends/web/src/contexts/ConfigProvider.tsx | 3 ++- frontends/web/src/utils/config.ts | 15 ++++++++++++++- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/frontends/web/src/api/config.ts b/frontends/web/src/api/config.ts index b975e81cc7..704fc8e2d2 100644 --- a/frontends/web/src/api/config.ts +++ b/frontends/web/src/api/config.ts @@ -115,19 +115,6 @@ export type TConfig = { readonly frontend: TFrontendConfig; }; -/** Partial backend config for updates; null clears userLanguage (see i18n.ts). */ -type TBackendConfigUpdate = - Omit, 'userLanguage'> & { - userLanguage?: string | null; - }; - -type TFrontendConfigUpdate = Partial; - -export type TConfigUpdate = { - backend?: TBackendConfigUpdate; - frontend?: TFrontendConfigUpdate; -}; - /** * Fetch raw config from the backend. Response always includes `backend` and * `frontend` objects (see handlers.getAppConfig). Nested backend fields may be diff --git a/frontends/web/src/contexts/ConfigContext.tsx b/frontends/web/src/contexts/ConfigContext.tsx index f34660cde0..b8751d7381 100644 --- a/frontends/web/src/contexts/ConfigContext.tsx +++ b/frontends/web/src/contexts/ConfigContext.tsx @@ -1,7 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 import { createContext } from 'react'; -import type { TConfig, TConfigUpdate } from '@/api/config'; +import type { TConfig } from '@/api/config'; +import type { TConfigUpdate } from '@/utils/config'; export type TConfigContext = { config: TConfig | undefined; diff --git a/frontends/web/src/contexts/ConfigProvider.tsx b/frontends/web/src/contexts/ConfigProvider.tsx index f400f5d4bf..0e7b59eaf3 100644 --- a/frontends/web/src/contexts/ConfigProvider.tsx +++ b/frontends/web/src/contexts/ConfigProvider.tsx @@ -2,7 +2,8 @@ import { ReactNode, useCallback, useContext, useEffect, useState } from 'react'; import { getConfig, setConfig as setConfigAPI } from '@/utils/config'; -import type { TConfig, TConfigUpdate } from '@/api/config'; +import type { TConfig } from '@/api/config'; +import type { TConfigUpdate } from '@/utils/config'; import { ConfigContext, TConfigContext } from './ConfigContext'; type TProps = { diff --git a/frontends/web/src/utils/config.ts b/frontends/web/src/utils/config.ts index e8c8c26a5f..4ae9f44f69 100644 --- a/frontends/web/src/utils/config.ts +++ b/frontends/web/src/utils/config.ts @@ -1,6 +1,19 @@ // SPDX-License-Identifier: Apache-2.0 -import { getConfig as apiGetConfig, setConfig as apiSetConfig, type TConfig, type TConfigUpdate } from '@/api/config'; +import { getConfig as apiGetConfig, setConfig as apiSetConfig, type TConfig, type TBackendConfig, type TFrontendConfig } from '@/api/config'; + +/** Partial backend config for updates; null clears userLanguage (see i18n.ts). */ +type TBackendConfigUpdate = + Omit, 'userLanguage'> & { + userLanguage?: string | null; + }; + +type TFrontendConfigUpdate = Partial; + +export type TConfigUpdate = { + backend?: TBackendConfigUpdate; + frontend?: TFrontendConfigUpdate; +}; let pendingConfig: TConfigUpdate = {}; From d9f38ee91883f03ae1309852f127588420122426 Mon Sep 17 00:00:00 2001 From: Sibilla Date: Wed, 20 May 2026 17:26:22 +0200 Subject: [PATCH 17/24] fix: type api getConfig as Promise --- frontends/web/src/api/config.ts | 10 +++------- frontends/web/src/utils/config.ts | 13 +------------ 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/frontends/web/src/api/config.ts b/frontends/web/src/api/config.ts index 704fc8e2d2..fb41ed87b6 100644 --- a/frontends/web/src/api/config.ts +++ b/frontends/web/src/api/config.ts @@ -116,14 +116,10 @@ export type TConfig = { }; /** - * Fetch raw config from the backend. Response always includes `backend` and - * `frontend` objects (see handlers.getAppConfig). Nested backend fields may be - * incomplete; use getConfig from @/utils/config for a normalized TConfig. + * Fetch config from the backend (see handlers.getAppConfig). + * Prefer getConfig from @/utils/config for merge-on-write helpers. */ -export const getConfig = () => - apiGet('config') as Promise< - Readonly<{ backend: Partial; frontend: TFrontendConfig }> - >; +export const getConfig = (): Promise => apiGet('config'); /** * Post a config object to the backend. diff --git a/frontends/web/src/utils/config.ts b/frontends/web/src/utils/config.ts index 4ae9f44f69..e86d0d6da3 100644 --- a/frontends/web/src/utils/config.ts +++ b/frontends/web/src/utils/config.ts @@ -17,21 +17,10 @@ export type TConfigUpdate = { let pendingConfig: TConfigUpdate = {}; -export const emptyBackendConfig = (): TConfig['backend'] => ({} as TConfig['backend']); - -export const emptyFrontendConfig = (): TConfig['frontend'] => ({}); - -const normalizeConfig = (raw: Awaited>): TConfig => ({ - backend: { ...emptyBackendConfig(), ...raw.backend }, - frontend: { ...raw.frontend }, -}); - /** * Fetch current config from the backend. */ -export const getConfig = (): Promise => { - return apiGetConfig().then(normalizeConfig); -}; +export const getConfig = (): Promise => apiGetConfig(); /** * Merge partial config with current, POST to backend, return new config. From ca0308b07aa7a054c27296d62f5f9b504ddbbbbb Mon Sep 17 00:00:00 2001 From: Sibilla Date: Wed, 20 May 2026 17:30:52 +0200 Subject: [PATCH 18/24] fix: prefix config types with TConfig --- frontends/web/src/api/config.ts | 28 +++++++++---------- .../src/components/new-badge/new-badge.tsx | 4 +-- .../web/src/components/status/status.tsx | 4 +-- .../custom-gap-limit-setting.tsx | 4 +-- .../advanced-settings/enable-auth-setting.tsx | 4 +-- .../enable-coin-control-setting.tsx | 4 +-- .../enable-custom-fees-toggle-setting.tsx | 4 +-- .../enable-tor-proxy-setting.tsx | 4 +-- .../advanced-settings/tor-proxy-dialog.tsx | 6 ++-- .../src/routes/settings/electrum-servers.tsx | 4 +-- frontends/web/src/utils/config.ts | 12 ++++---- 11 files changed, 39 insertions(+), 39 deletions(-) diff --git a/frontends/web/src/api/config.ts b/frontends/web/src/api/config.ts index fb41ed87b6..ce1d06b3f9 100644 --- a/frontends/web/src/api/config.ts +++ b/frontends/web/src/api/config.ts @@ -15,7 +15,7 @@ type TBtcCoinConfig = Readonly<{ }>; /** BTC-based coin keys in backend config (see backend/config/config.go). */ -export type TBtcCoinConfigKey = 'btc' | 'tbtc' | 'ltc' | 'tltc'; +export type TConfigBackendBtcCoinKey = 'btc' | 'tbtc' | 'ltc' | 'tltc'; // Mirrors backend/config/config.go ethCoinConfig: DeprecatedActiveERC20Tokens (JSON key // "activeERC20Tokens"). Deprecated — ERC20 activation is per-account in accounts config; kept @@ -24,34 +24,34 @@ type TEthCoinConfig = Readonly<{ activeERC20Tokens: string[]; }>; -export type TProxyConfig = Readonly<{ +export type TConfigBackendProxy = Readonly<{ useProxy: boolean; proxyAddress: string; }>; /** Keys used by NewBadge to mark UI elements as seen. */ -export type TFrontendBadgeConfigKey = +export type TConfigFrontendBadgeKey = | 'hasSeenMarketplaceNudge' | 'hasSeenSwapMarketTab' | 'hasSeenOtcMarketTab'; /** Dynamic frontend keys written when dismissing Status banners. */ -type TDynamicDismissibleFrontendKey = +type TConfigFrontendDismissibleDynamicKey = | `update-${string}` | `banner-backup-${string}` | `banner-${string}-${string}`; /** Known static frontend keys written when dismissing Status banners. */ -type TKnownDismissibleFrontendConfigKey = +type TConfigFrontendDismissibleKnownKey = | 'walletConnectDisclaimerDismissed' | 'skipTestingWarning' | 'mobile-data-warning'; -export type TDismissibleFrontendConfigKey = - | TKnownDismissibleFrontendConfigKey - | TDynamicDismissibleFrontendKey; +export type TConfigFrontendDismissibleKey = + | TConfigFrontendDismissibleKnownKey + | TConfigFrontendDismissibleDynamicKey; -export type TFrontendConfig = Readonly<{ +export type TConfigFrontend = Readonly<{ guideShown?: boolean; hideAmounts?: boolean; darkmode?: boolean; @@ -79,11 +79,11 @@ export type TFrontendConfig = Readonly<{ skipTestingWarning?: boolean; 'mobile-data-warning'?: boolean; }> & Readonly<{ - [key in TDynamicDismissibleFrontendKey]?: boolean; + [key in TConfigFrontendDismissibleDynamicKey]?: boolean; }>; -export type TBackendConfig = Readonly<{ - proxy: TProxyConfig; +export type TConfigBackend = Readonly<{ + proxy: TConfigBackendProxy; /** * Deprecated global coin activation flags (backend/config/config.go: DeprecatedBitcoinActive, * DeprecatedLitecoinActive, DeprecatedEthereumActive). Coins are configured per account now; @@ -111,8 +111,8 @@ export type TBackendConfig = Readonly<{ }>; export type TConfig = { - readonly backend: TBackendConfig; - readonly frontend: TFrontendConfig; + readonly backend: TConfigBackend; + readonly frontend: TConfigFrontend; }; /** diff --git a/frontends/web/src/components/new-badge/new-badge.tsx b/frontends/web/src/components/new-badge/new-badge.tsx index 606fcd4af9..c55972b1ad 100644 --- a/frontends/web/src/components/new-badge/new-badge.tsx +++ b/frontends/web/src/components/new-badge/new-badge.tsx @@ -2,13 +2,13 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import type { TFrontendBadgeConfigKey } from '@/api/config'; +import type { TConfigFrontendBadgeKey } from '@/api/config'; import { Badge } from '@/components/badge/badge'; import { useConfig } from '@/contexts/ConfigProvider'; type TProps = { className?: string; - configKey: TFrontendBadgeConfigKey; + configKey: TConfigFrontendBadgeKey; hideOnPathPrefix?: string; markAsSeen?: boolean; pathname?: string; diff --git a/frontends/web/src/components/status/status.tsx b/frontends/web/src/components/status/status.tsx index 81e1ab923c..7fa900fb3e 100644 --- a/frontends/web/src/components/status/status.tsx +++ b/frontends/web/src/components/status/status.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { ReactNode } from 'react'; -import type { TDismissibleFrontendConfigKey } from '@/api/config'; +import type { TConfigFrontendDismissibleKey } from '@/api/config'; import { useConfig } from '@/contexts/ConfigProvider'; import { CloseXDark, CloseXWhite } from '@/components/icon'; import { useDarkmode } from '@/hooks/darkmode'; @@ -16,7 +16,7 @@ type TProps = { // used as keyName in the config if dismissing the status should be persisted, so it is not // shown again. Use an empty string if it should be dismissible without storing it in the // config, so the status will be shown again the next time. - dismissibleKey: TDismissibleFrontendConfigKey | ''; + dismissibleKey: TConfigFrontendDismissibleKey | ''; className?: string; children: ReactNode; }; diff --git a/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx index 74a0b0854f..54d37e3983 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx @@ -6,12 +6,12 @@ import { SettingsItem } from '@/routes/settings/components/settingsItem/settings import { Dialog, DialogButtons } from '@/components/dialog/dialog'; import { Button, Input } from '@/components/forms'; import { useConfig } from '@/contexts/ConfigProvider'; -import type { TBackendConfig } from '@/api/config'; +import type { TConfigBackend } from '@/api/config'; import { Message } from '@/components/message/message'; import { useMediaQuery } from '@/hooks/mediaquery'; type TProps = { - backendConfig?: TBackendConfig; + backendConfig?: TConfigBackend; }; export const CustomGapLimitSettings = ({ backendConfig }: TProps) => { diff --git a/frontends/web/src/routes/settings/components/advanced-settings/enable-auth-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/enable-auth-setting.tsx index 9d1d1bbbb2..8b11641c5e 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/enable-auth-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/enable-auth-setting.tsx @@ -2,7 +2,7 @@ import { ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import type { TBackendConfig } from '@/api/config'; +import type { TConfigBackend } from '@/api/config'; import { Toggle } from '@/components/toggle/toggle'; import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; import { useConfig } from '@/contexts/ConfigProvider'; @@ -10,7 +10,7 @@ import { onAuthSettingChanged, TAuthEventObject, subscribeAuth, forceAuth } from import { runningInAndroid, runningInIOS } from '@/utils/env'; type TProps = { - backendConfig?: TBackendConfig; + backendConfig?: TConfigBackend; }; export const EnableAuthSetting = ({ backendConfig }: TProps) => { diff --git a/frontends/web/src/routes/settings/components/advanced-settings/enable-coin-control-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/enable-coin-control-setting.tsx index 27d4c494a0..790c4920ca 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/enable-coin-control-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/enable-coin-control-setting.tsx @@ -2,13 +2,13 @@ import { ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import type { TFrontendConfig } from '@/api/config'; +import type { TConfigFrontend } from '@/api/config'; import { Toggle } from '@/components/toggle/toggle'; import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; import { useConfig } from '@/contexts/ConfigProvider'; type TProps = { - frontendConfig?: TFrontendConfig; + frontendConfig?: TConfigFrontend; }; export const EnableCoinControlSetting = ({ frontendConfig }: TProps) => { diff --git a/frontends/web/src/routes/settings/components/advanced-settings/enable-custom-fees-toggle-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/enable-custom-fees-toggle-setting.tsx index 183f4b7b3c..544e6c84ca 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/enable-custom-fees-toggle-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/enable-custom-fees-toggle-setting.tsx @@ -2,13 +2,13 @@ import { ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import type { TFrontendConfig } from '@/api/config'; +import type { TConfigFrontend } from '@/api/config'; import { Toggle } from '@/components/toggle/toggle'; import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; import { useConfig } from '@/contexts/ConfigProvider'; type TProps = { - frontendConfig?: TFrontendConfig; + frontendConfig?: TConfigFrontend; }; export const EnableCustomFeesToggleSetting = ({ frontendConfig }: TProps) => { diff --git a/frontends/web/src/routes/settings/components/advanced-settings/enable-tor-proxy-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/enable-tor-proxy-setting.tsx index 4351b832cb..78de12a402 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/enable-tor-proxy-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/enable-tor-proxy-setting.tsx @@ -2,14 +2,14 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import type { TProxyConfig } from '@/api/config'; +import type { TConfigBackendProxy } from '@/api/config'; import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; import { TorProxyDialog } from './tor-proxy-dialog'; import { Message } from '@/components/message/message'; import { runningInIOS } from '@/utils/env'; import styles from './enable-tor-proxy-setting.module.css'; type TProps = { - proxyConfig?: TProxyConfig; + proxyConfig?: TConfigBackendProxy; }; export const EnableTorProxySetting = ({ proxyConfig }: TProps) => { diff --git a/frontends/web/src/routes/settings/components/advanced-settings/tor-proxy-dialog.tsx b/frontends/web/src/routes/settings/components/advanced-settings/tor-proxy-dialog.tsx index f97bdea378..dfeddb4c5e 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/tor-proxy-dialog.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/tor-proxy-dialog.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'; import { useEffect, useState } from 'react'; -import type { TProxyConfig } from '@/api/config'; +import type { TConfigBackendProxy } from '@/api/config'; import { useMediaQuery } from '@/hooks/mediaquery'; import { Dialog, DialogButtons } from '@/components/dialog/dialog'; import { Toggle } from '@/components/toggle/toggle'; @@ -13,7 +13,7 @@ import { alertUser } from '@/components/alert/Alert'; type TProps = { open: boolean; - proxyConfig?: TProxyConfig; + proxyConfig?: TConfigBackendProxy; onCloseDialog: () => void; handleShowRestartMessage: (show: boolean) => void; }; @@ -47,7 +47,7 @@ export const TorProxyDialog = ({ open, proxyConfig, onCloseDialog, handleShowRes } }; - const setProxyConfig = async (proxyConfig: TProxyConfig) => { + const setProxyConfig = async (proxyConfig: TConfigBackendProxy) => { await setConfig({ backend: { proxy: proxyConfig }, }); diff --git a/frontends/web/src/routes/settings/electrum-servers.tsx b/frontends/web/src/routes/settings/electrum-servers.tsx index 7200796208..6260f4dbef 100644 --- a/frontends/web/src/routes/settings/electrum-servers.tsx +++ b/frontends/web/src/routes/settings/electrum-servers.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import { useTranslation } from 'react-i18next'; -import type { TBtcCoinConfigKey } from '@/api/config'; +import type { TConfigBackendBtcCoinKey } from '@/api/config'; import type { TElectrumServer } from '@/api/node'; import { ElectrumAddServer } from './electrum-add-server'; import { ElectrumServer } from './electrum-server'; @@ -12,7 +12,7 @@ import { Button } from '@/components/forms'; import style from './electrum.module.css'; type Props = { - coin: TBtcCoinConfigKey; + coin: TConfigBackendBtcCoinKey; }; export const ElectrumServers = ({ diff --git a/frontends/web/src/utils/config.ts b/frontends/web/src/utils/config.ts index e86d0d6da3..c4db9a7cd3 100644 --- a/frontends/web/src/utils/config.ts +++ b/frontends/web/src/utils/config.ts @@ -1,18 +1,18 @@ // SPDX-License-Identifier: Apache-2.0 -import { getConfig as apiGetConfig, setConfig as apiSetConfig, type TConfig, type TBackendConfig, type TFrontendConfig } from '@/api/config'; +import { getConfig as apiGetConfig, setConfig as apiSetConfig, type TConfig, type TConfigBackend, type TConfigFrontend } from '@/api/config'; /** Partial backend config for updates; null clears userLanguage (see i18n.ts). */ -type TBackendConfigUpdate = - Omit, 'userLanguage'> & { +type TConfigBackendUpdate = + Omit, 'userLanguage'> & { userLanguage?: string | null; }; -type TFrontendConfigUpdate = Partial; +type TConfigFrontendUpdate = Partial; export type TConfigUpdate = { - backend?: TBackendConfigUpdate; - frontend?: TFrontendConfigUpdate; + backend?: TConfigBackendUpdate; + frontend?: TConfigFrontendUpdate; }; let pendingConfig: TConfigUpdate = {}; From d0eb1d8619481ce771b8548dac52c7d5f3c58bf3 Mon Sep 17 00:00:00 2001 From: Sibilla Date: Wed, 20 May 2026 17:35:17 +0200 Subject: [PATCH 19/24] fix: drop config normalize-on-read --- frontends/web/src/api/config.ts | 2 +- frontends/web/src/contexts/ConfigProvider.tsx | 3 ++- frontends/web/src/hooks/bitsurance.ts | 4 ++-- frontends/web/src/i18n/config.test.tsx | 24 +++++++++++++------ frontends/web/src/i18n/config.ts | 4 ++-- frontends/web/src/utils/config.ts | 11 +++------ 6 files changed, 27 insertions(+), 21 deletions(-) diff --git a/frontends/web/src/api/config.ts b/frontends/web/src/api/config.ts index ce1d06b3f9..e6371ebf88 100644 --- a/frontends/web/src/api/config.ts +++ b/frontends/web/src/api/config.ts @@ -117,7 +117,7 @@ export type TConfig = { /** * Fetch config from the backend (see handlers.getAppConfig). - * Prefer getConfig from @/utils/config for merge-on-write helpers. + * Use setConfig from @/utils/config for partial merge-on-write updates. */ export const getConfig = (): Promise => apiGet('config'); diff --git a/frontends/web/src/contexts/ConfigProvider.tsx b/frontends/web/src/contexts/ConfigProvider.tsx index 0e7b59eaf3..40be716382 100644 --- a/frontends/web/src/contexts/ConfigProvider.tsx +++ b/frontends/web/src/contexts/ConfigProvider.tsx @@ -1,7 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 import { ReactNode, useCallback, useContext, useEffect, useState } from 'react'; -import { getConfig, setConfig as setConfigAPI } from '@/utils/config'; +import { getConfig } from '@/api/config'; +import { setConfig as setConfigAPI } from '@/utils/config'; import type { TConfig } from '@/api/config'; import type { TConfigUpdate } from '@/utils/config'; import { ConfigContext, TConfigContext } from './ConfigContext'; diff --git a/frontends/web/src/hooks/bitsurance.ts b/frontends/web/src/hooks/bitsurance.ts index 4a640b944c..bcdb8259c5 100644 --- a/frontends/web/src/hooks/bitsurance.ts +++ b/frontends/web/src/hooks/bitsurance.ts @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; import * as accountApi from '@/api/account'; import { bitsuranceLookup } from '@/api/bitsurance'; import { alertUser } from '@/components/alert/Alert'; -import { getConfig } from '@/utils/config'; +import { getConfig } from '@/api/config'; import { useConfig } from '@/contexts/ConfigProvider'; import { getScriptName } from '@/routes/account/utils'; @@ -78,7 +78,7 @@ export const useBitsurance = ( // We fetch the config after the lookup as it could have changed. const freshConfig = await getConfig(); - let cancelledAccounts: string[] = freshConfig.frontend?.bitsuranceNotifyCancellation ?? []; + let cancelledAccounts: string[] = freshConfig.frontend.bitsuranceNotifyCancellation ?? []; if (cancelledAccounts.includes(code)) { alertUser(t('account.insuranceExpired')); diff --git a/frontends/web/src/i18n/config.test.tsx b/frontends/web/src/i18n/config.test.tsx index aa0eac96b3..25cd2d2b36 100644 --- a/frontends/web/src/i18n/config.test.tsx +++ b/frontends/web/src/i18n/config.test.tsx @@ -12,9 +12,19 @@ import { apiGet } from '@/utils/request'; import { languageFromConfig } from './config'; +const mockAppConfig = (backend: { userLanguage?: string } = {}) => ({ + backend: { userLanguage: '', ...backend }, + frontend: {}, +}); + describe('language detector', () => { it('defaults to english', () => new Promise(done => { - (apiGet as Mock).mockResolvedValue({}); + (apiGet as Mock).mockImplementation(endpoint => { + if (endpoint === 'config') { + return Promise.resolve(mockAppConfig()); + } + return Promise.resolve(); + }); languageFromConfig.detect((lang: any) => { expect(lang).toEqual('en'); done(); @@ -24,7 +34,7 @@ describe('language detector', () => { it('prefers userLanguage if available', () => new Promise(done => { (apiGet as Mock).mockImplementation(endpoint => { switch (endpoint) { - case 'config': { return Promise.resolve({ backend: { userLanguage: 'it' } }); } + case 'config': { return Promise.resolve(mockAppConfig({ userLanguage: 'it' })); } case 'native-locale': { return Promise.resolve('de'); } default: { return Promise.resolve(); } } @@ -38,7 +48,7 @@ describe('language detector', () => { it('uses native-locale if no config', () => new Promise(done => { (apiGet as Mock).mockImplementation(endpoint => { switch (endpoint) { - case 'config': { return Promise.resolve({}); } + case 'config': { return Promise.resolve(mockAppConfig()); } case 'native-locale': { return Promise.resolve('de'); } default: { return Promise.resolve(); } } @@ -52,7 +62,7 @@ describe('language detector', () => { it('uses defaultUserLanguage fallback if native-locale is C.UTF-8', () => new Promise(done => { (apiGet as Mock).mockImplementation(endpoint => { switch (endpoint) { - case 'config': { return Promise.resolve({}); } + case 'config': { return Promise.resolve(mockAppConfig()); } case 'native-locale': { return Promise.resolve('C.UTF-8'); } default: { return Promise.resolve(); } } @@ -66,7 +76,7 @@ describe('language detector', () => { it('uses native-locale if userLanguage is empty', () => new Promise(done => { (apiGet as Mock).mockImplementation(endpoint => { switch (endpoint) { - case 'config': { return Promise.resolve({ backend: { userLanguage: '' } }); } + case 'config': { return Promise.resolve(mockAppConfig()); } case 'native-locale': { return Promise.resolve('de'); } default: { return Promise.resolve(); } } @@ -80,7 +90,7 @@ describe('language detector', () => { it('uses weird Android native-locale if userLanguage is empty', () => new Promise(done => { (apiGet as Mock).mockImplementation(endpoint => { switch (endpoint) { - case 'config': { return Promise.resolve({ backend: { userLanguage: '' } }); } + case 'config': { return Promise.resolve(mockAppConfig()); } case 'native-locale': { return Promise.resolve('de-DE_#u-fw-mon-mu-celsius'); } default: { return Promise.resolve(); } } @@ -94,7 +104,7 @@ describe('language detector', () => { it('returns native-locale value acceptable by i18next', () => new Promise(done => { (apiGet as Mock).mockImplementation(endpoint => { switch (endpoint) { - case 'config': { return Promise.resolve({}); } + case 'config': { return Promise.resolve(mockAppConfig()); } case 'native-locale': { return Promise.resolve('pt_BR'); } default: { return Promise.resolve(); } } diff --git a/frontends/web/src/i18n/config.ts b/frontends/web/src/i18n/config.ts index a1892f8e5a..f533d219a3 100644 --- a/frontends/web/src/i18n/config.ts +++ b/frontends/web/src/i18n/config.ts @@ -2,7 +2,7 @@ import { LanguageDetectorAsyncModule } from 'i18next'; import { getNativeLocale } from '@/api/nativelocale'; -import { getConfig } from '@/utils/config'; +import { getConfig } from '@/api/config'; import { i18nextFormat } from './utils'; const defaultUserLanguage = 'en'; @@ -12,7 +12,7 @@ export const languageFromConfig: LanguageDetectorAsyncModule = { async: true, detect: (cb) => { getConfig().then(({ backend }) => { - if (backend && backend.userLanguage) { + if (backend.userLanguage) { cb(backend.userLanguage); return; } diff --git a/frontends/web/src/utils/config.ts b/frontends/web/src/utils/config.ts index c4db9a7cd3..4f43763e6a 100644 --- a/frontends/web/src/utils/config.ts +++ b/frontends/web/src/utils/config.ts @@ -18,16 +18,11 @@ export type TConfigUpdate = { let pendingConfig: TConfigUpdate = {}; /** - * Fetch current config from the backend. - */ -export const getConfig = (): Promise => apiGetConfig(); - -/** - * Merge partial config with current, POST to backend, return new config. - * Returns the locally merged config object. It does not refetch the config from the backend. + * Merge partial config with current, POST full TConfig to backend, return merged config. + * Does not refetch from the backend after POST. */ export const setConfig = (object: TConfigUpdate): Promise => { - return getConfig() + return apiGetConfig() .then((currentConfig) => { const nextConfig: TConfig = { backend: Object.assign( From bb9cdc00f87141b5f83f130deaab7df16ae74472 Mon Sep 17 00:00:00 2001 From: Sibilla Date: Wed, 20 May 2026 17:41:42 +0200 Subject: [PATCH 20/24] fix: remove cast utils setConfig merge --- .../web/src/i18n/i18n-emptystrings.test.ts | 7 +++- frontends/web/src/i18n/i18n.test.tsx | 25 ++++++++---- frontends/web/src/utils/config.ts | 39 ++++++++++++++++--- 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/frontends/web/src/i18n/i18n-emptystrings.test.ts b/frontends/web/src/i18n/i18n-emptystrings.test.ts index 97f84cccd0..4a9a970d70 100644 --- a/frontends/web/src/i18n/i18n-emptystrings.test.ts +++ b/frontends/web/src/i18n/i18n-emptystrings.test.ts @@ -5,7 +5,12 @@ import i18next, { i18n as I18nType } from 'i18next'; import { getI18NConfig } from './i18n'; vi.mock('@/utils/request', () => ({ - apiGet: vi.fn().mockResolvedValue('en'), // default native locale + apiGet: vi.fn().mockImplementation((endpoint: string) => { + if (endpoint === 'config') { + return Promise.resolve({ backend: { userLanguage: '' }, frontend: {} }); + } + return Promise.resolve('en'); // default native locale + }), apiPost: vi.fn().mockResolvedValue({}), })); diff --git a/frontends/web/src/i18n/i18n.test.tsx b/frontends/web/src/i18n/i18n.test.tsx index 9755fd9b2a..d79ca23df4 100644 --- a/frontends/web/src/i18n/i18n.test.tsx +++ b/frontends/web/src/i18n/i18n.test.tsx @@ -5,16 +5,28 @@ import { waitFor } from '@testing-library/react'; vi.mock('@/utils/request', () => ({ apiPost: vi.fn().mockImplementation(() => Promise.resolve()), - apiGet: vi.fn().mockResolvedValue(''), + apiGet: vi.fn().mockImplementation((endpoint: string) => { + if (endpoint === 'config') { + return Promise.resolve({ backend: { userLanguage: '' }, frontend: {} }); + } + return Promise.resolve(''); + }), })); import { apiGet, apiPost } from '@/utils/request'; +import { mockConfig } from '@/test/mock-config'; import { i18n } from './i18n'; describe('i18n', () => { describe('languageChanged', () => { beforeEach(() => { (apiPost as Mock).mockClear(); + (apiGet as Mock).mockImplementation(endpoint => { + if (endpoint === 'config') { + return Promise.resolve(mockConfig()); + } + return Promise.resolve(''); + }); }); const table = [ @@ -29,18 +41,17 @@ describe('i18n', () => { it(`sets userLanguage to ${test.userLang || 'null'} if native-locale is ${test.nativeLocale}`, async () => { (apiGet as Mock).mockImplementation(endpoint => { switch (endpoint) { - case 'config': { return Promise.resolve({}); } + case 'config': { return Promise.resolve(mockConfig()); } case 'native-locale': { return Promise.resolve(test.nativeLocale); } - default: { return Promise.resolve(); } + default: { return Promise.resolve(''); } } }); await i18n.changeLanguage(test.newLang); await waitFor(() => { expect(apiPost).toHaveBeenCalledTimes(1); - expect(apiPost).toHaveBeenCalledWith('config', { - frontend: {}, - backend: { userLanguage: test.userLang }, - }); + expect(apiPost).toHaveBeenCalledWith('config', mockConfig({ + backend: { userLanguage: test.userLang ?? '' }, + })); }); }); }); diff --git a/frontends/web/src/utils/config.ts b/frontends/web/src/utils/config.ts index 4f43763e6a..6c2245d9a8 100644 --- a/frontends/web/src/utils/config.ts +++ b/frontends/web/src/utils/config.ts @@ -17,6 +17,32 @@ export type TConfigUpdate = { let pendingConfig: TConfigUpdate = {}; +const mergeBackend = ( + current: TConfigBackend, + ...partials: (TConfigBackendUpdate | undefined)[] +): TConfigBackend => { + let backend = { ...current }; + for (const partial of partials) { + if (!partial) { + continue; + } + const { userLanguage, ...rest } = partial; + backend = { ...backend, ...rest }; + if (userLanguage !== undefined) { + backend = { ...backend, userLanguage: userLanguage ?? '' }; + } + } + return backend; +}; + +const mergeFrontend = ( + current: TConfigFrontend, + ...partials: (TConfigFrontendUpdate | undefined)[] +): TConfigFrontend => ({ + ...current, + ...Object.assign({}, ...partials.filter((partial): partial is TConfigFrontendUpdate => partial !== undefined)), +}); + /** * Merge partial config with current, POST full TConfig to backend, return merged config. * Does not refetch from the backend after POST. @@ -25,20 +51,21 @@ export const setConfig = (object: TConfigUpdate): Promise => { return apiGetConfig() .then((currentConfig) => { const nextConfig: TConfig = { - backend: Object.assign( - {}, + backend: mergeBackend( currentConfig.backend, pendingConfig.backend, object.backend, - ) as TConfig['backend'], - frontend: Object.assign( - {}, + ), + frontend: mergeFrontend( currentConfig.frontend, pendingConfig.frontend, object.frontend, ), }; - pendingConfig = nextConfig; + pendingConfig = { + backend: nextConfig.backend, + frontend: nextConfig.frontend, + }; return apiSetConfig(nextConfig) .then(() => { pendingConfig = {}; From f30f8949362699a4108a0f220a13ae2fbb8e6c51 Mon Sep 17 00:00:00 2001 From: Sibilla Date: Wed, 20 May 2026 17:49:07 +0200 Subject: [PATCH 21/24] fix: drop mock-config; inline TConfig casts in tests --- frontends/web/src/i18n/i18n.test.tsx | 15 +++++++----- .../routes/account/send/feetargets.test.tsx | 8 +++---- .../market/components/markettab.test.tsx | 24 +++++++++---------- .../web/src/routes/market/swap/swap.test.tsx | 6 ++--- frontends/web/src/test/mock-config.ts | 12 ---------- 5 files changed, 28 insertions(+), 37 deletions(-) delete mode 100644 frontends/web/src/test/mock-config.ts diff --git a/frontends/web/src/i18n/i18n.test.tsx b/frontends/web/src/i18n/i18n.test.tsx index d79ca23df4..9d7b9b2829 100644 --- a/frontends/web/src/i18n/i18n.test.tsx +++ b/frontends/web/src/i18n/i18n.test.tsx @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 +import type { TConfig } from '@/api/config'; import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { waitFor } from '@testing-library/react'; @@ -7,14 +8,13 @@ vi.mock('@/utils/request', () => ({ apiPost: vi.fn().mockImplementation(() => Promise.resolve()), apiGet: vi.fn().mockImplementation((endpoint: string) => { if (endpoint === 'config') { - return Promise.resolve({ backend: { userLanguage: '' }, frontend: {} }); + return Promise.resolve({ backend: { userLanguage: '' }, frontend: {} } as TConfig); } return Promise.resolve(''); }), })); import { apiGet, apiPost } from '@/utils/request'; -import { mockConfig } from '@/test/mock-config'; import { i18n } from './i18n'; describe('i18n', () => { @@ -23,7 +23,7 @@ describe('i18n', () => { (apiPost as Mock).mockClear(); (apiGet as Mock).mockImplementation(endpoint => { if (endpoint === 'config') { - return Promise.resolve(mockConfig()); + return Promise.resolve({ backend: { userLanguage: '' }, frontend: {} } as TConfig); } return Promise.resolve(''); }); @@ -41,7 +41,9 @@ describe('i18n', () => { it(`sets userLanguage to ${test.userLang || 'null'} if native-locale is ${test.nativeLocale}`, async () => { (apiGet as Mock).mockImplementation(endpoint => { switch (endpoint) { - case 'config': { return Promise.resolve(mockConfig()); } + case 'config': { + return Promise.resolve({ backend: { userLanguage: '' }, frontend: {} } as TConfig); + } case 'native-locale': { return Promise.resolve(test.nativeLocale); } default: { return Promise.resolve(''); } } @@ -49,9 +51,10 @@ describe('i18n', () => { await i18n.changeLanguage(test.newLang); await waitFor(() => { expect(apiPost).toHaveBeenCalledTimes(1); - expect(apiPost).toHaveBeenCalledWith('config', mockConfig({ + expect(apiPost).toHaveBeenCalledWith('config', { backend: { userLanguage: test.userLang ?? '' }, - })); + frontend: {}, + } as TConfig); }); }); }); diff --git a/frontends/web/src/routes/account/send/feetargets.test.tsx b/frontends/web/src/routes/account/send/feetargets.test.tsx index cc13a30126..8315d8237f 100644 --- a/frontends/web/src/routes/account/send/feetargets.test.tsx +++ b/frontends/web/src/routes/account/send/feetargets.test.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import '../../../../__mocks__/i18n'; -import { mockConfig } from '@/test/mock-config'; +import type { TConfig } from '@/api/config'; import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; vi.mock('@/utils/request', () => ({ @@ -13,7 +13,7 @@ vi.mock('@/utils/env', () => ({ })); vi.mock('@/contexts/ConfigProvider', () => ({ useConfig: vi.fn(() => ({ - config: mockConfig({ frontend: { expertFee: false } }), + config: { frontend: { expertFee: false } } as TConfig, setConfig: vi.fn(), })), })); @@ -37,7 +37,7 @@ describe('routes/account/send/feetargets', () => { vi.clearAllMocks(); mockRunningInIOS.mockReturnValue(false); mockUseConfig.mockReturnValue({ - config: mockConfig({ frontend: { expertFee: false } }), + config: { frontend: { expertFee: false } } as TConfig, setConfig: vi.fn(), }); }); @@ -71,7 +71,7 @@ describe('routes/account/send/feetargets', () => { it('normalizes custom fee values from iOS decimal input', async () => { mockRunningInIOS.mockReturnValue(true); mockUseConfig.mockReturnValue({ - config: mockConfig({ frontend: { expertFee: true } }), + config: { frontend: { expertFee: true } } as TConfig, setConfig: vi.fn(), }); const apiGetMock = (apiGet as Mock).mockResolvedValue({ diff --git a/frontends/web/src/routes/market/components/markettab.test.tsx b/frontends/web/src/routes/market/components/markettab.test.tsx index 1bae507fcb..75b73c12e2 100644 --- a/frontends/web/src/routes/market/components/markettab.test.tsx +++ b/frontends/web/src/routes/market/components/markettab.test.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import '../../../../__mocks__/i18n'; -import { mockConfig } from '@/test/mock-config'; +import type { TConfig } from '@/api/config'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -11,7 +11,7 @@ import { useConfig } from '@/contexts/ConfigProvider'; vi.mock('@/contexts/ConfigProvider', () => ({ useConfig: vi.fn(() => ({ - config: mockConfig(), + config: { frontend: {}, backend: {} } as TConfig, setConfig: vi.fn(), })), })); @@ -22,19 +22,19 @@ describe('routes/market/components/markettab', () => { beforeEach(() => { vi.clearAllMocks(); mockUseConfig.mockReturnValue({ - config: mockConfig(), + config: { frontend: {}, backend: {} } as TConfig, setConfig: vi.fn(), }); }); it('shows the new badge on swap when enabled', async () => { mockUseConfig.mockReturnValue({ - config: mockConfig({ + config: { frontend: { hasSeenOtcMarketTab: true, hasSeenSwapMarketTab: false, - } - }), + }, + } as TConfig, setConfig: vi.fn(), }); @@ -51,12 +51,12 @@ describe('routes/market/components/markettab', () => { it('hides the new badge on swap when disabled', async () => { mockUseConfig.mockReturnValue({ - config: mockConfig({ + config: { frontend: { hasSeenOtcMarketTab: true, hasSeenSwapMarketTab: true, - } - }), + }, + } as TConfig, setConfig: vi.fn(), }); @@ -92,12 +92,12 @@ describe('routes/market/components/markettab', () => { it('shows the new badge on otc when enabled', async () => { mockUseConfig.mockReturnValue({ - config: mockConfig({ + config: { frontend: { hasSeenOtcMarketTab: false, hasSeenSwapMarketTab: true, - } - }), + }, + } as TConfig, setConfig: vi.fn(), }); diff --git a/frontends/web/src/routes/market/swap/swap.test.tsx b/frontends/web/src/routes/market/swap/swap.test.tsx index 86ed1ac50a..db482f943a 100644 --- a/frontends/web/src/routes/market/swap/swap.test.tsx +++ b/frontends/web/src/routes/market/swap/swap.test.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 import '../../../../__mocks__/i18n'; -import { mockConfig } from '@/test/mock-config'; +import type { TConfig } from '@/api/config'; import type { ReactNode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import '@testing-library/jest-dom'; @@ -99,7 +99,7 @@ vi.mock('@/api/swap', async (importOriginal) => { }); vi.mock('@/contexts/ConfigProvider', () => ({ useConfig: vi.fn(() => ({ - config: mockConfig(), + config: { frontend: {}, backend: {} } as TConfig, setConfig: vi.fn(), })), })); @@ -220,7 +220,7 @@ describe('routes/market/swap', () => { }, }); mockUseConfig.mockReturnValue({ - config: mockConfig(), + config: { frontend: {}, backend: {} } as TConfig, setConfig: vi.fn(), }); }); diff --git a/frontends/web/src/test/mock-config.ts b/frontends/web/src/test/mock-config.ts deleted file mode 100644 index 4826efc06a..0000000000 --- a/frontends/web/src/test/mock-config.ts +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -import type { TConfig } from '@/api/config'; - -const emptyBackendConfig = (): TConfig['backend'] => ({} as TConfig['backend']); - -const emptyFrontendConfig = (): TConfig['frontend'] => ({}); - -export const mockConfig = (overrides: Partial = {}): TConfig => ({ - backend: { ...emptyBackendConfig(), ...overrides.backend }, - frontend: { ...emptyFrontendConfig(), ...overrides.frontend }, -}); From 403702412fefd88c9a18a3c897ca562874443442 Mon Sep 17 00:00:00 2001 From: Sibilla Date: Wed, 20 May 2026 17:51:29 +0200 Subject: [PATCH 22/24] fix: read gap limit config from useConfig in CustomGapLimitSettings --- .../src/routes/settings/advanced-settings.tsx | 2 +- .../custom-gap-limit-setting.tsx | 35 +++++++++---------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/frontends/web/src/routes/settings/advanced-settings.tsx b/frontends/web/src/routes/settings/advanced-settings.tsx index 0ee7475e2e..9130ca6716 100644 --- a/frontends/web/src/routes/settings/advanced-settings.tsx +++ b/frontends/web/src/routes/settings/advanced-settings.tsx @@ -58,7 +58,7 @@ export const AdvancedSettings = ({ devices, hasAccounts }: TPagePropsWithSetting - + diff --git a/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx index 54d37e3983..13e25f41f8 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/custom-gap-limit-setting.tsx @@ -6,39 +6,38 @@ import { SettingsItem } from '@/routes/settings/components/settingsItem/settings import { Dialog, DialogButtons } from '@/components/dialog/dialog'; import { Button, Input } from '@/components/forms'; import { useConfig } from '@/contexts/ConfigProvider'; -import type { TConfigBackend } from '@/api/config'; import { Message } from '@/components/message/message'; import { useMediaQuery } from '@/hooks/mediaquery'; -type TProps = { - backendConfig?: TConfigBackend; -}; +const DEFAULT_GAP_LIMIT_RECEIVE = 20; +const DEFAULT_GAP_LIMIT_CHANGE = 6; +const MAX_LIMIT = 2000; -export const CustomGapLimitSettings = ({ backendConfig }: TProps) => { +export const CustomGapLimitSettings = () => { const { t } = useTranslation(); - const { setConfig } = useConfig(); + const { config, setConfig } = useConfig(); const [showDialog, setShowDialog] = useState(false); const isMobile = useMediaQuery('(max-width: 768px)'); - const DEFAULT_GAP_LIMIT_RECEIVE = 20; - const DEFAULT_GAP_LIMIT_CHANGE = 6; - const MAX_LIMIT = 2000; - const [showRestartMessage, setShowRestartMessage] = useState(false); - const [gapLimitReceive, setGapLimitReceive] = useState(backendConfig?.gapLimitReceive || DEFAULT_GAP_LIMIT_RECEIVE); - const [gapLimitChange, setGapLimitChange] = useState(backendConfig?.gapLimitChange || DEFAULT_GAP_LIMIT_CHANGE); + const [gapLimitReceive, setGapLimitReceive] = useState(DEFAULT_GAP_LIMIT_RECEIVE); + const [gapLimitChange, setGapLimitChange] = useState(DEFAULT_GAP_LIMIT_CHANGE); useEffect(() => { - if (backendConfig) { - setGapLimitReceive(backendConfig.gapLimitReceive || DEFAULT_GAP_LIMIT_RECEIVE); - setGapLimitChange(backendConfig.gapLimitChange || DEFAULT_GAP_LIMIT_CHANGE); + if (!config) { + return; } - }, [backendConfig]); + setGapLimitReceive(config.backend.gapLimitReceive || DEFAULT_GAP_LIMIT_RECEIVE); + setGapLimitChange(config.backend.gapLimitChange || DEFAULT_GAP_LIMIT_CHANGE); + }, [config]); + + if (!config) { + return null; + } const handleSave = async () => { await setConfig({ backend: { - ...backendConfig, gapLimitReceive: Number(gapLimitReceive), gapLimitChange: Number(gapLimitChange), }, @@ -134,4 +133,4 @@ export const CustomGapLimitSettings = ({ backendConfig }: TProps) => { ); -}; \ No newline at end of file +}; From aa6a5bfdf9254c04ed1496735b243fcc1ef9cb3a Mon Sep 17 00:00:00 2001 From: Sibilla Date: Wed, 20 May 2026 17:54:31 +0200 Subject: [PATCH 23/24] fix: read auth setting from useConfig in EnableAuthSetting --- .../web/src/routes/settings/advanced-settings.tsx | 2 +- .../advanced-settings/enable-auth-setting.tsx | 13 ++++--------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/frontends/web/src/routes/settings/advanced-settings.tsx b/frontends/web/src/routes/settings/advanced-settings.tsx index 9130ca6716..7516827083 100644 --- a/frontends/web/src/routes/settings/advanced-settings.tsx +++ b/frontends/web/src/routes/settings/advanced-settings.tsx @@ -55,7 +55,7 @@ export const AdvancedSettings = ({ devices, hasAccounts }: TPagePropsWithSetting > - + diff --git a/frontends/web/src/routes/settings/components/advanced-settings/enable-auth-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/enable-auth-setting.tsx index 8b11641c5e..da9e5d21e2 100644 --- a/frontends/web/src/routes/settings/components/advanced-settings/enable-auth-setting.tsx +++ b/frontends/web/src/routes/settings/components/advanced-settings/enable-auth-setting.tsx @@ -2,20 +2,15 @@ import { ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import type { TConfigBackend } from '@/api/config'; import { Toggle } from '@/components/toggle/toggle'; import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; import { useConfig } from '@/contexts/ConfigProvider'; import { onAuthSettingChanged, TAuthEventObject, subscribeAuth, forceAuth } from '@/api/backend'; import { runningInAndroid, runningInIOS } from '@/utils/env'; -type TProps = { - backendConfig?: TConfigBackend; -}; - -export const EnableAuthSetting = ({ backendConfig }: TProps) => { +export const EnableAuthSetting = () => { const { t } = useTranslation(); - const { setConfig } = useConfig(); + const { config, setConfig } = useConfig(); const handleToggleAuth = async (e: ChangeEvent) => { // Before updating the config we need the user to authenticate. @@ -53,9 +48,9 @@ export const EnableAuthSetting = ({ backendConfig }: TProps) => { settingName={t('newSettings.advancedSettings.authentication.title')} secondaryText={t('newSettings.advancedSettings.authentication.description')} extraComponent={ - backendConfig !== undefined ? ( + config ? ( ) : null From c34305e2396320d2e4ae659ba2d44356834514fc Mon Sep 17 00:00:00 2001 From: Sibilla Date: Wed, 20 May 2026 18:15:06 +0200 Subject: [PATCH 24/24] fix: stabilize export-logs Android e2e after config provider --- .../mobiletests/e2e/export-logs-save.test.js | 19 +++++++++++++++++++ .../src/routes/settings/advanced-settings.tsx | 10 +++------- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/frontends/mobiletests/e2e/export-logs-save.test.js b/frontends/mobiletests/e2e/export-logs-save.test.js index 46d78eac9a..06d20824e3 100644 --- a/frontends/mobiletests/e2e/export-logs-save.test.js +++ b/frontends/mobiletests/e2e/export-logs-save.test.js @@ -21,6 +21,20 @@ const getSaveDialogElement = async (driver) => { return null; }; +/** Dismiss the in-app guide overlay if it is open (blocks clicks on settings). */ +const dismissGuideIfPresent = async (driver) => { + await driver.execute(() => { + const overlays = document.querySelectorAll('div'); + for (const el of overlays) { + const cls = el.className; + if (typeof cls === 'string' && cls.includes('overlay') && cls.includes('show')) { + el.click(); + return; + } + } + }); +}; + describe('Export logs uses save dialog', function () { this.timeout(180000); @@ -56,8 +70,13 @@ describe('Export logs uses save dialog', function () { const exportLogsButton = await driver.$('//button[contains(., "Export logs")]'); await exportLogsButton.waitForDisplayed({ timeout: 60000 }); + await dismissGuideIfPresent(driver); + await exportLogsButton.waitForClickable({ timeout: 10000 }); await exportLogsButton.click(); + // Allow native save picker to open after the export-log API call. + await driver.pause(3000); + await driver.switchContext('NATIVE_APP'); const saveDialog = await getSaveDialogElement(driver); expect(saveDialog, 'expected Android save dialog to be visible').to.not.equal(null); diff --git a/frontends/web/src/routes/settings/advanced-settings.tsx b/frontends/web/src/routes/settings/advanced-settings.tsx index 7516827083..6aa40426a5 100644 --- a/frontends/web/src/routes/settings/advanced-settings.tsx +++ b/frontends/web/src/routes/settings/advanced-settings.tsx @@ -27,10 +27,6 @@ export const AdvancedSettings = ({ devices, hasAccounts }: TPagePropsWithSetting const deviceIDs = Object.keys(devices); - if (config === undefined) { - return null; - } - return ( @@ -53,10 +49,10 @@ export const AdvancedSettings = ({ devices, hasAccounts }: TPagePropsWithSetting hideMobileMenu hasAccounts={hasAccounts} > - - + + - +