Skip to content

Commit d8faff0

Browse files
committed
frontend: implement block explorer selection
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.
1 parent 5d5d9de commit d8faff0

File tree

8 files changed

+294
-3
lines changed

8 files changed

+294
-3
lines changed

frontends/web/src/api/backend.ts

+8
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import type { FailResponse, SuccessResponse } from './response';
1919
import { apiGet, apiPost } from '@/utils/request';
2020
import { TSubscriptionCallback, subscribeEndpoint } from './subscribe';
2121

22+
23+
export type TBlockExplorer = { name: string; url: string };
24+
export type TAvailableExplorers = Record<CoinCode, TBlockExplorer[]>;
25+
2226
export interface ICoin {
2327
coinCode: CoinCode;
2428
name: string;
@@ -32,6 +36,10 @@ export interface ISuccess {
3236
errorCode?: string;
3337
}
3438

39+
export const getAvailableExplorers = (): Promise<TAvailableExplorers> => {
40+
return apiGet('available-explorers');
41+
};
42+
3543
export const getSupportedCoins = (): Promise<ICoin[]> => {
3644
return apiGet('supported-coins');
3745
};

frontends/web/src/locales/en/app.json

+26
Original file line numberDiff line numberDiff line change
@@ -1103,6 +1103,27 @@
11031103
"title": "Which servers does this app talk to?"
11041104
}
11051105
},
1106+
"settingsBlockExplorer": {
1107+
"instructions": {
1108+
"link": {
1109+
"text": "Guide on how to use a block explorer"
1110+
},
1111+
"text": "For a full tutorial, please visit our guide:",
1112+
"title": "How do I use a block explorer?"
1113+
},
1114+
"options": {
1115+
"text": "The BitBoxApp features a protection mechanism designed to prevent the opening of arbitrary links. This serves as a security measure against malicious links.",
1116+
"title": "Why can't I enter my own block explorer URL?"
1117+
},
1118+
"what": {
1119+
"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.",
1120+
"title": "What is this?"
1121+
},
1122+
"why": {
1123+
"text": "You can use your preferred block explorer to check out your transaction in more detail or to find additional information about the blockchain.",
1124+
"title": "Why should I use a block explorer?"
1125+
}
1126+
},
11061127
"title": "Guide",
11071128
"toggle": {
11081129
"close": "Close guide",
@@ -1638,6 +1659,10 @@
16381659
"description": "You can connect to your own Electrum full node.",
16391660
"title": "Connect your own full node"
16401661
},
1662+
"explorer": {
1663+
"description": "Change to your preferred block explorer.",
1664+
"title": "Choose block explorer"
1665+
},
16411666
"exportLogs": {
16421667
"description": "Export log file to help with troubleshooting and support.",
16431668
"title": "Export logs"
@@ -1678,6 +1703,7 @@
16781703
"title": "Manage notes"
16791704
},
16801705
"restart": "Please re-start the BitBoxApp for the changes to take effect.",
1706+
"save": "Save",
16811707
"services": {
16821708
"title": "Services"
16831709
},

frontends/web/src/routes/account/account.tsx

+13-3
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export const Account = ({
7070
const [uncoveredFunds, setUncoveredFunds] = useState<string[]>([]);
7171
const [stateCode, setStateCode] = useState<string>();
7272
const supportedExchanges = useLoad<SupportedExchanges>(getExchangeBuySupported(code), [code]);
73+
const [ blockExplorerTxPrefix, setBlockExplorerTxPrefix ] = useState<string>();
7374

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

@@ -123,8 +124,17 @@ export const Account = ({
123124

124125
useEffect(() => {
125126
maybeCheckBitsuranceStatus();
126-
getConfig().then(({ backend }) => setUsesProxy(backend.proxy.useProxy));
127-
}, [maybeCheckBitsuranceStatus]);
127+
getConfig().then(({ backend }) => {
128+
setUsesProxy(backend.proxy.useProxy);
129+
if (account) {
130+
if (backend[account.coinCode]) {
131+
setBlockExplorerTxPrefix(backend.blockExplorers[account.coinCode]);
132+
} else {
133+
setBlockExplorerTxPrefix(account.blockExplorerTxPrefix);
134+
}
135+
}
136+
});
137+
}, [maybeCheckBitsuranceStatus, account]);
128138

129139
const hasCard = useSDCard(devices, [code]);
130140

@@ -324,7 +334,7 @@ export const Account = ({
324334
{!isAccountEmpty && <Transactions
325335
accountCode={code}
326336
handleExport={exportAccount}
327-
explorerURL={account.blockExplorerTxPrefix}
337+
explorerURL={blockExplorerTxPrefix ? blockExplorerTxPrefix : account.blockExplorerTxPrefix }
328338
transactions={transactions}
329339
/> }
330340
</div>

frontends/web/src/routes/router.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { BitsuranceWidget } from './bitsurance/widget';
4444
import { BitsuranceDashboard } from './bitsurance/dashboard';
4545
import { ConnectScreenWalletConnect } from './account/walletconnect/connect';
4646
import { DashboardWalletConnect } from './account/walletconnect/dashboard';
47+
import { SelectExplorerSettings } from './settings/select-explorer';
4748

4849
type TAppRouterProps = {
4950
devices: TDevices;
@@ -246,6 +247,7 @@ export const AppRouter = ({ devices, deviceIDs, devicesKey, accounts, activeAcco
246247
<Route path="device-settings/passphrase/:deviceID" element={PassphraseEl} />
247248
<Route path="advanced-settings" element={AdvancedSettingsEl} />
248249
<Route path="electrum" element={<ElectrumSettings />} />
250+
<Route path="select-explorer" element={<SelectExplorerSettings accounts={accounts}/>} />
249251
<Route path="manage-accounts" element={
250252
<ManageAccounts
251253
accounts={accounts}

frontends/web/src/routes/settings/advanced-settings.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { MobileHeader } from './components/mobile-header';
3131
import { Guide } from '@/components/guide/guide';
3232
import { Entry } from '@/components/guide/entry';
3333
import { EnableAuthSetting } from './components/advanced-settings/enable-auth-setting';
34+
import { SelectExplorerSetting } from './components/advanced-settings/select-explorer-setting';
3435

3536
export type TProxyConfig = {
3637
proxyAddress: string;
@@ -91,6 +92,7 @@ export const AdvancedSettings = ({ deviceIDs, hasAccounts }: TPagePropsWithSetti
9192
<EnableTorProxySetting proxyConfig={proxyConfig} onChangeConfig={setConfig} />
9293
<ConnectFullNodeSetting />
9394
<ExportLogSetting />
95+
{ hasAccounts ? <SelectExplorerSetting /> : null }
9496
</WithSettingsTabs>
9597
</ViewContent>
9698
</View>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* Copyright 2022 Shift Crypto AG
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { CoinCode } from '@/api/account';
18+
import { TBlockExplorer } from '@/api/backend';
19+
import { SingleDropdown } from './components/dropdowns/singledropdown';
20+
21+
type TOption = {
22+
label: string;
23+
value: string;
24+
}
25+
26+
type TProps = {
27+
coin: CoinCode;
28+
explorerOptions: TBlockExplorer[];
29+
handleOnChange: (value: string, coin: CoinCode) => void
30+
selectedPrefix: string;
31+
};
32+
33+
export const BlockExplorers = ({ coin, explorerOptions, handleOnChange, selectedPrefix }: TProps) => {
34+
const options: TOption[] = explorerOptions.map(explorer => {
35+
return { label: explorer.name, value: explorer.url };
36+
});
37+
38+
const fullCoinName = new Map<CoinCode, string>([
39+
['btc', 'Bitcoin'],
40+
['tbtc', 'Testnet Bitcoin'],
41+
['ltc', 'Litecoin'],
42+
['tltc', 'Testnet Litecoin'],
43+
['eth', 'Ethereum'],
44+
['goeth', 'Goerli Ethereum'],
45+
['sepeth', 'Sepolia Ethereum'],
46+
]);
47+
48+
// find the index of the currently selected explorer. will be -1 if none is found.
49+
const activeExplorerIndex = explorerOptions.findIndex(explorer => explorer.url === selectedPrefix);
50+
51+
return (
52+
options.length > 0 &&
53+
<div>
54+
<h2>{fullCoinName.get(coin)}</h2>
55+
<SingleDropdown
56+
options={options}
57+
handleChange={value => handleOnChange(value, coin)}
58+
value={options[activeExplorerIndex > 0 ? activeExplorerIndex : 0]}
59+
/>
60+
</div>
61+
);
62+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Copyright 2023 Shift Crypto AG
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { useNavigate } from 'react-router';
18+
import { useTranslation } from 'react-i18next';
19+
import { ChevronRightDark } from '@/components/icon';
20+
import { SettingsItem } from '@/routes/settings/components/settingsItem/settingsItem';
21+
22+
export const SelectExplorerSetting = () => {
23+
const { t } = useTranslation();
24+
const navigate = useNavigate();
25+
26+
return (
27+
<SettingsItem
28+
settingName={t('settings.expert.explorer.title')}
29+
onClick={() => navigate('/settings/select-explorer')}
30+
secondaryText={t('settings.expert.explorer.description')}
31+
extraComponent={
32+
<ChevronRightDark
33+
width={24}
34+
height={24}
35+
/>
36+
}
37+
/>
38+
);
39+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* Copyright 2018 Shift Devices AG
3+
* Copyright 2022 Shift Crypto AG
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { useCallback, useEffect, useRef, useState } from 'react';
19+
import { useTranslation } from 'react-i18next';
20+
import { CoinCode, IAccount } from '@/api/account';
21+
import * as backendAPI from '@/api/backend';
22+
import { i18n } from '@/i18n/i18n';
23+
import { Guide } from '@/components/guide/guide';
24+
import { Entry } from '@/components/guide/entry';
25+
import { Button, ButtonLink } from '@/components/forms';
26+
import { Header } from '@/components/layout';
27+
import { getConfig, setConfig } from '@/utils/config';
28+
import { MobileHeader } from './components/mobile-header';
29+
import { BlockExplorers } from './block-explorers';
30+
31+
type TSelectExplorerSettingsProps = {
32+
accounts: IAccount[];
33+
}
34+
35+
export const SelectExplorerSettings = ({ accounts }: TSelectExplorerSettingsProps) => {
36+
const { t } = useTranslation();
37+
38+
const initialConfig = useRef<any>();
39+
const [config, setConfigState] = useState<any>();
40+
41+
const availableCoins = new Set(accounts.map(account => account.coinCode));
42+
const [allSelections, setAllSelections] = useState<backendAPI.TAvailableExplorers>();
43+
44+
const [saveDisabled, setSaveDisabled] = useState(true);
45+
46+
const loadConfig = () => {
47+
getConfig().then(setConfigState);
48+
};
49+
50+
51+
const updateConfigState = useCallback((newConfig: any) => {
52+
if (JSON.stringify(initialConfig.current) !== JSON.stringify(newConfig)) {
53+
setConfigState(newConfig);
54+
setSaveDisabled(false);
55+
} else {
56+
setSaveDisabled(true);
57+
}
58+
}, []);
59+
60+
const handleChange = useCallback((selectedTxPrefix: string, coin: CoinCode) => {
61+
if (config.backend.blockExplorers[coin] && config.backend.blockExplorers[coin] !== selectedTxPrefix) {
62+
config.backend.blockExplorers[coin] = selectedTxPrefix;
63+
updateConfigState(config);
64+
}
65+
}, [config, updateConfigState]);
66+
67+
const save = async () => {
68+
setSaveDisabled(true);
69+
await setConfig(config);
70+
initialConfig.current = await getConfig();
71+
};
72+
73+
useEffect(() => {
74+
const fetchData = async () => {
75+
const allExplorerSelection = await backendAPI.getAvailableExplorers();
76+
77+
// if set alongside config it will 'update' with it, but we want it to stay the same after initialization.
78+
initialConfig.current = await getConfig();
79+
80+
setAllSelections(allExplorerSelection);
81+
};
82+
83+
loadConfig();
84+
fetchData().catch(console.error);
85+
}, []);
86+
87+
if (config === undefined) {
88+
return null;
89+
}
90+
91+
return (
92+
<div className="contentWithGuide">
93+
<div className="container">
94+
<div className="innerContainer scrollableContainer">
95+
<Header
96+
hideSidebarToggler
97+
title={
98+
<>
99+
<h2 className={'hide-on-small'}>{t('settings.expert.explorer.title')}</h2>
100+
<MobileHeader withGuide title={t('settings.expert.explorer.title')}/>
101+
</>
102+
}/>
103+
<div className="content padded">
104+
{ Array.from(availableCoins).map(coin => {
105+
return <BlockExplorers
106+
key={coin}
107+
coin={coin}
108+
explorerOptions={allSelections?.[coin] ?? []}
109+
handleOnChange={handleChange}
110+
selectedPrefix={config.backend.blockExplorers?.[coin]}/>;
111+
}) }
112+
</div>
113+
<div className="content padded" style={{ display: 'flex', justifyContent: 'space-between' }}>
114+
<ButtonLink
115+
secondary
116+
className={'hide-on-small'}
117+
to={'/settings'}>
118+
{t('button.back')}
119+
</ButtonLink>
120+
<Button primary disabled={saveDisabled} onClick={() => save()}>{t('settings.save')}</Button>
121+
</div>
122+
</div>
123+
</div>
124+
<Guide>
125+
<Entry key="guide.settingsBlockExplorer.what" entry={t('guide.settingsBlockExplorer.what', { returnObjects: true })} />
126+
<Entry key="guide.settingsBlockExplorer.why" entry={t('guide.settingsBlockExplorer.why', { returnObjects: true })} />
127+
<Entry key="guide.settingsBlockExplorer.options" entry={t('guide.settingsBlockExplorer.options', { returnObjects: true })} />
128+
<Entry key="guide.settings-electrum.instructions" entry={{
129+
link: {
130+
text: t('guide.settingsBlockExplorer.instructions.link.text'),
131+
url: (i18n.resolvedLanguage === 'de')
132+
// TODO: DE guide.
133+
? 'https://shiftcrypto.support/help/en-us/23-bitcoin/205-how-to-use-a-block-explorer'
134+
: 'https://shiftcrypto.support/help/en-us/23-bitcoin/205-how-to-use-a-block-explorer'
135+
},
136+
text: t('guide.settingsBlockExplorer.instructions.text'),
137+
title: t('guide.settingsBlockExplorer.instructions.title')
138+
}} />
139+
</Guide>
140+
</div>
141+
);
142+
};

0 commit comments

Comments
 (0)