Skip to content

Commit f1aded9

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.
1 parent 7e30ce3 commit f1aded9

File tree

8 files changed

+292
-3
lines changed

8 files changed

+292
-3
lines changed

frontends/web/src/api/backend.ts

+8
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import { apiGet, apiPost } from '../utils/request';
1919
import { FailResponse, SuccessResponse } from './response';
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
@@ -1052,6 +1052,27 @@
10521052
"title": "Which servers does this app talk to?"
10531053
}
10541054
},
1055+
"settingsBlockExplorer": {
1056+
"instructions": {
1057+
"link": {
1058+
"text": "Guide on how to use a block explorer"
1059+
},
1060+
"text": "For a full tutorial, please visit our guide:",
1061+
"title": "How do I use a block explorer?"
1062+
},
1063+
"options": {
1064+
"text": "The BitBoxApp features a protection mechanism designed to prevent the opening of arbitrary links. This serves as a security measure against malicious links.",
1065+
"title": "Why can't I enter my own block explorer URL?"
1066+
},
1067+
"what": {
1068+
"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.",
1069+
"title": "What is this?"
1070+
},
1071+
"why": {
1072+
"text": "You can use your preferred block explorer to check out your transaction in more detail or to find additional information about the blockchain.",
1073+
"title": "Why should I use a block explorer?"
1074+
}
1075+
},
10551076
"title": "Guide",
10561077
"toggle": {
10571078
"close": "Close guide",
@@ -1577,6 +1598,10 @@
15771598
"description": "You can connect to your own Electrum full node.",
15781599
"title": "Connect your own full node"
15791600
},
1601+
"explorer": {
1602+
"description": "Change to your preferred block explorer.",
1603+
"title": "Choose block explorer"
1604+
},
15801605
"fee": "Enable custom fees",
15811606
"setProxyAddress": "Set proxy address",
15821607
"title": "Expert settings",
@@ -1593,6 +1618,7 @@
15931618
"version": "App Version"
15941619
},
15951620
"restart": "Please re-start the BitBoxApp for the changes to take effect.",
1621+
"save": "Save",
15961622
"services": {
15971623
"title": "Services"
15981624
},

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

+13-3
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export function 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 function 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 function 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
@@ -28,6 +28,7 @@ import { BitsuranceWidget } from './bitsurance/widget';
2828
import { BitsuranceDashboard } from './bitsurance/dashboard';
2929
import { ConnectScreenWalletConnect } from './account/walletconnect/connect';
3030
import { DashboardWalletConnect } from './account/walletconnect/dashboard';
31+
import { SelectExplorerSettings } from './settings/select-explorer';
3132

3233
type TAppRouterProps = {
3334
devices: TDevices;
@@ -229,6 +230,7 @@ export const AppRouter = ({ devices, deviceIDs, devicesKey, accounts, activeAcco
229230
<Route path="device-settings/passphrase/:deviceID" element={PassphraseEl} />
230231
<Route path="advanced-settings" element={AdvancedSettingsEl} />
231232
<Route path="electrum" element={<ElectrumSettings />} />
233+
<Route path="select-explorer" element={<SelectExplorerSettings />} />
232234
<Route path="manage-accounts" element={
233235
<ManageAccounts
234236
accounts={accounts}

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

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

3435
export type TProxyConfig = {
3536
proxyAddress: string;
@@ -89,6 +90,7 @@ export const AdvancedSettings = ({ deviceIDs, hasAccounts }: TPagePropsWithSetti
8990
<EnableAuthSetting backendConfig={backendConfig} onChangeConfig={setConfig} />
9091
<EnableTorProxySetting proxyConfig={proxyConfig} onChangeConfig={setConfig} />
9192
<ConnectFullNodeSetting />
93+
{ deviceIDs.length > 0 ? <SelectExplorerSetting /> : null }
9294
</WithSettingsTabs>
9395
</ViewContent>
9496
</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 { SingleDropdown } from './components/dropdowns/singledropdown';
18+
import { CoinCode } from '../../api/account';
19+
import { TBlockExplorer } from '../../api/backend';
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+
defaultValue={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 { useTranslation } from 'react-i18next';
18+
import { SettingsItem } from '../settingsItem/settingsItem';
19+
import { ChevronRightDark } from '../../../../components/icon';
20+
import { useNavigate } from 'react-router';
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,140 @@
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 { useTranslation } from 'react-i18next';
19+
import { i18n } from '../../i18n/i18n';
20+
import { Guide } from '../../components/guide/guide';
21+
import { Entry } from '../../components/guide/entry';
22+
import { Button, ButtonLink } from '../../components/forms';
23+
import { Header } from '../../components/layout';
24+
import { BlockExplorers } from './block-explorers';
25+
import * as backendAPI from '../../api/backend';
26+
import { getConfig, setConfig } from '../../utils/config';
27+
import { CoinCode } from '../../api/account';
28+
import { MobileHeader } from './components/mobile-header';
29+
import { useCallback, useEffect, useRef, useState } from 'react';
30+
31+
export const SelectExplorerSettings = () => {
32+
const { t } = useTranslation();
33+
34+
const initialConfig = useRef<any>();
35+
const [config, setConfigState] = useState<any>();
36+
37+
const [supportedCoins, setSupportedCoins] = useState<backendAPI.ICoin[]>([]);
38+
const [allSelections, setAllSelections] = useState<backendAPI.TAvailableExplorers>();
39+
40+
const [saveDisabled, setSaveDisabled] = useState(true);
41+
42+
const loadConfig = () => {
43+
getConfig().then(setConfigState);
44+
};
45+
46+
47+
const updateConfigState = useCallback((newConfig: any) => {
48+
if (JSON.stringify(initialConfig.current) !== JSON.stringify(newConfig)) {
49+
setConfigState(newConfig);
50+
setSaveDisabled(false);
51+
} else {
52+
setSaveDisabled(true);
53+
}
54+
}, []);
55+
56+
const handleChange = useCallback((selectedTxPrefix: string, coin: CoinCode) => {
57+
if (config.backend.blockExplorers[coin] && config.backend.blockExplorers[coin] !== selectedTxPrefix) {
58+
config.backend.blockExplorers[coin] = selectedTxPrefix;
59+
updateConfigState(config);
60+
}
61+
}, [config, updateConfigState]);
62+
63+
const save = async () => {
64+
setSaveDisabled(true);
65+
await setConfig(config);
66+
initialConfig.current = await getConfig();
67+
};
68+
69+
useEffect(() => {
70+
const fetchData = async () => {
71+
const coins = await backendAPI.getSupportedCoins();
72+
const allExplorerSelection = await backendAPI.getAvailableExplorers();
73+
74+
// if set alongside config it will 'update' with it, but we want it to stay the same after initialization.
75+
initialConfig.current = await getConfig();
76+
77+
setSupportedCoins(coins);
78+
setAllSelections(allExplorerSelection);
79+
};
80+
81+
loadConfig();
82+
fetchData().catch(console.error);
83+
}, []);
84+
85+
if (config === undefined) {
86+
return null;
87+
}
88+
89+
return (
90+
<div className="contentWithGuide">
91+
<div className="container">
92+
<div className="innerContainer scrollableContainer">
93+
<Header
94+
hideSidebarToggler
95+
title={
96+
<>
97+
<h2 className={'hide-on-small'}>{t('settings.expert.explorer.title')}</h2>
98+
<MobileHeader withGuide title={t('settings.expert.explorer.title')}/>
99+
</>
100+
}/>
101+
<div className="content padded">
102+
{supportedCoins.map(coin => {
103+
return <BlockExplorers
104+
key={coin.coinCode}
105+
coin={coin.coinCode}
106+
explorerOptions={allSelections?.[coin.coinCode] ?? []}
107+
handleOnChange={handleChange}
108+
selectedPrefix={config.backend.blockExplorers?.[coin.coinCode]}/>;
109+
})}
110+
</div>
111+
<div className="content padded" style={{ display: 'flex', justifyContent: 'space-between' }}>
112+
<ButtonLink
113+
secondary
114+
className={'hide-on-small'}
115+
to={'/settings'}>
116+
{t('button.back')}
117+
</ButtonLink>
118+
<Button primary disabled={saveDisabled} onClick={() => save()}>{t('settings.save')}</Button>
119+
</div>
120+
</div>
121+
</div>
122+
<Guide>
123+
<Entry key="guide.settingsBlockExplorer.what" entry={t('guide.settingsBlockExplorer.what')} />
124+
<Entry key="guide.settingsBlockExplorer.why" entry={t('guide.settingsBlockExplorer.why')} />
125+
<Entry key="guide.settingsBlockExplorer.options" entry={t('guide.settingsBlockExplorer.options')} />
126+
<Entry key="guide.settings-electrum.instructions" entry={{
127+
link: {
128+
text: t('guide.settingsBlockExplorer.instructions.link.text'),
129+
url: (i18n.resolvedLanguage === 'de')
130+
// TODO: DE guide.
131+
? 'https://shiftcrypto.support/help/en-us/23-bitcoin/205-how-to-use-a-block-explorer'
132+
: 'https://shiftcrypto.support/help/en-us/23-bitcoin/205-how-to-use-a-block-explorer'
133+
},
134+
text: t('guide.settingsBlockExplorer.instructions.text'),
135+
title: t('guide.settingsBlockExplorer.instructions.title')
136+
}} />
137+
</Guide>
138+
</div>
139+
);
140+
};

0 commit comments

Comments
 (0)