1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-22 18:00:18 +01:00

Show custom tokens in Swaps, add a custom token in Swaps (#11200)

* Show custom tokens in Swaps

* Add messages for adding a custom token in Swaps

* Add the first version of importing custom tokens in swaps

* Fix lint rules

* Create a new component: ImportToken

* Remove a pointer cursor from regular heading

* Fix a CSS issue for tokens with long names

* Update a comment

* Don’t return a custom token if it doesn’t have symbol or decimals

* Only search by contract address if nothing was found

* Track “Token Imported” event

* Fix unit tests

* Import tracking for “Token Imported”, increase token icon font size

* Disable token import for Source Token

* Update logic and content for notifications, update tests

* Do not hide a dropdown placeholder on click, so a user can click on a link

* Update a key name

* Update styling for the “danger” type notification in Swaps

* Show either a warning or danger notification based on token verification occurences

* Remove testnets from SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP

* Use the “shouldSearchForImports” prop

* Create a new function for handling token import: “onOpenImportTokenModalClick”

* Filter token duplicities before iterating over tokens

* Use “address” instead of “symbol” for checking uniqueness

* Trigger Build

* Use a new API (/token) to get token data for importing in Swaps

* Temporarily decrese Jest threshold for functions
This commit is contained in:
Daniel 2021-06-03 18:08:37 +02:00 committed by GitHub
parent 6700c460fd
commit c3b79bb358
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 562 additions and 51 deletions

View File

@ -52,6 +52,10 @@
"addContact": { "addContact": {
"message": "Add contact" "message": "Add contact"
}, },
"addCustomTokenByContractAddress": {
"message": "Cant find a token? You can manually add any token by pasting its address. Token contract addresses can be found on $1.",
"description": "$1 is a blockchain explorer for a specific network, e.g. Etherscan for Ethereum"
},
"addEthereumChainConfirmationDescription": { "addEthereumChainConfirmationDescription": {
"message": "This will allow this network to be used within MetaMask." "message": "This will allow this network to be used within MetaMask."
}, },
@ -410,6 +414,9 @@
"continueToWyre": { "continueToWyre": {
"message": "Continue to Wyre" "message": "Continue to Wyre"
}, },
"contract": {
"message": "Contract"
},
"contractAddressError": { "contractAddressError": {
"message": "You are sending tokens to the token's contract address. This may result in the loss of these tokens." "message": "You are sending tokens to the token's contract address. This may result in the loss of these tokens."
}, },
@ -893,6 +900,12 @@
"message": "or $1", "message": "or $1",
"description": "$1 represents the text from `importAccountLinkText` as a link" "description": "$1 represents the text from `importAccountLinkText` as a link"
}, },
"importTokenQuestion": {
"message": "Import token?"
},
"importTokenWarning": {
"message": "Anyone can create a token with any name, including fake versions of existing tokens. Add and trade at your own risk!"
},
"importWallet": { "importWallet": {
"message": "Import wallet" "message": "Import wallet"
}, },
@ -2066,13 +2079,13 @@
"message": "Swap $1 to $2", "message": "Swap $1 to $2",
"description": "Used in the transaction display list to describe a swap. $1 and $2 are the symbols of tokens in involved in a swap." "description": "Used in the transaction display list to describe a swap. $1 and $2 are the symbols of tokens in involved in a swap."
}, },
"swapTokenVerificationAddedManually": {
"message": "This token has been added manually."
},
"swapTokenVerificationMessage": { "swapTokenVerificationMessage": {
"message": "Always confirm the token address on $1.", "message": "Always confirm the token address on $1.",
"description": "Points the user to Etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"Etherscan\" followed by an info icon that shows more info on hover." "description": "Points the user to Etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"Etherscan\" followed by an info icon that shows more info on hover."
}, },
"swapTokenVerificationNoSource": {
"message": "This token has not been verified."
},
"swapTokenVerificationOnlyOneSource": { "swapTokenVerificationOnlyOneSource": {
"message": "Only verified on 1 source." "message": "Only verified on 1 source."
}, },
@ -2358,6 +2371,10 @@
"message": "Verify this token on $1", "message": "Verify this token on $1",
"description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\"" "description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\""
}, },
"verifyThisUnconfirmedTokenOn": {
"message": "Verify this token on $1 and make sure this is the token you want to trade.",
"description": "Points the user to etherscan as a place they can verify information about a token. $1 is replaced with the translation for \"etherscan\""
},
"viewAccount": { "viewAccount": {
"message": "View Account" "message": "View Account"
}, },

View File

@ -6,7 +6,7 @@ module.exports = {
coverageThreshold: { coverageThreshold: {
global: { global: {
branches: 32.75, branches: 32.75,
functions: 43.31, functions: 42.9,
lines: 43.12, lines: 43.12,
statements: 43.67, statements: 43.67,
}, },

View File

@ -63,6 +63,7 @@ const SWAPS_TESTNET_CHAIN_ID = '0x539';
const SWAPS_TESTNET_HOST = 'https://metaswap-api.airswap-dev.codefi.network'; const SWAPS_TESTNET_HOST = 'https://metaswap-api.airswap-dev.codefi.network';
const BSC_DEFAULT_BLOCK_EXPLORER_URL = 'https://bscscan.com/'; const BSC_DEFAULT_BLOCK_EXPLORER_URL = 'https://bscscan.com/';
const MAINNET_DEFAULT_BLOCK_EXPLORER_URL = 'https://etherscan.io/';
export const ALLOWED_SWAPS_CHAIN_IDS = { export const ALLOWED_SWAPS_CHAIN_IDS = {
[MAINNET_CHAIN_ID]: true, [MAINNET_CHAIN_ID]: true,
@ -90,4 +91,5 @@ export const SWAPS_CHAINID_DEFAULT_TOKEN_MAP = {
export const SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP = { export const SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP = {
[BSC_CHAIN_ID]: BSC_DEFAULT_BLOCK_EXPLORER_URL, [BSC_CHAIN_ID]: BSC_DEFAULT_BLOCK_EXPLORER_URL,
[MAINNET_CHAIN_ID]: MAINNET_DEFAULT_BLOCK_EXPLORER_URL,
}; };

View File

@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import contractMap from '@metamask/contract-metadata'; import contractMap from '@metamask/contract-metadata';
import BigNumber from 'bignumber.js'; import BigNumber from 'bignumber.js';
import { isEqual, shuffle } from 'lodash'; import { isEqual, shuffle, uniqBy } from 'lodash';
import { getTokenFiatAmount } from '../helpers/utils/token-util'; import { getTokenFiatAmount } from '../helpers/utils/token-util';
import { import {
getTokenExchangeRates, getTokenExchangeRates,
@ -119,7 +119,12 @@ export function useTokensToSearch({ usersTokens = [], topTokens = {} }) {
others: [], others: [],
}; };
memoizedTokensToSearch.forEach((token) => { const memoizedSwapsAndUserTokensWithoutDuplicities = uniqBy(
[...memoizedTokensToSearch, ...memoizedUsersToken],
'address',
);
memoizedSwapsAndUserTokensWithoutDuplicities.forEach((token) => {
const renderableDataToken = getRenderableTokenData( const renderableDataToken = getRenderableTokenData(
{ ...usersTokensAddressMap[token.address], ...token }, { ...usersTokensAddressMap[token.address], ...token },
tokenConversionRates, tokenConversionRates,
@ -129,8 +134,7 @@ export function useTokensToSearch({ usersTokens = [], topTokens = {} }) {
); );
if ( if (
isSwapsDefaultTokenSymbol(renderableDataToken.symbol, chainId) || isSwapsDefaultTokenSymbol(renderableDataToken.symbol, chainId) ||
(usersTokensAddressMap[token.address] && usersTokensAddressMap[token.address]
Number(renderableDataToken.balance ?? 0) !== 0)
) { ) {
tokensToSearchBuckets.owned.push(renderableDataToken); tokensToSearchBuckets.owned.push(renderableDataToken);
} else if (memoizedTopTokens[token.address]) { } else if (memoizedTopTokens[token.address]) {

View File

@ -51,12 +51,18 @@
} }
&--danger { &--danger {
background: $Red-100; background: $Red-000;
border: 1px solid $Red-500; border: 1px solid $Red-300;
justify-content: flex-start; justify-content: flex-start;
.actionable-message__message { .actionable-message__message {
color: $Red-500; color: $Black-100;
text-align: left;
}
button {
background: $Red-500;
color: #fff;
} }
} }

View File

@ -27,7 +27,7 @@ describe('AwaitingSwap', () => {
store, store,
); );
expect(getByText('Processing')).toBeInTheDocument(); expect(getByText('Processing')).toBeInTheDocument();
expect(getByText('View on Etherscan')).toBeInTheDocument(); expect(getByText('ETH')).toBeInTheDocument();
expect(getByText('View in activity')).toBeInTheDocument(); expect(getByText('View in activity')).toBeInTheDocument();
expect( expect(
document.querySelector('.awaiting-swap__main-descrption'), document.querySelector('.awaiting-swap__main-descrption'),

View File

@ -333,6 +333,38 @@ export default function BuildQuote({
dispatch(resetSwapsPostFetchState()); dispatch(resetSwapsPostFetchState());
}, [dispatch]); }, [dispatch]);
const BlockExplorerLink = () => {
return (
<a
className="build-quote__token-etherscan-link build-quote__underline"
key="build-quote-etherscan-link"
onClick={() => {
blockExplorerLinkClickedEvent();
global.platform.openTab({
url: blockExplorerTokenLink,
});
}}
target="_blank"
rel="noopener noreferrer"
>
{blockExplorerLabel}
</a>
);
};
let tokenVerificationDescription = '';
if (blockExplorerTokenLink) {
if (occurances === 1) {
tokenVerificationDescription = t('verifyThisTokenOn', [
<BlockExplorerLink key="block-explorer-link" />,
]);
} else if (occurances === 0) {
tokenVerificationDescription = t('verifyThisUnconfirmedTokenOn', [
<BlockExplorerLink key="block-explorer-link" />,
]);
}
}
return ( return (
<div className="build-quote"> <div className="build-quote">
<div className="build-quote__content"> <div className="build-quote__content">
@ -434,37 +466,21 @@ export default function BuildQuote({
listContainerClassName="build-quote__open-to-dropdown" listContainerClassName="build-quote__open-to-dropdown"
hideRightLabels hideRightLabels
defaultToAll defaultToAll
shouldSearchForImports
/> />
</div> </div>
{toTokenIsNotDefault && {toTokenIsNotDefault &&
(occurances < 2 ? ( (occurances < 2 ? (
<ActionableMessage <ActionableMessage
type={occurances === 1 ? 'warning' : 'danger'}
message={ message={
<div className="build-quote__token-verification-warning-message"> <div className="build-quote__token-verification-warning-message">
<div className="build-quote__bold"> <div className="build-quote__bold">
{occurances === 1 {occurances === 1
? t('swapTokenVerificationOnlyOneSource') ? t('swapTokenVerificationOnlyOneSource')
: t('swapTokenVerificationNoSource')} : t('swapTokenVerificationAddedManually')}
</div>
<div>
{blockExplorerTokenLink &&
t('verifyThisTokenOn', [
<a
className="build-quote__token-etherscan-link build-quote__underline"
key="build-quote-etherscan-link"
onClick={() => {
blockExplorerLinkClickedEvent();
global.platform.openTab({
url: blockExplorerTokenLink,
});
}}
target="_blank"
rel="noopener noreferrer"
>
{blockExplorerLabel}
</a>,
])}
</div> </div>
<div>{tokenVerificationDescription}</div>
</div> </div>
} }
primaryAction={ primaryAction={
@ -475,7 +491,6 @@ export default function BuildQuote({
onClick: () => setVerificationClicked(true), onClick: () => setVerificationClicked(true),
} }
} }
type="warning"
withRightButton withRightButton
infoTooltipText={ infoTooltipText={
blockExplorerTokenLink && blockExplorerTokenLink &&

View File

@ -109,6 +109,22 @@
width: 100%; width: 100%;
} }
.dropdown-input-pair {
.searchable-item-list {
&__item--add-token {
display: none;
}
}
&__to {
.searchable-item-list {
&__item--add-token {
display: flex;
}
}
}
}
&__open-to-dropdown { &__open-to-dropdown {
max-height: 194px; max-height: 194px;

View File

@ -1,6 +1,10 @@
import React from 'react'; import React from 'react';
import configureMockStore from 'redux-mock-store';
import { renderWithProvider } from '../../../../test/jest'; import {
renderWithProvider,
createSwapsMockStore,
} from '../../../../test/jest';
import DropdownInputPair from '.'; import DropdownInputPair from '.';
const createProps = (customProps = {}) => { const createProps = (customProps = {}) => {
@ -11,9 +15,11 @@ const createProps = (customProps = {}) => {
describe('DropdownInputPair', () => { describe('DropdownInputPair', () => {
it('renders the component with initial props', () => { it('renders the component with initial props', () => {
const store = configureMockStore()(createSwapsMockStore());
const props = createProps(); const props = createProps();
const { getByPlaceholderText } = renderWithProvider( const { getByPlaceholderText } = renderWithProvider(
<DropdownInputPair {...props} />, <DropdownInputPair {...props} />,
store,
); );
expect(getByPlaceholderText('0')).toBeInTheDocument(); expect(getByPlaceholderText('0')).toBeInTheDocument();
expect( expect(

View File

@ -5,6 +5,7 @@ import React, {
useContext, useContext,
useRef, useRef,
} from 'react'; } from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classnames from 'classnames'; import classnames from 'classnames';
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
@ -12,6 +13,16 @@ import { I18nContext } from '../../../contexts/i18n';
import SearchableItemList from '../searchable-item-list'; import SearchableItemList from '../searchable-item-list';
import PulseLoader from '../../../components/ui/pulse-loader'; import PulseLoader from '../../../components/ui/pulse-loader';
import UrlIcon from '../../../components/ui/url-icon'; import UrlIcon from '../../../components/ui/url-icon';
import ActionableMessage from '../actionable-message';
import ImportToken from '../import-token';
import { useNewMetricEvent } from '../../../hooks/useMetricEvent';
import {
isHardwareWallet,
getHardwareWalletType,
getCurrentChainId,
getRpcPrefsForCurrentProvider,
} from '../../../selectors/selectors';
import { SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../shared/constants/swaps';
export default function DropdownSearchList({ export default function DropdownSearchList({
searchListClassName, searchListClassName,
@ -31,10 +42,31 @@ export default function DropdownSearchList({
hideRightLabels, hideRightLabels,
hideItemIf, hideItemIf,
listContainerClassName, listContainerClassName,
shouldSearchForImports,
}) { }) {
const t = useContext(I18nContext); const t = useContext(I18nContext);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isImportTokenModalOpen, setIsImportTokenModalOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState(startingItem); const [selectedItem, setSelectedItem] = useState(startingItem);
const [tokenForImport, setTokenForImport] = useState(null);
const hardwareWalletUsed = useSelector(isHardwareWallet);
const hardwareWalletType = useSelector(getHardwareWalletType);
const chainId = useSelector(getCurrentChainId);
const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider);
const tokenImportedEvent = useNewMetricEvent({
event: 'Token Imported',
sensitiveProperties: {
symbol: tokenForImport?.symbol,
address: tokenForImport?.address,
chain_id: chainId,
is_hardware_wallet: hardwareWalletUsed,
hardware_wallet_type: hardwareWalletType,
},
category: 'swaps',
});
const close = useCallback(() => { const close = useCallback(() => {
setIsOpen(false); setIsOpen(false);
onClose?.(); onClose?.();
@ -49,6 +81,25 @@ export default function DropdownSearchList({
[onSelect, close], [onSelect, close],
); );
const onOpenImportTokenModalClick = (item) => {
setTokenForImport(item);
setIsImportTokenModalOpen(true);
};
const onImportTokenClick = () => {
tokenImportedEvent();
// Only when a user confirms import of a token, we add it and show it in a dropdown.
onSelect?.(tokenForImport);
setSelectedItem(tokenForImport);
setTokenForImport(null);
close();
};
const onImportTokenCloseClick = () => {
setIsImportTokenModalOpen(false);
close();
};
const onClickSelector = useCallback(() => { const onClickSelector = useCallback(() => {
if (!isOpen) { if (!isOpen) {
setIsOpen(true); setIsOpen(true);
@ -81,6 +132,34 @@ export default function DropdownSearchList({
} }
}; };
const blockExplorerLink =
rpcPrefs.blockExplorerUrl ??
SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[chainId] ??
null;
const blockExplorerLabel = rpcPrefs.blockExplorerUrl
? new URL(blockExplorerLink).hostname
: t('etherscan');
const blockExplorerLinkClickedEvent = useNewMetricEvent({
category: 'Swaps',
event: 'Clicked Block Explorer Link',
properties: {
link_type: 'Token Tracker',
action: 'Verify Contract Address',
block_explorer_domain: blockExplorerLink
? new URL(blockExplorerLink)?.hostname
: '',
},
});
const importTokenProps = {
onImportTokenCloseClick,
onImportTokenClick,
setIsImportTokenModalOpen,
tokenForImport,
};
return ( return (
<div <div
className={classnames('dropdown-search-list', className)} className={classnames('dropdown-search-list', className)}
@ -88,6 +167,9 @@ export default function DropdownSearchList({
onKeyUp={onKeyUp} onKeyUp={onKeyUp}
tabIndex="0" tabIndex="0"
> >
{tokenForImport && isImportTokenModalOpen && (
<ImportToken {...importTokenProps} />
)}
{!isOpen && ( {!isOpen && (
<div <div
className={classnames( className={classnames(
@ -141,6 +223,32 @@ export default function DropdownSearchList({
) : ( ) : (
<div className="dropdown-search-list__placeholder"> <div className="dropdown-search-list__placeholder">
{t('swapBuildQuotePlaceHolderText', [searchQuery])} {t('swapBuildQuotePlaceHolderText', [searchQuery])}
<div
tabIndex="0"
className="searchable-item-list__item searchable-item-list__item--add-token"
key="searchable-item-list-item-last"
>
<ActionableMessage
message={
blockExplorerLink &&
t('addCustomTokenByContractAddress', [
<a
key="dropdown-search-list__etherscan-link"
onClick={() => {
blockExplorerLinkClickedEvent();
global.platform.openTab({
url: blockExplorerLink,
});
}}
target="_blank"
rel="noopener noreferrer"
>
{blockExplorerLabel}
</a>,
])
}
/>
</div>
</div> </div>
) )
} }
@ -148,6 +256,7 @@ export default function DropdownSearchList({
fuseSearchKeys={fuseSearchKeys} fuseSearchKeys={fuseSearchKeys}
defaultToAll={defaultToAll} defaultToAll={defaultToAll}
onClickItem={onClickItem} onClickItem={onClickItem}
onOpenImportTokenModalClick={onOpenImportTokenModalClick}
maxListItems={maxListItems} maxListItems={maxListItems}
className={classnames( className={classnames(
'dropdown-search-list__token-container', 'dropdown-search-list__token-container',
@ -159,6 +268,7 @@ export default function DropdownSearchList({
hideRightLabels={hideRightLabels} hideRightLabels={hideRightLabels}
hideItemIf={hideItemIf} hideItemIf={hideItemIf}
listContainerClassName={listContainerClassName} listContainerClassName={listContainerClassName}
shouldSearchForImports={shouldSearchForImports}
/> />
<div <div
className="dropdown-search-list__close-area" className="dropdown-search-list__close-area"
@ -197,4 +307,5 @@ DropdownSearchList.propTypes = {
hideRightLabels: PropTypes.bool, hideRightLabels: PropTypes.bool,
hideItemIf: PropTypes.func, hideItemIf: PropTypes.func,
listContainerClassName: PropTypes.string, listContainerClassName: PropTypes.string,
shouldSearchForImports: PropTypes.bool,
}; };

View File

@ -1,6 +1,10 @@
import React from 'react'; import React from 'react';
import configureMockStore from 'redux-mock-store';
import { renderWithProvider } from '../../../../test/jest'; import {
renderWithProvider,
createSwapsMockStore,
} from '../../../../test/jest';
import DropdownSearchList from '.'; import DropdownSearchList from '.';
const createProps = (customProps = {}) => { const createProps = (customProps = {}) => {
@ -15,9 +19,11 @@ const createProps = (customProps = {}) => {
describe('DropdownSearchList', () => { describe('DropdownSearchList', () => {
it('renders the component with initial props', () => { it('renders the component with initial props', () => {
const store = configureMockStore()(createSwapsMockStore());
const props = createProps(); const props = createProps();
const { container, getByText } = renderWithProvider( const { container, getByText } = renderWithProvider(
<DropdownSearchList {...props} />, <DropdownSearchList {...props} />,
store,
); );
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
expect(getByText('symbol')).toBeInTheDocument(); expect(getByText('symbol')).toBeInTheDocument();

View File

@ -63,7 +63,7 @@
cursor: pointer; cursor: pointer;
position: relative; position: relative;
align-items: center; align-items: center;
width: 100%; flex: 1;
height: 60px; height: 60px;
i { i {
@ -128,12 +128,16 @@
color: $Grey-500; color: $Grey-500;
min-height: 300px; min-height: 300px;
position: relative; position: relative;
z-index: 1; z-index: 1002;
background: white; background: white;
border-radius: 6px; border-radius: 6px;
min-height: 194px; min-height: 194px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
.searchable-item-list__item--add-token {
padding: 8px 0;
}
} }
&__loading-item { &__loading-item {

View File

@ -0,0 +1,89 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { I18nContext } from '../../../contexts/i18n';
import UrlIcon from '../../../components/ui/url-icon';
import Popover from '../../../components/ui/popover';
import Button from '../../../components/ui/button';
import Box from '../../../components/ui/box';
import Typography from '../../../components/ui/typography';
import ActionableMessage from '../actionable-message';
import {
TYPOGRAPHY,
FONT_WEIGHT,
ALIGN_ITEMS,
DISPLAY,
} from '../../../helpers/constants/design-system';
export default function ImportToken({
onImportTokenCloseClick,
onImportTokenClick,
setIsImportTokenModalOpen,
tokenForImport,
}) {
const t = useContext(I18nContext);
const ImportTokenModalFooter = (
<>
<Button
type="secondary"
className="page-container__footer-button"
onClick={onImportTokenCloseClick}
rounded
>
{t('cancel')}
</Button>
<Button
type="confirm"
className="page-container__footer-button"
onClick={onImportTokenClick}
rounded
>
{t('import')}
</Button>
</>
);
return (
<Popover
title={t('importTokenQuestion')}
onClose={() => setIsImportTokenModalOpen(false)}
footer={ImportTokenModalFooter}
>
<Box
padding={[0, 6, 4, 6]}
alignItems={ALIGN_ITEMS.CENTER}
display={DISPLAY.FLEX}
className="import-token"
>
<ActionableMessage type="danger" message={t('importTokenWarning')} />
<UrlIcon
url={tokenForImport.iconUrl}
className="import-token__token-icon"
fallbackClassName="import-token__token-icon"
name={tokenForImport.symbol}
/>
<Typography
ariant={TYPOGRAPHY.H4}
fontWeight={FONT_WEIGHT.BOLD}
boxProps={{ marginTop: 2, marginBottom: 3 }}
>
{tokenForImport.name}
</Typography>
<Typography variant={TYPOGRAPHY.H6}>{t('contract')}:</Typography>
<Typography
className="import-token__contract-address"
variant={TYPOGRAPHY.H7}
boxProps={{ marginBottom: 6 }}
>
{tokenForImport.address}
</Typography>
</Box>
</Popover>
);
}
ImportToken.propTypes = {
onImportTokenCloseClick: PropTypes.func,
onImportTokenClick: PropTypes.func,
setIsImportTokenModalOpen: PropTypes.func,
tokenForImport: PropTypes.object,
};

View File

@ -0,0 +1,27 @@
import React from 'react';
import { renderWithProvider } from '../../../../test/jest';
import ImportToken from '.';
const createProps = (customProps = {}) => {
return {
onImportTokenCloseClick: jest.fn(),
onImportTokenClick: jest.fn(),
setIsImportTokenModalOpen: jest.fn(),
tokenForImport: {
symbol: 'POS',
name: 'PoSToken',
address: '0xee609fe292128cad03b786dbb9bc2634ccdbe7fc',
},
...customProps,
};
};
describe('ImportToken', () => {
it('renders the component with initial props', () => {
const props = createProps();
const { getByText } = renderWithProvider(<ImportToken {...props} />);
expect(getByText(props.tokenForImport.name)).toBeInTheDocument();
expect(getByText(props.tokenForImport.address)).toBeInTheDocument();
});
});

View File

@ -0,0 +1 @@
export { default } from './import-token';

View File

@ -0,0 +1,30 @@
.import-token {
flex-direction: column;
.actionable-message {
margin-top: 0;
&--danger {
border-color: $Red-300;
background: $Red-000;
}
&__message {
color: $Black-100;
text-align: left;
}
}
&__contract-address {
border-radius: 8px;
background-color: $Grey-000;
padding: 5px 10px;
}
&__token-icon {
font-size: $font-size-h2;
margin-top: 24px;
width: 69px;
height: 69px;
}
}

View File

@ -14,6 +14,7 @@
@import 'slippage-buttons/index'; @import 'slippage-buttons/index';
@import 'swaps-footer/index'; @import 'swaps-footer/index';
@import 'view-quote/index'; @import 'view-quote/index';
@import 'import-token/index';
.swaps { .swaps {
display: flex; display: flex;

View File

@ -43,7 +43,7 @@
&__list-container { &__list-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: scroll; overflow-y: auto;
} }
&__item { &__item {
@ -63,7 +63,7 @@
} }
&:last-of-type { &:last-of-type {
border-bottom: 1px solid $Grey-100; border-bottom: none;
} }
&:hover, &:hover,
@ -80,6 +80,38 @@
pointer-events: none; pointer-events: none;
} }
&--add-token {
min-height: auto;
opacity: 1;
pointer-events: none;
&:hover {
background: none;
}
.actionable-message {
margin: 0;
&__message {
text-align: left;
color: $Black-100;
}
a {
pointer-events: auto;
color: #037dd6;
cursor: pointer;
}
}
}
.btn-primary {
@include H7;
width: auto;
padding: 7px 11px;
}
> img { > img {
margin-top: -2px; margin-top: -2px;
} }

View File

@ -1,12 +1,23 @@
import React from 'react'; import React, { useContext } from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classnames from 'classnames'; import classnames from 'classnames';
import Identicon from '../../../../components/ui/identicon'; import Identicon from '../../../../components/ui/identicon';
import UrlIcon from '../../../../components/ui/url-icon'; import UrlIcon from '../../../../components/ui/url-icon';
import Button from '../../../../components/ui/button';
import ActionableMessage from '../../actionable-message';
import { I18nContext } from '../../../../contexts/i18n';
import {
getCurrentChainId,
getRpcPrefsForCurrentProvider,
} from '../../../../selectors';
import { SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP } from '../../../../../shared/constants/swaps';
import { useNewMetricEvent } from '../../../../hooks/useMetricEvent';
export default function ItemList({ export default function ItemList({
results = [], results = [],
onClickItem, onClickItem,
onOpenImportTokenModalClick,
Placeholder, Placeholder,
listTitle, listTitle,
maxListItems = 6, maxListItems = 6,
@ -16,6 +27,32 @@ export default function ItemList({
hideItemIf, hideItemIf,
listContainerClassName, listContainerClassName,
}) { }) {
const t = useContext(I18nContext);
const chainId = useSelector(getCurrentChainId);
const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider);
const blockExplorerLink =
rpcPrefs.blockExplorerUrl ??
SWAPS_CHAINID_DEFAULT_BLOCK_EXPLORER_URL_MAP[chainId] ??
null;
const blockExplorerLabel = rpcPrefs.blockExplorerUrl
? new URL(blockExplorerLink).hostname
: t('etherscan');
const blockExplorerLinkClickedEvent = useNewMetricEvent({
category: 'Swaps',
event: 'Clicked Block Explorer Link',
properties: {
link_type: 'Token Tracker',
action: 'Verify Contract Address',
block_explorer_domain: blockExplorerLink
? new URL(blockExplorerLink)?.hostname
: '',
},
});
// If there is a token for import based on a contract address, it's the only one in the list.
const hasTokenForImport = results.length === 1 && results[0].notImported;
return results.length === 0 ? ( return results.length === 0 ? (
Placeholder && <Placeholder searchQuery={searchQuery} /> Placeholder && <Placeholder searchQuery={searchQuery} />
) : ( ) : (
@ -35,7 +72,13 @@ export default function ItemList({
return null; return null;
} }
const onClick = () => onClickItem?.(result); const onClick = () => {
if (result.notImported) {
onOpenImportTokenModalClick(result);
} else {
onClickItem?.(result);
}
};
const { const {
iconUrl, iconUrl,
identiconAddress, identiconAddress,
@ -96,9 +139,42 @@ export default function ItemList({
</div> </div>
)} )}
</div> </div>
{result.notImported && (
<Button type="confirm" onClick={onClick} rounded>
{t('import')}
</Button>
)}
</div> </div>
); );
})} })}
{!hasTokenForImport && (
<div
tabIndex="0"
className="searchable-item-list__item searchable-item-list__item--add-token"
key="searchable-item-list-item-last"
>
<ActionableMessage
message={
blockExplorerLink &&
t('addCustomTokenByContractAddress', [
<a
key="searchable-item-list__etherscan-link"
onClick={() => {
blockExplorerLinkClickedEvent();
global.platform.openTab({
url: blockExplorerLink,
});
}}
target="_blank"
rel="noopener noreferrer"
>
{blockExplorerLabel}
</a>,
])
}
/>
</div>
)}
</div> </div>
</div> </div>
); );
@ -117,6 +193,7 @@ ItemList.propTypes = {
}), }),
), ),
onClickItem: PropTypes.func, onClickItem: PropTypes.func,
onOpenImportTokenModalClick: PropTypes.func,
Placeholder: PropTypes.func, Placeholder: PropTypes.func,
listTitle: PropTypes.string, listTitle: PropTypes.string,
maxListItems: PropTypes.number, maxListItems: PropTypes.number,

View File

@ -1,9 +1,14 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import log from 'loglevel';
import InputAdornment from '@material-ui/core/InputAdornment'; import InputAdornment from '@material-ui/core/InputAdornment';
import TextField from '../../../../components/ui/text-field'; import TextField from '../../../../components/ui/text-field';
import { usePrevious } from '../../../../hooks/usePrevious'; import { usePrevious } from '../../../../hooks/usePrevious';
import { isValidHexAddress } from '../../../../../shared/modules/hexstring-utils';
import { fetchToken } from '../../swaps.util';
import { getCurrentChainId } from '../../../../selectors/selectors';
const renderAdornment = () => ( const renderAdornment = () => (
<InputAdornment position="start" style={{ marginRight: '12px' }}> <InputAdornment position="start" style={{ marginRight: '12px' }}>
@ -18,17 +23,53 @@ export default function ListItemSearch({
fuseSearchKeys, fuseSearchKeys,
searchPlaceholderText, searchPlaceholderText,
defaultToAll, defaultToAll,
shouldSearchForImports,
}) { }) {
const fuseRef = useRef(); const fuseRef = useRef();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const chainId = useSelector(getCurrentChainId);
const handleSearch = (newSearchQuery) => { /**
setSearchQuery(newSearchQuery); * Search a custom token for import based on a contract address.
* @param {String} contractAddress
*/
const handleSearchTokenForImport = async (contractAddress) => {
setSearchQuery(contractAddress);
try {
const token = await fetchToken(contractAddress, chainId);
if (token) {
token.primaryLabel = token.symbol;
token.secondaryLabel = token.name;
token.notImported = true;
onSearch({
searchQuery: contractAddress,
results: [token],
});
return;
}
} catch (e) {
log.error('Token not found, show 0 results.', e);
}
onSearch({
searchQuery: contractAddress,
results: [], // No token for import found.
});
};
const handleSearch = async (newSearchQuery) => {
const trimmedNewSearchQuery = newSearchQuery.trim();
const validHexAddress = isValidHexAddress(trimmedNewSearchQuery);
const fuseSearchResult = fuseRef.current.search(newSearchQuery); const fuseSearchResult = fuseRef.current.search(newSearchQuery);
const results =
defaultToAll && newSearchQuery === '' ? listToSearch : fuseSearchResult;
if (shouldSearchForImports && results.length === 0 && validHexAddress) {
await handleSearchTokenForImport(trimmedNewSearchQuery);
return;
}
setSearchQuery(newSearchQuery);
onSearch({ onSearch({
searchQuery: newSearchQuery, searchQuery: newSearchQuery,
results: results,
defaultToAll && newSearchQuery === '' ? listToSearch : fuseSearchResult,
}); });
}; };
@ -83,4 +124,5 @@ ListItemSearch.propTypes = {
fuseSearchKeys: PropTypes.arrayOf(PropTypes.object).isRequired, fuseSearchKeys: PropTypes.arrayOf(PropTypes.object).isRequired,
searchPlaceholderText: PropTypes.string, searchPlaceholderText: PropTypes.string,
defaultToAll: PropTypes.bool, defaultToAll: PropTypes.bool,
shouldSearchForImports: PropTypes.bool,
}; };

View File

@ -12,11 +12,13 @@ export default function SearchableItemList({
listTitle, listTitle,
maxListItems, maxListItems,
onClickItem, onClickItem,
onOpenImportTokenModalClick,
Placeholder, Placeholder,
searchPlaceholderText, searchPlaceholderText,
hideRightLabels, hideRightLabels,
hideItemIf, hideItemIf,
listContainerClassName, listContainerClassName,
shouldSearchForImports,
}) { }) {
const itemListRef = useRef(); const itemListRef = useRef();
@ -38,11 +40,13 @@ export default function SearchableItemList({
error={itemSelectorError} error={itemSelectorError}
searchPlaceholderText={searchPlaceholderText} searchPlaceholderText={searchPlaceholderText}
defaultToAll={defaultToAll} defaultToAll={defaultToAll}
shouldSearchForImports={shouldSearchForImports}
/> />
<ItemList <ItemList
searchQuery={searchQuery} searchQuery={searchQuery}
results={results} results={results}
onClickItem={onClickItem} onClickItem={onClickItem}
onOpenImportTokenModalClick={onOpenImportTokenModalClick}
Placeholder={Placeholder} Placeholder={Placeholder}
listTitle={listTitle} listTitle={listTitle}
maxListItems={maxListItems} maxListItems={maxListItems}
@ -59,6 +63,7 @@ SearchableItemList.propTypes = {
itemSelectorError: PropTypes.string, itemSelectorError: PropTypes.string,
itemsToSearch: PropTypes.array, itemsToSearch: PropTypes.array,
onClickItem: PropTypes.func, onClickItem: PropTypes.func,
onOpenImportTokenModalClick: PropTypes.func,
Placeholder: PropTypes.func, Placeholder: PropTypes.func,
className: PropTypes.string, className: PropTypes.string,
searchPlaceholderText: PropTypes.string, searchPlaceholderText: PropTypes.string,
@ -74,4 +79,5 @@ SearchableItemList.propTypes = {
hideRightLabels: PropTypes.bool, hideRightLabels: PropTypes.bool,
hideItemIf: PropTypes.func, hideItemIf: PropTypes.func,
listContainerClassName: PropTypes.string, listContainerClassName: PropTypes.string,
shouldSearchForImports: PropTypes.bool,
}; };

View File

@ -1,6 +1,10 @@
import React from 'react'; import React from 'react';
import configureMockStore from 'redux-mock-store';
import { renderWithProvider } from '../../../../test/jest'; import {
renderWithProvider,
createSwapsMockStore,
} from '../../../../test/jest';
import SearchableItemList from '.'; import SearchableItemList from '.';
const createProps = (customProps = {}) => { const createProps = (customProps = {}) => {
@ -37,8 +41,12 @@ const createProps = (customProps = {}) => {
describe('SearchableItemList', () => { describe('SearchableItemList', () => {
it('renders the component with initial props', () => { it('renders the component with initial props', () => {
const store = configureMockStore()(createSwapsMockStore());
const props = createProps(); const props = createProps();
const { getByText } = renderWithProvider(<SearchableItemList {...props} />); const { getByText } = renderWithProvider(
<SearchableItemList {...props} />,
store,
);
expect(getByText(props.listTitle)).toBeInTheDocument(); expect(getByText(props.listTitle)).toBeInTheDocument();
expect(getByText(props.itemsToSearch[0].primaryLabel)).toBeInTheDocument(); expect(getByText(props.itemsToSearch[0].primaryLabel)).toBeInTheDocument();
expect( expect(

View File

@ -10,7 +10,6 @@
margin-bottom: 0; margin-bottom: 0;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
cursor: pointer;
background: unset; background: unset;
margin-bottom: 8px; margin-bottom: 8px;
} }

View File

@ -47,6 +47,8 @@ const getBaseApi = function (type, chainId = MAINNET_CHAIN_ID) {
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/trades?`; return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/trades?`;
case 'tokens': case 'tokens':
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/tokens`; return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/tokens`;
case 'token':
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/token`;
case 'topAssets': case 'topAssets':
return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/topAssets`; return `${METASWAP_CHAINID_API_HOST_MAP[chainId]}/topAssets`;
case 'featureFlag': case 'featureFlag':
@ -290,10 +292,20 @@ export async function fetchTradesInfo(
return newQuotes; return newQuotes;
} }
export async function fetchToken(contractAddress, chainId) {
const tokenUrl = getBaseApi('token', chainId);
const token = await fetchWithCache(
`${tokenUrl}?address=${contractAddress}`,
{ method: 'GET' },
{ cacheRefreshTime: CACHE_REFRESH_FIVE_MINUTES },
);
return token;
}
export async function fetchTokens(chainId) { export async function fetchTokens(chainId) {
const tokenUrl = getBaseApi('tokens', chainId); const tokensUrl = getBaseApi('tokens', chainId);
const tokens = await fetchWithCache( const tokens = await fetchWithCache(
tokenUrl, tokensUrl,
{ method: 'GET' }, { method: 'GET' },
{ cacheRefreshTime: CACHE_REFRESH_FIVE_MINUTES }, { cacheRefreshTime: CACHE_REFRESH_FIVE_MINUTES },
); );
@ -301,7 +313,7 @@ export async function fetchTokens(chainId) {
SWAPS_CHAINID_DEFAULT_TOKEN_MAP[chainId], SWAPS_CHAINID_DEFAULT_TOKEN_MAP[chainId],
...tokens.filter((token) => { ...tokens.filter((token) => {
return ( return (
validateData(TOKEN_VALIDATORS, token, tokenUrl) && validateData(TOKEN_VALIDATORS, token, tokensUrl) &&
!( !(
isSwapsDefaultTokenSymbol(token.symbol, chainId) || isSwapsDefaultTokenSymbol(token.symbol, chainId) ||
isSwapsDefaultTokenAddress(token.address, chainId) isSwapsDefaultTokenAddress(token.address, chainId)