diff options
author | Dan J Miller <danjm.com@gmail.com> | 2019-05-10 01:27:14 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-05-10 01:27:14 +0800 |
commit | 13be683701bc46d8f1bcbaa301e2b7f01a34e29c (patch) | |
tree | 18974b5a8c5c9054904c903a2554a6d7ce1b79d7 /ui | |
parent | 094e4cf555c698bfef50ca6679cd1e98f4ea9aa1 (diff) | |
download | tangerine-wallet-browser-13be683701bc46d8f1bcbaa301e2b7f01a34e29c.tar tangerine-wallet-browser-13be683701bc46d8f1bcbaa301e2b7f01a34e29c.tar.gz tangerine-wallet-browser-13be683701bc46d8f1bcbaa301e2b7f01a34e29c.tar.bz2 tangerine-wallet-browser-13be683701bc46d8f1bcbaa301e2b7f01a34e29c.tar.lz tangerine-wallet-browser-13be683701bc46d8f1bcbaa301e2b7f01a34e29c.tar.xz tangerine-wallet-browser-13be683701bc46d8f1bcbaa301e2b7f01a34e29c.tar.zst tangerine-wallet-browser-13be683701bc46d8f1bcbaa301e2b7f01a34e29c.zip |
New settings custom rpc form (#6490)
* Add networks tab to settings, with header.
* Adds network list to settings network tab.
* Adds form to settings networks tab and connects it to network list.
* Network tab: form adding and editing working
* Settings network form properly handles input errors
* Add translations for settings network form
* Clean up styles of settings network tab.
* Add popup-view styles and behaviour to settings network tab.
* Fix save button on settings network form
* Adds 'Add Network' button and addMode to settings networks tab
* Lint fix for settings networks tab addition
* Fix navigation in settings networks tab.
* Editing an rpcurl in networks tab does not create new network, just changes rpc of old
* Fix layout of settings tabs other than network
* Networks dropdown 'Custom Rpc' item links to networks tab in settings.
* Update settings sidebar networks subheader.
* Make networks tab buttons width consistent with input widths in extension view.
* Fix settings screen subheader height in popup view
* Fix height of add networks button in popup view
* Add optional label to chainId and symbol form labels in networks setting tab
* Style fixes for networks tab headers
* Add ability to customize block explorer used by custom rpc
* Stylistic improvements+fixes to custom rpc form.
* Hide cancel button.
* Highlight and show network form of provider by default.
* Standardize network subheader name to 'Networks'
* Update e2e tests for new settings network form
* Update unit tests for new rpcPrefs prop
* Extract blockexplorer url construction into method.
* Fix broken styles on non-network tabs in popup mode
* Fix block explorer url links for cases when provider in state has not been updated.
* Fix vertical spacing of network form
* Don't allow click of save button on network form if nothing has changed
* Ensure add network button is shown in popup view
* Lint fix for networks tab
* Fix block explorer url preference setting.
* Fix e2e tests for custom blockexplorer in account details modal changes.
* Update integration test states to include frequentRpcList property
* Fix some capitalizations in en/messages.json
* Remove some console.logs added during custom rpc form work
* Fix external account link text and url for modal and dropdown.
* Documentation, url validation, proptype required additions and lint fixes on network tab and form.
Diffstat (limited to 'ui')
22 files changed, 944 insertions, 30 deletions
diff --git a/ui/app/components/app/dropdowns/account-details-dropdown.js b/ui/app/components/app/dropdowns/account-details-dropdown.js index 3d4598946..cbeccdd81 100644 --- a/ui/app/components/app/dropdowns/account-details-dropdown.js +++ b/ui/app/components/app/dropdowns/account-details-dropdown.js @@ -4,7 +4,7 @@ const h = require('react-hyperscript') const inherits = require('util').inherits const connect = require('react-redux').connect const actions = require('../../../store/actions') -const { getSelectedIdentity } = require('../../../selectors/selectors') +const { getSelectedIdentity, getRpcPrefsForCurrentProvider } = require('../../../selectors/selectors') const genAccountLink = require('../../../../lib/account-link.js') const { Menu, Item, CloseArea } = require('./components/menu') @@ -20,6 +20,7 @@ function mapStateToProps (state) { selectedIdentity: getSelectedIdentity(state), network: state.metamask.network, keyrings: state.metamask.keyrings, + rpcPrefs: getRpcPrefsForCurrentProvider(state), } } @@ -28,8 +29,8 @@ function mapDispatchToProps (dispatch) { showAccountDetailModal: () => { dispatch(actions.showModal({ name: 'ACCOUNT_DETAILS' })) }, - viewOnEtherscan: (address, network) => { - global.platform.openWindow({ url: genAccountLink(address, network) }) + viewOnEtherscan: (address, network, rpcPrefs) => { + global.platform.openWindow({ url: genAccountLink(address, network, rpcPrefs) }) }, showRemoveAccountConfirmationModal: (identity) => { return dispatch(actions.showModal({ name: 'CONFIRM_REMOVE_ACCOUNT', identity })) @@ -56,7 +57,9 @@ AccountDetailsDropdown.prototype.render = function () { keyrings, showAccountDetailModal, viewOnEtherscan, - showRemoveAccountConfirmationModal } = this.props + showRemoveAccountConfirmationModal, + rpcPrefs, + } = this.props const address = selectedIdentity.address @@ -112,10 +115,12 @@ AccountDetailsDropdown.prototype.render = function () { name: 'Clicked View on Etherscan', }, }) - viewOnEtherscan(address, network) + viewOnEtherscan(address, network, rpcPrefs) this.props.onClose() }, - text: this.context.t('viewOnEtherscan'), + text: (rpcPrefs.blockExplorerUrl + ? this.context.t('blockExplorerView', [rpcPrefs.blockExplorerUrl.match(/^https?:\/\/(.+)/)[1]]) + : this.context.t('viewOnEtherscan')), icon: h(`img`, { src: 'images/open-etherscan.svg', style: { height: '15px' } }), }), isRemovable ? h(Item, { diff --git a/ui/app/components/app/dropdowns/network-dropdown.js b/ui/app/components/app/dropdowns/network-dropdown.js index dbe3f1bc8..378ad3ba6 100644 --- a/ui/app/components/app/dropdowns/network-dropdown.js +++ b/ui/app/components/app/dropdowns/network-dropdown.js @@ -10,7 +10,7 @@ const Dropdown = require('./components/dropdown').Dropdown const DropdownMenuItem = require('./components/dropdown').DropdownMenuItem const NetworkDropdownIcon = require('./components/network-dropdown-icon') const R = require('ramda') -const { ADVANCED_ROUTE } = require('../../../helpers/constants/routes') +const { NETWORKS_ROUTE } = require('../../../helpers/constants/routes') // classes from nodes of the toggle element. const notToggleElementClassnames = [ @@ -49,6 +49,7 @@ function mapDispatchToProps (dispatch) { }, showNetworkDropdown: () => dispatch(actions.showNetworkDropdown()), hideNetworkDropdown: () => dispatch(actions.hideNetworkDropdown()), + setNetworksTabAddMode: isInAddMode => dispatch(actions.setNetworksTabAddMode(isInAddMode)), } } @@ -72,7 +73,7 @@ module.exports = compose( // TODO: specify default props and proptypes NetworkDropdown.prototype.render = function () { const props = this.props - const { provider: { type: providerType, rpcTarget: activeNetwork } } = props + const { provider: { type: providerType, rpcTarget: activeNetwork }, setNetworksTabAddMode } = props const rpcListDetail = props.frequentRpcListDetail const isOpen = this.props.networkDropdownOpen const dropdownMenuItemStyle = { @@ -255,7 +256,10 @@ NetworkDropdown.prototype.render = function () { DropdownMenuItem, { closeMenu: () => this.props.hideNetworkDropdown(), - onClick: () => this.props.history.push(ADVANCED_ROUTE), + onClick: () => { + setNetworksTabAddMode(true) + this.props.history.push(NETWORKS_ROUTE) + }, style: dropdownMenuItemStyle, }, [ diff --git a/ui/app/components/app/modals/account-details-modal.js b/ui/app/components/app/modals/account-details-modal.js index 1b1ca6b8e..6cffc918b 100644 --- a/ui/app/components/app/modals/account-details-modal.js +++ b/ui/app/components/app/modals/account-details-modal.js @@ -5,7 +5,7 @@ const inherits = require('util').inherits const connect = require('react-redux').connect const actions = require('../../../store/actions') const AccountModalContainer = require('./account-modal-container') -const { getSelectedIdentity } = require('../../../selectors/selectors') +const { getSelectedIdentity, getRpcPrefsForCurrentProvider } = require('../../../selectors/selectors') const genAccountLink = require('../../../../lib/account-link.js') const QrView = require('../../ui/qr-code') const EditableLabel = require('../../ui/editable-label') @@ -17,6 +17,7 @@ function mapStateToProps (state) { network: state.metamask.network, selectedIdentity: getSelectedIdentity(state), keyrings: state.metamask.keyrings, + rpcPrefs: getRpcPrefsForCurrentProvider(state), } } @@ -54,6 +55,7 @@ AccountDetailsModal.prototype.render = function () { showExportPrivateKeyModal, setAccountLabel, keyrings, + rpcPrefs, } = this.props const { name, address } = selectedIdentity @@ -86,8 +88,12 @@ AccountDetailsModal.prototype.render = function () { h(Button, { type: 'secondary', className: 'account-modal__button', - onClick: () => global.platform.openWindow({ url: genAccountLink(address, network) }), - }, this.context.t('etherscanView')), + onClick: () => { + global.platform.openWindow({ url: genAccountLink(address, network, rpcPrefs) }) + }, + }, (rpcPrefs.blockExplorerUrl + ? this.context.t('blockExplorerView', [rpcPrefs.blockExplorerUrl.match(/^https?:\/\/(.+)/)[1]]) + : this.context.t('viewOnEtherscan'))), // Holding on redesign for Export Private Key functionality diff --git a/ui/app/components/app/transaction-list-item-details/transaction-list-item-details.component.js b/ui/app/components/app/transaction-list-item-details/transaction-list-item-details.component.js index 4a3b04998..72ca784e2 100644 --- a/ui/app/components/app/transaction-list-item-details/transaction-list-item-details.component.js +++ b/ui/app/components/app/transaction-list-item-details/transaction-list-item-details.component.js @@ -1,13 +1,15 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import copyToClipboard from 'copy-to-clipboard' +import { + getBlockExplorerUrlForTx, +} from '../../../helpers/utils/transactions.util' import SenderToRecipient from '../../ui/sender-to-recipient' import { FLAT_VARIANT } from '../../ui/sender-to-recipient/sender-to-recipient.constants' import TransactionActivityLog from '../transaction-activity-log' import TransactionBreakdown from '../transaction-breakdown' import Button from '../../ui/button' import Tooltip from '../../ui/tooltip' -import prefixForNetwork from '../../../../lib/etherscan-prefix-for-network' export default class TransactionListItemDetails extends PureComponent { static contextTypes = { @@ -22,6 +24,7 @@ export default class TransactionListItemDetails extends PureComponent { showRetry: PropTypes.bool, cancelDisabled: PropTypes.bool, transactionGroup: PropTypes.object, + rpcPrefs: PropTypes.object, } state = { @@ -30,12 +33,9 @@ export default class TransactionListItemDetails extends PureComponent { } handleEtherscanClick = () => { - const { transactionGroup: { primaryTransaction } } = this.props + const { transactionGroup: { primaryTransaction }, rpcPrefs } = this.props const { hash, metamaskNetworkId } = primaryTransaction - const prefix = prefixForNetwork(metamaskNetworkId) - const etherscanUrl = `https://${prefix}etherscan.io/tx/${hash}` - this.context.metricsEvent({ eventOpts: { category: 'Navigation', @@ -44,7 +44,7 @@ export default class TransactionListItemDetails extends PureComponent { }, }) - global.platform.openWindow({ url: etherscanUrl }) + global.platform.openWindow({ url: getBlockExplorerUrlForTx(metamaskNetworkId, hash, rpcPrefs) }) } handleCancel = event => { @@ -125,6 +125,7 @@ export default class TransactionListItemDetails extends PureComponent { showRetry, onCancel, onRetry, + rpcPrefs: { blockExplorerUrl } = {}, } = this.props const { primaryTransaction: transaction } = transactionGroup const { txParams: { to, from } = {} } = transaction @@ -158,7 +159,7 @@ export default class TransactionListItemDetails extends PureComponent { /> </Button> </Tooltip> - <Tooltip title={t('viewOnEtherscan')}> + <Tooltip title={blockExplorerUrl ? t('viewOnCustomBlockExplorer', [blockExplorerUrl]) : t('viewOnEtherscan')}> <Button type="raised" onClick={this.handleEtherscanClick} diff --git a/ui/app/components/app/transaction-list-item/transaction-list-item.component.js b/ui/app/components/app/transaction-list-item/transaction-list-item.component.js index c7d9dd7c7..0d4127b4f 100644 --- a/ui/app/components/app/transaction-list-item/transaction-list-item.component.js +++ b/ui/app/components/app/transaction-list-item/transaction-list-item.component.js @@ -33,6 +33,7 @@ export default class TransactionListItem extends PureComponent { value: PropTypes.string, fetchBasicGasAndTimeEstimates: PropTypes.func, fetchGasEstimates: PropTypes.func, + rpcPrefs: PropTypes.object, } static defaultProps = { @@ -161,6 +162,7 @@ export default class TransactionListItem extends PureComponent { showRetry, tokenData, transactionGroup, + rpcPrefs, } = this.props const { txParams = {} } = transaction const { showTransactionDetails } = this.state @@ -216,6 +218,7 @@ export default class TransactionListItem extends PureComponent { onCancel={this.handleCancel} showCancel={showCancel} cancelDisabled={!hasEnoughCancelGas} + rpcPrefs={rpcPrefs} /> </div> ) diff --git a/ui/app/components/app/transaction-list-item/transaction-list-item.container.js b/ui/app/components/app/transaction-list-item/transaction-list-item.container.js index a8fb8c246..5e88a2937 100644 --- a/ui/app/components/app/transaction-list-item/transaction-list-item.container.js +++ b/ui/app/components/app/transaction-list-item/transaction-list-item.container.js @@ -18,12 +18,14 @@ import { getIsMainnet, preferencesSelector, getSelectedAddress, conversionRateSe import { isBalanceSufficient } from '../../../pages/send/send.utils' const mapStateToProps = (state, ownProps) => { - const { metamask: { knownMethodData, accounts } } = state + const { metamask: { knownMethodData, accounts, provider, frequentRpcListDetail } } = state const { showFiatInTestnets } = preferencesSelector(state) const isMainnet = getIsMainnet(state) const { transactionGroup: { primaryTransaction } = {} } = ownProps const { txParams: { gas: gasLimit, gasPrice } = {} } = primaryTransaction const selectedAccountBalance = accounts[getSelectedAddress(state)].balance + const selectRpcInfo = frequentRpcListDetail.find(rpcInfo => rpcInfo.rpcUrl === provider.rpcTarget) + const { rpcPrefs } = selectRpcInfo || {} const hasEnoughCancelGas = primaryTransaction.txParams && isBalanceSufficient({ amount: '0x0', @@ -40,6 +42,7 @@ const mapStateToProps = (state, ownProps) => { showFiat: (isMainnet || !!showFiatInTestnets), selectedAccountBalance, hasEnoughCancelGas, + rpcPrefs, } } diff --git a/ui/app/components/ui/text-field/text-field.component.js b/ui/app/components/ui/text-field/text-field.component.js index 2c72d8124..1153a595b 100644 --- a/ui/app/components/ui/text-field/text-field.component.js +++ b/ui/app/components/ui/text-field/text-field.component.js @@ -41,11 +41,11 @@ const styles = { inputFocused: {}, inputRoot: { 'label + &': { - marginTop: '8px', + marginTop: '9px', }, - border: '1px solid #d2d8dd', + border: '2px solid #BBC0C5', height: '48px', - borderRadius: '4px', + borderRadius: '6px', padding: '0 16px', display: 'flex', alignItems: 'center', diff --git a/ui/app/ducks/app/app.js b/ui/app/ducks/app/app.js index 295507d70..b181092c1 100644 --- a/ui/app/ducks/app/app.js +++ b/ui/app/ducks/app/app.js @@ -77,6 +77,8 @@ function reduceApp (state, action) { ledger: `m/44'/60'/0'/0/0`, }, lastSelectedProvider: null, + networksTabSelectedRpcUrl: '', + networksTabIsInAddMode: false, }, state.appState) switch (action.type) { @@ -751,6 +753,16 @@ function reduceApp (state, action) { lastSelectedProvider: action.value, }) + case actions.SET_SELECTED_SETTINGS_RPC_URL: + return extend(appState, { + networksTabSelectedRpcUrl: action.value, + }) + + case actions.SET_NETWORKS_TAB_ADD_MODE: + return extend(appState, { + networksTabIsInAddMode: action.value, + }) + default: return appState } diff --git a/ui/app/helpers/constants/routes.js b/ui/app/helpers/constants/routes.js index df35112d1..d906fc8e6 100644 --- a/ui/app/helpers/constants/routes.js +++ b/ui/app/helpers/constants/routes.js @@ -8,6 +8,7 @@ const ADVANCED_ROUTE = '/settings/advanced' const SECURITY_ROUTE = '/settings/security' const COMPANY_ROUTE = '/settings/company' const ABOUT_US_ROUTE = '/settings/about-us' +const NETWORKS_ROUTE = '/settings/networks' const REVEAL_SEED_ROUTE = '/seed' const MOBILE_SYNC_ROUTE = '/mobile-sync' const CONFIRM_SEED_ROUTE = '/confirm-seed' @@ -86,4 +87,5 @@ module.exports = { COMPANY_ROUTE, GENERAL_ROUTE, ABOUT_US_ROUTE, + NETWORKS_ROUTE, } diff --git a/ui/app/helpers/utils/transactions.util.js b/ui/app/helpers/utils/transactions.util.js index cb6c9536c..99ccc3478 100644 --- a/ui/app/helpers/utils/transactions.util.js +++ b/ui/app/helpers/utils/transactions.util.js @@ -6,6 +6,8 @@ import { TRANSACTION_TYPE_CANCEL, TRANSACTION_STATUS_CONFIRMED, } from '../../../../app/scripts/controllers/transactions/enums' +import prefixForNetwork from '../../../lib/etherscan-prefix-for-network' + import { TOKEN_METHOD_TRANSFER, @@ -188,3 +190,17 @@ export function getStatusKey (transaction) { return transaction.status } + +/** + * Returns an external block explorer URL at which a transaction can be viewed. + * @param {number} networkId + * @param {string} hash + * @param {Object} rpcPrefs + */ +export function getBlockExplorerUrlForTx (networkId, hash, rpcPrefs = {}) { + if (rpcPrefs.blockExplorerUrl) { + return `${rpcPrefs.blockExplorerUrl}/tx/${hash}` + } + const prefix = prefixForNetwork(networkId) + return `https://${prefix}etherscan.io/tx/${hash}` +} diff --git a/ui/app/pages/settings/index.scss b/ui/app/pages/settings/index.scss index a19105bb4..66959ba93 100644 --- a/ui/app/pages/settings/index.scss +++ b/ui/app/pages/settings/index.scss @@ -1,5 +1,7 @@ @import 'info-tab/index'; +@import 'networks-tab/index'; + @import 'settings-tab/index'; .settings-page { @@ -13,7 +15,6 @@ flex-flow: row nowrap; padding: 12px 24px; align-items: center; - border-bottom: 1px solid $alto; flex: 0 0 auto; &__title { @@ -33,6 +34,34 @@ } } + &__sub-header { + height: 72px; + border-bottom: 1px solid #D8D8D8; + display: flex; + justify-content: space-between; + align-items: center; + + @media screen and (max-width: 575px) { + height: 69px; + position: relative; + text-align: center; + } + } + + &__sub-header-text { + font-family: Roboto; + font-style: normal; + font-weight: normal; + font-size: 24px; + line-height: 24px; + color: black; + + @media screen and (max-width: 575px) { + font-size: 16px; + width: 100%; + } + } + &__back-button { display: none; @@ -60,8 +89,9 @@ &__content { display: flex; flex-flow: row nowrap; - height: auto; + height: 100%; overflow: auto; + border-top: 1px solid #D8D8D8; &__tabs { display: flex; @@ -93,7 +123,7 @@ &__body { padding: 12px 24px; - + @media screen and (min-width: 576px) { padding: 12px; } diff --git a/ui/app/pages/settings/networks-tab/index.js b/ui/app/pages/settings/networks-tab/index.js new file mode 100644 index 000000000..362004498 --- /dev/null +++ b/ui/app/pages/settings/networks-tab/index.js @@ -0,0 +1 @@ +export { default } from './networks-tab.container' diff --git a/ui/app/pages/settings/networks-tab/index.scss b/ui/app/pages/settings/networks-tab/index.scss new file mode 100644 index 000000000..b0020437d --- /dev/null +++ b/ui/app/pages/settings/networks-tab/index.scss @@ -0,0 +1,200 @@ +.networks-tab { + &__content { + margin-top: 24px; + display: flex; + height: 100%; + max-width: 739px; + justify-content: space-between; + + @media screen and (max-width: 575px) { + margin-top: 0px; + } + } + + &__body { + padding: 12px 24px; + height: 100%; + display: flex; + flex-direction: column; + + @media screen and (max-width: 575px) { + padding: 0; + } + } + + &__back-button { + display: none; + + @media screen and (max-width: 575px) { + display: block; + background-image: url('/images/caret-left-black.svg'); + width: 18px; + height: 18px; + opacity: .5; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + margin-right: 16px; + cursor: pointer; + position: absolute; + margin-left: 10px; + } + } + + &__network-form { + flex: 0.5 0 auto; + max-width: 343px; + max-height: 465px; + display: flex; + flex-direction: column; + justify-content: space-between; + + .page-container__footer { + border-top: none; + + @media screen and (max-width: 575px) { + width: 93%; + } + + header { + padding: 10px 0px; + } + } + + @media screen and (max-width: 575px) { + display: flex; + flex: auto; + max-width: 100%; + max-height: 100%; + align-items: center; + width: 100%; + margin-top: 10px; + } + } + + &__network-form-row { + @media screen and (max-width: 575px) { + display: flex; + flex-direction: column; + width: 93%; + } + } + + &__network-form-label { + font-family: Roboto; + font-style: normal; + font-weight: normal; + font-size: 14px; + line-height: 20px; + color: #000000; + } + + &__networks-list { + flex: 0.5 0 auto; + max-width: 343px; + + @media screen and (max-width: 575px) { + max-width: 100vw; + width: 100vw; + overflow-y: scroll; + } + } + + &__add-network-button-wrapper { + display: none; + + @media screen and (max-width: 575px) { + display: flex; + padding-top: 19px; + padding-bottom: 23px; + justify-content: center; + align-items: center; + border-top: 1px solid #D8D8D8; + + .button { + width: 178px; + } + } + } + + &__add-network-header-button-wrapper { + padding-top: 15px; + padding-bottom: 21px; + justify-content: center; + + .button { + width: 178px; + } + + @media screen and (max-width: 575px) { + display: none; + } + } + + &__networks-list--selection { + @media screen and (max-width: 575px) { + display: none; + } + } + + &__networks-list-item { + display: flex; + padding: 13px 0px 13px 17px; + position: relative; + + .menu-icon-circle { + &:hover { + cursor: pointer; + } + } + + @media screen and (max-width: 575px) { + padding: 20px 23px 21px 17px; + border-bottom: 1px solid #D8D8D8; + } + } + + &__networks-list-item:last-of-type { + @media screen and (max-width: 575px) { + border-bottom: none; + } + } + + &__networks-list-name { + margin-left: 11px; + font-family: Roboto; + font-style: normal; + font-weight: normal; + font-size: 16px; + line-height: 23px; + color: #6A737D; + + &:hover { + cursor: pointer; + } + } + + &__networks-list-arrow { + display: none; + + @media screen and (max-width: 575px) { + display: block; + background-image: url('/images/caret-right.svg'); + width: 24px; + height: 24px; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + right: 10px; + cursor: pointer; + position: absolute; + width: 24px; + height: 24px; + } + } + + &__networks-list-name--selected { + font-weight: bold; + color: #000000; + } +}
\ No newline at end of file diff --git a/ui/app/pages/settings/networks-tab/network-form/index.js b/ui/app/pages/settings/networks-tab/network-form/index.js new file mode 100644 index 000000000..89d9de42b --- /dev/null +++ b/ui/app/pages/settings/networks-tab/network-form/index.js @@ -0,0 +1 @@ +export { default } from './network-form.component' diff --git a/ui/app/pages/settings/networks-tab/network-form/network-form.component.js b/ui/app/pages/settings/networks-tab/network-form/network-form.component.js new file mode 100644 index 000000000..5e455b65e --- /dev/null +++ b/ui/app/pages/settings/networks-tab/network-form/network-form.component.js @@ -0,0 +1,225 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import validUrl from 'valid-url' +import PageContainerFooter from '../../../../components/ui/page-container/page-container-footer' +import TextField from '../../../../components/ui/text-field' + +export default class NetworksTab extends PureComponent { + static contextTypes = { + t: PropTypes.func.isRequired, + metricsEvent: PropTypes.func.isRequired, + } + + static propTypes = { + editRpc: PropTypes.func.isRequired, + rpcUrl: PropTypes.string, + chainId: PropTypes.string, + ticker: PropTypes.string, + viewOnly: PropTypes.bool, + networkName: PropTypes.string, + onClear: PropTypes.func.isRequired, + setRpcTarget: PropTypes.func.isRequired, + networksTabIsInAddMode: PropTypes.bool, + blockExplorerUrl: PropTypes.string, + rpcPrefs: PropTypes.object, + } + + state = { + rpcUrl: this.props.rpcUrl, + chainId: this.props.chainId, + ticker: this.props.ticker, + networkName: this.props.networkName, + blockExplorerUrl: this.props.blockExplorerUrl, + errors: {}, + } + + componentDidUpdate (prevProps) { + const { rpcUrl: prevRpcUrl, networksTabIsInAddMode: prevAddMode } = prevProps + const { + rpcUrl, + chainId, + ticker, + networkName, + networksTabIsInAddMode, + blockExplorerUrl, + } = this.props + + if (!prevAddMode && networksTabIsInAddMode) { + this.setState({ + rpcUrl: '', + chainId: '', + ticker: '', + networkName: '', + blockExplorerUrl: '', + errors: {}, + }) + } else if (prevRpcUrl !== rpcUrl) { + this.setState({ rpcUrl, chainId, ticker, networkName, blockExplorerUrl, errors: {} }) + } + } + + componentWillUnmount () { + this.props.onClear() + this.setState({ + rpcUrl: '', + chainId: '', + ticker: '', + networkName: '', + blockExplorerUrl: '', + errors: {}, + }) + } + + stateIsUnchanged () { + const { + rpcUrl, + chainId, + ticker, + networkName, + blockExplorerUrl, + } = this.props + + const { + rpcUrl: stateRpcUrl, + chainId: stateChainId, + ticker: stateTicker, + networkName: stateNetworkName, + blockExplorerUrl: stateBlockExplorerUrl, + } = this.state + + return ( + stateRpcUrl === rpcUrl && + stateChainId === chainId && + stateTicker === ticker && + stateNetworkName === networkName && + stateBlockExplorerUrl === blockExplorerUrl + ) + } + + renderFormTextField (fieldKey, textFieldId, onChange, value, optionalTextFieldKey) { + const { errors } = this.state + const { viewOnly } = this.props + + return ( + <div className="networks-tab__network-form-row"> + <div className="networks-tab__network-form-label">{this.context.t(optionalTextFieldKey || fieldKey)}</div> + <TextField + type="text" + id={textFieldId} + onChange={onChange} + fullWidth + margin="dense" + value={value} + disabled={viewOnly} + error={errors[fieldKey]} + /> + </div> + ) + } + + setStateWithValue = (stateKey, validator) => { + return (e) => { + validator && validator(e.target.value, stateKey) + this.setState({ [stateKey]: e.target.value }) + } + } + + setErrorTo = (errorKey, errorVal) => { + this.setState({ + errors: { + ...this.state.errors, + [errorKey]: errorVal, + }, + }) + } + + validateChainId = (chainId) => { + this.setErrorTo('chainId', !!chainId && Number.isNaN(parseInt(chainId)) + ? `${this.context.t('invalidInput')} chainId` + : '' + ) + } + + validateUrl = (url, stateKey) => { + if (validUrl.isWebUri(url)) { + this.setErrorTo(stateKey, '') + } else { + const appendedRpc = `http://${url}` + const validWhenAppended = validUrl.isWebUri(appendedRpc) && !url.match(/^https?:\/\/$/) + + this.setErrorTo(stateKey, this.context.t(validWhenAppended ? 'uriErrorMsg' : 'invalidRPC')) + } + } + + render () { + const { setRpcTarget, viewOnly, rpcUrl: propsRpcUrl, editRpc, rpcPrefs = {} } = this.props + const { + networkName, + rpcUrl, + chainId, + ticker, + blockExplorerUrl, + errors, + } = this.state + + + return ( + <div className="networks-tab__network-form"> + {this.renderFormTextField( + 'networkName', + 'network-name', + this.setStateWithValue('networkName'), + networkName, + )} + {this.renderFormTextField( + 'rpcUrl', + 'rpc-url', + this.setStateWithValue('rpcUrl', this.validateUrl), + rpcUrl, + )} + {this.renderFormTextField( + 'chainId', + 'chainId', + this.setStateWithValue('chainId', this.validateChainId), + chainId, + 'optionalChainId', + )} + {this.renderFormTextField( + 'symbol', + 'network-ticker', + this.setStateWithValue('ticker'), + ticker, + 'optionalSymbol', + )} + {this.renderFormTextField( + 'blockExplorerUrl', + 'block-explorer-url', + this.setStateWithValue('blockExplorerUrl', this.validateUrl), + blockExplorerUrl, + 'optionalBlockExplorerUrl', + )} + <PageContainerFooter + cancelText={this.context.t('cancel')} + hideCancel={true} + onSubmit={() => { + if (propsRpcUrl && rpcUrl !== propsRpcUrl) { + editRpc(propsRpcUrl, rpcUrl, chainId, ticker, networkName, { + blockExplorerUrl: blockExplorerUrl || rpcPrefs.blockExplorerUrl, + ...rpcPrefs, + }) + } else { + setRpcTarget(rpcUrl, chainId, ticker, networkName, { + blockExplorerUrl: blockExplorerUrl || rpcPrefs.blockExplorerUrl, + ...rpcPrefs, + }) + } + }} + submitText={this.context.t('save')} + submitButtonType={'confirm'} + disabled={viewOnly || this.stateIsUnchanged() || Object.values(errors).some(x => x) || !rpcUrl} + /> + </div> + ) + } + +} diff --git a/ui/app/pages/settings/networks-tab/networks-tab.component.js b/ui/app/pages/settings/networks-tab/networks-tab.component.js new file mode 100644 index 000000000..2f921a892 --- /dev/null +++ b/ui/app/pages/settings/networks-tab/networks-tab.component.js @@ -0,0 +1,214 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import { SETTINGS_ROUTE } from '../../../helpers/constants/routes' +import { ENVIRONMENT_TYPE_POPUP } from '../../../../../app/scripts/lib/enums' +import { getEnvironmentType } from '../../../../../app/scripts/lib/util' +import classnames from 'classnames' +import Button from '../../../components/ui/button' +import NetworkForm from './network-form' +import NetworkDropdownIcon from '../../../components/app/dropdowns/components/network-dropdown-icon' + +export default class NetworksTab extends PureComponent { + static contextTypes = { + t: PropTypes.func.isRequired, + metricsEvent: PropTypes.func.isRequired, + } + + static propTypes = { + editRpc: PropTypes.func.isRequired, + history: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + networkIsSelected: PropTypes.bool, + networksTabIsInAddMode: PropTypes.bool, + networksToRender: PropTypes.array.isRequired, + selectedNetwork: PropTypes.object, + setNetworksTabAddMode: PropTypes.func.isRequired, + setRpcTarget: PropTypes.func.isRequired, + setSelectedSettingsRpcUrl: PropTypes.func.isRequired, + providerUrl: PropTypes.string, + providerType: PropTypes.string, + networkDefaultedToProvider: PropTypes.bool, + } + + componentWillMount () { + this.props.setSelectedSettingsRpcUrl(null) + } + + isCurrentPath (pathname) { + return this.props.location.pathname === pathname + } + + renderSubHeader () { + const { + networkIsSelected, + setSelectedSettingsRpcUrl, + setNetworksTabAddMode, + networksTabIsInAddMode, + networkDefaultedToProvider, + } = this.props + + return ( + <div className="settings-page__sub-header"> + <div + className="networks-tab__back-button" + onClick={(networkIsSelected && !networkDefaultedToProvider) || networksTabIsInAddMode + ? () => { + setNetworksTabAddMode(false) + setSelectedSettingsRpcUrl(null) + } + : () => this.props.history.push(SETTINGS_ROUTE) + } + /> + <span className="settings-page__sub-header-text">{ this.context.t('networks') }</span> + <div className="networks-tab__add-network-header-button-wrapper"> + <Button + type="primary" + onClick={event => { + event.preventDefault() + setSelectedSettingsRpcUrl(null) + setNetworksTabAddMode(true) + }} + > + { this.context.t('addNetwork') } + </Button> + </div> + </div> + ) + } + + renderNetworkListItem (network, selectRpcUrl) { + const { + setSelectedSettingsRpcUrl, + setNetworksTabAddMode, + networkIsSelected, + providerUrl, + providerType, + networksTabIsInAddMode, + } = this.props + const { + border, + iconColor, + label, + labelKey, + rpcUrl, + providerType: currentProviderType, + } = network + + const listItemNetworkIsSelected = selectRpcUrl && selectRpcUrl === rpcUrl + const listItemUrlIsProviderUrl = rpcUrl === providerUrl + const listItemTypeIsProviderNonRpcType = providerType !== 'rpc' && currentProviderType === providerType + const listItemNetworkIsCurrentProvider = !networkIsSelected && !networksTabIsInAddMode && (listItemUrlIsProviderUrl || listItemTypeIsProviderNonRpcType) + const displayNetworkListItemAsSelected = listItemNetworkIsSelected || listItemNetworkIsCurrentProvider + + return ( + <div + key={'settings-network-list-item:' + rpcUrl} + className="networks-tab__networks-list-item" + onClick={ () => { + setNetworksTabAddMode(false) + setSelectedSettingsRpcUrl(rpcUrl) + }} + > + <NetworkDropdownIcon + backgroundColor={iconColor || 'white'} + innerBorder={border} + /> + <div className={ classnames('networks-tab__networks-list-name', { + 'networks-tab__networks-list-name--selected': displayNetworkListItemAsSelected, + }) }> + { label || this.context.t(labelKey) } + </div> + <div className="networks-tab__networks-list-arrow" /> + </div> + ) + } + + renderNetworksList () { + const { networksToRender, selectedNetwork, networkIsSelected, networksTabIsInAddMode, networkDefaultedToProvider } = this.props + + return ( + <div className={classnames('networks-tab__networks-list', { + 'networks-tab__networks-list--selection': (networkIsSelected && !networkDefaultedToProvider) || networksTabIsInAddMode, + })}> + { networksToRender.map(network => this.renderNetworkListItem(network, selectedNetwork.rpcUrl)) } + </div> + ) + } + + renderNetworksTabContent () { + const { + setRpcTarget, + setSelectedSettingsRpcUrl, + setNetworksTabAddMode, + selectedNetwork: { + labelKey, + label, + rpcUrl, + chainId, + ticker, + viewOnly, + rpcPrefs, + blockExplorerUrl, + }, + networksTabIsInAddMode, + editRpc, + networkDefaultedToProvider, + } = this.props + const envIsPopup = getEnvironmentType() === ENVIRONMENT_TYPE_POPUP + + return ( + <div className="networks-tab__content"> + {this.renderNetworksList()} + {networksTabIsInAddMode || !envIsPopup || (envIsPopup && !networkDefaultedToProvider) + ? <NetworkForm + setRpcTarget={setRpcTarget} + editRpc={editRpc} + networkName={label || labelKey && this.context.t(labelKey) || ''} + rpcUrl={rpcUrl} + chainId={chainId} + ticker={ticker} + onClear={() => { + setNetworksTabAddMode(false) + setSelectedSettingsRpcUrl(null) + }} + viewOnly={viewOnly} + networksTabIsInAddMode={networksTabIsInAddMode} + rpcPrefs={rpcPrefs} + blockExplorerUrl={blockExplorerUrl} + /> + : null + } + </div> + ) + } + + renderContent () { + const { setNetworksTabAddMode, setSelectedSettingsRpcUrl, networkIsSelected, networksTabIsInAddMode } = this.props + + return ( + <div className="networks-tab__body"> + {this.renderSubHeader()} + {this.renderNetworksTabContent()} + {!networkIsSelected && !networksTabIsInAddMode + ? <div className="networks-tab__add-network-button-wrapper"> + <Button + type="primary" + onClick={event => { + event.preventDefault() + setSelectedSettingsRpcUrl(null) + setNetworksTabAddMode(true) + }} + > + { this.context.t('addNetwork') } + </Button> + </div> + : null + } + </div> + ) + } + + render () { + return this.renderContent() + } +} diff --git a/ui/app/pages/settings/networks-tab/networks-tab.constants.js b/ui/app/pages/settings/networks-tab/networks-tab.constants.js new file mode 100644 index 000000000..d3d1a01cc --- /dev/null +++ b/ui/app/pages/settings/networks-tab/networks-tab.constants.js @@ -0,0 +1,50 @@ +const defaultNetworksData = [ + { + labelKey: 'mainnet', + iconColor: '#29B6AF', + providerType: 'mainnet', + rpcUrl: 'https://api.infura.io/v1/jsonrpc/mainnet', + chainId: '1', + ticker: 'ETH', + blockExplorerUrl: 'https://etherscan.io', + }, + { + labelKey: 'ropsten', + iconColor: '#FF4A8D', + providerType: 'ropsten', + rpcUrl: 'https://api.infura.io/v1/jsonrpc/ropsten', + chainId: '3', + ticker: 'ETH', + blockExplorerUrl: 'https://ropsten.etherscan.io', + }, + { + labelKey: 'kovan', + iconColor: '#9064FF', + providerType: 'kovan', + rpcUrl: 'https://api.infura.io/v1/jsonrpc/kovan', + chainId: '4', + ticker: 'ETH', + blockExplorerUrl: 'https://etherscan.io', + }, + { + labelKey: 'rinkeby', + iconColor: '#F6C343', + providerType: 'rinkeby', + rpcUrl: 'https://api.infura.io/v1/jsonrpc/rinkeby', + chainId: '42', + ticker: 'ETH', + blockExplorerUrl: 'https://rinkeby.etherscan.io', + }, + { + labelKey: 'localhost', + iconColor: 'white', + border: '1px solid #6A737D', + providerType: 'localhost', + rpcUrl: 'http://localhost:8545/', + blockExplorerUrl: 'https://etherscan.io', + }, +] + +export { + defaultNetworksData, +} diff --git a/ui/app/pages/settings/networks-tab/networks-tab.container.js b/ui/app/pages/settings/networks-tab/networks-tab.container.js new file mode 100644 index 000000000..a5d71f714 --- /dev/null +++ b/ui/app/pages/settings/networks-tab/networks-tab.container.js @@ -0,0 +1,77 @@ +import NetworksTab from './networks-tab.component' +import { compose } from 'recompose' +import { connect } from 'react-redux' +import { withRouter } from 'react-router-dom' +import { + setSelectedSettingsRpcUrl, + updateAndSetCustomRpc, + displayWarning, + setNetworksTabAddMode, + editRpc, +} from '../../../store/actions' +import { defaultNetworksData } from './networks-tab.constants' +const defaultNetworks = defaultNetworksData.map(network => ({ ...network, viewOnly: true })) + +const mapStateToProps = state => { + const { + frequentRpcListDetail, + provider, + } = state.metamask + const { + networksTabSelectedRpcUrl, + networksTabIsInAddMode, + } = state.appState + + const frequentRpcNetworkListDetails = frequentRpcListDetail.map(rpc => { + return { + label: rpc.nickname, + iconColor: '#6A737D', + providerType: 'rpc', + rpcUrl: rpc.rpcUrl, + chainId: rpc.chainId, + ticker: rpc.ticker, + blockExplorerUrl: rpc.rpcPrefs && rpc.rpcPrefs.blockExplorerUrl || '', + } + }) + + const networksToRender = [ ...defaultNetworks, ...frequentRpcNetworkListDetails ] + let selectedNetwork = networksToRender.find(network => network.rpcUrl === networksTabSelectedRpcUrl) || {} + const networkIsSelected = Boolean(selectedNetwork.rpcUrl) + + let networkDefaultedToProvider = false + if (!networkIsSelected && !networksTabIsInAddMode) { + selectedNetwork = networksToRender.find(network => { + return network.rpcUrl === provider.rpcTarget || network.providerType !== 'rpc' && network.providerType === provider.type + }) || {} + networkDefaultedToProvider = true + } + + return { + selectedNetwork, + networksToRender, + networkIsSelected, + networksTabIsInAddMode, + providerType: provider.type, + providerUrl: provider.rpcTarget, + networkDefaultedToProvider, + } +} + +const mapDispatchToProps = dispatch => { + return { + setSelectedSettingsRpcUrl: newRpcUrl => dispatch(setSelectedSettingsRpcUrl(newRpcUrl)), + setRpcTarget: (newRpc, chainId, ticker, nickname, rpcPrefs) => { + dispatch(updateAndSetCustomRpc(newRpc, chainId, ticker, nickname, rpcPrefs)) + }, + displayWarning: warning => dispatch(displayWarning(warning)), + setNetworksTabAddMode: isInAddMode => dispatch(setNetworksTabAddMode(isInAddMode)), + editRpc: (oldRpc, newRpc, chainId, ticker, nickname, rpcPrefs) => { + dispatch(editRpc(oldRpc, newRpc, chainId, ticker, nickname, rpcPrefs)) + }, + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(NetworksTab) diff --git a/ui/app/pages/settings/settings.component.js b/ui/app/pages/settings/settings.component.js index fe799a6e8..a2f137264 100644 --- a/ui/app/pages/settings/settings.component.js +++ b/ui/app/pages/settings/settings.component.js @@ -6,6 +6,7 @@ import { getEnvironmentType } from '../../../../app/scripts/lib/util' import TabBar from '../../components/app/tab-bar' import c from 'classnames' import SettingsTab from './settings-tab' +import NetworksTab from './networks-tab' import AdvancedTab from './advanced-tab' import InfoTab from './info-tab' import SecurityTab from './security-tab' @@ -16,6 +17,7 @@ import { GENERAL_ROUTE, ABOUT_US_ROUTE, SETTINGS_ROUTE, + NETWORKS_ROUTE, } from '../../helpers/constants/routes' const ROUTES_TO_I18N_KEYS = { @@ -55,7 +57,7 @@ class SettingsPage extends PureComponent { > <div className="settings-page__header"> { - !this.isCurrentPath(SETTINGS_ROUTE) && ( + !this.isCurrentPath(SETTINGS_ROUTE) && !this.isCurrentPath(NETWORKS_ROUTE) && ( <div className="settings-page__back-button" onClick={() => history.push(SETTINGS_ROUTE)} @@ -104,6 +106,7 @@ class SettingsPage extends PureComponent { { content: t('general'), description: t('generalSettingsDescription'), key: GENERAL_ROUTE }, { content: t('advanced'), description: t('advancedSettingsDescription'), key: ADVANCED_ROUTE }, { content: t('securityAndPrivacy'), description: t('securitySettingsDescription'), key: SECURITY_ROUTE }, + { content: t('networks'), description: t('networkSettingsDescription'), key: NETWORKS_ROUTE }, { content: t('about'), description: t('aboutSettingsDescription'), key: ABOUT_US_ROUTE }, ]} isActive={key => { @@ -137,6 +140,11 @@ class SettingsPage extends PureComponent { /> <Route exact + path={NETWORKS_ROUTE} + component={NetworksTab} + /> + <Route + exact path={SECURITY_ROUTE} component={SecurityTab} /> diff --git a/ui/app/selectors/selectors.js b/ui/app/selectors/selectors.js index ce02d067e..c7cb80024 100644 --- a/ui/app/selectors/selectors.js +++ b/ui/app/selectors/selectors.js @@ -49,6 +49,7 @@ const selectors = { getNumberOfTokens, isEthereumNetwork, getMetaMetricState, + getRpcPrefsForCurrentProvider, } module.exports = selectors @@ -327,3 +328,10 @@ function getMetaMetricState (state) { participateInMetaMetrics: state.metamask.participateInMetaMetrics, } } + +function getRpcPrefsForCurrentProvider (state) { + const { frequentRpcListDetail, provider } = state.metamask + const selectRpcInfo = frequentRpcListDetail.find(rpcInfo => rpcInfo.rpcUrl === provider.rpcTarget) + const { rpcPrefs = {} } = selectRpcInfo || {} + return rpcPrefs +} diff --git a/ui/app/store/actions.js b/ui/app/store/actions.js index 95c6dbb77..7d45f0932 100644 --- a/ui/app/store/actions.js +++ b/ui/app/store/actions.js @@ -239,6 +239,7 @@ var actions = { updateAndSetCustomRpc: updateAndSetCustomRpc, setRpcTarget: setRpcTarget, delRpcTarget: delRpcTarget, + editRpc: editRpc, setProviderType: setProviderType, SET_HARDWARE_WALLET_DEFAULT_HD_PATH: 'SET_HARDWARE_WALLET_DEFAULT_HD_PATH', setHardwareWalletDefaultHdPath, @@ -350,6 +351,11 @@ var actions = { setFirstTimeFlowType, SET_FIRST_TIME_FLOW_TYPE: 'SET_FIRST_TIME_FLOW_TYPE', + + SET_SELECTED_SETTINGS_RPC_URL: 'SET_SELECTED_SETTINGS_RPC_URL', + setSelectedSettingsRpcUrl, + SET_NETWORKS_TAB_ADD_MODE: 'SET_NETWORKS_TAB_ADD_MODE', + setNetworksTabAddMode, } module.exports = actions @@ -1958,10 +1964,10 @@ function setPreviousProvider (type) { } } -function updateAndSetCustomRpc (newRpc, chainId, ticker = 'ETH', nickname) { +function updateAndSetCustomRpc (newRpc, chainId, ticker = 'ETH', nickname, rpcPrefs) { return (dispatch) => { log.debug(`background.updateAndSetCustomRpc: ${newRpc} ${chainId} ${ticker} ${nickname}`) - background.updateAndSetCustomRpc(newRpc, chainId, ticker, nickname || newRpc, (err) => { + background.updateAndSetCustomRpc(newRpc, chainId, ticker, nickname || newRpc, rpcPrefs, (err) => { if (err) { log.error(err) return dispatch(actions.displayWarning('Had a problem changing networks!')) @@ -1974,6 +1980,29 @@ function updateAndSetCustomRpc (newRpc, chainId, ticker = 'ETH', nickname) { } } +function editRpc (oldRpc, newRpc, chainId, ticker = 'ETH', nickname, rpcPrefs) { + return (dispatch) => { + log.debug(`background.delRpcTarget: ${oldRpc}`) + background.delCustomRpc(oldRpc, (err) => { + if (err) { + log.error(err) + return dispatch(self.displayWarning('Had a problem removing network!')) + } + dispatch(actions.setSelectedToken()) + background.updateAndSetCustomRpc(newRpc, chainId, ticker, nickname || newRpc, rpcPrefs, (err) => { + if (err) { + log.error(err) + return dispatch(actions.displayWarning('Had a problem changing networks!')) + } + dispatch({ + type: actions.SET_RPC_TARGET, + value: newRpc, + }) + }) + }) + } +} + function setRpcTarget (newRpc, chainId, ticker = 'ETH', nickname) { return (dispatch) => { log.debug(`background.setRpcTarget: ${newRpc} ${chainId} ${ticker} ${nickname}`) @@ -2000,6 +2029,7 @@ function delRpcTarget (oldRpc) { } } + // Calls the addressBookController to add a new address. function addToAddressBook (recipient, nickname = '') { log.debug(`background.addToAddressBook`) @@ -2716,3 +2746,17 @@ function setFirstTimeFlowType (type) { }) } } + +function setSelectedSettingsRpcUrl (newRpcUrl) { + return { + type: actions.SET_SELECTED_SETTINGS_RPC_URL, + value: newRpcUrl, + } +} + +function setNetworksTabAddMode (isInAddMode) { + return { + type: actions.SET_NETWORKS_TAB_ADD_MODE, + value: isInAddMode, + } +} diff --git a/ui/lib/account-link.js b/ui/lib/account-link.js index 3eaa7cf71..f1428ba92 100644 --- a/ui/lib/account-link.js +++ b/ui/lib/account-link.js @@ -1,4 +1,8 @@ -module.exports = function (address, network) { +module.exports = function (address, network, rpcPrefs) { + if (rpcPrefs.blockExplorerUrl) { + return `${rpcPrefs.blockExplorerUrl}/address/${address}` + } + const net = parseInt(network) let link switch (net) { |