mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-12-23 01:39:44 +01:00
Add modal with directions to re-add token as NFT (#13291)
* Add modal pop for NFTs previously added as tokens - with directions to re-add as NFTs
This commit is contained in:
parent
5b92dc4cf0
commit
f087e501a1
@ -1306,9 +1306,6 @@
|
||||
"history": {
|
||||
"message": "Ιστορικό"
|
||||
},
|
||||
"id": {
|
||||
"message": "Αναγνωριστικό"
|
||||
},
|
||||
"import": {
|
||||
"message": "Εισαγωγή",
|
||||
"description": "Button to import an account from a selected file"
|
||||
|
@ -596,6 +596,9 @@
|
||||
"contractInteraction": {
|
||||
"message": "Contract Interaction"
|
||||
},
|
||||
"convertTokenToNFTDescription": {
|
||||
"message": "We've detected that this asset is an NFT. Metamask now has full native support for NFTs. Would you like to remove it from your token list and add it as an NFT?"
|
||||
},
|
||||
"copiedExclamation": {
|
||||
"message": "Copied!"
|
||||
},
|
||||
@ -1367,9 +1370,6 @@
|
||||
"history": {
|
||||
"message": "History"
|
||||
},
|
||||
"id": {
|
||||
"message": "ID"
|
||||
},
|
||||
"import": {
|
||||
"message": "Import",
|
||||
"description": "Button to import an account from a selected file"
|
||||
@ -1406,9 +1406,15 @@
|
||||
"importNFT": {
|
||||
"message": "Import NFT"
|
||||
},
|
||||
"importNFTAddressToolTip": {
|
||||
"message": "On OpenSea, for example, on the NFT's page under Details, there is a blue hyperlinked value labeled 'Contract Address'. If you click on this, it will take you to the contract's address on Etherscan; at the top-left of that page, there should be an icon labeled 'Contract', and to the right, a long string of letters and numbers. This is the address of the contract that created your NFT. Click on the 'copy' icon to the right of the address, and you'll have it on your clipboard."
|
||||
},
|
||||
"importNFTPage": {
|
||||
"message": "Import NFT page"
|
||||
},
|
||||
"importNFTTokenIdToolTip": {
|
||||
"message": "A collectible's ID is a unique identifier since no two NFTs are alike. Again, on OpenSea this number is under 'Details'. Make a note of it, or copy it onto your clipboard."
|
||||
},
|
||||
"importNFTs": {
|
||||
"message": "Import NFTs"
|
||||
},
|
||||
@ -1462,6 +1468,9 @@
|
||||
"invalidAddressRecipientNotEthNetwork": {
|
||||
"message": "Not ETH network, set to lowercase"
|
||||
},
|
||||
"invalidAssetType": {
|
||||
"message": "This asset is an NFT and needs to be re-added on the Import NFTs page found under the NFTs tab"
|
||||
},
|
||||
"invalidBlockExplorerURL": {
|
||||
"message": "Invalid Block Explorer URL"
|
||||
},
|
||||
@ -1914,7 +1923,7 @@
|
||||
"description": "The next nonce according to MetaMask's internal logic"
|
||||
},
|
||||
"nftTokenIdPlaceholder": {
|
||||
"message": "Enter the collectible ID"
|
||||
"message": "Enter the Token ID"
|
||||
},
|
||||
"nfts": {
|
||||
"message": "NFTs"
|
||||
@ -3531,6 +3540,9 @@
|
||||
"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"
|
||||
},
|
||||
"yes": {
|
||||
"message": "Yes"
|
||||
},
|
||||
"yesLetsTry": {
|
||||
"message": "Yes, let's try"
|
||||
},
|
||||
|
@ -1306,9 +1306,6 @@
|
||||
"history": {
|
||||
"message": "Historique"
|
||||
},
|
||||
"id": {
|
||||
"message": "ID"
|
||||
},
|
||||
"import": {
|
||||
"message": "Importer",
|
||||
"description": "Button to import an account from a selected file"
|
||||
|
@ -1306,9 +1306,6 @@
|
||||
"history": {
|
||||
"message": "इतिहास"
|
||||
},
|
||||
"id": {
|
||||
"message": "ID"
|
||||
},
|
||||
"import": {
|
||||
"message": "आयात करें",
|
||||
"description": "Button to import an account from a selected file"
|
||||
|
@ -1306,9 +1306,6 @@
|
||||
"history": {
|
||||
"message": "Riwayat"
|
||||
},
|
||||
"id": {
|
||||
"message": "ID"
|
||||
},
|
||||
"import": {
|
||||
"message": "Impor",
|
||||
"description": "Button to import an account from a selected file"
|
||||
|
@ -1306,9 +1306,6 @@
|
||||
"history": {
|
||||
"message": "履歴"
|
||||
},
|
||||
"id": {
|
||||
"message": "ID"
|
||||
},
|
||||
"import": {
|
||||
"message": "インポート",
|
||||
"description": "Button to import an account from a selected file"
|
||||
|
@ -1306,9 +1306,6 @@
|
||||
"history": {
|
||||
"message": "기록"
|
||||
},
|
||||
"id": {
|
||||
"message": "ID"
|
||||
},
|
||||
"import": {
|
||||
"message": "가져오기",
|
||||
"description": "Button to import an account from a selected file"
|
||||
|
@ -1306,9 +1306,6 @@
|
||||
"history": {
|
||||
"message": "История"
|
||||
},
|
||||
"id": {
|
||||
"message": "Ид."
|
||||
},
|
||||
"import": {
|
||||
"message": "Импорт",
|
||||
"description": "Button to import an account from a selected file"
|
||||
|
@ -1306,9 +1306,6 @@
|
||||
"history": {
|
||||
"message": "History"
|
||||
},
|
||||
"id": {
|
||||
"message": "ID"
|
||||
},
|
||||
"import": {
|
||||
"message": "Mag-import",
|
||||
"description": "Button to import an account from a selected file"
|
||||
|
@ -1306,9 +1306,6 @@
|
||||
"history": {
|
||||
"message": "Geçmiş"
|
||||
},
|
||||
"id": {
|
||||
"message": "Kimlik"
|
||||
},
|
||||
"import": {
|
||||
"message": "Al",
|
||||
"description": "Button to import an account from a selected file"
|
||||
|
@ -1306,9 +1306,6 @@
|
||||
"history": {
|
||||
"message": "Lịch sử"
|
||||
},
|
||||
"id": {
|
||||
"message": "ID"
|
||||
},
|
||||
"import": {
|
||||
"message": "Nhập",
|
||||
"description": "Button to import an account from a selected file"
|
||||
|
@ -1306,9 +1306,6 @@
|
||||
"history": {
|
||||
"message": "历史记录"
|
||||
},
|
||||
"id": {
|
||||
"message": "ID"
|
||||
},
|
||||
"import": {
|
||||
"message": "导入",
|
||||
"description": "Button to import an account from a selected file"
|
||||
|
@ -13,6 +13,7 @@ import { useMetricEvent } from '../../../hooks/useMetricEvent';
|
||||
import { ASSET_TYPES, updateSendAsset } from '../../../ducks/send';
|
||||
import { SEND_ROUTE } from '../../../helpers/constants/routes';
|
||||
import { SEVERITIES } from '../../../helpers/constants/design-system';
|
||||
import { INVALID_ASSET_TYPE } from '../../../helpers/constants/error-keys';
|
||||
|
||||
const AssetListItem = ({
|
||||
className,
|
||||
@ -65,21 +66,26 @@ const AssetListItem = ({
|
||||
<Button
|
||||
type="link"
|
||||
className="asset-list-item__send-token-button"
|
||||
onClick={(e) => {
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
sendTokenEvent();
|
||||
dispatch(
|
||||
updateSendAsset({
|
||||
type: ASSET_TYPES.TOKEN,
|
||||
details: {
|
||||
address: tokenAddress,
|
||||
decimals: tokenDecimals,
|
||||
symbol: tokenSymbol,
|
||||
},
|
||||
}),
|
||||
).then(() => {
|
||||
try {
|
||||
await dispatch(
|
||||
updateSendAsset({
|
||||
type: ASSET_TYPES.TOKEN,
|
||||
details: {
|
||||
address: tokenAddress,
|
||||
decimals: tokenDecimals,
|
||||
symbol: tokenSymbol,
|
||||
},
|
||||
}),
|
||||
);
|
||||
history.push(SEND_ROUTE);
|
||||
});
|
||||
} catch (err) {
|
||||
if (!err.message.includes(INVALID_ASSET_TYPE)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('sendSpecifiedTokens', [tokenSymbol])}
|
||||
|
@ -20,7 +20,7 @@ export default function CollectiblesDetectionNotice() {
|
||||
const history = useHistory();
|
||||
|
||||
return (
|
||||
<Box marginBottom={8} className="collectibles-detection-notice">
|
||||
<Box marginBottom={4} className="collectibles-detection-notice">
|
||||
<Dialog type="message" className="collectibles-detection-notice__message">
|
||||
<button
|
||||
onClick={() => setCollectiblesDetectionNoticeDismissed()}
|
||||
|
@ -22,7 +22,7 @@
|
||||
a.collectibles-detection-notice__message__link {
|
||||
@include H6;
|
||||
|
||||
width: 60%;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
justify-content: flex-start;
|
||||
font-weight: bold;
|
||||
|
@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import Modal from '../../modal';
|
||||
import Typography from '../../../ui/typography';
|
||||
import { TYPOGRAPHY } from '../../../../helpers/constants/design-system';
|
||||
import withModalProps from '../../../../helpers/higher-order-components/with-modal-props';
|
||||
import { useI18nContext } from '../../../../hooks/useI18nContext';
|
||||
import { ADD_COLLECTIBLE_ROUTE } from '../../../../helpers/constants/routes';
|
||||
|
||||
const ConvertTokenToNFTModal = ({ hideModal, tokenAddress }) => {
|
||||
const history = useHistory();
|
||||
const t = useI18nContext();
|
||||
return (
|
||||
<Modal
|
||||
onSubmit={() => {
|
||||
history.push({
|
||||
pathname: ADD_COLLECTIBLE_ROUTE,
|
||||
state: { tokenAddress },
|
||||
});
|
||||
hideModal();
|
||||
}}
|
||||
submitText={t('yes')}
|
||||
onCancel={() => hideModal()}
|
||||
cancelText={t('cancel')}
|
||||
>
|
||||
<div className="convert-token-to-nft-modal">
|
||||
<Typography
|
||||
variant={TYPOGRAPHY.H6}
|
||||
boxProps={{
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{t('convertTokenToNFTDescription')}
|
||||
</Typography>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
ConvertTokenToNFTModal.propTypes = {
|
||||
hideModal: PropTypes.func.isRequired,
|
||||
tokenAddress: PropTypes.string,
|
||||
};
|
||||
|
||||
export default withModalProps(ConvertTokenToNFTModal);
|
@ -0,0 +1,4 @@
|
||||
.convert-token-to-nft-modal {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
}
|
@ -12,6 +12,7 @@
|
||||
@import 'qr-scanner/index';
|
||||
@import 'transaction-confirmed/index';
|
||||
@import 'customize-nonce/index';
|
||||
@import 'convert-token-to-nft-modal/index';
|
||||
|
||||
.modal {
|
||||
z-index: 1050;
|
||||
|
@ -30,6 +30,7 @@ import AddToAddressBookModal from './add-to-addressbook-modal';
|
||||
import EditApprovalPermission from './edit-approval-permission';
|
||||
import NewAccountModal from './new-account-modal';
|
||||
import CustomizeNonceModal from './customize-nonce';
|
||||
import ConvertTokenToNftModal from './convert-token-to-nft-modal/convert-token-to-nft-modal';
|
||||
|
||||
const modalContainerBaseStyle = {
|
||||
transform: 'translate3d(-50%, 0, 0px)',
|
||||
@ -237,6 +238,19 @@ const MODALS = {
|
||||
},
|
||||
},
|
||||
|
||||
CONVERT_TOKEN_TO_NFT: {
|
||||
contents: <ConvertTokenToNftModal />,
|
||||
mobileModalStyle: {
|
||||
...modalContainerMobileStyle,
|
||||
},
|
||||
laptopModalStyle: {
|
||||
...modalContainerLaptopStyle,
|
||||
},
|
||||
contentStyle: {
|
||||
borderRadius: '8px',
|
||||
},
|
||||
},
|
||||
|
||||
CONFIRM_DELETE_NETWORK: {
|
||||
contents: <ConfirmDeleteNetwork />,
|
||||
mobileModalStyle: {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
@ -29,6 +29,8 @@ import SwapIcon from '../../ui/icon/swap-icon.component';
|
||||
import SendIcon from '../../ui/icon/overview-send-icon.component';
|
||||
|
||||
import IconButton from '../../ui/icon-button';
|
||||
import { INVALID_ASSET_TYPE } from '../../../helpers/constants/error-keys';
|
||||
import { showModal } from '../../../store/actions';
|
||||
import WalletOverview from './wallet-overview';
|
||||
|
||||
const TokenOverview = ({ className, token }) => {
|
||||
@ -59,6 +61,17 @@ const TokenOverview = ({ className, token }) => {
|
||||
category: 'swaps',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (token.isERC721) {
|
||||
dispatch(
|
||||
showModal({
|
||||
name: 'CONVERT_TOKEN_TO_NFT',
|
||||
tokenAddress: token.address,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [token.isERC721, token.address, dispatch]);
|
||||
|
||||
return (
|
||||
<WalletOverview
|
||||
balance={
|
||||
@ -81,16 +94,21 @@ const TokenOverview = ({ className, token }) => {
|
||||
<>
|
||||
<IconButton
|
||||
className="token-overview__button"
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
sendTokenEvent();
|
||||
dispatch(
|
||||
updateSendAsset({
|
||||
type: ASSET_TYPES.TOKEN,
|
||||
details: token,
|
||||
}),
|
||||
).then(() => {
|
||||
try {
|
||||
await dispatch(
|
||||
updateSendAsset({
|
||||
type: ASSET_TYPES.TOKEN,
|
||||
details: token,
|
||||
}),
|
||||
);
|
||||
history.push(SEND_ROUTE);
|
||||
});
|
||||
} catch (err) {
|
||||
if (!err.message.includes(INVALID_ASSET_TYPE)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}}
|
||||
Icon={SendIcon}
|
||||
label={t('send')}
|
||||
|
74
ui/components/app/wallet-overview/token-overview.test.js
Normal file
74
ui/components/app/wallet-overview/token-overview.test.js
Normal file
@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import { renderWithProvider } from '../../../../test/jest/rendering';
|
||||
import TokenOverview from './token-overview';
|
||||
|
||||
describe('TokenOverview', () => {
|
||||
const mockStore = {
|
||||
metamask: {
|
||||
provider: {
|
||||
type: 'test',
|
||||
},
|
||||
preferences: {
|
||||
useNativeCurrencyAsPrimaryCurrency: true,
|
||||
},
|
||||
identities: {
|
||||
'0x1': {
|
||||
address: '0x1',
|
||||
},
|
||||
},
|
||||
selectedAddress: '0x1',
|
||||
keyrings: [
|
||||
{
|
||||
type: 'HD Key Tree',
|
||||
accounts: ['0x1', '0x2'],
|
||||
},
|
||||
{
|
||||
type: 'Ledger Hardware',
|
||||
accounts: [],
|
||||
},
|
||||
],
|
||||
contractExchangeRates: {},
|
||||
},
|
||||
};
|
||||
|
||||
const store = configureMockStore([thunk])(mockStore);
|
||||
|
||||
afterEach(() => {
|
||||
store.clearActions();
|
||||
});
|
||||
|
||||
describe('TokenOverview', () => {
|
||||
it('should not show a modal when token passed in props is not an ERC721', () => {
|
||||
const token = {
|
||||
name: 'test',
|
||||
isERC721: false,
|
||||
address: '0x01',
|
||||
symbol: 'test',
|
||||
};
|
||||
renderWithProvider(<TokenOverview token={token} />, store);
|
||||
|
||||
const actions = store.getActions();
|
||||
expect(actions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should show ConvertTokenToNFT modal when token passed in props is an ERC721', () => {
|
||||
const token = {
|
||||
name: 'test',
|
||||
isERC721: true,
|
||||
address: '0x01',
|
||||
symbol: 'test',
|
||||
};
|
||||
renderWithProvider(<TokenOverview token={token} />, store);
|
||||
|
||||
const actions = store.getActions();
|
||||
expect(actions).toHaveLength(1);
|
||||
expect(actions[0].type).toBe('UI_MODAL_OPEN');
|
||||
expect(actions[0].payload).toStrictEqual({
|
||||
name: 'CONVERT_TOKEN_TO_NFT',
|
||||
tokenAddress: '0x01',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -30,6 +30,7 @@ export default function FormField({
|
||||
password,
|
||||
allowDecimals,
|
||||
disabled,
|
||||
placeholder,
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@ -85,6 +86,7 @@ export default function FormField({
|
||||
allowDecimals={allowDecimals}
|
||||
disabled={disabled}
|
||||
dataTestId={dataTestId}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
@ -97,6 +99,7 @@ export default function FormField({
|
||||
autoFocus={autoFocus}
|
||||
disabled={disabled}
|
||||
data-testid={dataTestId}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
@ -170,6 +173,10 @@ FormField.propTypes = {
|
||||
* Check if the form disabled
|
||||
*/
|
||||
disabled: PropTypes.bool,
|
||||
/**
|
||||
* Set the placeholder text for the input field
|
||||
*/
|
||||
placeholder: PropTypes.string,
|
||||
};
|
||||
|
||||
FormField.defaultProps = {
|
||||
|
@ -30,6 +30,7 @@ export default {
|
||||
password: { control: 'boolean' },
|
||||
allowDecimals: { control: 'boolean' },
|
||||
disabled: { control: 'boolean' },
|
||||
placeholder: { control: 'text' },
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -15,6 +15,7 @@ export default function NumericInput({
|
||||
allowDecimals = true,
|
||||
disabled = false,
|
||||
dataTestId,
|
||||
placeholder,
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@ -40,6 +41,7 @@ export default function NumericInput({
|
||||
autoFocus={autoFocus}
|
||||
disabled={disabled}
|
||||
data-testid={dataTestId}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{detailText && (
|
||||
<Typography color={COLORS.UI4} variant={TYPOGRAPHY.H7} tag="span">
|
||||
@ -59,4 +61,5 @@ NumericInput.propTypes = {
|
||||
allowDecimals: PropTypes.bool,
|
||||
disabled: PropTypes.bool,
|
||||
dataTestId: PropTypes.string,
|
||||
placeholder: PropTypes.string,
|
||||
};
|
||||
|
@ -53,11 +53,12 @@ import {
|
||||
hideLoadingIndication,
|
||||
showConfTxPage,
|
||||
showLoadingIndication,
|
||||
updateTokenType,
|
||||
updateTransaction,
|
||||
addPollingTokenToAppState,
|
||||
removePollingTokenFromAppState,
|
||||
isCollectibleOwner,
|
||||
getTokenStandardAndDetails,
|
||||
showModal,
|
||||
} from '../../store/actions';
|
||||
import { setCustomGasLimit } from '../gas/gas.duck';
|
||||
import {
|
||||
@ -91,9 +92,16 @@ import {
|
||||
isValidHexAddress,
|
||||
} from '../../../shared/modules/hexstring-utils';
|
||||
import { CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP } from '../../../shared/constants/network';
|
||||
import { ERC20, ETH, GWEI } from '../../helpers/constants/common';
|
||||
import {
|
||||
ERC20,
|
||||
ERC721,
|
||||
ERC1155,
|
||||
ETH,
|
||||
GWEI,
|
||||
} from '../../helpers/constants/common';
|
||||
import { TRANSACTION_ENVELOPE_TYPES } from '../../../shared/constants/transaction';
|
||||
import { readAddressAsContract } from '../../../shared/modules/contract-utils';
|
||||
import { INVALID_ASSET_TYPE } from '../../helpers/constants/error-keys';
|
||||
// typedefs
|
||||
/**
|
||||
* @typedef {import('@reduxjs/toolkit').PayloadAction} PayloadAction
|
||||
@ -607,6 +615,8 @@ export const initialState = {
|
||||
// In the case of tokens, the address, decimals and symbol of the token
|
||||
// will be included in details
|
||||
details: null,
|
||||
// error to display when there is an issue with the asset
|
||||
error: null,
|
||||
},
|
||||
draftTransaction: {
|
||||
// The metamask internal id of the transaction. Only populated in the EDIT
|
||||
@ -736,6 +746,7 @@ const slice = createSlice({
|
||||
state.amount.value = action.payload.amount;
|
||||
state.gas.error = null;
|
||||
state.amount.error = null;
|
||||
state.asset.error = null;
|
||||
state.recipient.address = action.payload.address;
|
||||
state.recipient.nickname = action.payload.nickname;
|
||||
state.draftTransaction.id = action.payload.id;
|
||||
@ -887,6 +898,7 @@ const slice = createSlice({
|
||||
updateAsset: (state, action) => {
|
||||
state.asset.type = action.payload.type;
|
||||
state.asset.balance = action.payload.balance;
|
||||
state.asset.error = action.payload.error;
|
||||
if (
|
||||
state.asset.type === ASSET_TYPES.TOKEN ||
|
||||
state.asset.type === ASSET_TYPES.COLLECTIBLE
|
||||
@ -1146,7 +1158,7 @@ const slice = createSlice({
|
||||
},
|
||||
validateSendState: (state) => {
|
||||
switch (true) {
|
||||
// 1 + 2. State is invalid when either gas or amount fields have errors
|
||||
// 1 + 2. State is invalid when either gas or amount or asset fields have errors
|
||||
// 3. State is invalid if asset type is a token and the token details
|
||||
// are unknown.
|
||||
// 4. State is invalid if no recipient has been added
|
||||
@ -1156,6 +1168,7 @@ const slice = createSlice({
|
||||
// 8. State is invalid if the selected asset is a ERC721
|
||||
case Boolean(state.amount.error):
|
||||
case Boolean(state.gas.error):
|
||||
case Boolean(state.asset.error):
|
||||
case state.asset.type === ASSET_TYPES.TOKEN &&
|
||||
state.asset.details === null:
|
||||
case state.stage === SEND_STAGES.ADD_RECIPIENT:
|
||||
@ -1166,10 +1179,6 @@ const slice = createSlice({
|
||||
):
|
||||
state.status = SEND_STATUSES.INVALID;
|
||||
break;
|
||||
case state.asset.type === ASSET_TYPES.TOKEN &&
|
||||
state.asset.details.isERC721 === true:
|
||||
state.status = SEND_STATUSES.INVALID;
|
||||
break;
|
||||
default:
|
||||
state.status = SEND_STATUSES.VALID;
|
||||
// Recompute the draftTransaction object
|
||||
@ -1419,31 +1428,43 @@ export function updateSendAmount(amount) {
|
||||
export function updateSendAsset({ type, details }) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
let { balance } = state.send.asset;
|
||||
let { balance, error } = state.send.asset;
|
||||
const userAddress = state.send.account.address ?? getSelectedAddress(state);
|
||||
if (type === ASSET_TYPES.TOKEN) {
|
||||
// if changing to a token, get the balance from the network. The asset
|
||||
// overview page and asset list on the wallet overview page contain
|
||||
// send buttons that call this method before initialization occurs.
|
||||
// When this happens we don't yet have an account.address so default to
|
||||
// the currently active account. In addition its possible for the balance
|
||||
// check to take a decent amount of time, so we display a loading
|
||||
// indication so that that immediate feedback is displayed to the user.
|
||||
await dispatch(showLoadingIndication());
|
||||
balance = await getERC20Balance(
|
||||
details,
|
||||
state.send.account.address ?? getSelectedAddress(state),
|
||||
);
|
||||
// TODO remove along with migration of isERC721 tokens and stripping away this designation
|
||||
if (details) {
|
||||
if (details.isERC721 === undefined) {
|
||||
const updatedAssetDetails = await updateTokenType(details.address);
|
||||
details.isERC721 = updatedAssetDetails.isERC721;
|
||||
}
|
||||
if (details.standard === undefined) {
|
||||
details.standard = ERC20;
|
||||
await dispatch(showLoadingIndication());
|
||||
const { standard } = await getTokenStandardAndDetails(
|
||||
details.address,
|
||||
userAddress,
|
||||
);
|
||||
if (standard === ERC721 || standard === ERC1155) {
|
||||
await dispatch(hideLoadingIndication());
|
||||
dispatch(
|
||||
showModal({
|
||||
name: 'CONVERT_TOKEN_TO_NFT',
|
||||
tokenAddress: details.address,
|
||||
}),
|
||||
);
|
||||
error = INVALID_ASSET_TYPE;
|
||||
throw new Error(error);
|
||||
}
|
||||
details.standard = standard;
|
||||
}
|
||||
|
||||
// if changing to a token, get the balance from the network. The asset
|
||||
// overview page and asset list on the wallet overview page contain
|
||||
// send buttons that call this method before initialization occurs.
|
||||
// When this happens we don't yet have an account.address so default to
|
||||
// the currently active account. In addition its possible for the balance
|
||||
// check to take a decent amount of time, so we display a loading
|
||||
// indication so that that immediate feedback is displayed to the user.
|
||||
if (details.standard === ERC20) {
|
||||
error = null;
|
||||
balance = await getERC20Balance(details, userAddress);
|
||||
}
|
||||
await dispatch(hideLoadingIndication());
|
||||
}
|
||||
await dispatch(hideLoadingIndication());
|
||||
} else if (type === ASSET_TYPES.COLLECTIBLE) {
|
||||
let isCurrentOwner = true;
|
||||
try {
|
||||
@ -1452,16 +1473,17 @@ export function updateSendAsset({ type, details }) {
|
||||
details.address,
|
||||
details.tokenId,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.message.includes('Unable to verify ownership.')) {
|
||||
} catch (err) {
|
||||
if (err.message.includes('Unable to verify ownership.')) {
|
||||
// this would indicate that either our attempts to verify ownership failed because of network issues,
|
||||
// or, somehow a token has been added to collectibles state with an incorrect chainId.
|
||||
} else {
|
||||
// Any other error is unexpected and should be surfaced.
|
||||
dispatch(displayWarning(error.message));
|
||||
dispatch(displayWarning(err.message));
|
||||
}
|
||||
}
|
||||
if (isCurrentOwner) {
|
||||
error = null;
|
||||
balance = '0x1';
|
||||
} else {
|
||||
throw new Error(
|
||||
@ -1469,12 +1491,13 @@ export function updateSendAsset({ type, details }) {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
error = null;
|
||||
// if changing to native currency, get it from the account key in send
|
||||
// state which is kept in sync when accounts change.
|
||||
balance = state.send.account.balance;
|
||||
}
|
||||
// update the asset in state which will re-run amount and gas validation
|
||||
await dispatch(actions.updateAsset({ type, details, balance }));
|
||||
await dispatch(actions.updateAsset({ type, details, balance, error }));
|
||||
await dispatch(computeEstimatedGasLimit());
|
||||
};
|
||||
}
|
||||
@ -1870,7 +1893,6 @@ export function getGasInputMode(state) {
|
||||
}
|
||||
|
||||
// Asset Selectors
|
||||
|
||||
export function getSendAsset(state) {
|
||||
return state[name].asset;
|
||||
}
|
||||
@ -1886,6 +1908,10 @@ export function getIsAssetSendable(state) {
|
||||
return state[name].asset.details.isERC721 === false;
|
||||
}
|
||||
|
||||
export function getAssetError(state) {
|
||||
return state[name].asset.error;
|
||||
}
|
||||
|
||||
// Amount Selectors
|
||||
export function getSendAmount(state) {
|
||||
return state[name].amount.value;
|
||||
|
@ -19,6 +19,7 @@ import {
|
||||
TRANSACTION_ENVELOPE_TYPES,
|
||||
TRANSACTION_TYPES,
|
||||
} from '../../../shared/constants/transaction';
|
||||
import * as Actions from '../../store/actions';
|
||||
import sendReducer, {
|
||||
initialState,
|
||||
initializeSendState,
|
||||
@ -67,17 +68,6 @@ import sendReducer, {
|
||||
|
||||
const mockStore = createMockStore([thunk]);
|
||||
|
||||
jest.mock('../../store/actions', () => {
|
||||
const actual = jest.requireActual('../../store/actions');
|
||||
return {
|
||||
...actual,
|
||||
estimateGas: jest.fn(() => Promise.resolve('0x0')),
|
||||
getGasFeeEstimatesAndStartPolling: jest.fn(() => Promise.resolve()),
|
||||
updateTokenType: jest.fn(() => Promise.resolve({ isERC721: false })),
|
||||
isCollectibleOwner: jest.fn(() => Promise.resolve(true)),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('./send', () => {
|
||||
const actual = jest.requireActual('./send');
|
||||
return {
|
||||
@ -88,6 +78,25 @@ jest.mock('./send', () => {
|
||||
});
|
||||
|
||||
describe('Send Slice', () => {
|
||||
let getTokenStandardAndDetailsStub;
|
||||
beforeEach(() => {
|
||||
getTokenStandardAndDetailsStub = jest
|
||||
.spyOn(Actions, 'getTokenStandardAndDetails')
|
||||
.mockImplementation(() => Promise.resolve({ standard: 'ERC20' }));
|
||||
jest
|
||||
.spyOn(Actions, 'estimateGas')
|
||||
.mockImplementation(() => Promise.resolve('0x0'));
|
||||
jest
|
||||
.spyOn(Actions, 'getGasFeeEstimatesAndStartPolling')
|
||||
.mockImplementation(() => Promise.resolve());
|
||||
jest
|
||||
.spyOn(Actions, 'updateTokenType')
|
||||
.mockImplementation(() => Promise.resolve({ isERC721: false }));
|
||||
jest
|
||||
.spyOn(Actions, 'isCollectibleOwner')
|
||||
.mockImplementation(() => Promise.resolve(true));
|
||||
});
|
||||
|
||||
describe('Reducers', () => {
|
||||
describe('updateSendAmount', () => {
|
||||
it('should', async () => {
|
||||
@ -1457,6 +1466,7 @@ describe('Send Slice', () => {
|
||||
expect(actionResult[0].payload).toStrictEqual({
|
||||
...newSendAsset,
|
||||
balance: '',
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(actionResult[1].type).toStrictEqual(
|
||||
@ -1499,6 +1509,7 @@ describe('Send Slice', () => {
|
||||
expect(actionResult[2].payload).toStrictEqual({
|
||||
...newSendAsset,
|
||||
balance: '0x0',
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(actionResult[3].type).toStrictEqual(
|
||||
@ -1511,6 +1522,37 @@ describe('Send Slice', () => {
|
||||
'send/computeEstimatedGasLimit/fulfilled',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show ConvertTokenToNFT modal and throw "invalidAssetType" error when token passed in props is an ERC721 or ERC1155', async () => {
|
||||
getTokenStandardAndDetailsStub.mockImplementation(() =>
|
||||
Promise.resolve({ standard: 'ERC1155' }),
|
||||
);
|
||||
const store = mockStore(defaultSendAssetState);
|
||||
|
||||
const newSendAsset = {
|
||||
type: ASSET_TYPES.TOKEN,
|
||||
details: {
|
||||
address: 'tokenAddress',
|
||||
symbol: 'tokenSymbol',
|
||||
decimals: 'tokenDecimals',
|
||||
},
|
||||
};
|
||||
|
||||
await expect(() =>
|
||||
store.dispatch(updateSendAsset(newSendAsset)),
|
||||
).rejects.toThrow('invalidAssetType');
|
||||
const actionResult = store.getActions();
|
||||
expect(actionResult).toHaveLength(3);
|
||||
expect(actionResult[0].type).toStrictEqual('SHOW_LOADING_INDICATION');
|
||||
expect(actionResult[1].type).toStrictEqual('HIDE_LOADING_INDICATION');
|
||||
expect(actionResult[2]).toStrictEqual({
|
||||
payload: {
|
||||
name: 'CONVERT_TOKEN_TO_NFT',
|
||||
tokenAddress: 'tokenAddress',
|
||||
},
|
||||
type: 'UI_MODAL_OPEN',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRecipientUserInput', () => {
|
||||
@ -2231,6 +2273,7 @@ describe('Send Slice', () => {
|
||||
expect(actionResult[0].payload).toStrictEqual({
|
||||
balance: '0x1',
|
||||
type: ASSET_TYPES.COLLECTIBLE,
|
||||
error: null,
|
||||
details: {
|
||||
address: '0xTokenAddress',
|
||||
description: 'A test NFT dispensed from faucet.paradigm.xyz.',
|
||||
@ -2361,11 +2404,11 @@ describe('Send Slice', () => {
|
||||
expect(actionResult[2].payload).toStrictEqual({
|
||||
balance: '0x0',
|
||||
type: ASSET_TYPES.TOKEN,
|
||||
error: null,
|
||||
details: {
|
||||
address: '0xTokenAddress',
|
||||
decimals: 18,
|
||||
symbol: 'SYMB',
|
||||
isERC721: false,
|
||||
standard: 'ERC20',
|
||||
},
|
||||
});
|
||||
|
@ -7,6 +7,7 @@ export const SECONDARY = 'SECONDARY';
|
||||
|
||||
export const ERC20 = 'ERC20';
|
||||
export const ERC721 = 'ERC721';
|
||||
export const ERC1155 = 'ERC1155';
|
||||
|
||||
export const GAS_ESTIMATE_TYPES = {
|
||||
SLOW: 'SLOW',
|
||||
|
@ -7,3 +7,4 @@ export const GAS_PRICE_FETCH_FAILURE_ERROR_KEY = 'gasPriceFetchFailed';
|
||||
export const GAS_PRICE_EXCESSIVE_ERROR_KEY = 'gasPriceExcessive';
|
||||
export const UNSENDABLE_ASSET_ERROR_KEY = 'unsendableAsset';
|
||||
export const INSUFFICIENT_FUNDS_FOR_GAS_ERROR_KEY = 'insufficientFundsForGas';
|
||||
export const INVALID_ASSET_TYPE = 'invalidAssetType';
|
||||
|
@ -17,6 +17,8 @@ import {
|
||||
getShouldShowFiat,
|
||||
getPreferences,
|
||||
txDataSelector,
|
||||
getCurrentKeyring,
|
||||
getTokenExchangeRates,
|
||||
} from '../../selectors';
|
||||
import { ETH } from '../../helpers/constants/common';
|
||||
|
||||
@ -140,6 +142,12 @@ export const generateUseSelectorRouter = ({
|
||||
if (selector === getEIP1559V2Enabled) {
|
||||
return eip1559V2Enabled;
|
||||
}
|
||||
if (selector === getCurrentKeyring) {
|
||||
return { type: '' };
|
||||
}
|
||||
if (selector === getTokenExchangeRates) {
|
||||
return { '0x1': '1' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
|
@ -1,41 +1,60 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { util } from '@metamask/controllers';
|
||||
import { useI18nContext } from '../../hooks/useI18nContext';
|
||||
import { DEFAULT_ROUTE } from '../../helpers/constants/routes';
|
||||
|
||||
import Box from '../../components/ui/box';
|
||||
import TextField from '../../components/ui/text-field';
|
||||
import PageContainer from '../../components/ui/page-container';
|
||||
import {
|
||||
addCollectibleVerifyOwnership,
|
||||
removeToken,
|
||||
setNewCollectibleAddedMessage,
|
||||
} from '../../store/actions';
|
||||
import FormField from '../../components/ui/form-field';
|
||||
import { getIsMainnet, getUseCollectibleDetection } from '../../selectors';
|
||||
import { getCollectiblesDetectionNoticeDismissed } from '../../ducks/metamask/metamask';
|
||||
import CollectiblesDetectionNotice from '../../components/app/collectibles-detection-notice';
|
||||
|
||||
export default function AddCollectible() {
|
||||
const t = useI18nContext();
|
||||
const history = useHistory();
|
||||
const dispatch = useDispatch();
|
||||
const useCollectibleDetection = useSelector(getUseCollectibleDetection);
|
||||
const isMainnet = useSelector(getIsMainnet);
|
||||
const collectibleDetectionNoticeDismissed = useSelector(
|
||||
getCollectiblesDetectionNoticeDismissed,
|
||||
);
|
||||
const addressEnteredOnImportTokensPage =
|
||||
history?.location?.state?.addressEnteredOnImportTokensPage;
|
||||
const contractAddressToConvertFromTokenToCollectible =
|
||||
history?.location?.state?.tokenAddress;
|
||||
|
||||
const [address, setAddress] = useState(
|
||||
addressEnteredOnImportTokensPage ?? '',
|
||||
addressEnteredOnImportTokensPage ??
|
||||
contractAddressToConvertFromTokenToCollectible ??
|
||||
'',
|
||||
);
|
||||
|
||||
const [tokenId, setTokenId] = useState('');
|
||||
const [disabled, setDisabled] = useState(true);
|
||||
|
||||
const handleAddCollectible = async () => {
|
||||
try {
|
||||
await dispatch(addCollectibleVerifyOwnership(address, tokenId));
|
||||
await dispatch(
|
||||
addCollectibleVerifyOwnership(address, tokenId.toString()),
|
||||
);
|
||||
} catch (error) {
|
||||
const { message } = error;
|
||||
dispatch(setNewCollectibleAddedMessage(message));
|
||||
history.push(DEFAULT_ROUTE);
|
||||
return;
|
||||
}
|
||||
if (contractAddressToConvertFromTokenToCollectible) {
|
||||
await dispatch(
|
||||
removeToken(contractAddressToConvertFromTokenToCollectible),
|
||||
);
|
||||
}
|
||||
dispatch(setNewCollectibleAddedMessage('success'));
|
||||
history.push(DEFAULT_ROUTE);
|
||||
};
|
||||
@ -66,29 +85,33 @@ export default function AddCollectible() {
|
||||
disabled={disabled}
|
||||
contentComponent={
|
||||
<Box padding={4}>
|
||||
{isMainnet &&
|
||||
!useCollectibleDetection &&
|
||||
!collectibleDetectionNoticeDismissed ? (
|
||||
<CollectiblesDetectionNotice />
|
||||
) : null}
|
||||
<Box>
|
||||
<TextField
|
||||
<FormField
|
||||
id="address"
|
||||
label={t('address')}
|
||||
titleText={t('address')}
|
||||
placeholder="0x..."
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => validateAndSetAddress(e.target.value)}
|
||||
fullWidth
|
||||
onChange={(val) => validateAndSetAddress(val)}
|
||||
tooltipText={t('importNFTAddressToolTip')}
|
||||
autoFocus
|
||||
margin="normal"
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<TextField
|
||||
<FormField
|
||||
id="token-id"
|
||||
label={t('id')}
|
||||
titleText={t('tokenId')}
|
||||
placeholder={t('nftTokenIdPlaceholder')}
|
||||
type="number"
|
||||
value={tokenId}
|
||||
onChange={(e) => validateAndSetTokenId(e.target.value)}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
onChange={(val) => {
|
||||
validateAndSetTokenId(val);
|
||||
}}
|
||||
tooltipText={t('importNFTTokenIdToolTip')}
|
||||
numeric
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
@ -21,7 +21,7 @@ const Asset = () => {
|
||||
|
||||
const collectible = collectibles.find(
|
||||
({ address, tokenId }) =>
|
||||
isEqualCaseInsensitive(address, asset) && id === tokenId,
|
||||
isEqualCaseInsensitive(address, asset) && id === tokenId.toString(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -216,7 +216,7 @@ class ImportToken extends Component {
|
||||
const isMainnetNetwork = this.props.chainId === '0x1';
|
||||
|
||||
let standard;
|
||||
if (addressIsValid) {
|
||||
if (addressIsValid && process.env.COLLECTIBLES_V1) {
|
||||
try {
|
||||
({ standard } = await this.props.getTokenStandardAndDetails(
|
||||
standardAddress,
|
||||
|
@ -37,6 +37,7 @@ export default class SendContent extends Component {
|
||||
getIsBalanceInsufficient: PropTypes.bool,
|
||||
asset: PropTypes.object,
|
||||
to: PropTypes.string,
|
||||
assetError: PropTypes.string,
|
||||
};
|
||||
|
||||
render() {
|
||||
@ -49,6 +50,7 @@ export default class SendContent extends Component {
|
||||
networkOrAccountNotSupports1559,
|
||||
getIsBalanceInsufficient,
|
||||
asset,
|
||||
assetError,
|
||||
} = this.props;
|
||||
|
||||
let gasError;
|
||||
@ -67,6 +69,7 @@ export default class SendContent extends Component {
|
||||
return (
|
||||
<PageContainerContent>
|
||||
<div className="send-v2__form">
|
||||
{assetError ? this.renderError(assetError) : null}
|
||||
{gasError ? this.renderError(gasError) : null}
|
||||
{isEthGasPrice
|
||||
? this.renderWarning(ETH_GAS_PRICE_FETCH_WARNING_KEY)
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
getIsBalanceInsufficient,
|
||||
getSendTo,
|
||||
getSendAsset,
|
||||
getAssetError,
|
||||
} from '../../../ducks/send';
|
||||
|
||||
import SendContent from './send-content.component';
|
||||
@ -32,6 +33,7 @@ function mapStateToProps(state) {
|
||||
),
|
||||
getIsBalanceInsufficient: getIsBalanceInsufficient(state),
|
||||
asset: getSendAsset(state),
|
||||
assetError: getAssetError(state),
|
||||
};
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user