diff --git a/frontends/web/src/api/backend.ts b/frontends/web/src/api/backend.ts index 6ca02e7ff1..7d17586e2b 100644 --- a/frontends/web/src/api/backend.ts +++ b/frontends/web/src/api/backend.ts @@ -19,6 +19,10 @@ import type { FailResponse, SuccessResponse } from './response'; import { apiGet, apiPost } from '@/utils/request'; import { TSubscriptionCallback, subscribeEndpoint } from './subscribe'; + +export type TBlockExplorer = { name: string; url: string }; +export type TAvailableExplorers = Record; + export interface ICoin { coinCode: CoinCode; name: string; @@ -32,6 +36,10 @@ export interface ISuccess { errorCode?: string; } +export const getAvailableExplorers = (): Promise => { + return apiGet('available-explorers'); +}; + export const getSupportedCoins = (): Promise => { return apiGet('supported-coins'); }; diff --git a/frontends/web/src/locales/en/app.json b/frontends/web/src/locales/en/app.json index e4a52c8aa9..bc1ffe9d11 100644 --- a/frontends/web/src/locales/en/app.json +++ b/frontends/web/src/locales/en/app.json @@ -1103,6 +1103,27 @@ "title": "Which servers does this app talk to?" } }, + "settingsBlockExplorer": { + "instructions": { + "link": { + "text": "Guide on how to use a block explorer" + }, + "text": "For a full tutorial, please visit our guide:", + "title": "How do I use a block explorer?" + }, + "options": { + "text": "The BitBoxApp features a protection mechanism designed to prevent the opening of arbitrary links. This serves as a security measure against malicious links.", + "title": "Why can't I enter my own block explorer URL?" + }, + "what": { + "text": "A block explorer lets you dive into the details of the blockchain, helping you understand what's happening. You can choose from various block explorers to find one that suits you.", + "title": "What is this?" + }, + "why": { + "text": "You can use your preferred block explorer to check out your transaction in more detail or to find additional information about the blockchain.", + "title": "Why should I use a block explorer?" + } + }, "title": "Guide", "toggle": { "close": "Close guide", @@ -1638,6 +1659,10 @@ "description": "You can connect to your own Electrum full node.", "title": "Connect your own full node" }, + "explorer": { + "description": "Change to your preferred block explorer.", + "title": "Choose block explorer" + }, "exportLogs": { "description": "Export log file to help with troubleshooting and support.", "title": "Export logs" @@ -1678,6 +1703,7 @@ "title": "Manage notes" }, "restart": "Please re-start the BitBoxApp for the changes to take effect.", + "save": "Save", "services": { "title": "Services" }, diff --git a/frontends/web/src/routes/account/account.tsx b/frontends/web/src/routes/account/account.tsx index 3b619c9e5a..5dfe6c1e83 100644 --- a/frontends/web/src/routes/account/account.tsx +++ b/frontends/web/src/routes/account/account.tsx @@ -70,6 +70,7 @@ export const Account = ({ const [uncoveredFunds, setUncoveredFunds] = useState([]); const [stateCode, setStateCode] = useState(); const supportedExchanges = useLoad(getExchangeBuySupported(code), [code]); + const [ blockExplorerTxPrefix, setBlockExplorerTxPrefix ] = useState(); const account = accounts && accounts.find(acct => acct.code === code); @@ -123,8 +124,17 @@ export const Account = ({ useEffect(() => { maybeCheckBitsuranceStatus(); - getConfig().then(({ backend }) => setUsesProxy(backend.proxy.useProxy)); - }, [maybeCheckBitsuranceStatus]); + getConfig().then(({ backend }) => { + setUsesProxy(backend.proxy.useProxy); + if (account) { + if (backend[account.coinCode]) { + setBlockExplorerTxPrefix(backend.blockExplorers[account.coinCode]); + } else { + setBlockExplorerTxPrefix(account.blockExplorerTxPrefix); + } + } + }); + }, [maybeCheckBitsuranceStatus, account]); const hasCard = useSDCard(devices, [code]); @@ -324,7 +334,7 @@ export const Account = ({ {!isAccountEmpty && } diff --git a/frontends/web/src/routes/router.tsx b/frontends/web/src/routes/router.tsx index 53a224c7f2..57c0de7f33 100644 --- a/frontends/web/src/routes/router.tsx +++ b/frontends/web/src/routes/router.tsx @@ -44,6 +44,7 @@ import { BitsuranceWidget } from './bitsurance/widget'; import { BitsuranceDashboard } from './bitsurance/dashboard'; import { ConnectScreenWalletConnect } from './account/walletconnect/connect'; import { DashboardWalletConnect } from './account/walletconnect/dashboard'; +import { SelectExplorerSettings } from './settings/select-explorer'; type TAppRouterProps = { devices: TDevices; @@ -246,6 +247,7 @@ export const AppRouter = ({ devices, deviceIDs, devicesKey, accounts, activeAcco } /> + } /> + { hasAccounts ? : null } diff --git a/frontends/web/src/routes/settings/block-explorers.tsx b/frontends/web/src/routes/settings/block-explorers.tsx new file mode 100644 index 0000000000..666bb6d9e9 --- /dev/null +++ b/frontends/web/src/routes/settings/block-explorers.tsx @@ -0,0 +1,62 @@ +/** + * Copyright 2022 Shift Crypto AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CoinCode } from '@/api/account'; +import { TBlockExplorer } from '@/api/backend'; +import { SingleDropdown } from './components/dropdowns/singledropdown'; + +type TOption = { + label: string; + value: string; +} + +type TProps = { + coin: CoinCode; + explorerOptions: TBlockExplorer[]; + handleOnChange: (value: string, coin: CoinCode) => void + selectedPrefix: string; +}; + +export const BlockExplorers = ({ coin, explorerOptions, handleOnChange, selectedPrefix }: TProps) => { + const options: TOption[] = explorerOptions.map(explorer => { + return { label: explorer.name, value: explorer.url }; + }); + + const fullCoinName = new Map([ + ['btc', 'Bitcoin'], + ['tbtc', 'Testnet Bitcoin'], + ['ltc', 'Litecoin'], + ['tltc', 'Testnet Litecoin'], + ['eth', 'Ethereum'], + ['goeth', 'Goerli Ethereum'], + ['sepeth', 'Sepolia Ethereum'], + ]); + + // find the index of the currently selected explorer. will be -1 if none is found. + const activeExplorerIndex = explorerOptions.findIndex(explorer => explorer.url === selectedPrefix); + + return ( + options.length > 0 && +
+

{fullCoinName.get(coin)}

+ handleOnChange(value, coin)} + value={options[activeExplorerIndex > 0 ? activeExplorerIndex : 0]} + /> +
+ ); +}; diff --git a/frontends/web/src/routes/settings/components/advanced-settings/select-explorer-setting.tsx b/frontends/web/src/routes/settings/components/advanced-settings/select-explorer-setting.tsx new file mode 100644 index 0000000000..762d79c3a9 --- /dev/null +++ b/frontends/web/src/routes/settings/components/advanced-settings/select-explorer-setting.tsx @@ -0,0 +1,39 @@ +/** + * Copyright 2023 Shift Crypto AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useNavigate } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { ChevronRightDark } from '@/components/icon'; +import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem'; + +export const SelectExplorerSetting = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + return ( + navigate('/settings/select-explorer')} + secondaryText={t('settings.expert.explorer.description')} + extraComponent={ + + } + /> + ); +}; diff --git a/frontends/web/src/routes/settings/select-explorer.tsx b/frontends/web/src/routes/settings/select-explorer.tsx new file mode 100644 index 0000000000..b1ddc9444b --- /dev/null +++ b/frontends/web/src/routes/settings/select-explorer.tsx @@ -0,0 +1,142 @@ +/** + * Copyright 2018 Shift Devices AG + * Copyright 2022 Shift Crypto AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CoinCode, IAccount } from '@/api/account'; +import * as backendAPI from '@/api/backend'; +import { i18n } from '@/i18n/i18n'; +import { Guide } from '@/components/guide/guide'; +import { Entry } from '@/components/guide/entry'; +import { Button, ButtonLink } from '@/components/forms'; +import { Header } from '@/components/layout'; +import { getConfig, setConfig } from '@/utils/config'; +import { MobileHeader } from './components/mobile-header'; +import { BlockExplorers } from './block-explorers'; + +type TSelectExplorerSettingsProps = { + accounts: IAccount[]; +} + +export const SelectExplorerSettings = ({ accounts }: TSelectExplorerSettingsProps) => { + const { t } = useTranslation(); + + const initialConfig = useRef(); + const [config, setConfigState] = useState(); + + const availableCoins = new Set(accounts.map(account => account.coinCode)); + const [allSelections, setAllSelections] = useState(); + + const [saveDisabled, setSaveDisabled] = useState(true); + + const loadConfig = () => { + getConfig().then(setConfigState); + }; + + + const updateConfigState = useCallback((newConfig: any) => { + if (JSON.stringify(initialConfig.current) !== JSON.stringify(newConfig)) { + setConfigState(newConfig); + setSaveDisabled(false); + } else { + setSaveDisabled(true); + } + }, []); + + const handleChange = useCallback((selectedTxPrefix: string, coin: CoinCode) => { + if (config.backend.blockExplorers[coin] && config.backend.blockExplorers[coin] !== selectedTxPrefix) { + config.backend.blockExplorers[coin] = selectedTxPrefix; + updateConfigState(config); + } + }, [config, updateConfigState]); + + const save = async () => { + setSaveDisabled(true); + await setConfig(config); + initialConfig.current = await getConfig(); + }; + + useEffect(() => { + const fetchData = async () => { + const allExplorerSelection = await backendAPI.getAvailableExplorers(); + + // if set alongside config it will 'update' with it, but we want it to stay the same after initialization. + initialConfig.current = await getConfig(); + + setAllSelections(allExplorerSelection); + }; + + loadConfig(); + fetchData().catch(console.error); + }, []); + + if (config === undefined) { + return null; + } + + return ( +
+
+
+
+

{t('settings.expert.explorer.title')}

+ + + }/> +
+ { Array.from(availableCoins).map(coin => { + return ; + }) } +
+
+ + {t('button.back')} + + +
+
+
+ + + + + + +
+ ); +};