Skip to content

Commit

Permalink
frontend: implement block explorer selection
Browse files Browse the repository at this point in the history
Add a setting that lets users configure their preferred block
explorer. Only whitelisted block explorers are available options.
The frontend decides which selections to show (e.g only BTC or BTC and
ETH) based on the accounts that are present.
  • Loading branch information
NicolaLS committed Jul 30, 2024
1 parent 5d5d9de commit d8faff0
Show file tree
Hide file tree
Showing 8 changed files with 294 additions and 3 deletions.
8 changes: 8 additions & 0 deletions frontends/web/src/api/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CoinCode, TBlockExplorer[]>;

export interface ICoin {
coinCode: CoinCode;
name: string;
Expand All @@ -32,6 +36,10 @@ export interface ISuccess {
errorCode?: string;
}

export const getAvailableExplorers = (): Promise<TAvailableExplorers> => {
return apiGet('available-explorers');
};

export const getSupportedCoins = (): Promise<ICoin[]> => {
return apiGet('supported-coins');
};
Expand Down
26 changes: 26 additions & 0 deletions frontends/web/src/locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -1678,6 +1703,7 @@
"title": "Manage notes"
},
"restart": "Please re-start the BitBoxApp for the changes to take effect.",
"save": "Save",
"services": {
"title": "Services"
},
Expand Down
16 changes: 13 additions & 3 deletions frontends/web/src/routes/account/account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export const Account = ({
const [uncoveredFunds, setUncoveredFunds] = useState<string[]>([]);
const [stateCode, setStateCode] = useState<string>();
const supportedExchanges = useLoad<SupportedExchanges>(getExchangeBuySupported(code), [code]);
const [ blockExplorerTxPrefix, setBlockExplorerTxPrefix ] = useState<string>();

const account = accounts && accounts.find(acct => acct.code === code);

Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -324,7 +334,7 @@ export const Account = ({
{!isAccountEmpty && <Transactions
accountCode={code}
handleExport={exportAccount}
explorerURL={account.blockExplorerTxPrefix}
explorerURL={blockExplorerTxPrefix ? blockExplorerTxPrefix : account.blockExplorerTxPrefix }
transactions={transactions}
/> }
</div>
Expand Down
2 changes: 2 additions & 0 deletions frontends/web/src/routes/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -246,6 +247,7 @@ export const AppRouter = ({ devices, deviceIDs, devicesKey, accounts, activeAcco
<Route path="device-settings/passphrase/:deviceID" element={PassphraseEl} />
<Route path="advanced-settings" element={AdvancedSettingsEl} />
<Route path="electrum" element={<ElectrumSettings />} />
<Route path="select-explorer" element={<SelectExplorerSettings accounts={accounts}/>} />
<Route path="manage-accounts" element={
<ManageAccounts
accounts={accounts}
Expand Down
2 changes: 2 additions & 0 deletions frontends/web/src/routes/settings/advanced-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { MobileHeader } from './components/mobile-header';
import { Guide } from '@/components/guide/guide';
import { Entry } from '@/components/guide/entry';
import { EnableAuthSetting } from './components/advanced-settings/enable-auth-setting';
import { SelectExplorerSetting } from './components/advanced-settings/select-explorer-setting';

export type TProxyConfig = {
proxyAddress: string;
Expand Down Expand Up @@ -91,6 +92,7 @@ export const AdvancedSettings = ({ deviceIDs, hasAccounts }: TPagePropsWithSetti
<EnableTorProxySetting proxyConfig={proxyConfig} onChangeConfig={setConfig} />
<ConnectFullNodeSetting />
<ExportLogSetting />
{ hasAccounts ? <SelectExplorerSetting /> : null }
</WithSettingsTabs>
</ViewContent>
</View>
Expand Down
62 changes: 62 additions & 0 deletions frontends/web/src/routes/settings/block-explorers.tsx
Original file line number Diff line number Diff line change
@@ -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<CoinCode, string>([
['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 &&
<div>
<h2>{fullCoinName.get(coin)}</h2>
<SingleDropdown
options={options}
handleChange={value => handleOnChange(value, coin)}
value={options[activeExplorerIndex > 0 ? activeExplorerIndex : 0]}
/>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -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 (
<SettingsItem
settingName={t('settings.expert.explorer.title')}
onClick={() => navigate('/settings/select-explorer')}
secondaryText={t('settings.expert.explorer.description')}
extraComponent={
<ChevronRightDark
width={24}
height={24}
/>
}
/>
);
};
142 changes: 142 additions & 0 deletions frontends/web/src/routes/settings/select-explorer.tsx
Original file line number Diff line number Diff line change
@@ -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<any>();
const [config, setConfigState] = useState<any>();

const availableCoins = new Set(accounts.map(account => account.coinCode));
const [allSelections, setAllSelections] = useState<backendAPI.TAvailableExplorers>();

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 (
<div className="contentWithGuide">
<div className="container">
<div className="innerContainer scrollableContainer">
<Header
hideSidebarToggler
title={
<>
<h2 className={'hide-on-small'}>{t('settings.expert.explorer.title')}</h2>
<MobileHeader withGuide title={t('settings.expert.explorer.title')}/>
</>
}/>
<div className="content padded">
{ Array.from(availableCoins).map(coin => {
return <BlockExplorers
key={coin}
coin={coin}
explorerOptions={allSelections?.[coin] ?? []}
handleOnChange={handleChange}
selectedPrefix={config.backend.blockExplorers?.[coin]}/>;
}) }
</div>
<div className="content padded" style={{ display: 'flex', justifyContent: 'space-between' }}>
<ButtonLink
secondary
className={'hide-on-small'}
to={'/settings'}>
{t('button.back')}
</ButtonLink>
<Button primary disabled={saveDisabled} onClick={() => save()}>{t('settings.save')}</Button>
</div>
</div>
</div>
<Guide>
<Entry key="guide.settingsBlockExplorer.what" entry={t('guide.settingsBlockExplorer.what', { returnObjects: true })} />
<Entry key="guide.settingsBlockExplorer.why" entry={t('guide.settingsBlockExplorer.why', { returnObjects: true })} />
<Entry key="guide.settingsBlockExplorer.options" entry={t('guide.settingsBlockExplorer.options', { returnObjects: true })} />
<Entry key="guide.settings-electrum.instructions" entry={{
link: {
text: t('guide.settingsBlockExplorer.instructions.link.text'),
url: (i18n.resolvedLanguage === 'de')
// TODO: DE guide.
? 'https://shiftcrypto.support/help/en-us/23-bitcoin/205-how-to-use-a-block-explorer'
: 'https://shiftcrypto.support/help/en-us/23-bitcoin/205-how-to-use-a-block-explorer'
},
text: t('guide.settingsBlockExplorer.instructions.text'),
title: t('guide.settingsBlockExplorer.instructions.title')
}} />
</Guide>
</div>
);
};

0 comments on commit d8faff0

Please sign in to comment.