mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-12-23 09:52:26 +01:00
Add Custom Network UI (#10310)
This commit is contained in:
parent
f196c9feb8
commit
aabe653240
@ -52,6 +52,23 @@
|
||||
"addAlias": {
|
||||
"message": "Add alias"
|
||||
},
|
||||
"addEthereumChainConfirmationDescription": {
|
||||
"message": "This will allow this network to be used within MetaMask."
|
||||
},
|
||||
"addEthereumChainConfirmationRisks": {
|
||||
"message": "MetaMask does not verify custom networks."
|
||||
},
|
||||
"addEthereumChainConfirmationRisksLearnMore": {
|
||||
"message": "Learn about $1.",
|
||||
"description": "$1 is a link with text that is provided by the 'addEthereumChainConfirmationRisksLearnMoreLink' key"
|
||||
},
|
||||
"addEthereumChainConfirmationRisksLearnMoreLink": {
|
||||
"message": "scams and network security risks",
|
||||
"description": "Link text for the 'addEthereumChainConfirmationRisksLearnMore' translation key"
|
||||
},
|
||||
"addEthereumChainConfirmationTitle": {
|
||||
"message": "Allow this site to add a network?"
|
||||
},
|
||||
"addNetwork": {
|
||||
"message": "Add Network"
|
||||
},
|
||||
@ -150,6 +167,9 @@
|
||||
"approve": {
|
||||
"message": "Approve spend limit"
|
||||
},
|
||||
"approveButtonText": {
|
||||
"message": "Approve"
|
||||
},
|
||||
"approveSpendLimit": {
|
||||
"message": "Approve $1 spend limit",
|
||||
"description": "The token symbol that is being approved"
|
||||
@ -212,7 +232,10 @@
|
||||
"message": "Basic"
|
||||
},
|
||||
"blockExplorerUrl": {
|
||||
"message": "Block Explorer"
|
||||
"message": "Block Explorer URL"
|
||||
},
|
||||
"blockExplorerUrlDefinition": {
|
||||
"message": "The URL used as the block explorer for this network."
|
||||
},
|
||||
"blockExplorerView": {
|
||||
"message": "View account at $1",
|
||||
@ -254,6 +277,9 @@
|
||||
"chainId": {
|
||||
"message": "Chain ID"
|
||||
},
|
||||
"chainIdDefinition": {
|
||||
"message": "The chain ID used to sign transactions for this network."
|
||||
},
|
||||
"chromeRequiredForHardwareWallets": {
|
||||
"message": "You need to use MetaMask on Google Chrome in order to connect to your Hardware Wallet."
|
||||
},
|
||||
@ -409,6 +435,12 @@
|
||||
"currencyConversion": {
|
||||
"message": "Currency Conversion"
|
||||
},
|
||||
"currencySymbol": {
|
||||
"message": "Currency Symbol"
|
||||
},
|
||||
"currencySymbolDefinition": {
|
||||
"message": "The ticker symbol displayed for this network’s currency."
|
||||
},
|
||||
"currentAccountNotConnected": {
|
||||
"message": "Your current account is not connected"
|
||||
},
|
||||
@ -1005,6 +1037,14 @@
|
||||
"metametricsOptInDescription": {
|
||||
"message": "MetaMask would like to gather usage data to better understand how our users interact with the extension. This data will be used to continually improve the usability and user experience of our product and the Ethereum ecosystem."
|
||||
},
|
||||
"mismatchedChain": {
|
||||
"message": "This network details for this Chain ID do not match our records. We recommend that you $1 before proceeding.",
|
||||
"description": "$1 is a clickable link with text defined by the 'mismatchedChainLinkText' key"
|
||||
},
|
||||
"mismatchedChainLinkText": {
|
||||
"message": "verify the network details",
|
||||
"description": "Serves as link text for the 'mismatchedChain' key. This text will be embedded inside the translation for that key."
|
||||
},
|
||||
"mobileSyncText": {
|
||||
"message": "Please enter your password to confirm it's you!"
|
||||
},
|
||||
@ -1030,15 +1070,27 @@
|
||||
"negativeETH": {
|
||||
"message": "Can not send negative amounts of ETH."
|
||||
},
|
||||
"networkDetails": {
|
||||
"message": "Network Details"
|
||||
},
|
||||
"networkName": {
|
||||
"message": "Network Name"
|
||||
},
|
||||
"networkNameDefinition": {
|
||||
"message": "The name associated with this network."
|
||||
},
|
||||
"networkSettingsChainIdDescription": {
|
||||
"message": "The chain ID is used for signing transactions. It must match the chain ID returned by the network. You can enter a decimal or '0x'-prefixed hexadecimal number, but we will display the number in decimal."
|
||||
},
|
||||
"networkSettingsDescription": {
|
||||
"message": "Add and edit custom RPC networks"
|
||||
},
|
||||
"networkURL": {
|
||||
"message": "Network URL"
|
||||
},
|
||||
"networkURLDefinition": {
|
||||
"message": "The URL used to access this network."
|
||||
},
|
||||
"networks": {
|
||||
"message": "Networks"
|
||||
},
|
||||
@ -1892,12 +1944,24 @@
|
||||
"swapsViewInActivity": {
|
||||
"message": "View in activity"
|
||||
},
|
||||
"switchEthereumChainConfirmationDescription": {
|
||||
"message": "This will will switch the selected network within MetaMask to a previously added network:"
|
||||
},
|
||||
"switchEthereumChainConfirmationTitle": {
|
||||
"message": "Allow this site to switch the network?"
|
||||
},
|
||||
"switchNetwork": {
|
||||
"message": "Switch network"
|
||||
},
|
||||
"switchNetworks": {
|
||||
"message": "Switch Networks"
|
||||
},
|
||||
"switchToThisAccount": {
|
||||
"message": "Switch to this account"
|
||||
},
|
||||
"switchingNetworksCancelsPendingConfirmations": {
|
||||
"message": "Switching networks will cancel all pending confirmations"
|
||||
},
|
||||
"symbol": {
|
||||
"message": "Symbol"
|
||||
},
|
||||
@ -2073,6 +2137,14 @@
|
||||
"unlockMessage": {
|
||||
"message": "The decentralized web awaits"
|
||||
},
|
||||
"unrecognizedChain": {
|
||||
"message": "This custom network is not recognized. We recommend that you $1 before proceeding",
|
||||
"description": "$1 is a clickable link with text defined by the 'unrecognizedChanLinkText' key. The link will open to instructions for users to validate custom network details."
|
||||
},
|
||||
"unrecognizedChainLinkText": {
|
||||
"message": "verify the network details",
|
||||
"description": "Serves as link text for the 'unrecognizedChain' key. This text will be embedded inside the translation for that key."
|
||||
},
|
||||
"updatedWithDate": {
|
||||
"message": "Updated $1"
|
||||
},
|
||||
@ -2145,6 +2217,10 @@
|
||||
"message": "$1 of $2",
|
||||
"description": "$1 and $2 are intended to be two numbers, where $2 is a total, and $1 is a count towards that total"
|
||||
},
|
||||
"xOfYPending": {
|
||||
"message": "$1 of $2 pending",
|
||||
"description": "$1 and $2 are intended to be two numbers, where $2 is a total number of pending confirmations, and $1 is a count towards that total"
|
||||
},
|
||||
"yesLetsTry": {
|
||||
"message": "Yes, let's try"
|
||||
},
|
||||
|
@ -172,6 +172,7 @@ async function verifyEnglishLocale() {
|
||||
// and gradually phase out the key based search
|
||||
const globsToStrictSearch = [
|
||||
'ui/app/components/app/metamask-translation/*.js',
|
||||
'ui/app/pages/confirmation/templates/*.js',
|
||||
];
|
||||
const testGlob = '**/*.test.js';
|
||||
const javascriptFiles = await glob(['ui/**/*.js', 'shared/**/*.js'], {
|
||||
|
@ -135,6 +135,7 @@
|
||||
"fuse.js": "^3.2.0",
|
||||
"globalthis": "^1.0.1",
|
||||
"human-standard-token-abi": "^2.0.0",
|
||||
"immer": "^8.0.1",
|
||||
"json-rpc-engine": "^6.1.0",
|
||||
"json-rpc-middleware-stream": "^2.1.1",
|
||||
"jsonschema": "^1.2.4",
|
||||
|
@ -6,9 +6,11 @@ import Popover from '../../ui/popover';
|
||||
import Typography from '../../ui/typography';
|
||||
import Box from '../../ui/box';
|
||||
import MetaMaskTranslation from '../metamask-translation';
|
||||
import NetworkDisplay from '../network-display';
|
||||
|
||||
export const safeComponentList = {
|
||||
MetaMaskTranslation,
|
||||
a: 'a',
|
||||
b: 'b',
|
||||
p: 'p',
|
||||
div: 'div',
|
||||
@ -20,4 +22,5 @@ export const safeComponentList = {
|
||||
Button,
|
||||
Popover,
|
||||
Box,
|
||||
NetworkDisplay,
|
||||
};
|
||||
|
@ -70,11 +70,8 @@ describe('Account Details Modal', function () {
|
||||
wrapper.setProps({ rpcPrefs: { blockExplorerUrl } });
|
||||
|
||||
const modalButton = wrapper.find('.account-details-modal__button');
|
||||
const blockExplorerLink = modalButton.first();
|
||||
const blockExplorerLink = modalButton.first().shallow();
|
||||
|
||||
assert.strictEqual(
|
||||
blockExplorerLink.html(),
|
||||
'<button class="button btn-secondary account-details-modal__button">blockExplorerView</button>',
|
||||
);
|
||||
assert.strictEqual(blockExplorerLink.text(), 'blockExplorerView');
|
||||
});
|
||||
});
|
||||
|
@ -2,7 +2,10 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { NETWORK_TYPE_RPC } from '../../../../../shared/constants/network';
|
||||
import {
|
||||
NETWORK_TYPE_RPC,
|
||||
NETWORK_TYPE_TO_ID_MAP,
|
||||
} from '../../../../../shared/constants/network';
|
||||
|
||||
import LoadingIndicator from '../../ui/loading-indicator';
|
||||
import ColorIndicator from '../../ui/color-indicator';
|
||||
@ -21,15 +24,19 @@ export default function NetworkDisplay({
|
||||
indicatorSize,
|
||||
disabled,
|
||||
labelProps,
|
||||
targetNetwork,
|
||||
onClick,
|
||||
}) {
|
||||
const { network, networkNickname, networkType } = useSelector((state) => ({
|
||||
const currentNetwork = useSelector((state) => ({
|
||||
network: state.metamask.network,
|
||||
networkNickname: state.metamask.provider.nickname,
|
||||
networkType: state.metamask.provider.type,
|
||||
nickname: state.metamask.provider.nickname,
|
||||
type: state.metamask.provider.type,
|
||||
}));
|
||||
const t = useI18nContext();
|
||||
|
||||
const { network = '', nickname: networkNickname, type: networkType } =
|
||||
targetNetwork ?? currentNetwork;
|
||||
|
||||
return (
|
||||
<Chip
|
||||
borderColor={outline ? COLORS.UI3 : COLORS.TRANSPARENT}
|
||||
@ -45,7 +52,9 @@ export default function NetworkDisplay({
|
||||
size={indicatorSize}
|
||||
type={ColorIndicator.TYPES.FILLED}
|
||||
iconClassName={
|
||||
networkType === NETWORK_TYPE_RPC ? 'fa fa-question' : undefined
|
||||
networkType === NETWORK_TYPE_RPC && indicatorSize !== SIZES.XS
|
||||
? 'fa fa-question'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</LoadingIndicator>
|
||||
@ -75,8 +84,13 @@ export default function NetworkDisplay({
|
||||
NetworkDisplay.propTypes = {
|
||||
colored: PropTypes.bool,
|
||||
indicatorSize: PropTypes.oneOf(Object.values(SIZES)),
|
||||
labelProps: PropTypes.shape({
|
||||
...Chip.propTypes.labelProps,
|
||||
labelProps: Chip.propTypes.labelProps,
|
||||
targetNetwork: PropTypes.shape({
|
||||
type: PropTypes.oneOf([
|
||||
...Object.values(NETWORK_TYPE_TO_ID_MAP),
|
||||
NETWORK_TYPE_RPC,
|
||||
]),
|
||||
nickname: PropTypes.string,
|
||||
}),
|
||||
outline: PropTypes.bool,
|
||||
disabled: PropTypes.bool,
|
||||
|
@ -166,7 +166,7 @@ export default class SignatureRequestOriginal extends Component {
|
||||
{originMetadata?.icon ? (
|
||||
<SiteIcon
|
||||
icon={originMetadata.icon}
|
||||
name={originMetadata.name}
|
||||
name={originMetadata.hostname}
|
||||
size={24}
|
||||
/>
|
||||
) : null}
|
||||
|
@ -45,6 +45,15 @@ const Button = ({
|
||||
} else if (submit) {
|
||||
buttonProps.type = 'submit';
|
||||
}
|
||||
if (typeof buttonProps.onClick === 'function') {
|
||||
buttonProps.onKeyUp ??= (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
buttonProps.onClick();
|
||||
}
|
||||
};
|
||||
buttonProps.role ??= 'button';
|
||||
buttonProps.tabIndex ??= 0;
|
||||
}
|
||||
return (
|
||||
<Tag
|
||||
className={classnames(
|
||||
|
@ -9,6 +9,10 @@
|
||||
grid-template-rows: 1fr;
|
||||
transition: opacity 0.75s 0s;
|
||||
|
||||
a {
|
||||
color: $primary-1;
|
||||
}
|
||||
|
||||
&--dismissible {
|
||||
&#{$self}--first {
|
||||
box-shadow: 0 -5px 5px -5px rgba(0, 0, 0, 0.18);
|
||||
|
@ -21,6 +21,8 @@ export default function Chip({
|
||||
}
|
||||
};
|
||||
|
||||
const isInteractive = typeof onClick === 'function';
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
@ -30,8 +32,8 @@ export default function Chip({
|
||||
'chip--with-right-icon': Boolean(rightIcon),
|
||||
[`chip--${borderColor}`]: true,
|
||||
})}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
role={isInteractive ? 'button' : undefined}
|
||||
tabIndex={isInteractive ? 0 : undefined}
|
||||
>
|
||||
{leftIcon && <div className="chip__left-icon">{leftIcon}</div>}
|
||||
{children ?? (
|
||||
|
@ -14,7 +14,7 @@ export default class Tooltip extends PureComponent {
|
||||
offset: 0,
|
||||
size: 'small',
|
||||
title: null,
|
||||
trigger: 'mouseenter',
|
||||
trigger: 'mouseenter focus',
|
||||
wrapperClassName: undefined,
|
||||
theme: '',
|
||||
};
|
||||
@ -35,6 +35,7 @@ export default class Tooltip extends PureComponent {
|
||||
wrapperClassName: PropTypes.string,
|
||||
style: PropTypes.object,
|
||||
theme: PropTypes.string,
|
||||
tabIndex: PropTypes.number,
|
||||
};
|
||||
|
||||
render() {
|
||||
@ -54,6 +55,7 @@ export default class Tooltip extends PureComponent {
|
||||
wrapperClassName,
|
||||
style,
|
||||
theme,
|
||||
tabIndex,
|
||||
} = this.props;
|
||||
|
||||
if (!title && !html) {
|
||||
@ -77,6 +79,7 @@ export default class Tooltip extends PureComponent {
|
||||
title={title}
|
||||
trigger={trigger}
|
||||
theme={theme}
|
||||
tabIndex={tabIndex || 0}
|
||||
>
|
||||
{children}
|
||||
</ReactTippy>
|
||||
|
@ -64,6 +64,7 @@ const CONFIRM_TOKEN_METHOD_PATH = '/token-method';
|
||||
const SIGNATURE_REQUEST_PATH = '/signature-request';
|
||||
const DECRYPT_MESSAGE_REQUEST_PATH = '/decrypt-message-request';
|
||||
const ENCRYPTION_PUBLIC_KEY_REQUEST_PATH = '/encryption-public-key-request';
|
||||
const CONFIRMATION_V_NEXT_ROUTE = '/confirmation';
|
||||
|
||||
// Used to pull a convenient name for analytics tracking events. The key must
|
||||
// be react-router ready path, and can include params such as :id for popup windows
|
||||
@ -170,6 +171,7 @@ export {
|
||||
SIGNATURE_REQUEST_PATH,
|
||||
DECRYPT_MESSAGE_REQUEST_PATH,
|
||||
ENCRYPTION_PUBLIC_KEY_REQUEST_PATH,
|
||||
CONFIRMATION_V_NEXT_ROUTE,
|
||||
INITIALIZE_METAMETRICS_OPT_IN_ROUTE,
|
||||
ADVANCED_ROUTE,
|
||||
SECURITY_ROUTE,
|
||||
|
@ -350,6 +350,17 @@ export function stripHttpSchemes(urlString) {
|
||||
return urlString.replace(/^https?:\/\//u, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips the following schemes from URL strings:
|
||||
* - https
|
||||
*
|
||||
* @param {string} urlString - The URL string to strip the scheme from.
|
||||
* @returns {string} The URL string, without the scheme, if it was stripped.
|
||||
*/
|
||||
export function stripHttpsScheme(urlString) {
|
||||
return urlString.replace(/^https:\/\//u, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a URL-like value (object or string) is an extension URL.
|
||||
*
|
||||
|
42
ui/app/hooks/useOriginMetadata.js
Normal file
42
ui/app/hooks/useOriginMetadata.js
Normal file
@ -0,0 +1,42 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getDomainMetadata } from '../selectors';
|
||||
|
||||
/**
|
||||
* @typedef {Object} OriginMetadata
|
||||
* @property {string} host - The host of the origin
|
||||
* @property {string} hostname - The hostname of the origin (host + port)
|
||||
* @property {string} origin - The original origin string itself
|
||||
* @property {string} [icon] - The origin's site icon if available
|
||||
* @property {number} [lastUpdated] - Timestamp of the last update to the
|
||||
* origin's metadata
|
||||
* @property {string} [name] - The registered name of the origin if available
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets origin metadata from redux and formats it appropriately.
|
||||
* @param {string} origin - The fully formed url of the site interacting with
|
||||
* MetaMask
|
||||
* @returns {OriginMetadata | null} - The origin metadata available for the
|
||||
* current origin
|
||||
*/
|
||||
export function useOriginMetadata(origin) {
|
||||
const domainMetaData = useSelector(getDomainMetadata);
|
||||
if (!origin) {
|
||||
return null;
|
||||
}
|
||||
const url = new URL(origin);
|
||||
|
||||
const minimumOriginMetadata = {
|
||||
host: url.host,
|
||||
hostname: url.hostname,
|
||||
origin,
|
||||
};
|
||||
|
||||
if (domainMetaData?.[origin]) {
|
||||
return {
|
||||
...minimumOriginMetadata,
|
||||
...domainMetaData[origin],
|
||||
};
|
||||
}
|
||||
return minimumOriginMetadata;
|
||||
}
|
@ -179,7 +179,7 @@ export default class ConfirmDecryptMessage extends Component {
|
||||
const { t } = this.context;
|
||||
|
||||
const originMetadata = domainMetadata[txData.msgParams.origin];
|
||||
const name = originMetadata?.name || txData.msgParams.origin;
|
||||
const name = originMetadata?.hostname || txData.msgParams.origin;
|
||||
const notice = t('decryptMessageNotice', [txData.msgParams.origin]);
|
||||
|
||||
const {
|
||||
|
@ -160,7 +160,7 @@ export default class ConfirmEncryptionPublicKey extends Component {
|
||||
|
||||
const originMetadata = domainMetadata[txData.origin];
|
||||
const notice = t('encryptionPublicKeyNotice', [txData.origin]);
|
||||
const name = originMetadata?.name || txData.origin;
|
||||
const name = originMetadata?.hostname || txData.origin;
|
||||
|
||||
return (
|
||||
<div className="request-encryption-public-key__body">
|
||||
|
@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Button from '../../../../components/ui/button';
|
||||
|
||||
export default function ConfirmationFooter({
|
||||
onApprove,
|
||||
onCancel,
|
||||
approveText,
|
||||
cancelText,
|
||||
alerts,
|
||||
}) {
|
||||
return (
|
||||
<div className="confirmation-footer">
|
||||
{alerts}
|
||||
<div className="confirmation-footer__actions">
|
||||
<Button rounded type="secondary" onClick={onCancel}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button rounded type="primary" onClick={onApprove}>
|
||||
{approveText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ConfirmationFooter.propTypes = {
|
||||
alerts: PropTypes.node,
|
||||
onApprove: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
approveText: PropTypes.string.isRequired,
|
||||
cancelText: PropTypes.string.isRequired,
|
||||
};
|
@ -0,0 +1,14 @@
|
||||
.confirmation-footer {
|
||||
grid-area: footer;
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
border-top: 1px solid $ui-2;
|
||||
background-color: white;
|
||||
padding: 16px;
|
||||
|
||||
& .button:first-child {
|
||||
margin-right: 16px;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from './confirmation-footer';
|
240
ui/app/pages/confirmation/confirmation.js
Normal file
240
ui/app/pages/confirmation/confirmation.js
Normal file
@ -0,0 +1,240 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { isEqual } from 'lodash';
|
||||
import { produce } from 'immer';
|
||||
import { getEnvironmentType } from '../../../../app/scripts/lib/util';
|
||||
import {
|
||||
ENVIRONMENT_TYPE_FULLSCREEN,
|
||||
ENVIRONMENT_TYPE_POPUP,
|
||||
} from '../../../../shared/constants/app';
|
||||
import Box from '../../components/ui/box';
|
||||
import Chip from '../../components/ui/chip';
|
||||
import MetaMaskTemplateRenderer from '../../components/app/metamask-template-renderer';
|
||||
import SiteIcon from '../../components/ui/site-icon';
|
||||
import { DEFAULT_ROUTE } from '../../helpers/constants/routes';
|
||||
import { stripHttpsScheme } from '../../helpers/utils/util';
|
||||
import { useI18nContext } from '../../hooks/useI18nContext';
|
||||
import { useOriginMetadata } from '../../hooks/useOriginMetadata';
|
||||
import { getUnapprovedConfirmations } from '../../selectors';
|
||||
import NetworkDisplay from '../../components/app/network-display/network-display';
|
||||
import { COLORS, SIZES } from '../../helpers/constants/design-system';
|
||||
import Callout from '../../components/ui/callout';
|
||||
import ConfirmationFooter from './components/confirmation-footer';
|
||||
import { getTemplateValues, getTemplateAlerts } from './templates';
|
||||
|
||||
/**
|
||||
* a very simple reducer using produce from Immer to keep state manipulation
|
||||
* immutable and painless. This state is not stored in redux state because it
|
||||
* should persist only for the lifespan of the current session, and will only
|
||||
* be used on this page. Dismissing alerts for confirmations should persist
|
||||
* while the user pages back and forth between confirmations. However, if the
|
||||
* user closes the confirmation window and later reopens the extension they
|
||||
* should be displayed the alerts again.
|
||||
*/
|
||||
const alertStateReducer = produce((state, action) => {
|
||||
switch (action.type) {
|
||||
case 'dismiss':
|
||||
if (state?.[action.confirmationId]?.[action.alertId]) {
|
||||
state[action.confirmationId][action.alertId].dismissed = true;
|
||||
}
|
||||
break;
|
||||
case 'set':
|
||||
if (!state[action.confirmationId]) {
|
||||
state[action.confirmationId] = {};
|
||||
}
|
||||
action.alerts.forEach((alert) => {
|
||||
state[action.confirmationId][alert.id] = {
|
||||
...alert,
|
||||
dismissed: false,
|
||||
};
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
'You must provide a type when dispatching an action for alertState',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Encapsulates the state and effects needed to manage alert state for the
|
||||
* confirmation page in a custom hook. This hook is not likely to be used
|
||||
* outside of this file, but it helps to reduce complexity of the primary
|
||||
* component.
|
||||
* @param {Object} pendingConfirmation - a pending confirmation waiting for
|
||||
* user approval
|
||||
* @returns {[alertState: Object, dismissAlert: Function]} - tuple with
|
||||
* the current alert state and function to dismiss an alert by id
|
||||
*/
|
||||
function useAlertState(pendingConfirmation) {
|
||||
const [alertState, dispatch] = useReducer(alertStateReducer, {});
|
||||
|
||||
/**
|
||||
* Computation of the current alert state happens every time the current
|
||||
* pendingConfirmation changes. The async function getTemplateAlerts is
|
||||
* responsible for returning alert state. Setting state on unmounted
|
||||
* components is an anti-pattern, so we use a isMounted variable to keep
|
||||
* track of the current state of the component. Returning a function that
|
||||
* sets isMounted to false when the component is unmounted.
|
||||
*/
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
if (pendingConfirmation) {
|
||||
getTemplateAlerts(pendingConfirmation).then((alerts) => {
|
||||
if (isMounted && alerts) {
|
||||
dispatch({
|
||||
type: 'set',
|
||||
confirmationId: pendingConfirmation.id,
|
||||
alerts,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [pendingConfirmation]);
|
||||
|
||||
const dismissAlert = useCallback(
|
||||
(alertId) => {
|
||||
dispatch({
|
||||
type: 'dismiss',
|
||||
confirmationId: pendingConfirmation.id,
|
||||
alertId,
|
||||
});
|
||||
},
|
||||
[pendingConfirmation],
|
||||
);
|
||||
|
||||
return [alertState, dismissAlert];
|
||||
}
|
||||
|
||||
export default function ConfirmationPage() {
|
||||
const t = useI18nContext();
|
||||
const dispatch = useDispatch();
|
||||
const history = useHistory();
|
||||
const pendingConfirmations = useSelector(getUnapprovedConfirmations, isEqual);
|
||||
const [currentPendingConfirmation, setCurrentPendingConfirmation] = useState(
|
||||
0,
|
||||
);
|
||||
const pendingConfirmation = pendingConfirmations[currentPendingConfirmation];
|
||||
const originMetadata = useOriginMetadata(pendingConfirmation?.origin);
|
||||
const [alertState, dismissAlert] = useAlertState(pendingConfirmation);
|
||||
|
||||
// Generating templatedValues is potentially expensive, and if done on every render
|
||||
// will result in a new object. Avoiding calling this generation unnecessarily will
|
||||
// improve performance and prevent unnecessary draws.
|
||||
const templatedValues = useMemo(() => {
|
||||
return pendingConfirmation
|
||||
? getTemplateValues(pendingConfirmation, t, dispatch)
|
||||
: {};
|
||||
}, [pendingConfirmation, t, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const environmentType = getEnvironmentType();
|
||||
// If the number of pending confirmations reduces to zero when the user
|
||||
// is in the fullscreen or popup UI, return them to the default route.
|
||||
// Otherwise, if the number of pending confirmations reduces to a number
|
||||
// that is less than the currently viewed index, reset the index.
|
||||
if (
|
||||
pendingConfirmations.length === 0 &&
|
||||
(environmentType === ENVIRONMENT_TYPE_FULLSCREEN ||
|
||||
environmentType === ENVIRONMENT_TYPE_POPUP)
|
||||
) {
|
||||
history.push(DEFAULT_ROUTE);
|
||||
} else if (pendingConfirmations.length <= currentPendingConfirmation) {
|
||||
setCurrentPendingConfirmation(pendingConfirmations.length - 1);
|
||||
}
|
||||
}, [pendingConfirmations, history, currentPendingConfirmation]);
|
||||
if (!pendingConfirmation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="confirmation-page">
|
||||
{pendingConfirmations.length > 1 && (
|
||||
<div className="confirmation-page__navigation">
|
||||
<p>
|
||||
{t('xOfYPending', [
|
||||
currentPendingConfirmation + 1,
|
||||
pendingConfirmations.length,
|
||||
])}
|
||||
</p>
|
||||
{currentPendingConfirmation > 0 && (
|
||||
<button
|
||||
className="confirmation-page__navigation-button"
|
||||
onClick={() =>
|
||||
setCurrentPendingConfirmation(currentPendingConfirmation - 1)
|
||||
}
|
||||
>
|
||||
<i className="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="confirmation-page__navigation-button"
|
||||
disabled={
|
||||
currentPendingConfirmation + 1 === pendingConfirmations.length
|
||||
}
|
||||
onClick={() =>
|
||||
setCurrentPendingConfirmation(currentPendingConfirmation + 1)
|
||||
}
|
||||
>
|
||||
<i className="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="confirmation-page__content">
|
||||
<Box justifyContent="center">
|
||||
<NetworkDisplay
|
||||
colored={false}
|
||||
indicatorSize={SIZES.XS}
|
||||
labelProps={{ color: COLORS.BLACK }}
|
||||
/>
|
||||
</Box>
|
||||
<Box justifyContent="center" padding={[1, 4, 4]}>
|
||||
<Chip
|
||||
label={stripHttpsScheme(originMetadata.origin)}
|
||||
leftIcon={
|
||||
<SiteIcon
|
||||
icon={originMetadata.icon}
|
||||
name={originMetadata.hostname}
|
||||
size={32}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<MetaMaskTemplateRenderer sections={templatedValues.content} />
|
||||
</div>
|
||||
<ConfirmationFooter
|
||||
alerts={
|
||||
alertState[pendingConfirmation.id] &&
|
||||
Object.values(alertState[pendingConfirmation.id])
|
||||
.filter((alert) => alert.dismissed === false)
|
||||
.map((alert, idx, filtered) => (
|
||||
<Callout
|
||||
key={alert.id}
|
||||
severity={alert.severity}
|
||||
dismiss={() => dismissAlert(alert.id)}
|
||||
isFirst={idx === 0}
|
||||
isLast={idx === filtered.length - 1}
|
||||
isMultiple={filtered.length > 1}
|
||||
>
|
||||
<MetaMaskTemplateRenderer sections={alert.content} />
|
||||
</Callout>
|
||||
))
|
||||
}
|
||||
onApprove={templatedValues.onApprove}
|
||||
onCancel={templatedValues.onCancel}
|
||||
approveText={templatedValues.approvalText}
|
||||
cancelText={templatedValues.cancelText}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
62
ui/app/pages/confirmation/confirmation.scss
Normal file
62
ui/app/pages/confirmation/confirmation.scss
Normal file
@ -0,0 +1,62 @@
|
||||
@import 'components/confirmation-footer/confirmation-footer';
|
||||
|
||||
.confirmation-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background: white;
|
||||
display: grid;
|
||||
flex-direction: column;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
grid-template-areas:
|
||||
'navigation'
|
||||
'content'
|
||||
'footer';
|
||||
|
||||
a {
|
||||
color: $primary-1;
|
||||
}
|
||||
|
||||
&__content {
|
||||
grid-area: content;
|
||||
padding: 16px 16px 0;
|
||||
|
||||
& > :last-child {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__navigation {
|
||||
@include H7;
|
||||
|
||||
grid-area: navigation;
|
||||
background-color: $Grey-000;
|
||||
border-bottom: 1px solid $geyser;
|
||||
padding: 6px 16px 5px 16px;
|
||||
color: $Grey-500;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(0, auto) minmax(0, auto);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__navigation-button {
|
||||
background-color: white;
|
||||
border-radius: 100px;
|
||||
color: $Grey-500;
|
||||
font-size: $font-size-h6;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
padding: 0;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
background-color: $Grey-100;
|
||||
color: $Grey-300;
|
||||
}
|
||||
}
|
||||
|
||||
&__navigation &__navigation-button:last-child {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
1
ui/app/pages/confirmation/index.js
Normal file
1
ui/app/pages/confirmation/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './confirmation';
|
216
ui/app/pages/confirmation/templates/add-ethereum-chain.js
Normal file
216
ui/app/pages/confirmation/templates/add-ethereum-chain.js
Normal file
@ -0,0 +1,216 @@
|
||||
import { ethErrors } from 'eth-rpc-errors';
|
||||
import {
|
||||
SEVERITIES,
|
||||
TYPOGRAPHY,
|
||||
} from '../../../helpers/constants/design-system';
|
||||
import fetchWithCache from '../../../helpers/utils/fetch-with-cache';
|
||||
|
||||
const UNRECOGNIZED_CHAIN = {
|
||||
id: 'UNRECOGNIZED_CHAIN',
|
||||
severity: SEVERITIES.WARNING,
|
||||
content: {
|
||||
element: 'span',
|
||||
children: {
|
||||
element: 'MetaMaskTranslation',
|
||||
props: {
|
||||
translationKey: 'unrecognizedChain',
|
||||
variables: [
|
||||
{
|
||||
element: 'a',
|
||||
key: 'unrecognizedChainLink',
|
||||
props: {
|
||||
href:
|
||||
'https://metamask.zendesk.com/hc/en-us/articles/360056196151',
|
||||
target: '__blank',
|
||||
tabIndex: 0,
|
||||
},
|
||||
children: {
|
||||
element: 'MetaMaskTranslation',
|
||||
props: {
|
||||
translationKey: 'unrecognizedChainLinkText',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const INVALID_CHAIN = {
|
||||
id: 'INVALID_CHAIN',
|
||||
severity: SEVERITIES.DANGER,
|
||||
content: {
|
||||
element: 'span',
|
||||
children: {
|
||||
element: 'MetaMaskTranslation',
|
||||
props: {
|
||||
translationKey: 'mismatchedChain',
|
||||
variables: [
|
||||
{
|
||||
element: 'a',
|
||||
key: 'mismatchedChainLink',
|
||||
props: {
|
||||
href:
|
||||
'https://metamask.zendesk.com/hc/en-us/articles/360056196151',
|
||||
target: '__blank',
|
||||
tabIndex: 0,
|
||||
},
|
||||
children: {
|
||||
element: 'MetaMaskTranslation',
|
||||
props: {
|
||||
translationKey: 'mismatchedChainLinkText',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async function getAlerts(pendingApproval) {
|
||||
const alerts = [];
|
||||
const safeChainsList = await fetchWithCache(
|
||||
'https://chainid.network/chains.json',
|
||||
);
|
||||
const matchedChain = safeChainsList.find(
|
||||
(chain) =>
|
||||
chain.chainId === parseInt(pendingApproval.requestData.chainId, 16),
|
||||
);
|
||||
let validated = Boolean(matchedChain);
|
||||
|
||||
if (matchedChain) {
|
||||
if (
|
||||
matchedChain.nativeCurrency?.decimals !== 18 ||
|
||||
matchedChain.name.toLowerCase() !==
|
||||
pendingApproval.requestData.chainName.toLowerCase() ||
|
||||
matchedChain.nativeCurrency?.symbol !== pendingApproval.requestData.ticker
|
||||
) {
|
||||
validated = false;
|
||||
}
|
||||
|
||||
const { origin } = new URL(pendingApproval.requestData.rpcUrl);
|
||||
if (!matchedChain.rpc.map((rpc) => new URL(rpc).origin).includes(origin)) {
|
||||
validated = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedChain) {
|
||||
alerts.push(UNRECOGNIZED_CHAIN);
|
||||
} else if (!validated) {
|
||||
alerts.push(INVALID_CHAIN);
|
||||
}
|
||||
return alerts;
|
||||
}
|
||||
|
||||
function getValues(pendingApproval, t, actions) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
element: 'Typography',
|
||||
key: 'title',
|
||||
children: t('addEthereumChainConfirmationTitle'),
|
||||
props: {
|
||||
variant: TYPOGRAPHY.H3,
|
||||
align: 'center',
|
||||
fontWeight: 'bold',
|
||||
boxProps: {
|
||||
margin: [0, 0, 4],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
element: 'Typography',
|
||||
key: 'description',
|
||||
children: t('addEthereumChainConfirmationDescription'),
|
||||
props: {
|
||||
variant: TYPOGRAPHY.H7,
|
||||
align: 'center',
|
||||
boxProps: {
|
||||
margin: [0, 0, 4],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
element: 'Typography',
|
||||
key: 'only-add-networks-you-trust',
|
||||
children: [
|
||||
{
|
||||
element: 'b',
|
||||
key: 'bolded-text',
|
||||
children: `${t('addEthereumChainConfirmationRisks')} `,
|
||||
},
|
||||
{
|
||||
element: 'MetaMaskTranslation',
|
||||
key: 'learn-about-risks',
|
||||
props: {
|
||||
translationKey: 'addEthereumChainConfirmationRisksLearnMore',
|
||||
variables: [
|
||||
{
|
||||
element: 'a',
|
||||
children: t('addEthereumChainConfirmationRisksLearnMoreLink'),
|
||||
key: 'addEthereumChainConfirmationRisksLearnMoreLink',
|
||||
props: {
|
||||
href: '#',
|
||||
target: '__blank',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
props: {
|
||||
variant: TYPOGRAPHY.H7,
|
||||
align: 'center',
|
||||
boxProps: {
|
||||
margin: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
element: 'TruncatedDefinitionList',
|
||||
key: 'network-details',
|
||||
props: {
|
||||
title: t('networkDetails'),
|
||||
tooltips: {
|
||||
[t('networkName')]: t('networkNameDefinition'),
|
||||
[t('networkURL')]: t('networkURLDefinition'),
|
||||
[t('chainId')]: t('chainIdDefinition'),
|
||||
[t('currencySymbol')]: t('currencySymbolDefinition'),
|
||||
[t('blockExplorerUrl')]: t('blockExplorerUrlDefinition'),
|
||||
},
|
||||
dictionary: {
|
||||
[t('networkName')]: pendingApproval.requestData.chainName,
|
||||
[t('networkURL')]: pendingApproval.requestData.rpcUrl,
|
||||
[t('chainId')]: parseInt(pendingApproval.requestData.chainId, 16),
|
||||
[t('currencySymbol')]: pendingApproval.requestData.ticker,
|
||||
[t('blockExplorerUrl')]: pendingApproval.requestData
|
||||
.blockExplorerUrl,
|
||||
},
|
||||
prefaceKeys: [t('networkName'), t('networkURL'), t('chainId')],
|
||||
},
|
||||
},
|
||||
],
|
||||
approvalText: t('approveButtonText'),
|
||||
cancelText: t('cancel'),
|
||||
onApprove: () =>
|
||||
actions.resolvePendingApproval(
|
||||
pendingApproval.id,
|
||||
pendingApproval.requestData,
|
||||
),
|
||||
|
||||
onCancel: () =>
|
||||
actions.rejectPendingApproval(
|
||||
pendingApproval.id,
|
||||
ethErrors.provider.userRejectedRequest(),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const addEthereumChain = {
|
||||
getAlerts,
|
||||
getValues,
|
||||
};
|
||||
|
||||
export default addEthereumChain;
|
127
ui/app/pages/confirmation/templates/index.js
Normal file
127
ui/app/pages/confirmation/templates/index.js
Normal file
@ -0,0 +1,127 @@
|
||||
import { omit, pick } from 'lodash';
|
||||
import { MESSAGE_TYPE } from '../../../../../shared/constants/app';
|
||||
import {
|
||||
rejectPendingApproval,
|
||||
resolvePendingApproval,
|
||||
} from '../../../store/actions';
|
||||
import addEthereumChain from './add-ethereum-chain';
|
||||
import switchEthereumChain from './switch-ethereum-chain';
|
||||
|
||||
const APPROVAL_TEMPLATES = {
|
||||
[MESSAGE_TYPE.ADD_ETHEREUM_CHAIN]: addEthereumChain,
|
||||
[MESSAGE_TYPE.SWITCH_ETHEREUM_CHAIN]: switchEthereumChain,
|
||||
};
|
||||
|
||||
const ALLOWED_TEMPLATE_KEYS = [
|
||||
'content',
|
||||
'approvalText',
|
||||
'cancelText',
|
||||
'onApprove',
|
||||
'onCancel',
|
||||
];
|
||||
|
||||
/**
|
||||
* @typedef {Object} PendingApproval
|
||||
* @property {string} id - The randomly generated id of the approval
|
||||
* @property {string} origin - The origin of the site requesting this approval
|
||||
* @property {number} time - The time the approval was requested
|
||||
* @property {string} type - The type of approval being requested
|
||||
* @property {Object} requestData - The data submitted with the request
|
||||
*/
|
||||
|
||||
/**
|
||||
* getTemplateAlerts calls the getAlerts function exported by the template if
|
||||
* it exists, and then returns the result of that function. In the confirmation
|
||||
* page the alerts returned from the getAlerts method will be set into the
|
||||
* alertState state object.
|
||||
*
|
||||
* @param {Object} pendingApproval - the object representing the confirmation
|
||||
*/
|
||||
export async function getTemplateAlerts(pendingApproval) {
|
||||
const fn = APPROVAL_TEMPLATES[pendingApproval.type]?.getAlerts;
|
||||
const results = fn ? await fn(pendingApproval) : undefined;
|
||||
if (!Array.isArray(results)) {
|
||||
throw new Error(`Template alerts must be an array, received: ${results}`);
|
||||
}
|
||||
if (results.some((result) => result?.id === undefined)) {
|
||||
throw new Error(
|
||||
`Template alert entries must be objects with an id key. Received: ${results}`,
|
||||
);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* The function call to return state must be a promise returning function
|
||||
* this "NOOP" is here to conform to the requirements for templates without
|
||||
* state.
|
||||
*/
|
||||
async function emptyState() {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* getTemplateState calls the getState function exported by the template if
|
||||
* it exists, and then returns the result of that function. In the confirmation
|
||||
* page the object returned from the getState method will be set into the
|
||||
* confirmationState state object. Note, this state is not consumed by the page
|
||||
* itself.
|
||||
* @param {Object} pendingApproval - the object representing the confirmation
|
||||
*/
|
||||
export async function getTemplateState(pendingApproval) {
|
||||
const fn = APPROVAL_TEMPLATES[pendingApproval.type]?.getState ?? emptyState;
|
||||
const result = await fn(pendingApproval);
|
||||
if (typeof result !== 'object' || Array.isArray(result)) {
|
||||
throw new Error(`Template state must be an object, received: ${result}`);
|
||||
} else if (result === null || result === undefined) {
|
||||
return {};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* We do not want to pass the entire dispatch function to the template.
|
||||
* This function should return an object of actions that we generally consider
|
||||
* to be safe for templates to invoke. In the future we could put these behind
|
||||
* permission sets so that snaps that wish to manipulate state must ask for
|
||||
* explicit permission to do so.
|
||||
* @param {Function} dispatch - Redux dispatch function
|
||||
*/
|
||||
function getAttenuatedDispatch(dispatch) {
|
||||
return {
|
||||
rejectPendingApproval: (...args) =>
|
||||
dispatch(rejectPendingApproval(...args)),
|
||||
resolvePendingApproval: (...args) =>
|
||||
dispatch(resolvePendingApproval(...args)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the templated values to be consumed in the confirmation page
|
||||
* @param {Object} pendingApproval - The pending confirmation object
|
||||
* @param {Function} t - Translation function
|
||||
* @param {Function} dispatch - Redux dispatch function
|
||||
*/
|
||||
export function getTemplateValues(pendingApproval, t, dispatch) {
|
||||
const fn = APPROVAL_TEMPLATES[pendingApproval.type]?.getValues;
|
||||
if (!fn) {
|
||||
throw new Error(
|
||||
`MESSAGE_TYPE: '${pendingApproval.type}' is not specified in approval templates`,
|
||||
);
|
||||
}
|
||||
|
||||
const safeActions = getAttenuatedDispatch(dispatch);
|
||||
const values = fn(pendingApproval, t, safeActions);
|
||||
const extraneousKeys = omit(values, ALLOWED_TEMPLATE_KEYS);
|
||||
const safeValues = pick(values, ALLOWED_TEMPLATE_KEYS);
|
||||
if (extraneousKeys.length > 0) {
|
||||
throw new Error(
|
||||
`Received extraneous keys from ${
|
||||
pendingApproval.type
|
||||
}.getValues. These keys are not passed to the confirmation page: ${Object.keys(
|
||||
extraneousKeys,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
return safeValues;
|
||||
}
|
96
ui/app/pages/confirmation/templates/switch-ethereum-chain.js
Normal file
96
ui/app/pages/confirmation/templates/switch-ethereum-chain.js
Normal file
@ -0,0 +1,96 @@
|
||||
import { ethErrors } from 'eth-rpc-errors';
|
||||
import { NETWORK_TYPE_RPC } from '../../../../../shared/constants/network';
|
||||
import {
|
||||
JUSTIFY_CONTENT,
|
||||
SEVERITIES,
|
||||
TYPOGRAPHY,
|
||||
} from '../../../helpers/constants/design-system';
|
||||
|
||||
const PENDING_TX_DROP_NOTICE = {
|
||||
id: 'PENDING_TX_DROP_NOTICE',
|
||||
severity: SEVERITIES.WARNING,
|
||||
content: {
|
||||
element: 'span',
|
||||
children: {
|
||||
element: 'MetaMaskTranslation',
|
||||
props: {
|
||||
translationKey: 'switchingNetworksCancelsPendingConfirmations',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async function getAlerts() {
|
||||
return [PENDING_TX_DROP_NOTICE];
|
||||
}
|
||||
|
||||
function getValues(pendingApproval, t, actions) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
element: 'Typography',
|
||||
key: 'title',
|
||||
children: t('switchEthereumChainConfirmationTitle'),
|
||||
props: {
|
||||
variant: TYPOGRAPHY.H3,
|
||||
align: 'center',
|
||||
fontWeight: 'bold',
|
||||
boxProps: {
|
||||
margin: [0, 0, 4],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
element: 'Typography',
|
||||
key: 'description',
|
||||
children: t('switchEthereumChainConfirmationDescription'),
|
||||
props: {
|
||||
variant: TYPOGRAPHY.H7,
|
||||
align: 'center',
|
||||
boxProps: {
|
||||
margin: [0, 0, 4],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
element: 'Box',
|
||||
key: 'status-box',
|
||||
props: {
|
||||
justifyContent: JUSTIFY_CONTENT.CENTER,
|
||||
},
|
||||
children: {
|
||||
element: 'NetworkDisplay',
|
||||
key: 'network-being-switched',
|
||||
props: {
|
||||
colored: false,
|
||||
outline: true,
|
||||
targetNetwork: {
|
||||
type: NETWORK_TYPE_RPC,
|
||||
nickname: pendingApproval.requestData.nickname,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
approvalText: t('switchNetwork'),
|
||||
cancelText: t('cancel'),
|
||||
onApprove: () =>
|
||||
actions.resolvePendingApproval(
|
||||
pendingApproval.id,
|
||||
pendingApproval.requestData,
|
||||
),
|
||||
|
||||
onCancel: () =>
|
||||
actions.rejectPendingApproval(
|
||||
pendingApproval.id,
|
||||
ethErrors.provider.userRejectedRequest(),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const switchEthereumChain = {
|
||||
getAlerts,
|
||||
getValues,
|
||||
};
|
||||
|
||||
export default switchEthereumChain;
|
@ -27,6 +27,7 @@ import {
|
||||
AWAITING_SWAP_ROUTE,
|
||||
BUILD_QUOTE_ROUTE,
|
||||
VIEW_QUOTE_ROUTE,
|
||||
CONFIRMATION_V_NEXT_ROUTE,
|
||||
} from '../../helpers/constants/routes';
|
||||
|
||||
const LEARN_MORE_URL =
|
||||
@ -72,6 +73,7 @@ export default class Home extends PureComponent {
|
||||
setWeb3ShimUsageAlertDismissed: PropTypes.func.isRequired,
|
||||
originOfCurrentTab: PropTypes.string,
|
||||
disableWeb3ShimUsageAlert: PropTypes.func.isRequired,
|
||||
pendingApprovals: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
@ -89,6 +91,7 @@ export default class Home extends PureComponent {
|
||||
haveSwapsQuotes,
|
||||
showAwaitingSwapScreen,
|
||||
swapsFetchParams,
|
||||
pendingApprovals,
|
||||
} = this.props;
|
||||
|
||||
this.setState({ mounted: true });
|
||||
@ -106,6 +109,8 @@ export default class Home extends PureComponent {
|
||||
history.push(CONFIRM_TRANSACTION_ROUTE);
|
||||
} else if (Object.keys(suggestedTokens).length > 0) {
|
||||
history.push(CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE);
|
||||
} else if (pendingApprovals.length > 0) {
|
||||
history.push(CONFIRMATION_V_NEXT_ROUTE);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -52,6 +52,7 @@ const mapStateToProps = (state) => {
|
||||
connectedStatusPopoverHasBeenShown,
|
||||
defaultHomeActiveTabName,
|
||||
swapsState,
|
||||
pendingApprovals = {},
|
||||
} = metamask;
|
||||
const accountBalance = getCurrentEthBalance(state);
|
||||
const { forgottenPassword, threeBoxLastUpdated } = appState;
|
||||
@ -101,6 +102,7 @@ const mapStateToProps = (state) => {
|
||||
isMainnet: getIsMainnet(state),
|
||||
originOfCurrentTab,
|
||||
shouldShowWeb3ShimUsageNotification,
|
||||
pendingApprovals: Object.values(pendingApprovals),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -5,6 +5,7 @@
|
||||
@import 'confirm-approve/index';
|
||||
@import 'confirm-decrypt-message/confirm-decrypt-message';
|
||||
@import 'confirm-encryption-public-key/confirm-encryption-public-key';
|
||||
@import 'confirmation/confirmation';
|
||||
@import 'connected-sites/index';
|
||||
@import 'connected-accounts/index';
|
||||
@import 'connected-sites/index';
|
||||
|
@ -53,6 +53,7 @@ import {
|
||||
SETTINGS_ROUTE,
|
||||
UNLOCK_ROUTE,
|
||||
BUILD_QUOTE_ROUTE,
|
||||
CONFIRMATION_V_NEXT_ROUTE,
|
||||
} from '../../helpers/constants/routes';
|
||||
|
||||
import {
|
||||
@ -61,6 +62,7 @@ import {
|
||||
} from '../../../../shared/constants/app';
|
||||
import { getEnvironmentType } from '../../../../app/scripts/lib/util';
|
||||
import { TRANSACTION_STATUSES } from '../../../../shared/constants/transaction';
|
||||
import ConfirmationPage from '../confirmation';
|
||||
|
||||
export default class Routes extends Component {
|
||||
static propTypes = {
|
||||
@ -158,6 +160,10 @@ export default class Routes extends Component {
|
||||
component={ConfirmAddSuggestedTokenPage}
|
||||
exact
|
||||
/>
|
||||
<Authenticated
|
||||
path={CONFIRMATION_V_NEXT_ROUTE}
|
||||
component={ConfirmationPage}
|
||||
/>
|
||||
<Authenticated path={NEW_ACCOUNT_ROUTE} component={CreateAccountPage} />
|
||||
<Authenticated
|
||||
path={`${CONNECT_ROUTE}/:id`}
|
||||
@ -244,7 +250,14 @@ export default class Routes extends Component {
|
||||
}),
|
||||
);
|
||||
|
||||
return isHandlingPermissionsRequest;
|
||||
const isHandlingAddEthereumChainRequest = Boolean(
|
||||
matchPath(location.pathname, {
|
||||
path: CONFIRMATION_V_NEXT_ROUTE,
|
||||
exact: false,
|
||||
}),
|
||||
);
|
||||
|
||||
return isHandlingPermissionsRequest || isHandlingAddEthereumChainRequest;
|
||||
}
|
||||
|
||||
render() {
|
||||
|
@ -282,11 +282,6 @@ export function getPermissionsRequests(state) {
|
||||
return state.metamask.permissionsRequests || [];
|
||||
}
|
||||
|
||||
export function getPermissionsRequestCount(state) {
|
||||
const permissionsRequests = getPermissionsRequests(state);
|
||||
return permissionsRequests.length;
|
||||
}
|
||||
|
||||
export function getFirstPermissionRequest(state) {
|
||||
const requests = getPermissionsRequests(state);
|
||||
return requests && requests[0] ? requests[0] : null;
|
||||
|
@ -11,7 +11,6 @@ import {
|
||||
checksumAddress,
|
||||
getAccountByAddress,
|
||||
} from '../helpers/utils/util';
|
||||
import { getPermissionsRequestCount } from './permissions';
|
||||
|
||||
export function getNetworkIdentifier(state) {
|
||||
const {
|
||||
@ -259,6 +258,7 @@ export function getTotalUnapprovedCount(state) {
|
||||
unapprovedDecryptMsgCount = 0,
|
||||
unapprovedEncryptionPublicKeyMsgCount = 0,
|
||||
unapprovedTypedMessagesCount = 0,
|
||||
pendingApprovalCount = 0,
|
||||
} = state.metamask;
|
||||
|
||||
return (
|
||||
@ -268,7 +268,7 @@ export function getTotalUnapprovedCount(state) {
|
||||
unapprovedEncryptionPublicKeyMsgCount +
|
||||
unapprovedTypedMessagesCount +
|
||||
getUnapprovedTxCount(state) +
|
||||
getPermissionsRequestCount(state) +
|
||||
pendingApprovalCount +
|
||||
getSuggestedTokenCount(state)
|
||||
);
|
||||
}
|
||||
@ -278,6 +278,11 @@ function getUnapprovedTxCount(state) {
|
||||
return Object.keys(unapprovedTxs).length;
|
||||
}
|
||||
|
||||
export function getUnapprovedConfirmations(state) {
|
||||
const { pendingApprovals } = state.metamask;
|
||||
return Object.values(pendingApprovals);
|
||||
}
|
||||
|
||||
function getSuggestedTokenCount(state) {
|
||||
const { suggestedTokens = {} } = state.metamask;
|
||||
return Object.keys(suggestedTokens).length;
|
||||
|
@ -2463,6 +2463,44 @@ export function clearPermissions() {
|
||||
};
|
||||
}
|
||||
|
||||
// Pending Approvals
|
||||
|
||||
/**
|
||||
* Resolves a pending approval and closes the current notification window if no
|
||||
* further approvals are pending after the background state updates.
|
||||
* @param {string} id - The pending approval id
|
||||
* @param {any} [value] - The value required to confirm a pending approval
|
||||
*/
|
||||
export function resolvePendingApproval(id, value) {
|
||||
return async (dispatch) => {
|
||||
await promisifiedBackground.resolvePendingApproval(id, value);
|
||||
// Before closing the current window, check if any additional confirmations
|
||||
// are added as a result of this confirmation being accepted
|
||||
const { pendingApprovals } = await promisifiedBackground.getState();
|
||||
if (Object.values(pendingApprovals).length === 0) {
|
||||
dispatch(closeCurrentNotificationWindow());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a pending approval and closes the current notification window if no
|
||||
* further approvals are pending after the background state updates.
|
||||
* @param {string} id - The pending approval id
|
||||
* @param {Error} [error] - The error to throw when rejecting the approval
|
||||
*/
|
||||
export function rejectPendingApproval(id, error) {
|
||||
return async (dispatch) => {
|
||||
await promisifiedBackground.rejectPendingApproval(id, error);
|
||||
// Before closing the current window, check if any additional confirmations
|
||||
// are added as a result of this confirmation being rejected
|
||||
const { pendingApprovals } = await promisifiedBackground.getState();
|
||||
if (Object.values(pendingApprovals).length === 0) {
|
||||
dispatch(closeCurrentNotificationWindow());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function setFirstTimeFlowType(type) {
|
||||
return (dispatch) => {
|
||||
log.debug(`background.setFirstTimeFlowType`);
|
||||
|
@ -12842,7 +12842,7 @@ immer@1.10.0:
|
||||
resolved "https://registry.yarnpkg.com/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d"
|
||||
integrity sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg==
|
||||
|
||||
immer@^8.0.0:
|
||||
immer@^8.0.0, immer@^8.0.1:
|
||||
version "8.0.1"
|
||||
resolved "https://registry.yarnpkg.com/immer/-/immer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656"
|
||||
integrity sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA==
|
||||
|
Loading…
Reference in New Issue
Block a user