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/api/config.ts b/frontends/web/src/api/config.ts new file mode 100644 index 0000000000..e6371ebf88 --- /dev/null +++ b/frontends/web/src/api/config.ts @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { apiGet, apiPost } from '@/utils/request'; +import type { Fiat } from '@/api/account'; +import type { BtcUnit } from '@/api/coins'; + +type TElectrumServerInfo = Readonly<{ + server: string; + tls: boolean; + pemCert: string; +}>; + +type TBtcCoinConfig = Readonly<{ + electrumServers: TElectrumServerInfo[]; +}>; + +/** BTC-based coin keys in backend config (see backend/config/config.go). */ +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 +// for migration / compatibility with persisted app config. +type TEthCoinConfig = Readonly<{ + activeERC20Tokens: string[]; +}>; + +export type TConfigBackendProxy = Readonly<{ + useProxy: boolean; + proxyAddress: string; +}>; + +/** Keys used by NewBadge to mark UI elements as seen. */ +export type TConfigFrontendBadgeKey = + | 'hasSeenMarketplaceNudge' + | 'hasSeenSwapMarketTab' + | 'hasSeenOtcMarketTab'; + +/** Dynamic frontend keys written when dismissing Status banners. */ +type TConfigFrontendDismissibleDynamicKey = + | `update-${string}` + | `banner-backup-${string}` + | `banner-${string}-${string}`; + +/** Known static frontend keys written when dismissing Status banners. */ +type TConfigFrontendDismissibleKnownKey = + | 'walletConnectDisclaimerDismissed' + | 'skipTestingWarning' + | 'mobile-data-warning'; + +export type TConfigFrontendDismissibleKey = + | TConfigFrontendDismissibleKnownKey + | TConfigFrontendDismissibleDynamicKey; + +export type TConfigFrontend = 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 TConfigFrontendDismissibleDynamicKey]?: boolean; +}>; + +export type TConfigBackend = Readonly<{ + proxy: TConfigBackendProxy; + /** + * 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; + 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; +}>; + +export type TConfig = { + readonly backend: TConfigBackend; + readonly frontend: TConfigFrontend; +}; + +/** + * Fetch config from the backend (see handlers.getAppConfig). + * Use setConfig from @/utils/config for partial merge-on-write updates. + */ +export const getConfig = (): Promise => apiGet('config'); + +/** + * Post a config object to the backend. + */ +export const setConfig = (config: TConfig): 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..781c984fa5 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?.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 33c40bb108..c55972b1ad 100644 --- a/frontends/web/src/components/new-badge/new-badge.tsx +++ b/frontends/web/src/components/new-badge/new-badge.tsx @@ -2,15 +2,13 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import type { TConfigFrontendBadgeKey } 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'; +import { useConfig } from '@/contexts/ConfigProvider'; type TProps = { className?: string; - configKey: TConfigKey; + configKey: TConfigFrontendBadgeKey; hideOnPathPrefix?: string; markAsSeen?: boolean; pathname?: string; @@ -28,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(() => { @@ -37,14 +35,13 @@ export const NewBadge = ({ } setShowBadge(false); setConfig({ frontend: { [configKey]: true } }); - }, [configKey, showBadge]); + }, [configKey, setConfig, showBadge]); useEffect(() => { if (!config) { return; } - const frontendConfig = config.frontend as Record | undefined; - const hasSeenBadge = Boolean(frontendConfig?.[configKey]); + const hasSeenBadge = 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 5ece304d78..7fa900fb3e 100644 --- a/frontends/web/src/components/status/status.tsx +++ b/frontends/web/src/components/status/status.tsx @@ -1,7 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 -import { ReactNode, useCallback, useEffect, useState } from 'react'; -import { getConfig, setConfig } from '@/utils/config'; +import { ReactNode } from 'react'; +import type { TConfigFrontendDismissibleKey } from '@/api/config'; +import { useConfig } from '@/contexts/ConfigProvider'; import { CloseXDark, CloseXWhite } from '@/components/icon'; import { useDarkmode } from '@/hooks/darkmode'; import { Message } from '@/components/message/message'; @@ -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: TConfigFrontendDismissibleKey | ''; className?: string; children: ReactNode; }; @@ -28,21 +29,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 ? !config.frontend[dismissibleKey] : true) + : true; const dismiss = async () => { if (!dismissibleKey) { @@ -53,10 +48,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..2831931e84 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,17 @@ 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); - } - }); - }, []); + if (!config) { + return; + } + const { frontend } = config; + if (frontend.guideShown !== undefined) { + setGuideShown(frontend.guideShown); + } + if (frontend.hideAmounts !== undefined) { + setHideAmounts(frontend.hideAmounts); + } + }, [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..40be716382 --- /dev/null +++ b/frontends/web/src/contexts/ConfigProvider.tsx @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { ReactNode, useCallback, useContext, useEffect, useState } from 'react'; +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'; + +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: TConfigUpdate) => { + 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..cb3fccdd30 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,33 @@ 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. + setConfig({ + frontend: { + ...config.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..db1661103e 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,24 @@ 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(); } + setDefaultCurrency(config.backend.mainFiat); + setActiveCurrencies(config.backend.fiatList); + setBtcUnit(config.backend.btcUnit); + return Promise.resolve(); + }, [config]); - if (appConf.backend?.fiatList && appConf.backend?.btcUnit) { - setActiveCurrencies(appConf.backend.fiatList); - setBtcUnit(appConf.backend.btcUnit); - } - }; + 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..8e0c858d66 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 ?? false; 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..bcdb8259c5 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 '@/api/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,18 @@ 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 ?? []; - if (cancelledAccounts?.includes(code)) { + 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); + try { + await setConfig({ frontend: { bitsuranceNotifyCancellation: filtered } }); + } catch (error) { + console.error(error); + } } const bitsuranceAccount = insuredAccounts.bitsuranceAccounts[0]; @@ -93,7 +98,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.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/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..9d7b9b2829 100644 --- a/frontends/web/src/i18n/i18n.test.tsx +++ b/frontends/web/src/i18n/i18n.test.tsx @@ -1,11 +1,17 @@ // 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'; 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: {} } as TConfig); + } + return Promise.resolve(''); + }), })); import { apiGet, apiPost } from '@/utils/request'; @@ -15,6 +21,12 @@ describe('i18n', () => { describe('languageChanged', () => { beforeEach(() => { (apiPost as Mock).mockClear(); + (apiGet as Mock).mockImplementation(endpoint => { + if (endpoint === 'config') { + return Promise.resolve({ backend: { userLanguage: '' }, frontend: {} } as TConfig); + } + return Promise.resolve(''); + }); }); const table = [ @@ -29,18 +41,20 @@ 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({ backend: { userLanguage: '' }, frontend: {} } as TConfig); + } 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', { + backend: { userLanguage: test.userLang ?? '' }, frontend: {}, - backend: { userLanguage: test.userLang }, - }); + } as TConfig); }); }); }); diff --git a/frontends/web/src/routes/account/send/coin-control.tsx b/frontends/web/src/routes/account/send/coin-control.tsx index f9fe840ccc..a06f8ce9df 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,17 @@ 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) { + return; } - }, [account.coinCode]); + setCoinControlEnabled(config.frontend.coinControl ?? false); + }, [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..8315d8237f 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', () => ({ @@ -10,15 +11,21 @@ vi.mock('@/i18n/i18n'); vi.mock('@/utils/env', () => ({ runningInIOS: vi.fn(() => false), })); +vi.mock('@/contexts/ConfigProvider', () => ({ + useConfig: vi.fn(() => ({ + config: { frontend: { expertFee: false } } as TConfig, + 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 +36,13 @@ describe('routes/account/send/feetargets', () => { beforeEach(() => { vi.clearAllMocks(); mockRunningInIOS.mockReturnValue(false); + mockUseConfig.mockReturnValue({ + config: { frontend: { expertFee: false } } as TConfig, + 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 +70,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 } } as TConfig, + 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..aa229bd0b3 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); @@ -125,6 +125,9 @@ 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; if (withCustomFee && feetargetInfo) { diff --git a/frontends/web/src/routes/bitsurance/widget.tsx b/frontends/web/src/routes/bitsurance/widget.tsx index f16c616577..c0dbf46bbd 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(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 48cc26a64b..8a2d52b45f 100644 --- a/frontends/web/src/routes/market/bitrefill.tsx +++ b/frontends/web/src/routes/market/bitrefill.tsx @@ -9,11 +9,10 @@ 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'; -import { useLoad } from '@/hooks/api'; import { BitrefillTerms, localeMapping } from '@/components/terms/bitrefill-terms'; import { getBitrefillInfo } from '@/api/market'; import { getURLOrigin } from '@/utils/url'; @@ -45,17 +44,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(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 17faa42508..fff31bdf69 100644 --- a/frontends/web/src/routes/market/btcdirect.tsx +++ b/frontends/web/src/routes/market/btcdirect.tsx @@ -7,11 +7,10 @@ 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'; -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 +44,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 +54,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(config?.frontend.skipBTCDirectWidgetDisclaimer ?? false); const handlePaymentRequest = useCallback(async (event: MessageEvent) => { const { diff --git a/frontends/web/src/routes/market/components/markettab.test.tsx b/frontends/web/src/routes/market/components/markettab.test.tsx index 166bbb4f48..75b73c12e2 100644 --- a/frontends/web/src/routes/market/components/markettab.test.tsx +++ b/frontends/web/src/routes/market/components/markettab.test.tsx @@ -1,31 +1,41 @@ // 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 { 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: { frontend: {}, backend: {} } as TConfig, + setConfig: vi.fn(), + })), })); -const mockedGetConfig = vi.mocked(getConfig); +const mockUseConfig = vi.mocked(useConfig); describe('routes/market/components/markettab', () => { beforeEach(() => { - mockedGetConfig.mockResolvedValue({ frontend: {} }); + vi.clearAllMocks(); + mockUseConfig.mockReturnValue({ + config: { frontend: {}, backend: {} } as TConfig, + setConfig: vi.fn(), + }); }); it('shows the new badge on swap when enabled', async () => { - mockedGetConfig.mockResolvedValue({ - frontend: { - hasSeenOtcMarketTab: true, - hasSeenSwapMarketTab: false, - } + mockUseConfig.mockReturnValue({ + config: { + frontend: { + hasSeenOtcMarketTab: true, + hasSeenSwapMarketTab: false, + }, + } as TConfig, + setConfig: vi.fn(), }); render( @@ -40,11 +50,14 @@ describe('routes/market/components/markettab', () => { }); it('hides the new badge on swap when disabled', async () => { - mockedGetConfig.mockResolvedValue({ - frontend: { - hasSeenOtcMarketTab: true, - hasSeenSwapMarketTab: true, - } + mockUseConfig.mockReturnValue({ + config: { + frontend: { + hasSeenOtcMarketTab: true, + hasSeenSwapMarketTab: true, + }, + } as TConfig, + setConfig: vi.fn(), }); render( @@ -55,9 +68,6 @@ describe('routes/market/components/markettab', () => { />, ); - await waitFor(() => { - expect(mockedGetConfig).toHaveBeenCalled(); - }); expect(screen.queryByTestId('swap-new-badge')).not.toBeInTheDocument(); }); @@ -81,11 +91,14 @@ describe('routes/market/components/markettab', () => { }); it('shows the new badge on otc when enabled', async () => { - mockedGetConfig.mockResolvedValue({ - frontend: { - hasSeenOtcMarketTab: false, - hasSeenSwapMarketTab: true, - } + mockUseConfig.mockReturnValue({ + config: { + frontend: { + hasSeenOtcMarketTab: false, + hasSeenSwapMarketTab: true, + }, + } as TConfig, + setConfig: vi.fn(), }); render( diff --git a/frontends/web/src/routes/market/market.tsx b/frontends/web/src/routes/market/market.tsx index 9153eb3647..921261ac84 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)); @@ -63,11 +63,11 @@ export const Market = ({ const { agreedTerms: agreedBTCDirectOTCTerms, - } = useVendorTerms(!!config?.frontend?.skipBitsuranceDisclaimer); + } = useVendorTerms(config?.frontend.skipBitsuranceDisclaimer ?? false); const { agreedTerms: agreedPocketOTCTerms, - } = useVendorTerms(!!config?.frontend?.skipPocketOTCDisclaimer); + } = useVendorTerms(config?.frontend.skipPocketOTCDisclaimer ?? false); // keep account list in sync and ensure a valid selected account. useEffect(() => { @@ -99,7 +99,7 @@ export const Market = ({ 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..c001f85f8c 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(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 4c64140512..e870039410 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(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.test.tsx b/frontends/web/src/routes/market/swap/swap.test.tsx index 3646931772..db482f943a 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'; @@ -96,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: { frontend: {}, backend: {} } as TConfig, + setConfig: vi.fn(), + })), +})); import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -110,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, @@ -217,9 +219,9 @@ describe('routes/market/swap', () => { }], }, }); - vi.mocked(config.getConfig).mockResolvedValue({ - frontend: {}, - backend: {}, + mockUseConfig.mockReturnValue({ + config: { frontend: {}, backend: {} } as TConfig, + setConfig: vi.fn(), }); }); diff --git a/frontends/web/src/routes/market/swap/swap.tsx b/frontends/web/src/routes/market/swap/swap.tsx index 7545630693..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,8 +159,8 @@ export const Swap = ({ [btcUnit, sellAccount], ); - const config = useLoad(getConfig); - const { agreedTerms, setAgreedTerms } = useVendorTerms(!!config?.frontend?.skipSwapkitDisclaimer); + const { config } = useConfig(); + const { agreedTerms, setAgreedTerms } = useVendorTerms(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 2482cf13bd..6aa40426a5 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'; @@ -23,41 +21,9 @@ 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 fetchedConfig = useLoad(getConfig) as TConfig; - const [config, setConfig] = useState(); - - const frontendConfig = config?.frontend; - const backendConfig = config?.backend; - const proxyConfig = config?.backend?.proxy; - - useEffect(() => { - setConfig(fetchedConfig); - }, [fetchedConfig]); + const { config } = useConfig(); const deviceIDs = Object.keys(devices); @@ -83,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 824f29f544..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 @@ -5,45 +5,43 @@ 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 { Message } from '@/components/message/message'; import { useMediaQuery } from '@/hooks/mediaquery'; -type TProps = { - backendConfig?: TBackendConfig; - onChangeConfig: (config: TConfig) => void; -}; +const DEFAULT_GAP_LIMIT_RECEIVE = 20; +const DEFAULT_GAP_LIMIT_CHANGE = 6; +const MAX_LIMIT = 2000; -export const CustomGapLimitSettings = ({ backendConfig, onChangeConfig }: TProps) => { +export const CustomGapLimitSettings = () => { const { t } = useTranslation(); + 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 () => { - const config = await setConfig({ + await setConfig({ backend: { - ...backendConfig, - gapLimitReceive, - gapLimitChange, + gapLimitReceive: Number(gapLimitReceive), + gapLimitChange: Number(gapLimitChange), }, }); - onChangeConfig(config); setShowDialog(false); }; @@ -135,4 +133,4 @@ export const CustomGapLimitSettings = ({ backendConfig, onChangeConfig }: TProps ); -}; \ No newline at end of file +}; 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..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 @@ -1,21 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 -import { ChangeEvent, Dispatch } from 'react'; +import { ChangeEvent } from 'react'; 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'; -type TProps = { - backendConfig?: TBackendConfig; - onChangeConfig: Dispatch; -}; - -export const EnableAuthSetting = ({ backendConfig, onChangeConfig }: TProps) => { +export const EnableAuthSetting = () => { const { t } = useTranslation(); + const { config, setConfig } = useConfig(); const handleToggleAuth = async (e: ChangeEvent) => { // Before updating the config we need the user to authenticate. @@ -38,11 +33,10 @@ export const EnableAuthSetting = ({ backendConfig, onChangeConfig }: TProps) => }; const updateConfig = async (auth: boolean) => { - const config = await setConfig({ + await setConfig({ backend: { authentication: auth }, - }) as TConfig; + }); onAuthSettingChanged(); - onChangeConfig(config); }; if (!runningInAndroid() && !runningInIOS()) { @@ -54,9 +48,9 @@ export const EnableAuthSetting = ({ backendConfig, onChangeConfig }: TProps) => settingName={t('newSettings.advancedSettings.authentication.title')} secondaryText={t('newSettings.advancedSettings.authentication.description')} extraComponent={ - backendConfig !== undefined ? ( + config ? ( ) : null 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..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 @@ -1,27 +1,26 @@ // SPDX-License-Identifier: Apache-2.0 -import { ChangeEvent, Dispatch } from 'react'; +import { ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; +import type { TConfigFrontend } 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 { setConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; type TProps = { - frontendConfig?: TFrontendConfig; - onChangeConfig: Dispatch; + frontendConfig?: TConfigFrontend; }; -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 + coinControl: e.target.checked }, - }) as TConfig; - 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 4ac519ce7e..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 @@ -1,27 +1,26 @@ // SPDX-License-Identifier: Apache-2.0 -import { ChangeEvent, Dispatch } from 'react'; +import { ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; +import type { TConfigFrontend } 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 { setConfig } from '@/utils/config'; +import { useConfig } from '@/contexts/ConfigProvider'; type TProps = { - frontendConfig?: TFrontendConfig; - onChangeConfig: Dispatch; + frontendConfig?: TConfigFrontend; }; -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 + expertFee: e.target.checked }, - }) as TConfig; - 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 997d725209..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 @@ -1,19 +1,18 @@ // 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 { 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; - onChangeConfig: Dispatch; + proxyConfig?: TConfigBackendProxy; }; -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 36c691d711..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 @@ -1,43 +1,37 @@ // 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 } from '@/routes/settings/advanced-settings'; 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; -}; - -export const RestartInTestnetSetting = ({ onChangeConfig }: TProps) => { +export const RestartInTestnetSetting = () => { const { t } = useTranslation(); + const { setConfig } = useConfig(); const [showRestartMessage, setShowRestartMessage] = useState(false); const { isTesting } = useContext(AppContext); 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 97720d7739..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 @@ -1,25 +1,25 @@ // SPDX-License-Identifier: Apache-2.0 import { useTranslation } from 'react-i18next'; -import { Dispatch, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; +import type { TConfigBackendProxy } from '@/api/config'; 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'; type TProps = { open: boolean; - proxyConfig?: TProxyConfig; + proxyConfig?: TConfigBackendProxy; onCloseDialog: () => void; - onChangeConfig: (config: any) => void; - handleShowRestartMessage: Dispatch; + 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(); const isMobile = useMediaQuery('(max-width: 768px)'); @@ -35,8 +35,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; @@ -48,12 +47,11 @@ export const TorProxyDialog = ({ open, proxyConfig, onCloseDialog, onChangeConfi } }; - const setProxyConfig = async (proxyConfig: TProxyConfig) => { - const config = await setConfig({ + const setProxyConfig = async (proxyConfig: TConfigBackendProxy) => { + await setConfig({ backend: { proxy: proxyConfig }, - }) as TConfig; + }); setProxyAddress(proxyConfig.proxyAddress); - onChangeConfig(config); handleShowRestartMessage(true); }; 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..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 @@ -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,14 +12,16 @@ 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) { + setShouldNotShowDialog(false); + return; } + setShouldNotShowDialog(config.frontend.hideEnableRememberWalletDialog ?? false); }, [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 6d0d4b82dd..122729c950 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,16 +17,17 @@ 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) { - setWatchonly(keystore.watchonly); + if (!config) { + return; } + setWatchonly(keystore.watchonly); }, [config, keystore]); const toggleWatchonly = async () => { diff --git a/frontends/web/src/routes/settings/electrum-servers.tsx b/frontends/web/src/routes/settings/electrum-servers.tsx index f3f70d98e2..6260f4dbef 100644 --- a/frontends/web/src/routes/settings/electrum-servers.tsx +++ b/frontends/web/src/routes/settings/electrum-servers.tsx @@ -1,39 +1,40 @@ // SPDX-License-Identifier: Apache-2.0 -import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import type { TConfigBackendBtcCoinKey } from '@/api/config'; 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'; type Props = { - coin: 'btc' | 'tbtc' | 'ltc' | 'tltc'; + coin: TConfigBackendBtcCoinKey; }; 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 save = async (newElectrumServers: TElectrumServer[]) => { - const currentConfig = await getConfig(); - currentConfig.backend[coin].electrumServers = newElectrumServers; - await setConfig(currentConfig); - setConfigState(currentConfig); + await setConfig({ + backend: { + [coin]: { + ...config.backend[coin], + electrumServers: newElectrumServers + } + } + }); }; const onAdd = (server: TElectrumServer) => { diff --git a/frontends/web/src/utils/config.ts b/frontends/web/src/utils/config.ts index 71e2cb2afc..6c2245d9a8 100644 --- a/frontends/web/src/utils/config.ts +++ b/frontends/web/src/utils/config.ts @@ -1,37 +1,72 @@ // SPDX-License-Identifier: Apache-2.0 -import { apiGet, apiPost } from '@/utils/request'; +import { getConfig as apiGetConfig, setConfig as apiSetConfig, type TConfig, type TConfigBackend, type TConfigFrontend } from '@/api/config'; -type TConfig = { - backend?: unknown; - frontend?: unknown; +/** Partial backend config for updates; null clears userLanguage (see i18n.ts). */ +type TConfigBackendUpdate = + Omit, 'userLanguage'> & { + userLanguage?: string | null; + }; + +type TConfigFrontendUpdate = Partial; + +export type TConfigUpdate = { + backend?: TConfigBackendUpdate; + frontend?: TConfigFrontendUpdate; }; -let pendingConfig: TConfig = {}; +let pendingConfig: TConfigUpdate = {}; -/** - * get current configs - * i.e. await getConfig() - * returns a promise with backend and frontend configs - */ -export const getConfig = (): Promise => { - return apiGet('config'); +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)), +}); + /** - * 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 full TConfig to backend, return merged config. + * Does not refetch from the backend after POST. */ -export const setConfig = (object: TConfig) => { - return getConfig() - .then((currentConfig = {}) => { - const nextConfig = Object.assign(currentConfig, { - backend: Object.assign({}, currentConfig.backend, pendingConfig.backend, object.backend), - frontend: Object.assign({}, currentConfig.frontend, pendingConfig.frontend, object.frontend) - }); - pendingConfig = nextConfig; - return apiPost('config', nextConfig) +export const setConfig = (object: TConfigUpdate): Promise => { + return apiGetConfig() + .then((currentConfig) => { + const nextConfig: TConfig = { + backend: mergeBackend( + currentConfig.backend, + pendingConfig.backend, + object.backend, + ), + frontend: mergeFrontend( + currentConfig.frontend, + pendingConfig.frontend, + object.frontend, + ), + }; + pendingConfig = { + backend: nextConfig.backend, + frontend: nextConfig.frontend, + }; + return apiSetConfig(nextConfig) .then(() => { pendingConfig = {}; return nextConfig;