Skip to content

Commit c0c1d30

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 8a851fd commit c0c1d30

File tree

7 files changed

+301
-3
lines changed

7 files changed

+301
-3
lines changed

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

+27
Original file line numberDiff line numberDiff line change
@@ -1013,6 +1013,27 @@
10131013
"title": "Why is there a network fee?"
10141014
}
10151015
},
1016+
"settings-block-explorer": {
1017+
"instructions": {
1018+
"link": {
1019+
"text": "Guide on how to use a block explorer"
1020+
},
1021+
"text": "For a full tutorial, please visit our guide:",
1022+
"title": "How do I use a block explorer?"
1023+
},
1024+
"options": {
1025+
"text": "The BitBoxApp features a protection mechanism designed to prevent the opening of arbitrary links. This serves as a security measure against malicious links. It cannot be bypassed on an individual basis; rather, its suspension would be required to allow users to configure their own URLs.",
1026+
"title": "Why can't I enter my own block explorer URL?"
1027+
},
1028+
"what": {
1029+
"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.",
1030+
"title": "What is this?"
1031+
},
1032+
"why": {
1033+
"text": "You can use your preferred block explorer to check out your transaction in more detail or to find additional information about the blockchain.",
1034+
"title": "Why should I use a block explorer?"
1035+
}
1036+
},
10161037
"settings-electrum": {
10171038
"connection": {
10181039
"text": "If you intend to only connect to your node when you are on the same network (e.g. your home wifi), then using regular network communication is sufficient.\nIn this case it is advisable that your Electrum server provides a TLS certificate to encrypt the communication.\nIf you intend to connect to your node from anywhere, using Tor is the better option. No TLS certificate is necessary in that case.",
@@ -1577,6 +1598,11 @@
15771598
"description": "You can connect to your own Electrum full node.",
15781599
"title": "Connect your own full node"
15791600
},
1601+
"explorer": {
1602+
"description": "Choose block explorer.",
1603+
"title": "Change to your preferred block explorer.",
1604+
"title-small": "Change block explorer."
1605+
},
15801606
"fee": "Enable custom fees",
15811607
"setProxyAddress": "Set proxy address",
15821608
"title": "Expert settings",
@@ -1593,6 +1619,7 @@
15931619
"version": "App Version"
15941620
},
15951621
"restart": "Please re-start the BitBoxApp for the changes to take effect.",
1622+
"save": "Save",
15961623
"services": {
15971624
"title": "Services"
15981625
},

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[account.coinCode].blockExplorerTxPrefix);
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,66 @@
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+
20+
type TOption = {
21+
label: string;
22+
value: string;
23+
}
24+
25+
type TExplorer = {
26+
name: string;
27+
url: string;
28+
}
29+
30+
type TProps = {
31+
coin: CoinCode;
32+
explorerOptions: TExplorer[];
33+
handleOnChange: (value: string, coin: CoinCode) => void
34+
selectedPrefix: string;
35+
};
36+
37+
export const BlockExplorers = ({ coin, explorerOptions, handleOnChange, selectedPrefix }: TProps) => {
38+
const options: TOption[] = explorerOptions.map(explorer => {
39+
return { label: explorer.name, value: explorer.url };
40+
});
41+
42+
const fullCoinName = new Map<CoinCode, string>([
43+
['btc', 'Bitcoin'],
44+
['tbtc', 'Testnet Bitcoin'],
45+
['ltc', 'Litecoin'],
46+
['tltc', 'Testnet Litecoin'],
47+
['eth', 'Ethereum'],
48+
['goeth', 'Goerli Ethereum'],
49+
['sepeth', 'Sepolia Ethereum'],
50+
]);
51+
52+
// find the index of the currently selected explorer. will be -1 if none is found.
53+
const activeExplorerIndex = explorerOptions.findIndex(explorer => explorer.url === selectedPrefix);
54+
55+
return (
56+
options.length > 0 &&
57+
<div>
58+
<h2>{fullCoinName.get(coin)}</h2>
59+
<SingleDropdown
60+
options={options}
61+
handleChange={value => handleOnChange(value, coin)}
62+
defaultValue={options[activeExplorerIndex > 0 ? activeExplorerIndex : 0]}
63+
/>
64+
</div>
65+
);
66+
};
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,152 @@
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 { apiGet } from '../../utils/request';
27+
import { getConfig, setConfig } from '../../utils/config';
28+
import { CoinCode } from '../../api/account';
29+
import { MobileHeader } from './components/mobile-header';
30+
import { useCallback, useEffect, useRef, useState } from 'react';
31+
32+
33+
type TBlockExplorer = { name: string; url: string };
34+
type TSelectionData = Record<CoinCode, TBlockExplorer[]>;
35+
36+
export const SelectExplorerSettings = () => {
37+
const { t } = useTranslation();
38+
39+
const initialConfig = useRef<any>();
40+
const [config, setConfigState] = useState<any>();
41+
42+
const [supportedCoins, setSupportedCoins] = useState<backendAPI.ICoin[]>([]);
43+
const [allSelections, setAllSelections] = useState<TSelectionData>();
44+
45+
const [saveDisabled, setSaveDisabled] = useState(true);
46+
47+
const loadConfig = () => {
48+
getConfig().then(setConfigState);
49+
};
50+
51+
52+
const updateConfigState = useCallback((newConfig: any) => {
53+
if (JSON.stringify(initialConfig.current) !== JSON.stringify(newConfig)) {
54+
setConfigState(newConfig);
55+
setSaveDisabled(false);
56+
} else {
57+
setSaveDisabled(true);
58+
}
59+
}, []);
60+
61+
const handleChange = useCallback((selectedTxPrefix: string, coin: CoinCode) => {
62+
if (config.backend[coin]) {
63+
// if you select another explorer and then change it back to the previous state,
64+
// save will be enabled...it would be nicer if we know the initial state and enable
65+
// might refactor later but this is ok imo.
66+
if (config.backend[coin].blockExplorerTxPrefix !== selectedTxPrefix) {
67+
config.backend[coin].blockExplorerTxPrefix = selectedTxPrefix;
68+
// setSaveDisabled(false);
69+
// setConfigState(config);
70+
updateConfigState(config);
71+
}
72+
}
73+
}, [config, updateConfigState]);
74+
75+
const save = async () => {
76+
setSaveDisabled(true);
77+
await setConfig(config);
78+
initialConfig.current = await getConfig();
79+
};
80+
81+
useEffect(() => {
82+
const fetchData = async () => {
83+
const coins = await backendAPI.getSupportedCoins();
84+
const allExplorerSelection = await apiGet('available-explorers');
85+
86+
// if set alongside config it will 'update' with it, but we want it to stay the same after initialization.
87+
initialConfig.current = await getConfig();
88+
89+
setSupportedCoins(coins);
90+
setAllSelections(allExplorerSelection);
91+
};
92+
93+
loadConfig();
94+
fetchData().catch(console.error);
95+
}, []);
96+
97+
if (config === undefined) {
98+
return null;
99+
}
100+
101+
return (
102+
<div className="contentWithGuide">
103+
<div className="container">
104+
<div className="innerContainer scrollableContainer">
105+
<Header
106+
hideSidebarToggler
107+
title={
108+
<>
109+
<h2 className={'hide-on-small'}>{t('settings.expert.explorer.title')}</h2>
110+
<MobileHeader withGuide title={t('settings.expert.explorer.title-small')}/>
111+
</>
112+
}/>
113+
<div className="content padded">
114+
{supportedCoins.map(coin => {
115+
return <BlockExplorers
116+
key={coin.coinCode}
117+
coin={coin.coinCode}
118+
explorerOptions={allSelections?.[coin.coinCode] ?? []}
119+
handleOnChange={handleChange}
120+
selectedPrefix={config?.backend?.[coin.coinCode]?.blockExplorerTxPrefix}/>;
121+
})}
122+
</div>
123+
<div className="content padded" style={{ display: 'flex', justifyContent: 'space-between' }}>
124+
<ButtonLink
125+
secondary
126+
className={'hide-on-small'}
127+
to={'/settings'}>
128+
{t('button.back')}
129+
</ButtonLink>
130+
<Button primary disabled={saveDisabled} onClick={() => save()}>{t('settings.save')}</Button>
131+
</div>
132+
</div>
133+
</div>
134+
<Guide>
135+
<Entry key="guide.settings-block-explorer.what" entry={t('guide.settings-block-explorer.what')} />
136+
<Entry key="guide.settings-block-explorer.why" entry={t('guide.settings-block-explorer.why')} />
137+
<Entry key="guide.settings-block-explorer.options" entry={t('guide.settings-block-explorer.options')} />
138+
<Entry key="guide.settings-electrum.instructions" entry={{
139+
link: {
140+
text: t('guide.settings-block-explorer.instructions.link.text'),
141+
url: (i18n.resolvedLanguage === 'de')
142+
// TODO: DE guide.
143+
? 'https://shiftcrypto.support/help/en-us/23-bitcoin/205-how-to-use-a-block-explorer'
144+
: 'https://shiftcrypto.support/help/en-us/23-bitcoin/205-how-to-use-a-block-explorer'
145+
},
146+
text: t('guide.settings-block-explorer.instructions.text'),
147+
title: t('guide.settings-block-explorer.instructions.title')
148+
}} />
149+
</Guide>
150+
</div>
151+
);
152+
};

0 commit comments

Comments
 (0)