diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 43396b0fe8a0..c6fce85a2190 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -509,6 +509,9 @@ "allCustodianAccountsConnectedTitle": { "message": "No accounts available to connect" }, + "allNetworks": { + "message": "All networks" + }, "allOfYour": { "message": "All of your $1", "description": "$1 is the symbol or name of the token that the user is approving spending" @@ -580,6 +583,9 @@ "message": "MetaMask Institutional", "description": "The name of the application (MMI)" }, + "apply": { + "message": "Apply" + }, "approve": { "message": "Approve spend limit" }, @@ -2934,6 +2940,7 @@ "loadingScreenSnapMessage": { "message": "Please complete the transaction on the Snap." }, + "loadingTokenList": { "message": "Loading token list" }, "loadingTokens": { "message": "Loading tokens..." }, @@ -4828,6 +4835,9 @@ "searchTokens": { "message": "Search tokens" }, + "searchTokensByNameOrAddress": { + "message": "Search tokens by name or address" + }, "secretRecoveryPhrase": { "message": "Secret Recovery Phrase" }, @@ -5193,6 +5203,9 @@ "simulationsSettingSubHeader": { "message": "Estimate balance changes" }, + "singleNetwork": { + "message": "1 network" + }, "siweIssued": { "message": "Issued" }, @@ -5422,6 +5435,9 @@ "solanaSupportToggleTitle": { "message": "Enable \"Add a new Solana account (Beta)\"" }, + "someNetworks": { + "message": "$1 networks" + }, "somethingDoesntLookRight": { "message": "Something doesn't look right? $1", "description": "A false positive message for users to contact support. $1 is a link to the support page." diff --git a/shared/constants/bridge.ts b/shared/constants/bridge.ts index eb2ceb065590..06e0d55b8195 100644 --- a/shared/constants/bridge.ts +++ b/shared/constants/bridge.ts @@ -1,4 +1,4 @@ -import { CHAIN_IDS } from './network'; +import { CHAIN_IDS, NETWORK_TO_NAME_MAP } from './network'; // TODO read from feature flags export const ALLOWED_BRIDGE_CHAIN_IDS = [ @@ -31,3 +31,18 @@ export const BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE = 0.8; // if a quote export const BRIDGE_PREFERRED_GAS_ESTIMATE = 'medium'; export const BRIDGE_DEFAULT_SLIPPAGE = 0.5; + +export const NETWORK_TO_SHORT_NETWORK_NAME_MAP: Record< + AllowedBridgeChainIds, + string +> = { + [CHAIN_IDS.MAINNET]: 'Ethereum', + [CHAIN_IDS.LINEA_MAINNET]: 'Linea', + [CHAIN_IDS.POLYGON]: NETWORK_TO_NAME_MAP[CHAIN_IDS.POLYGON], + [CHAIN_IDS.AVALANCHE]: 'Avalanche', + [CHAIN_IDS.BSC]: NETWORK_TO_NAME_MAP[CHAIN_IDS.BSC], + [CHAIN_IDS.ARBITRUM]: NETWORK_TO_NAME_MAP[CHAIN_IDS.ARBITRUM], + [CHAIN_IDS.OPTIMISM]: NETWORK_TO_NAME_MAP[CHAIN_IDS.OPTIMISM], + [CHAIN_IDS.ZKSYNC_ERA]: 'ZkSync Era', + [CHAIN_IDS.BASE]: 'Base', +}; diff --git a/test/data/mock-send-state.json b/test/data/mock-send-state.json index f9c602808719..0b7cc237547f 100644 --- a/test/data/mock-send-state.json +++ b/test/data/mock-send-state.json @@ -64,6 +64,11 @@ }, "metamask": { "accountsByChainId": {}, + "tokenBalances": { + "0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc": { + "0x5": {} + } + }, "ipfsGateway": "", "dismissSeedBackUpReminder": false, "usePhishDetect": true, diff --git a/test/e2e/page-objects/pages/home/asset-list.ts b/test/e2e/page-objects/pages/home/asset-list.ts index c80eb9e96eed..abc5870ec04a 100644 --- a/test/e2e/page-objects/pages/home/asset-list.ts +++ b/test/e2e/page-objects/pages/home/asset-list.ts @@ -119,7 +119,7 @@ class AssetListPage { */ async hideToken(tokenName: string): Promise { console.log(`Hide token ${tokenName} on homepage`); - await this.driver.clickElement({ text: tokenName, tag: 'span' }); + await this.driver.clickElement({ text: tokenName, tag: 'p' }); await this.driver.clickElement(this.assetOptionsButton); await this.driver.clickElement(this.hideTokenButton); await this.driver.waitForSelector(this.hideTokenConfirmationModalTitle); diff --git a/test/e2e/tests/dapp-interactions/block-explorer.spec.js b/test/e2e/tests/dapp-interactions/block-explorer.spec.js index 0b01aa65aab3..d6d64dcf4322 100644 --- a/test/e2e/tests/dapp-interactions/block-explorer.spec.js +++ b/test/e2e/tests/dapp-interactions/block-explorer.spec.js @@ -82,7 +82,7 @@ describe('Block Explorer', function () { await driver.clickElement({ text: 'TST', - tag: 'span', + tag: 'p', }); await driver.clickElement('[data-testid="asset-options__button"]'); diff --git a/test/e2e/tests/multichain/asset-picker-send.spec.ts b/test/e2e/tests/multichain/asset-picker-send.spec.ts index a071bec9426d..a6d8e193d537 100644 --- a/test/e2e/tests/multichain/asset-picker-send.spec.ts +++ b/test/e2e/tests/multichain/asset-picker-send.spec.ts @@ -88,7 +88,7 @@ describe('AssetPickerSendFlow @no-mmi', function () { await searchInputField.sendKeys('CHZ'); // check that CHZ is disabled - const [, tkn] = await driver.findElements( + const [tkn] = await driver.findElements( '[data-testid="multichain-token-list-button"]', ); diff --git a/test/e2e/tests/network/custom-rpc-history.spec.js b/test/e2e/tests/network/custom-rpc-history.spec.js index 6e92f532698f..2d1a52994d10 100644 --- a/test/e2e/tests/network/custom-rpc-history.spec.js +++ b/test/e2e/tests/network/custom-rpc-history.spec.js @@ -264,6 +264,7 @@ describe('Custom RPC history', function () { const customRpcs = await driver.findElements({ text: 'Localhost 8545', tag: 'p', + css: '.multichain-network-list-item__tooltip', }); // click Mainnet to dismiss network dropdown diff --git a/test/e2e/tests/network/update-network.spec.ts b/test/e2e/tests/network/update-network.spec.ts index 3f0b9882688f..f7861f46ab99 100644 --- a/test/e2e/tests/network/update-network.spec.ts +++ b/test/e2e/tests/network/update-network.spec.ts @@ -23,7 +23,7 @@ const selectors = { deleteButton: { text: 'Delete', tag: 'button' }, cancelButton: { text: 'Cancel', tag: 'button' }, saveButton: { text: 'Save', tag: 'button' }, - updatedNetworkDropDown: { tag: 'span', text: 'Update Network' }, + updatedNetworkDropDown: { tag: 'p', text: 'Update Network' }, errorMessageInvalidUrl: { text: 'URLs require the appropriate HTTP/HTTPS prefix.', }, diff --git a/test/e2e/tests/responsive-ui/metamask-responsive-ui.spec.js b/test/e2e/tests/responsive-ui/metamask-responsive-ui.spec.js index db43124355c2..54236d84f2f5 100644 --- a/test/e2e/tests/responsive-ui/metamask-responsive-ui.spec.js +++ b/test/e2e/tests/responsive-ui/metamask-responsive-ui.spec.js @@ -163,7 +163,7 @@ describe('MetaMask Responsive UI', function () { // Import Secret Recovery Phrase await driver.waitForSelector({ - tag: 'span', + tag: 'p', text: 'Localhost 8545', }); await driver.clickElement({ diff --git a/test/e2e/tests/tokens/add-multiple-tokens.spec.js b/test/e2e/tests/tokens/add-multiple-tokens.spec.js index 8be2a430b622..bc6ea5e6f2c0 100644 --- a/test/e2e/tests/tokens/add-multiple-tokens.spec.js +++ b/test/e2e/tests/tokens/add-multiple-tokens.spec.js @@ -104,7 +104,7 @@ describe('Multiple ERC20 Watch Asset', function () { // Check all three tokens have been added to the token list. const addedTokens = await driver.findElements({ - tag: 'span', + tag: 'p', text: 'TST', }); assert.equal(addedTokens.length, 3); diff --git a/test/e2e/tests/transaction/change-assets.spec.js b/test/e2e/tests/transaction/change-assets.spec.js index 9764941771b7..33944f7a2160 100644 --- a/test/e2e/tests/transaction/change-assets.spec.js +++ b/test/e2e/tests/transaction/change-assets.spec.js @@ -111,7 +111,7 @@ describe('Change assets', function () { // Click the Send button await driver.clickElement({ - css: '[data-testid="multichain-token-list-button"] span', + css: '[data-testid="multichain-token-list-button"] p', text: 'TST', }); @@ -471,7 +471,7 @@ describe('Change assets', function () { // Click the Send button await driver.clickElement({ - css: '[data-testid="multichain-token-list-button"] span', + css: '[data-testid="multichain-token-list-button"] p', text: 'TST', }); diff --git a/test/jest/mock-store.js b/test/jest/mock-store.js index f937dcac0cf5..84d18e1a3803 100644 --- a/test/jest/mock-store.js +++ b/test/jest/mock-store.js @@ -740,6 +740,113 @@ export const createBridgeMockStore = ( }, currencyRates: { ETH: { conversionRate: 2524.25 }, + usd: { conversionRate: 1 }, + }, + marketData: { + '0x1': { + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { + currency: 'usd', + price: 2.3, + }, + }, + }, + allTokens: { + [CHAIN_IDS.MAINNET]: { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': [ + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + balance: 'a', + decimals: 6, + }, + { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + balance: 'e', + }, + ], + '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b': [ + { + address: '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2', + balance: 'e', + }, + ], + }, + [CHAIN_IDS.LINEA_MAINNET]: { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': [ + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + balance: 'e', + }, + ], + '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b': [ + { + address: '0x6b3595068778dd592e39a122f4f5a5cf09c90fe2', + balance: 'e', + }, + ], + }, + }, + accountsByChainId: { + [CHAIN_IDS.MAINNET]: { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': { + address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + balance: '0xa', + }, + '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b': { + address: '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b', + balance: '0xe', + }, + }, + [CHAIN_IDS.LINEA_MAINNET]: { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': { + address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + balance: '0xe', + }, + '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b': { + address: '0xec1adf982415d2ef5ec55899b9bfb8bc0f29251b', + balance: '0xe', + }, + }, + }, + tokensChainsCache: { + [CHAIN_IDS.MAINNET]: { + timestamp: 111111, + data: [ + { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + }, + { + address: '0xc00e94cb662c3520282e6f5717214004a7f26888', + symbol: 'COMP', + decimals: 18, + }, + ], + }, + [CHAIN_IDS.LINEA_MAINNET]: { + timestamp: 111111, + data: { + '0x514910771af9ca656af840dff83e8264ecf986ca': { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + }, + '0xc00e94cb662c3520282e6f5717214004a7f26888': { + address: '0xc00e94cb662c3520282e6f5717214004a7f26888', + symbol: 'COMP', + decimals: 18, + }, + }, + }, + }, + tokenBalances: { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': { + '0x5': {}, + '0x1': { + '0x514910771af9ca656af840dff83e8264ecf986ca': '0x1', + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': '0x738', + }, + }, }, ...metamaskStateOverrides, bridgeState: { diff --git a/ui/components/app/assets/token-cell/__snapshots__/token-cell.test.tsx.snap b/ui/components/app/assets/token-cell/__snapshots__/token-cell.test.tsx.snap index 9a62f050e644..7fb51f212ebd 100644 --- a/ui/components/app/assets/token-cell/__snapshots__/token-cell.test.tsx.snap +++ b/ui/components/app/assets/token-cell/__snapshots__/token-cell.test.tsx.snap @@ -3,13 +3,14 @@ exports[`Token Cell should match snapshot 1`] = `
diff --git a/ui/components/component-library/picker-network/__snapshots__/picker-network.test.tsx.snap b/ui/components/component-library/picker-network/__snapshots__/picker-network.test.tsx.snap index 3e43996dc512..a451e62576a6 100644 --- a/ui/components/component-library/picker-network/__snapshots__/picker-network.test.tsx.snap +++ b/ui/components/component-library/picker-network/__snapshots__/picker-network.test.tsx.snap @@ -1,5 +1,129 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`PickerNetwork should render multiple avatars when avatarGroupProps is present 1`] = ` +
+ +
+`; + +exports[`PickerNetwork should render multiple avatars with a stacked tag when isTagOverlay is present 1`] = ` +
+ +
+`; + exports[`PickerNetwork should render the label inside the PickerNetwork 1`] = `
- Imported - +

= (args) => ( ); + +export const AvatarGroupProps: StoryFn = () => ( + ({ + avatarValue: + CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP[ + c as keyof typeof CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP + ], + symbol: + NETWORK_TO_SHORT_NETWORK_NAME_MAP[ + c as keyof typeof NETWORK_TO_SHORT_NETWORK_NAME_MAP + ], + })), + avatarType: AvatarType.NETWORK, + }} + /> +); diff --git a/ui/components/component-library/picker-network/picker-network.test.tsx b/ui/components/component-library/picker-network/picker-network.test.tsx index ac7ee292b55c..d05e4a52caf9 100644 --- a/ui/components/component-library/picker-network/picker-network.test.tsx +++ b/ui/components/component-library/picker-network/picker-network.test.tsx @@ -2,6 +2,9 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { IconName } from '..'; +import { renderWithProvider } from '../../../../test/lib/render-helpers'; +import configureStore from '../../../store/store'; +import { AvatarType } from '../../multichain/avatar-group/avatar-group.types'; import { PickerNetwork } from './picker-network'; describe('PickerNetwork', () => { @@ -77,4 +80,46 @@ describe('PickerNetwork', () => { ); expect(getByTestId('picker-network-label')).toHaveClass('test-class'); }); + // avatarGroupProps + it('should render multiple avatars when avatarGroupProps is present', () => { + const { container } = renderWithProvider( + , + configureStore({ metamask: { useBlockie: false } }), + ); + expect(container).toMatchSnapshot(); + }); + it('should render multiple avatars with a stacked tag when isTagOverlay is present', () => { + const { container } = renderWithProvider( + , + configureStore({ metamask: { useBlockie: true } }), + ); + expect(container).toMatchSnapshot(); + }); }); diff --git a/ui/components/component-library/picker-network/picker-network.tsx b/ui/components/component-library/picker-network/picker-network.tsx index 7363b1e16bc3..f92ad174beed 100644 --- a/ui/components/component-library/picker-network/picker-network.tsx +++ b/ui/components/component-library/picker-network/picker-network.tsx @@ -18,6 +18,7 @@ import { Text, } from '..'; import { BoxProps, PolymorphicRef } from '../box'; +import { AvatarGroup } from '../../multichain/avatar-group'; import { PickerNetworkComponent, PickerNetworkProps, @@ -27,6 +28,7 @@ export const PickerNetwork: PickerNetworkComponent = React.forwardRef( ( { className = '', + avatarGroupProps, avatarNetworkProps, iconProps, label, @@ -50,14 +52,19 @@ export const PickerNetwork: PickerNetworkComponent = React.forwardRef( display={Display.Flex} {...(props as BoxProps)} > - - + {avatarGroupProps ? ( + + ) : ( + + )} + + {label}
C
- Chain 5 - +

C
- Chain 5 - +

{ return mockState.getTokenList; } else if (selector === getIntlLocale) { return mockState.getIntlLocale; + } else if (selector === getNetworkConfigurationIdByChainId) { + return { + '0x1': { networkName: 'Ethereum', iconUrl: 'network-icon-url' }, + }; } return undefined; }); @@ -69,6 +76,7 @@ describe('Asset', () => { balance="10000000000000000000" decimals={18} tooltipText="tooltip" + chainId="0x1" />, ); diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/Asset.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/Asset.tsx index f6e7e7f3ccc9..5561af182dc0 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/Asset.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/Asset.tsx @@ -1,17 +1,26 @@ import React from 'react'; import { useSelector } from 'react-redux'; import { BigNumber } from 'bignumber.js'; -import { getCurrentChainId } from '../../../../../shared/modules/selectors/networks'; -import { getTokenList } from '../../../../selectors'; +import { + getCurrentCurrency, + getNetworkConfigurationIdByChainId, + getTokenList, +} from '../../../../selectors'; import { useTokenFiatAmount } from '../../../../hooks/useTokenFiatAmount'; import { TokenListItem } from '../../token-list-item'; import { isEqualCaseInsensitive } from '../../../../../shared/modules/string-utils'; import { formatAmount } from '../../../../pages/confirmations/components/simulation-details/formatAmount'; import { getIntlLocale } from '../../../../ducks/locale/locale'; -import { AssetWithDisplayData, ERC20Asset } from './types'; +import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP } from '../../../../../shared/constants/network'; +import { formatCurrency } from '../../../../helpers/utils/confirm-tx.util'; +import { AssetWithDisplayData, ERC20Asset, NativeAsset } from './types'; -type AssetProps = AssetWithDisplayData & { +type AssetProps = AssetWithDisplayData & { tooltipText?: string; + assetItemProps?: Pick< + React.ComponentProps, + 'isTitleNetworkName' | 'isTitleHidden' + >; }; export default function Asset({ @@ -20,11 +29,18 @@ export default function Asset({ symbol, string: decimalTokenAmount, tooltipText, + tokenFiatAmount, + chainId, + assetItemProps = {}, }: AssetProps) { const locale = useSelector(getIntlLocale); - const chainId = useSelector(getCurrentChainId); + const currency = useSelector(getCurrentCurrency); const tokenList = useSelector(getTokenList); + const allNetworks = useSelector(getNetworkConfigurationIdByChainId); + const isTokenChainIdInWallet = Boolean( + chainId ? allNetworks[chainId as keyof typeof allNetworks] : true, + ); const tokenData = address ? Object.values(tokenList).find( (token) => @@ -45,20 +61,30 @@ export default function Asset({ const formattedAmount = decimalTokenAmount ? `${formatAmount( locale, - new BigNumber(decimalTokenAmount || '0', 10), + new BigNumber(decimalTokenAmount.toString(), 10), )} ${symbol}` : undefined; + const primaryAmountToUse = tokenFiatAmount + ? formatCurrency(tokenFiatAmount.toString(), currency, 2) + : formattedFiat; return ( ); } diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.test.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.test.tsx index c4e4ba0ceb29..d3807b4952d1 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.test.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; import { useSelector } from 'react-redux'; import { + getCurrentNetwork, getPreferences, getSelectedAccountCachedBalance, } from '../../../../selectors'; @@ -10,6 +11,7 @@ import { useUserPreferencedCurrency } from '../../../../hooks/useUserPreferenced import { useCurrencyDisplay } from '../../../../hooks/useCurrencyDisplay'; import { AssetType } from '../../../../../shared/constants/transaction'; import { CHAIN_ID_TOKEN_IMAGE_MAP } from '../../../../../shared/constants/network'; +import { getCurrentChainId } from '../../../../../shared/modules/selectors/networks'; import AssetList from './AssetList'; import { AssetWithDisplayData, ERC20Asset, NativeAsset } from './types'; @@ -55,6 +57,7 @@ describe('AssetList', () => { string: '10', decimals: 18, balance: '0', + chainId: '0x1', }, { address: '0xToken2', @@ -64,6 +67,7 @@ describe('AssetList', () => { string: '20', decimals: 6, balance: '10', + chainId: '0x1', }, { address: null, @@ -73,6 +77,7 @@ describe('AssetList', () => { string: '30', decimals: 18, balance: '0x121', + chainId: '0x1', }, ]; const primaryCurrency = 'USD'; @@ -110,6 +115,15 @@ describe('AssetList', () => { }); it('should render the token list', () => { + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === getCurrentChainId) { + return '0x1'; + } + if (selector === getCurrentNetwork) { + return { chainId: '0x1' }; + } + return undefined; + }); render( { type: AssetType.native, image: CHAIN_ID_TOKEN_IMAGE_MAP['0x1'], symbol: 'ETH', + chainId: '0x1', }} tokenList={tokenList} />, @@ -134,6 +149,7 @@ describe('AssetList', () => { type: AssetType.native, image: CHAIN_ID_TOKEN_IMAGE_MAP['0x1'], symbol: 'ETH', + chainId: '0x1', }} tokenList={tokenList} />, @@ -166,6 +182,7 @@ describe('AssetList', () => { type: AssetType.native, image: CHAIN_ID_TOKEN_IMAGE_MAP['0x1'], symbol: 'ETH', + chainId: '0x1', }} tokenList={tokenList} isTokenDisabled={(token) => token.address === '0xToken1'} diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.tsx index 6f867d25ca85..ece3d7046fde 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/AssetList.tsx @@ -1,9 +1,14 @@ import React from 'react'; import { useSelector } from 'react-redux'; import classnames from 'classnames'; +import { + AddNetworkFields, + NetworkConfiguration, +} from '@metamask/network-controller'; import { getCurrentChainId } from '../../../../../shared/modules/selectors/networks'; import { getCurrentCurrency, + getCurrentNetwork, getSelectedAccountCachedBalance, } from '../../../../selectors'; import { getNativeCurrency } from '../../../../ducks/metamask/metamask'; @@ -18,6 +23,9 @@ import { FlexWrap, } from '../../../../helpers/constants/design-system'; import { TokenListItem } from '../..'; +import LoadingScreen from '../../../ui/loading-screen'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; +import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP } from '../../../../../shared/constants/network'; import AssetComponent from './Asset'; import { AssetWithDisplayData, ERC20Asset, NativeAsset } from './types'; @@ -33,6 +41,12 @@ type AssetListProps = { isTokenDisabled?: ( token: AssetWithDisplayData | AssetWithDisplayData, ) => boolean; + network?: NetworkConfiguration | AddNetworkFields; + isTokenListLoading?: boolean; + assetItemProps?: Pick< + React.ComponentProps, + 'isTitleNetworkName' | 'isTitleHidden' + >; }; export default function AssetList({ @@ -40,8 +54,20 @@ export default function AssetList({ asset, tokenList, isTokenDisabled, + network, + isTokenListLoading = false, + assetItemProps = {}, }: AssetListProps) { - const selectedToken = asset?.address; + const t = useI18nContext(); + const selectedTokenAddress = asset?.address; + + const currentNetwork = useSelector(getCurrentNetwork); + // If a network is provided, display tokens in that network + // Otherwise, assume tokens in the current network are displayed + const networkToUse = network ?? currentNetwork; + // This indicates whether tokens in the wallet's active network are displayed + const isSelectedNetworkActive = + networkToUse.chainId === currentNetwork.chainId; const chainId = useSelector(getCurrentChainId); const nativeCurrency = useSelector(getNativeCurrency); @@ -59,9 +85,24 @@ export default function AssetList({ return ( + {isTokenListLoading && ( + + )} {tokenList.map((token) => { const tokenAddress = token.address?.toLowerCase(); - const isSelected = tokenAddress === selectedToken?.toLowerCase(); + + const isMatchingChainId = token.chainId === networkToUse?.chainId; + const isMatchingAddress = + // the native asset can have an undefined, null, '', or zero address + (token.type === AssetType.native && + !token.address && + !selectedTokenAddress) || + tokenAddress === selectedTokenAddress?.toLowerCase(); + const isSelected = isMatchingChainId && isMatchingAddress; + const isDisabled = isTokenDisabled?.(token) ?? false; return ( @@ -69,7 +110,7 @@ export default function AssetList({ padding={0} gap={0} margin={0} - key={token.symbol} + key={`${token.symbol}-${tokenAddress ?? ''}-${token.chainId}`} backgroundColor={ isSelected ? BackgroundColor.primaryMuted @@ -101,24 +142,33 @@ export default function AssetList({ flexWrap={FlexWrap.NoWrap} alignItems={AlignItems.center} > - - {token.type === AssetType.native ? ( + + {token.type === AssetType.native && + token.chainId === chainId && + isSelectedNetworkActive ? ( + // Only use this component for the native token of the active network ) : ( )} diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/__snapshots__/asset-picker-modal-network.test.tsx.snap b/ui/components/multichain/asset-picker-amount/asset-picker-modal/__snapshots__/asset-picker-modal-network.test.tsx.snap index 4e0e3640a75a..f0d99284b7a8 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/__snapshots__/asset-picker-modal-network.test.tsx.snap +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/__snapshots__/asset-picker-modal-network.test.tsx.snap @@ -71,11 +71,10 @@ exports[`AssetPickerModalNetwork renders modal with no network list by default 1
@@ -161,20 +160,19 @@ exports[`AssetPickerModalNetwork should not show selected network when network p
Network name 3 logo @@ -185,14 +183,25 @@ exports[`AssetPickerModalNetwork should not show selected network when network p >
-

- Network name 3 -

+
+

+ Ethereum +

+
+
@@ -200,10 +209,10 @@ exports[`AssetPickerModalNetwork should not show selected network when network p class="mm-box multichain-network-list-item mm-box--padding-top-4 mm-box--padding-right-4 mm-box--padding-bottom-4 mm-box--padding-left-4 mm-box--display-flex mm-box--gap-4 mm-box--justify-content-space-between mm-box--align-items-center mm-box--width-full mm-box--background-color-transparent" >
Network name 4 logo @@ -214,14 +223,25 @@ exports[`AssetPickerModalNetwork should not show selected network when network p >
-

- Network name 4 -

+
+

+ OP Mainnet +

+
+
@@ -310,11 +330,10 @@ exports[`AssetPickerModalNetwork should use passed in network as default when ne
Network name 3 logo @@ -337,14 +356,25 @@ exports[`AssetPickerModalNetwork should use passed in network as default when ne >
-

- Network name 3 -

+
+

+ Ethereum +

+
+
@@ -352,10 +382,10 @@ exports[`AssetPickerModalNetwork should use passed in network as default when ne class="mm-box multichain-network-list-item mm-box--padding-top-4 mm-box--padding-right-4 mm-box--padding-bottom-4 mm-box--padding-left-4 mm-box--display-flex mm-box--gap-4 mm-box--justify-content-space-between mm-box--align-items-center mm-box--width-full mm-box--background-color-transparent" >
Network name 4 logo @@ -366,14 +396,25 @@ exports[`AssetPickerModalNetwork should use passed in network as default when ne >
-

- Network name 4 -

+
+

+ OP Mainnet +

+
+
diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-network.test.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-network.test.tsx index 3fc1e8cf7952..a9246874943e 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-network.test.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-network.test.tsx @@ -3,8 +3,10 @@ import configureStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { screen, fireEvent } from '@testing-library/react'; import { RpcEndpointType } from '@metamask/network-controller'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; import { renderWithProvider } from '../../../../../test/lib/render-helpers'; import mockState from '../../../../../test/data/mock-send-state.json'; +import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../../shared/constants/bridge'; import { AssetPickerModalNetwork } from './asset-picker-modal-network'; const mockOnClose = jest.fn(); @@ -26,7 +28,7 @@ describe('AssetPickerModalNetwork', () => { const networkProps = { network: { - chainId: '0x1', + chainId: CHAIN_IDS.MAINNET, nativeCurrency: 'ETH', defaultBlockExplorerUrlIndex: 0, blockExplorerUrls: ['https://explorerurl'], @@ -43,7 +45,7 @@ describe('AssetPickerModalNetwork', () => { } as any, networks: [ { - chainId: '0x1', + chainId: CHAIN_IDS.MAINNET, nativeCurrency: 'ETH', defaultBlockExplorerUrlIndex: 0, blockExplorerUrls: ['https://explorerurl'], @@ -58,7 +60,7 @@ describe('AssetPickerModalNetwork', () => { name: 'Network name 3', }, { - chainId: '0xa', + chainId: CHAIN_IDS.OPTIMISM, nativeCurrency: 'ETH', defaultBlockExplorerUrlIndex: 0, blockExplorerUrls: ['https://explorerurl'], @@ -110,7 +112,10 @@ describe('AssetPickerModalNetwork', () => { }); it('should call onClose and onBack when header buttons are clicked', () => { - renderWithProvider(, store); + renderWithProvider( + , + store, + ); fireEvent.click(screen.getByLabelText('Close')); expect(mockOnClose).toHaveBeenCalledTimes(1); @@ -125,7 +130,9 @@ describe('AssetPickerModalNetwork', () => { store, ); - fireEvent.click(screen.getByText('Network name 3')); + fireEvent.click( + screen.getByText(NETWORK_TO_SHORT_NETWORK_NAME_MAP[CHAIN_IDS.MAINNET]), + ); expect(mockOnBack).toHaveBeenCalledTimes(1); expect(mockOnNetworkChange).toHaveBeenCalledTimes(1); }); diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-network.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-network.tsx index f64209543557..7d6963240619 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-network.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-network.tsx @@ -1,11 +1,19 @@ -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; -import { NetworkConfiguration } from '@metamask/network-controller'; +import { + AddNetworkFields, + NetworkConfiguration, +} from '@metamask/network-controller'; +import { IconName } from '@metamask/snaps-sdk/jsx'; import { Display, FlexDirection, BlockSize, + AlignItems, + TextVariant, + IconColor, + BackgroundColor, } from '../../../../helpers/constants/design-system'; import { ModalOverlay, @@ -13,27 +21,37 @@ import { ModalHeader, Modal, Box, + ButtonLink, + Checkbox, + Text, + AvatarNetworkSize, } from '../../../component-library'; import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP } from '../../../../../shared/constants/network'; ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) import { useI18nContext } from '../../../../hooks/useI18nContext'; ///: END:ONLY_INCLUDE_IF import { NetworkListItem } from '../../network-list-item'; -import { - getNetworkConfigurationsByChainId, - getProviderConfig, -} from '../../../../../shared/modules/selectors/networks'; +import { getNetworkConfigurationsByChainId } from '../../../../../shared/modules/selectors/networks'; +import { getCurrentCurrency } from '../../../../selectors'; +import { formatCurrency } from '../../../../helpers/utils/confirm-tx.util'; +import { useMultichainBalances } from '../../../../hooks/useMultichainBalances'; +import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../../shared/constants/bridge'; /** * AssetPickerModalNetwork component displays a modal for selecting a network in the asset picker. * * @param props * @param props.isOpen - Determines whether the modal is open or not. - * @param props.network - The currently selected network, not necessarily the active wallet network. + * @param props.network - The currently selected network, not necessarily the active wallet network, and possibly not imported yet. * @param props.networks - The list of selectable networks. * @param props.onNetworkChange - The callback function to handle network change. * @param props.onClose - The callback function to handle modal close. * @param props.onBack - The callback function to handle going back in the modal. + * @param props.shouldDisableNetwork - The callback function to determine if a network should be disabled. + * @param props.header - A custom header for the modal. + * @param props.onMultiselectSubmit - The callback function to run when multiple networks are selected. + * @param props.selectedChainIds - A list of selected chainIds. + * @param props.isMultiselectEnabled - Determines whether selecting multiple networks is enabled. * @returns A modal with a list of selectable networks. */ export const AssetPickerModalNetwork = ({ @@ -43,26 +61,95 @@ export const AssetPickerModalNetwork = ({ network, networks, onNetworkChange, + shouldDisableNetwork, + header, + isMultiselectEnabled, + onMultiselectSubmit, + selectedChainIds, }: { isOpen: boolean; - network?: NetworkConfiguration; - networks?: NetworkConfiguration[]; - onNetworkChange: (network: NetworkConfiguration) => void; + network?: NetworkConfiguration | AddNetworkFields; + networks?: (NetworkConfiguration | AddNetworkFields)[]; + onNetworkChange: (network: NetworkConfiguration | AddNetworkFields) => void; + shouldDisableNetwork?: ( + network: NetworkConfiguration | AddNetworkFields, + ) => boolean; onClose: () => void; onBack: () => void; + header?: JSX.Element | string | null; + isMultiselectEnabled?: boolean; + selectedChainIds?: string[]; + onMultiselectSubmit?: (selectedChainIds: string[]) => void; }) => { ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) const t = useI18nContext(); ///: END:ONLY_INCLUDE_IF - const currentNetwork = useSelector(getProviderConfig); + const { balanceByChainId } = useMultichainBalances(); + const allNetworks = useSelector(getNetworkConfigurationsByChainId); + const currency = useSelector(getCurrentCurrency); + // Use the networks prop if it is provided, otherwise use all available networks + // Sort the networks by balance in descending order + const networksList = useMemo( + () => + (networks ?? Object.values(allNetworks) ?? []).sort( + (a, b) => balanceByChainId[b.chainId] - balanceByChainId[a.chainId], + ), + [], + ); + + // Tracks the selection/checked state of each network + // Initialized with the selectedChainIds if provided + const [checkedChainIds, setCheckedChainIds] = useState< + Record + >( + networksList?.reduce( + (acc, { chainId }) => ({ + ...acc, + [chainId]: selectedChainIds + ? selectedChainIds.includes(chainId) + : false, + }), + {}, + ) ?? {}, + ); - const selectedNetwork = - network ?? (currentNetwork?.chainId && allNetworks[currentNetwork.chainId]); + // Reset checkedChainIds if selectedChainIds change in parent component + useEffect(() => { + networksList && + setCheckedChainIds( + networksList.reduce( + (acc, { chainId }) => ({ + ...acc, + [chainId]: selectedChainIds + ? selectedChainIds.includes(chainId) + : false, + }), + {}, + ), + ); + }, [networksList, selectedChainIds]); - const networksList: NetworkConfiguration[] = - networks ?? Object.values(allNetworks) ?? []; + const handleToggleNetwork = (chainId: string) => { + setCheckedChainIds((prev) => ({ + ...prev, + [chainId]: !prev[chainId], + })); + }; + + // Toggles all networks to be checked or unchecked + const handleToggleAllNetworks = () => { + setCheckedChainIds( + Object.keys(checkedChainIds)?.reduce( + (agg, chainId) => ({ + ...agg, + [chainId]: !Object.values(checkedChainIds).every((v) => v), + }), + {}, + ), + ); + }; return ( - - {t('bridgeSelectNetwork')} + !v)} + onClick={() => { + onMultiselectSubmit?.( + Object.keys(checkedChainIds).filter( + (chainId) => checkedChainIds[chainId], + ), + ); + onBack(); + }} + > + {t('apply')} + + ) : undefined + } + > + {header ?? t('bridgeSelectNetwork')} - + {isMultiselectEnabled && ( + + v)} + iconProps={{ + name: Object.values(checkedChainIds).some((v) => !v) + ? IconName.MinusBold + : IconName.Add, + color: IconColor.primaryInverse, + backgroundColor: BackgroundColor.primaryDefault, + }} + isChecked + onChange={() => { + handleToggleAllNetworks(); + }} + /> + { + handleToggleAllNetworks(); + }} + style={{ + alignSelf: AlignItems.flexStart, + paddingInline: 16, + }} + > + {t('selectAll')} + + + )} + {networksList.map((networkConfig) => { const { name, chainId } = networkConfig; return ( { + if (isMultiselectEnabled) { + handleToggleNetwork(chainId); + return; + } onNetworkChange(networkConfig); onBack(); }} @@ -101,7 +249,29 @@ export const AssetPickerModalNetwork = ({ chainId as keyof typeof CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP ] } + iconSize={AvatarNetworkSize.Sm} focus={false} + disabled={shouldDisableNetwork?.(networkConfig)} + startAccessory={ + isMultiselectEnabled ? ( + + ) : undefined + } + showEndAccessory={isMultiselectEnabled} + variant={TextVariant.bodyMdMedium} + endAccessory={ + isMultiselectEnabled ? ( + + {formatCurrency( + balanceByChainId[chainId]?.toString(), + currency, + )} + + ) : undefined + } /> ); })} diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-search.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-search.tsx index c7c0d699793e..9299f508e1d8 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-search.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-search.tsx @@ -19,6 +19,8 @@ import { useI18nContext } from '../../../../hooks/useI18nContext'; * @param props.onChange - The function to handle search query changes. * @param props.isNFTSearch - Indicates if the search is for NFTs. * @param props.props - Additional props for the containing Box component. + * @param props.placeholder - A custom placeholder for the search input. + * @param props.autoFocus * @returns The rendered search component. */ export const Search = ({ @@ -26,11 +28,15 @@ export const Search = ({ onChange, isNFTSearch = false, props, + placeholder, + autoFocus = true, }: { searchQuery: string; onChange: (value: string) => void; isNFTSearch?: boolean; props?: React.ComponentProps; + placeholder?: JSX.Element | string | null; + autoFocus?: boolean; }) => { const t = useI18nContext(); @@ -38,21 +44,26 @@ export const Search = ({ onChange(e.target.value)} error={false} - autoFocus + autoFocus={autoFocus} autoComplete={false} width={BlockSize.Full} clearButtonOnClick={() => onChange('')} clearButtonProps={{ size: ButtonIconSize.Sm, }} + style={{ paddingInline: 8 }} showClearButton className="asset-picker-modal__search-list" inputProps={{ 'data-testid': 'asset-picker-modal-search-input', + marginRight: 0, }} endAccessory={null} size={TextFieldSearchSize.Lg} diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.test.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.test.tsx index fc4796073c74..724cba8a1ec6 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.test.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.test.tsx @@ -29,7 +29,10 @@ import { getTopAssets } from '../../../../ducks/swaps/swaps'; import { getRenderableTokenData } from '../../../../hooks/useTokensToSearch'; import * as actions from '../../../../store/actions'; import { getSwapsBlockedTokens } from '../../../../ducks/send'; -import { getCurrentChainId } from '../../../../../shared/modules/selectors/networks'; +import { + getCurrentChainId, + getNetworkConfigurationsByChainId, +} from '../../../../../shared/modules/selectors/networks'; import { AssetPickerModal } from './asset-picker-modal'; import AssetList from './AssetList'; import { ERC20Asset } from './types'; @@ -57,6 +60,11 @@ jest.mock('../../../../hooks/useTokensToSearch', () => ({ getRenderableTokenData: jest.fn(), })); +const mockUseMultichainBalances = jest.fn(); +jest.mock('../../../../hooks/useMultichainBalances', () => ({ + useMultichainBalances: () => mockUseMultichainBalances(), +})); + describe('AssetPickerModal', () => { const useSelectorMock = useSelector as jest.Mock; const useI18nContextMock = useI18nContext as jest.Mock; @@ -88,8 +96,11 @@ describe('AssetPickerModal', () => { beforeEach(() => { useSelectorMock.mockImplementation((selector) => { + if (selector === getNetworkConfigurationsByChainId) { + return { '0x1': { chainId: '0x1' } }; + } if (selector === getCurrentChainId) { - return '1'; + return '0x1'; } if (selector === getCurrentCurrency) { return 'USD'; @@ -151,6 +162,7 @@ describe('AssetPickerModal', () => { tokensWithBalances: [], }); (getRenderableTokenData as jest.Mock).mockReturnValue({}); + mockUseMultichainBalances.mockReturnValue({}); }); afterEach(() => { @@ -162,7 +174,9 @@ describe('AssetPickerModal', () => { renderWithProvider(, store); expect(screen.getByTestId('asset-picker-modal')).toBeInTheDocument(); - expect(screen.getByPlaceholderText('searchTokens')).toBeInTheDocument(); + expect( + screen.getByPlaceholderText('searchTokensByNameOrAddress'), + ).toBeInTheDocument(); }); it('calls onClose when modal is closed', () => { @@ -195,17 +209,23 @@ describe('AssetPickerModal', () => { it('filters tokens based on search query', () => { renderWithProvider(, store); - fireEvent.change(screen.getByPlaceholderText('searchTokens'), { - target: { value: 'TO' }, - }); + fireEvent.change( + screen.getByPlaceholderText('searchTokensByNameOrAddress'), + { + target: { value: 'TO' }, + }, + ); expect( (AssetList as jest.Mock).mock.calls.slice(-1)[0][0].tokenList.length, ).toBe(2); - fireEvent.change(screen.getByPlaceholderText('searchTokens'), { - target: { value: 'UNAVAILABLE TOKEN' }, - }); + fireEvent.change( + screen.getByPlaceholderText('searchTokensByNameOrAddress'), + { + target: { value: 'UNAVAILABLE TOKEN' }, + }, + ); expect((AssetList as jest.Mock).mock.calls[1][0]).not.toEqual( expect.objectContaining({ @@ -245,7 +265,9 @@ describe('AssetPickerModal', () => { store, ); const modalTitle = getByText('sendSelectReceiveAsset'); - const searchPlaceholder = getByPlaceholderText('searchTokens'); + const searchPlaceholder = getByPlaceholderText( + 'searchTokensByNameOrAddress', + ); expect(modalTitle).toBeInTheDocument(); expect(searchPlaceholder).toBeInTheDocument(); @@ -260,17 +282,23 @@ describe('AssetPickerModal', () => { store, ); - fireEvent.change(screen.getByPlaceholderText('searchTokens'), { - target: { value: 'TO' }, - }); + fireEvent.change( + screen.getByPlaceholderText('searchTokensByNameOrAddress'), + { + target: { value: 'TO' }, + }, + ); expect( (AssetList as jest.Mock).mock.calls.slice(-1)[0][0].tokenList.length, ).toBe(2); - fireEvent.change(screen.getByPlaceholderText('searchTokens'), { - target: { value: 'TOKEN1' }, - }); + fireEvent.change( + screen.getByPlaceholderText('searchTokensByNameOrAddress'), + { + target: { value: 'TOKEN1' }, + }, + ); expect((AssetList as jest.Mock).mock.calls[1][0]).not.toEqual( expect.objectContaining({ @@ -322,7 +350,7 @@ describe('AssetPickerModal', () => { expect(modalTitle).toBeInTheDocument(); expect(getAllByRole('img')).toHaveLength(2); - const modalContent = getByText('Network name'); + const modalContent = getByText('Ethereum Mainnet'); expect(modalContent).toBeInTheDocument(); }); diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.tsx index 48c56c5106ec..d0b9b32988df 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.tsx @@ -8,6 +8,7 @@ import { TokenListToken, } from '@metamask/assets-controllers'; import { Hex } from '@metamask/utils'; +import { zeroAddress } from 'ethereumjs-util'; import { Modal, ModalContent, @@ -29,7 +30,10 @@ import { import { useI18nContext } from '../../../../hooks/useI18nContext'; import { AssetType } from '../../../../../shared/constants/transaction'; -import { getCurrentChainId } from '../../../../../shared/modules/selectors/networks'; +import { + getCurrentChainId, + getNetworkConfigurationsByChainId, +} from '../../../../../shared/modules/selectors/networks'; import { getAllTokens, getCurrentCurrency, @@ -49,7 +53,13 @@ import { getTopAssets } from '../../../../ducks/swaps/swaps'; import { getRenderableTokenData } from '../../../../hooks/useTokensToSearch'; import { getSwapsBlockedTokens } from '../../../../ducks/send'; import { isEqualCaseInsensitive } from '../../../../../shared/modules/string-utils'; -import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP } from '../../../../../shared/constants/network'; +import { + CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP, + NETWORK_TO_NAME_MAP, +} from '../../../../../shared/constants/network'; +import { useMultichainBalances } from '../../../../hooks/useMultichainBalances'; +import { AvatarType } from '../../avatar-group/avatar-group.types'; +import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../../shared/constants/bridge'; import { ERC20Asset, NativeAsset, @@ -68,6 +78,7 @@ type AssetPickerModalProps = { isOpen: boolean; onClose: () => void; action?: 'send' | 'receive'; + onBack?: () => void; asset?: ERC20Asset | NativeAsset | Pick; onAssetChange: ( asset: AssetWithDisplayData | AssetWithDisplayData, @@ -82,15 +93,23 @@ type AssetPickerModalProps = { * by a custom order. */ customTokenListGenerator?: ( - filterPredicate: (symbol: string, address?: string) => boolean, + filterPredicate: ( + symbol: string, + address?: null | string, + chainId?: string, + ) => boolean, ) => Generator< AssetWithDisplayData | AssetWithDisplayData >; + isTokenListLoading?: boolean; } & Pick< React.ComponentProps, 'visibleTabs' | 'defaultActiveTabKey' > & - Pick, 'network'>; + Pick< + React.ComponentProps, + 'network' | 'networks' | 'isMultiselectEnabled' | 'selectedChainIds' + >; const MAX_UNOWNED_TOKENS_RENDERED = 30; @@ -98,13 +117,18 @@ export function AssetPickerModal({ header, isOpen, onClose, + onBack, asset, onAssetChange, sendingAsset, network, + networks, action, onNetworkPickerClick, customTokenListGenerator, + isTokenListLoading = false, + isMultiselectEnabled, + selectedChainIds, ...tabProps }: AssetPickerModalProps) { const t = useI18nContext(); @@ -118,7 +142,13 @@ export function AssetPickerModal({ const handleAssetChange = useCallback(onAssetChange, [onAssetChange]); - const chainId = useSelector(getCurrentChainId); + const currentChainId = useSelector(getCurrentChainId); + const allNetworks = useSelector(getNetworkConfigurationsByChainId); + const selectedNetwork = + network ?? (currentChainId && allNetworks[currentChainId]); + const allNetworksToUse = networks ?? Object.values(allNetworks ?? {}); + // This indicates whether tokens in the wallet's active network are displayed + const isSelectedNetworkActive = selectedNetwork.chainId === currentChainId; const nativeCurrencyImage = useSelector(getNativeCurrencyImage); const nativeCurrency = useSelector(getNativeCurrency); @@ -136,7 +166,7 @@ export function AssetPickerModal({ const detectedTokens: Record> = useSelector( getAllTokens, ); - const tokens = detectedTokens?.[chainId]?.[selectedAddress] ?? []; + const tokens = detectedTokens?.[currentChainId]?.[selectedAddress] ?? []; const { tokensWithBalances }: { tokensWithBalances: TokenWithBalance[] } = useTokenTracker({ @@ -145,6 +175,9 @@ export function AssetPickerModal({ hideZeroBalanceTokens: Boolean(shouldHideZeroBalanceTokens), }); + const { assetsWithBalance: multichainTokensWithBalance } = + useMultichainBalances(); + // Swaps token list const tokenList = useSelector(getTokenList) as TokenListMap; const topTokens = useSelector(getTopAssets, isEqual); @@ -176,10 +209,15 @@ export function AssetPickerModal({ const tokenListGenerator = useCallback( function* ( - shouldAddToken: (symbol: string, address?: null | string) => boolean, + shouldAddToken: ( + symbol: string, + address?: null | string, + tokenChainId?: string, + ) => boolean, ): Generator< | AssetWithDisplayData | ((Token | TokenListToken) & { + chainId: string; balance?: string; string?: string; }) @@ -191,42 +229,61 @@ export function AssetPickerModal({ image: nativeCurrencyImage, balance: balanceValue, string: undefined, + chainId: currentChainId, type: AssetType.native, }; - if (shouldAddToken(nativeToken.symbol, nativeToken.address)) { + if ( + shouldAddToken( + nativeToken.symbol, + nativeToken.address, + nativeToken.chainId, + ) + ) { yield nativeToken; } const blockedTokens = []; + // Yield multichain tokens with balances + if (isMultiselectEnabled) { + for (const token of multichainTokensWithBalance) { + if (shouldAddToken(token.symbol, token.address, token.chainId)) { + yield token; + } + } + } + for (const token of memoizedUsersTokens) { - if (shouldAddToken(token.symbol, token.address)) { - yield token; + if (shouldAddToken(token.symbol, token.address, currentChainId)) { + yield { ...token, chainId: currentChainId }; } } // topTokens should already be sorted by popularity for (const address of Object.keys(topTokens)) { const token = tokenList?.[address]; - if (token && shouldAddToken(token.symbol, token.address)) { + if ( + token && + shouldAddToken(token.symbol, token.address, currentChainId) + ) { if (getIsDisabled(token)) { blockedTokens.push(token); continue; } else { - yield token; + yield { ...token, chainId: currentChainId }; } } } for (const token of Object.values(tokenList)) { - if (shouldAddToken(token.symbol, token.address)) { - yield token; + if (shouldAddToken(token.symbol, token.address, currentChainId)) { + yield { ...token, chainId: currentChainId }; } } for (const token of blockedTokens) { - yield token; + yield { ...token, chainId: currentChainId }; } }, [ @@ -237,6 +294,8 @@ export function AssetPickerModal({ topTokens, tokenList, getIsDisabled, + isMultiselectEnabled, + multichainTokensWithBalance, ], ); @@ -245,16 +304,33 @@ export function AssetPickerModal({ | AssetWithDisplayData | AssetWithDisplayData )[] = []; - // undefined would be the native token address + // List of token identifiers formatted like `chainId:address` const filteredTokensAddresses = new Set(); + const getTokenKey = (address?: string | null, tokenChainId?: string) => + `${address?.toLowerCase() ?? zeroAddress()}:${ + tokenChainId ?? currentChainId + }`; // Default filter predicate for whether a token should be included in displayed list - const shouldAddToken = (symbol: string, address?: string | null) => { - const trimmedSearchQuery = searchQuery.trim(); - return ( - (!trimmedSearchQuery || - symbol?.toLowerCase().includes(trimmedSearchQuery.toLowerCase())) && - !filteredTokensAddresses.has(address?.toLowerCase()) + const shouldAddToken = ( + symbol: string, + address?: string | null, + tokenChainId?: string, + ) => { + const trimmedSearchQuery = searchQuery.trim().toLowerCase(); + const isMatchedBySearchQuery = Boolean( + !trimmedSearchQuery || + symbol?.toLowerCase().includes(trimmedSearchQuery) || + address?.toLowerCase().includes(trimmedSearchQuery), + ); + const isTokenInSelectedChain = isMultiselectEnabled + ? tokenChainId && selectedChainIds?.includes(tokenChainId) + : selectedNetwork?.chainId === tokenChainId; + + return Boolean( + isTokenInSelectedChain && + isMatchedBySearchQuery && + !filteredTokensAddresses.has(getTokenKey(address, tokenChainId)), ); }; @@ -267,7 +343,7 @@ export function AssetPickerModal({ continue; } - filteredTokensAddresses.add(token.address?.toLowerCase()); + filteredTokensAddresses.add(getTokenKey(token.address, token.chainId)); filteredTokens.push( customTokenListGenerator ? token @@ -282,7 +358,7 @@ export function AssetPickerModal({ tokenConversionRates, conversionRate, currentCurrency, - chainId, + token.chainId, tokenList, ), ); @@ -298,11 +374,43 @@ export function AssetPickerModal({ tokenConversionRates, conversionRate, currentCurrency, - chainId, + currentChainId, tokenListGenerator, customTokenListGenerator, + isMultiselectEnabled, + selectedChainIds, + isMultiselectEnabled, + selectedNetwork, ]); + const getNetworkImageUrl = (networkChainId: string) => + CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP[ + networkChainId as keyof typeof CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP + ]; + + const getNetworkPickerLabel = () => { + if (!isMultiselectEnabled) { + return ( + (selectedNetwork?.chainId && + NETWORK_TO_NAME_MAP[ + selectedNetwork.chainId as keyof typeof NETWORK_TO_NAME_MAP + ]) ?? + selectedNetwork?.name ?? + t('bridgeSelectNetwork') + ); + } + switch (selectedChainIds?.length) { + case allNetworksToUse.length: + return t('allNetworks'); + case 1: + return t('singleNetwork'); + case 0: + return t('bridgeSelectNetwork'); + default: + return t('someNetworks', [selectedChainIds?.length]); + } + }; + return ( - + {header} @@ -337,12 +445,26 @@ export function AssetPickerModal({ {onNetworkPickerClick && ( ({ + avatarValue: getNetworkImageUrl(c), + symbol: + NETWORK_TO_SHORT_NETWORK_NAME_MAP[ + c as keyof typeof NETWORK_TO_SHORT_NETWORK_NAME_MAP + ], + })), + avatarType: AvatarType.NETWORK, + } + : undefined } onClick={onNetworkPickerClick} data-testid="multichain-asset-picker__network" @@ -355,12 +477,23 @@ export function AssetPickerModal({ setSearchQuery(value)} + autoFocus={!isMultiselectEnabled} /> & { +export type ERC20Asset = { type: AssetType.token; image: string; -}; +} & Pick & + Pick; + export type NativeAsset = { type: AssetType.native; - address?: null; + address?: null | string; image: typeof CHAIN_ID_TOKEN_IMAGE_MAP extends Record ? V : never; // only allow wallet's hardcoded images symbol: typeof CHAIN_ID_TO_CURRENCY_SYMBOL_MAP extends Record ? V : never; // only allow wallet's hardcoded symbols -}; +} & Pick; /** * ERC20Asset or NativeAsset, plus additional fields for display purposes in the Asset component */ export type AssetWithDisplayData = T & { - balance: T['type'] extends AssetType.token ? string : Hex; // raw balance + balance: string; // raw balance string: string | undefined; // normalized balance as a stringified number -} & Pick; +} & Pick & { + tokenFiatAmount?: TokenWithFiatAmount['tokenFiatAmount']; + }; export type Collection = { collectionName: string; diff --git a/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.stories.tsx b/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.stories.tsx index 0572e2d237c2..e25780561ccb 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.stories.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.stories.tsx @@ -25,6 +25,7 @@ const props = { address: '0xaddress1', image: CHAIN_ID_TOKEN_IMAGE_MAP['0x1'], type: AssetType.token, + chainId: '0x1', } as ERC20Asset, }; export const DefaultStory = () => { @@ -65,6 +66,7 @@ export const SendDestStory = () => { symbol: 'ETH', image: CHAIN_ID_TOKEN_IMAGE_MAP['0x1'], type: AssetType.native, + chainId: '0x1', }} sendingAsset={{ image: CHAIN_ID_TOKEN_IMAGE_MAP['0x1'], @@ -93,8 +95,70 @@ SendDestStory.decorators = [ SendDestStory.storyName = 'With Sending Asset'; -export const NetworksStory = ({ isOpen }: { isOpen: boolean }) => { - const t = useI18nContext(); +const networkProps = { + network: { + chainId: '0x1', + name: 'Mainnet', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + networkClientId: 'test1', + url: 'https://mainnet.infura.io/v3/', + type: RpcEndpointType.Custom, + }, + ], + nativeCurrency: 'ETH', + }, + networks: [ + { + chainId: '0x1', + name: 'Mainnet Name That Is Very Long', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + networkClientId: 'test1', + url: 'https://mainnet.infura.io/v3/', + type: RpcEndpointType.Custom, + }, + ], + nativeCurrency: 'ETH', + }, + { + chainId: '0x10', + name: 'Optimism', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + networkClientId: 'test2', + url: 'https://optimism.infura.io/v3/', + type: RpcEndpointType.Custom, + }, + ], + nativeCurrency: 'ETH', + }, + { + chainId: CHAIN_IDS.LINEA_MAINNET, + name: 'Linea Mainnet Test Name', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + networkClientId: 'test3', + url: 'https://linea.infura.io/v3/', + type: RpcEndpointType.Custom, + }, + ], + nativeCurrency: 'ETH', + }, + ], + shouldDisableNetwork: (networkConfig) => + networkConfig.chainId === CHAIN_IDS.LINEA_MAINNET, + onNetworkChange: () => ({}), +}; +export const NetworksStory = () => { return ( { symbol: 'ETH', image: CHAIN_ID_TOKEN_IMAGE_MAP['0x1'], type: AssetType.native, + chainId: '0x1', }} - networkProps={{ - network: { - chainId: '0x1', - name: 'Mainnet', - blockExplorerUrls: [], - defaultRpcEndpointIndex: 0, - rpcEndpoints: [ - { - networkClientId: 'test1', - url: 'https://mainnet.infura.io/v3/', - type: RpcEndpointType.Custom, - }, - ], - nativeCurrency: 'ETH', - }, - networks: [ - { - chainId: '0x1', - name: 'Mainnet', - blockExplorerUrls: [], - defaultRpcEndpointIndex: 0, - rpcEndpoints: [ - { - networkClientId: 'test1', - url: 'https://mainnet.infura.io/v3/', - type: RpcEndpointType.Custom, - }, - ], - nativeCurrency: 'ETH', - }, - { - chainId: '0x10', - name: 'Optimism', - blockExplorerUrls: [], - defaultRpcEndpointIndex: 0, - rpcEndpoints: [ - { - networkClientId: 'test2', - url: 'https://optimism.infura.io/v3/', - type: RpcEndpointType.Custom, - }, - ], - nativeCurrency: 'ETH', - }, - ], - onNetworkChange: () => ({}), - }} + networkProps={networkProps as never} visibleTabs={[TabName.TOKENS]} /> ); @@ -163,4 +182,29 @@ NetworksStory.decorators = [ NetworksStory.storyName = 'With Network Picker'; +export const MultichainNetworksStory = () => { + return ( + ({})} + {...props} + asset={{ + symbol: 'ETH', + image: CHAIN_ID_TOKEN_IMAGE_MAP['0x1'], + type: AssetType.native, + chainId: '0x1', + }} + isMultiselectEnabled={true} + networkProps={networkProps as never} + visibleTabs={[TabName.TOKENS]} + /> + ); +}; + +MultichainNetworksStory.decorators = [ + (story) => {story()}, +]; + +MultichainNetworksStory.storyName = 'With Multichain Network Picker'; + export default storybook; diff --git a/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.tsx b/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.tsx index cf87ee3ba7f1..1388a8bf5b92 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; import { AvatarTokenSize, @@ -24,8 +24,10 @@ import { } from '../../../../helpers/constants/design-system'; import { AssetType } from '../../../../../shared/constants/transaction'; import { AssetPickerModal } from '../asset-picker-modal/asset-picker-modal'; -import { getNetworkConfigurationsByChainId } from '../../../../../shared/modules/selectors/networks'; -import { getCurrentNetwork } from '../../../../selectors'; +import { + getCurrentChainId, + getNetworkConfigurationsByChainId, +} from '../../../../../shared/modules/selectors/networks'; import Tooltip from '../../../ui/tooltip'; import { LARGE_SYMBOL_LENGTH } from '../constants'; ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) @@ -45,10 +47,15 @@ import { GOERLI_DISPLAY_NAME, SEPOLIA_DISPLAY_NAME, } from '../../../../../shared/constants/network'; +import { useMultichainBalances } from '../../../../hooks/useMultichainBalances'; const ELLIPSIFY_LENGTH = 13; // 6 (start) + 4 (end) + 3 (...) export type AssetPickerProps = { + children?: ( + onClick: () => void, + networkImageSrc?: string, + ) => React.ReactElement; // Overrides default button asset?: | ERC20Asset | NativeAsset @@ -65,17 +72,27 @@ export type AssetPickerProps = { onClick?: () => void; isDisabled?: boolean; action?: 'send' | 'receive'; + isMultiselectEnabled?: boolean; networkProps?: Pick< React.ComponentProps, - 'network' | 'networks' | 'onNetworkChange' + | 'network' + | 'networks' + | 'onNetworkChange' + | 'shouldDisableNetwork' + | 'header' >; } & Pick< React.ComponentProps, - 'visibleTabs' | 'header' | 'sendingAsset' | 'customTokenListGenerator' + | 'visibleTabs' + | 'header' + | 'sendingAsset' + | 'customTokenListGenerator' + | 'isTokenListLoading' >; // A component that lets the user pick from a list of assets. export function AssetPicker({ + children, header, asset, onAssetChange, @@ -86,6 +103,8 @@ export function AssetPicker({ isDisabled = false, visibleTabs, customTokenListGenerator, + isTokenListLoading = false, + isMultiselectEnabled = false, }: AssetPickerProps) { ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) const t = useI18nContext(); @@ -106,12 +125,32 @@ export function AssetPicker({ : symbol; // Badge details - const currentNetwork = useSelector(getCurrentNetwork); + const currentChainId = useSelector(getCurrentChainId); const allNetworks = useSelector(getNetworkConfigurationsByChainId); + const currentNetwork = allNetworks[currentChainId]; const selectedNetwork = networkProps?.network ?? (currentNetwork?.chainId && allNetworks[currentNetwork.chainId]); + const allNetworksToUse = networkProps?.networks ?? Object.values(allNetworks); + const { balanceByChainId } = useMultichainBalances(); + // This is used to determine which tokens to display when isMultiselectEnabled=true + const [selectedChainIds, setSelectedChainIds] = useState( + isMultiselectEnabled + ? allNetworksToUse + ?.map(({ chainId }) => chainId) + .sort((a, b) => balanceByChainId[b] - balanceByChainId[a]) ?? [] + : [], + ); + const [isSelectingNetwork, setIsSelectingNetwork] = useState(false); + + useEffect(() => { + const newChainId = networkProps?.network?.chainId; + newChainId && + !selectedChainIds.includes(newChainId) && + setSelectedChainIds((c) => [...c, newChainId]); + }, [networkProps?.network?.chainId]); + const handleAssetPickerTitle = (): string | undefined => { ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) if (isDisabled) { @@ -122,20 +161,47 @@ export function AssetPicker({ return undefined; }; - const [isSelectingNetwork, setIsSelectingNetwork] = useState(false); + const networkImageSrc = + selectedNetwork?.chainId && + CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP[ + selectedNetwork.chainId as keyof typeof CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP + ]; + + const handleButtonClick = () => { + if (networkProps && !networkProps.network) { + setIsSelectingNetwork(true); + } else { + setShowAssetPickerModal(true); + } + onClick?.(); + }; return ( <> {networkProps && ( { - setIsSelectingNetwork(false); - }} + onClose={() => setIsSelectingNetwork(false)} onBack={() => { setIsSelectingNetwork(false); setShowAssetPickerModal(true); }} + isMultiselectEnabled={isMultiselectEnabled} + onMultiselectSubmit={(chainIds: string[]) => { + setSelectedChainIds(chainIds); + // If there is only 1 selected network switch to that network to populate tokens + if ( + chainIds.length === 1 && + chainIds[0] !== currentNetwork?.chainId + ) { + if (networkProps?.onNetworkChange) { + networkProps.onNetworkChange( + allNetworks[chainIds[0] as keyof typeof allNetworks], + ); + } + } + }} + selectedChainIds={selectedChainIds} {...networkProps} /> )} @@ -152,11 +218,23 @@ export function AssetPicker({ | AssetWithDisplayData | AssetWithDisplayData, ) => { + // If isMultiselectEnabled=true, update the network when a token is selected + if (isMultiselectEnabled && networkProps?.onNetworkChange) { + const networkFromToken = token.chainId + ? allNetworks[token.chainId as keyof typeof allNetworks] + : undefined; + if (networkFromToken) { + networkProps.onNetworkChange(networkFromToken); + } + } onAssetChange(token); setShowAssetPickerModal(false); }} + isMultiselectEnabled={isMultiselectEnabled} sendingAsset={sendingAsset} - network={networkProps?.network ? networkProps.network : undefined} + network={networkProps?.network} + networks={networkProps?.networks} + selectedChainIds={selectedChainIds} onNetworkPickerClick={ networkProps ? () => { @@ -169,37 +247,32 @@ export function AssetPicker({ asset?.type === AssetType.NFT ? TabName.NFTS : TabName.TOKENS } customTokenListGenerator={customTokenListGenerator} + isTokenListLoading={isTokenListLoading} /> - { - if (networkProps && !networkProps.network) { - setIsSelectingNetwork(true); - } else { - setShowAssetPickerModal(true); - } - onClick?.(); - }} - endIconName={IconName.ArrowDown} - endIconProps={{ - color: IconColor.iconDefault, - marginInlineStart: 0, - display: isDisabled ? Display.None : Display.InlineBlock, - }} - title={handleAssetPickerTitle()} - > - {asset ? ( + {/** If a child prop is passed in, use it as the trigger button instead of the default */} + {children?.(handleButtonClick, networkImageSrc) || ( + - ) : ( - - {t('swapSelectToken')} - - )} - + + )} ); } diff --git a/ui/components/multichain/asset-picker-amount/asset-picker/index.scss b/ui/components/multichain/asset-picker-amount/asset-picker/index.scss index 6cfaf877efbd..d30ce8c016d7 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker/index.scss +++ b/ui/components/multichain/asset-picker-amount/asset-picker/index.scss @@ -25,9 +25,4 @@ opacity: 1; cursor: not-allowed; } - - &__fallback { - text-wrap: nowrap; - padding-left: 8px; - } } diff --git a/ui/components/multichain/avatar-group/__snapshots__/avatar-group.test.tsx.snap b/ui/components/multichain/avatar-group/__snapshots__/avatar-group.test.tsx.snap index ff4c6a297c38..4e08ceaf5177 100644 --- a/ui/components/multichain/avatar-group/__snapshots__/avatar-group.test.tsx.snap +++ b/ui/components/multichain/avatar-group/__snapshots__/avatar-group.test.tsx.snap @@ -7,7 +7,7 @@ exports[`AvatarGroup should render AvatarGroup component 1`] = ` data-testid="avatar-group" >
= () => ( + +); + +export const NetworksWithOverlayTag: StoryFn = () => ( + +); diff --git a/ui/components/multichain/avatar-group/avatar-group.tsx b/ui/components/multichain/avatar-group/avatar-group.tsx index 0de7cf5638eb..39af437980d9 100644 --- a/ui/components/multichain/avatar-group/avatar-group.tsx +++ b/ui/components/multichain/avatar-group/avatar-group.tsx @@ -10,9 +10,15 @@ import { AvatarAccount, AvatarAccountSize, AvatarAccountVariant, + AvatarNetwork, + AvatarNetworkSize, + AvatarBase, + AvatarBaseSize, } from '../../component-library'; import { AlignItems, + BackgroundColor, + BorderColor, BorderRadius, Display, TextColor, @@ -27,6 +33,7 @@ export const AvatarGroup: React.FC = ({ size = AvatarTokenSize.Xs, avatarType = AvatarType.TOKEN, borderColor, + isTagOverlay = false, }): JSX.Element => { const membersCount = members.length; const visibleMembers = members.slice(0, limit).reverse(); @@ -50,7 +57,7 @@ export const AvatarGroup: React.FC = ({ data-testid="avatar-group" gap={1} > - + {visibleMembers.map((member, i) => { return ( = ({ key={i} style={{ marginLeft: i === 0 ? '0' : marginLeftValue }} > - {avatarType === AvatarType.TOKEN ? ( + {avatarType === AvatarType.TOKEN && ( - ) : ( + )} + {avatarType === AvatarType.ACCOUNT && ( = ({ borderColor={borderColor} /> )} + {avatarType === AvatarType.NETWORK && ( + + )} ); })} + {showTag && isTagOverlay && ( + + {tagValue} + + )} - {showTag ? ( + {showTag && !isTagOverlay ? ( {tagValue} diff --git a/ui/components/multichain/avatar-group/avatar-group.types.tsx b/ui/components/multichain/avatar-group/avatar-group.types.tsx index 6737b64eeea7..b5c52b1ed9f5 100644 --- a/ui/components/multichain/avatar-group/avatar-group.types.tsx +++ b/ui/components/multichain/avatar-group/avatar-group.types.tsx @@ -2,9 +2,7 @@ import { BorderColor } from '../../../helpers/constants/design-system'; import { AvatarTokenSize } from '../../component-library'; import type { StyleUtilityProps } from '../../component-library/box'; -// TODO: Convert to a `type` in a future major version. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export interface AvatarGroupProps extends StyleUtilityProps { +export type AvatarGroupProps = StyleUtilityProps & { /** * Additional class name for the AvatarGroup component */ className?: string; /** * Limit to show only a certain number of tokens and extras in Text */ @@ -22,9 +20,12 @@ export interface AvatarGroupProps extends StyleUtilityProps { size?: AvatarTokenSize; /** * Border Color of Avatar Tokens */ borderColor?: BorderColor; -} + /** * Whether the tag should be displayed as separate text or within an overlay avatar */ + isTagOverlay?: boolean; +}; export enum AvatarType { TOKEN = 'TOKEN', ACCOUNT = 'ACCOUNT', + NETWORK = 'NETWORK', } diff --git a/ui/components/multichain/network-list-item/__snapshots__/network-list-item.test.js.snap b/ui/components/multichain/network-list-item/__snapshots__/network-list-item.test.js.snap index 38f44e767f77..08590b29ee88 100644 --- a/ui/components/multichain/network-list-item/__snapshots__/network-list-item.test.js.snap +++ b/ui/components/multichain/network-list-item/__snapshots__/network-list-item.test.js.snap @@ -22,12 +22,23 @@ exports[`NetworkListItem renders properly 1`] = ` class="mm-box mm-box--display-flex mm-box--align-items-center mm-box--width-full" data-testid="Polygon" > -

- Polygon -

+
+

+ Polygon +

+
+
+

+

+ +

+

+

+ +

+
+ +
+ + +`; + +exports[`TokenListItem should display warning scam modal 1`] = ` +
+
+
+
+
+ S +
+
+ ? +
+
+
+
+
+

+ +

+ +
+
+

+

+ 11.9751 ETH + + SCAM_TOKEN +

+
+
+
+
+
+`; + +exports[`TokenListItem should display warning scam modal fallback when safechains fails to resolve correctly 1`] = ` +
+
+
+
+
+ S +
+
+
+ ? +
+
+
+
+
+

+ +

+ +
+

-

- -

+

+ 11.9751 ETH + + SCAM_TOKEN +

+
+
+
+
+`; + +exports[`TokenListItem should render correctly 1`] = ` + `; + +exports[`TokenListItem should render crypto balance 1`] = ` +
+
+
+
+
+ ? +
+
+
+ ? +
+
+
+
+
+

+

+

+
+

+

+ 11.9751 ETH + +

+
+
+
+
+
+`; + +exports[`TokenListItem should render crypto balance with warning scam 1`] = ` +
+
+
+
+
+ ? +
+
+
+ ? +
+
+
+
+
+

+

+

+
+

+

+ 11.9751 ETH + +

+
+
+
+
+
+`; diff --git a/ui/components/multichain/token-list-item/token-list-item.stories.js b/ui/components/multichain/token-list-item/token-list-item.stories.js index bd5d7849bbec..d78535fd23e2 100644 --- a/ui/components/multichain/token-list-item/token-list-item.stories.js +++ b/ui/components/multichain/token-list-item/token-list-item.stories.js @@ -93,3 +93,33 @@ export const NoImagesStory = Template.bind({}); NoImagesStory.args = { tokenImage: '', }; + +export const CrossChainTokenStory = (args) => ( +
+ +
+); +CrossChainTokenStory.decorators = [ + (Story) => ( + + + + ), +]; + +CrossChainTokenStory.args = { + title: 'USDC', + secondary: '$94556756776.80 USD', + primary: '34449765768526.00', + isTitleNetworkName: true, + chainId: CHAIN_IDS.LINEA_SEPOLIA, +}; diff --git a/ui/components/multichain/token-list-item/token-list-item.test.tsx b/ui/components/multichain/token-list-item/token-list-item.test.tsx index 6f08f276a302..e32397dfa1a8 100644 --- a/ui/components/multichain/token-list-item/token-list-item.test.tsx +++ b/ui/components/multichain/token-list-item/token-list-item.test.tsx @@ -124,11 +124,12 @@ describe('TokenListItem', () => { title: '', chainId: '0x1', }; - const { getByText } = renderWithProvider( + const { getByText, container } = renderWithProvider( , store, ); expect(getByText('11.9751 ETH')).toBeInTheDocument(); + expect(container).toMatchSnapshot(); }); it('should display warning scam modal', () => { @@ -152,10 +153,11 @@ describe('TokenListItem', () => { tokenSymbol: 'SCAM_TOKEN', chainId: '0x1', }; - const { getByTestId, getByText } = renderWithProvider( + const { getByTestId, getByText, container } = renderWithProvider( , store, ); + expect(container).toMatchSnapshot(); const warningScamModal = getByTestId('scam-warning'); fireEvent.click(warningScamModal); @@ -180,11 +182,12 @@ describe('TokenListItem', () => { tokenSymbol: 'SCAM_TOKEN', chainId: '0x1', }; - const { getByTestId, getByText } = renderWithProvider( + const { getByTestId, getByText, container } = renderWithProvider( , store, ); + expect(container).toMatchSnapshot(); const warningScamModal = getByTestId('scam-warning'); fireEvent.click(warningScamModal); @@ -209,11 +212,12 @@ describe('TokenListItem', () => { chainId: '0x1', }; - const { getByText } = renderWithProvider( + const { getByText, container } = renderWithProvider( , store, ); expect(getByText('11.9751 ETH')).toBeInTheDocument(); + expect(container).toMatchSnapshot(); }); it('handles click action and fires onClick', () => { @@ -232,7 +236,7 @@ describe('TokenListItem', () => { it('handles clicking staking opens tab', async () => { const store = configureMockStore()(state); - const { queryByTestId } = renderWithProvider( + const { queryByTestId, container } = renderWithProvider( , store, ); @@ -243,6 +247,7 @@ describe('TokenListItem', () => { expect(stakeButton).toBeInTheDocument(); expect(stakeButton).not.toBeDisabled(); + expect(container).toMatchSnapshot(); stakeButton && fireEvent.click(stakeButton); expect(openTabSpy).toHaveBeenCalledTimes(1); diff --git a/ui/components/multichain/token-list-item/token-list-item.tsx b/ui/components/multichain/token-list-item/token-list-item.tsx index 5ee4c19c8c52..76152770dbc9 100644 --- a/ui/components/multichain/token-list-item/token-list-item.tsx +++ b/ui/components/multichain/token-list-item/token-list-item.tsx @@ -5,7 +5,6 @@ import classnames from 'classnames'; import { getNativeTokenAddress } from '@metamask/assets-controllers'; import { Hex } from '@metamask/utils'; import { - AlignItems, BackgroundColor, BlockSize, Display, @@ -69,6 +68,7 @@ import { SafeChain, useSafeChains, } from '../../../pages/settings/networks-tab/networks-form/use-safe-chains'; +import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../shared/constants/bridge'; import { PercentageChange } from './price/percentage-change/percentage-change'; type TokenListItemProps = { @@ -82,6 +82,8 @@ type TokenListItemProps = { tooltipText?: string; isNativeCurrency?: boolean; isStakeable?: boolean; + isTitleNetworkName?: boolean; + isTitleHidden?: boolean; tokenChainImage?: string; chainId: string; address?: string | null; @@ -104,6 +106,8 @@ export const TokenListItem = ({ isPrimaryTokenSymbolHidden = false, isNativeCurrency = false, isStakeable = false, + isTitleNetworkName = false, + isTitleHidden = false, address = null, showPercentage = false, privacyMode = false, @@ -141,6 +145,14 @@ export const TokenListItem = ({ const history = useHistory(); const getTokenTitle = () => { + if (isTitleNetworkName) { + return NETWORK_TO_SHORT_NETWORK_NAME_MAP[ + chainId as keyof typeof NETWORK_TO_SHORT_NETWORK_NAME_MAP + ]; + } + if (isTitleHidden) { + return undefined; + } switch (title) { case CURRENCY_SYMBOLS.ETH: return t('networkNameEthereum'); @@ -160,9 +172,8 @@ export const TokenListItem = ({ : null; const tokenTitle = getTokenTitle(); - const tokenMainTitleToDisplay = shouldShowPercentage - ? tokenTitle - : tokenSymbol; + const tokenMainTitleToDisplay = + shouldShowPercentage && !isTitleNetworkName ? tokenTitle : tokenSymbol; const stakeableTitle = ( - - {title?.length > 12 ? ( - - - {isStakeable ? ( - <> - {tokenMainTitleToDisplay} {stakeableTitle} - - ) : ( - tokenMainTitleToDisplay - )} - - - ) : ( + {title?.length > 12 ? ( + - {isStakeable ? ( - - {tokenMainTitleToDisplay} - {stakeableTitle} - - ) : ( - tokenMainTitleToDisplay - )} + {tokenMainTitleToDisplay} + {isStakeable && stakeableTitle} - )} - - {shouldShowPercentage ? ( - - ) : ( - - {tokenTitle} - - )} - - - {showScamWarning ? ( - + ) : ( + - , - ) => { - e.preventDefault(); - e.stopPropagation(); - setShowScamWarningModal(true); - }} - color={IconColor.errorDefault} - size={ButtonIconSize.Md} - backgroundColor={BackgroundColor.transparent} - data-testid="scam-warning" - ariaLabel={''} - /> + {tokenMainTitleToDisplay} + {isStakeable && stakeableTitle} + + )} - - {primary} {isPrimaryTokenSymbolHidden ? '' : tokenSymbol} - - + {showScamWarning ? ( + , + ) => { + e.preventDefault(); + e.stopPropagation(); + setShowScamWarningModal(true); + }} + color={IconColor.errorDefault} + size={ButtonIconSize.Md} + backgroundColor={BackgroundColor.transparent} + data-testid="scam-warning" + ariaLabel={''} + /> ) : ( - - - {secondary} - - - {primary} {isPrimaryTokenSymbolHidden ? '' : tokenSymbol} - - + {secondary} + )} + + > + {shouldShowPercentage ? ( + + ) : ( + + {tokenTitle} + + )} + + {showScamWarning ? ( + + {primary} {isPrimaryTokenSymbolHidden ? '' : tokenSymbol} + + ) : ( + + {primary} {isPrimaryTokenSymbolHidden ? '' : tokenSymbol} + + )} +
{isEvm && showScamWarningModal ? ( diff --git a/ui/ducks/bridge/utils.ts b/ui/ducks/bridge/utils.ts index 1ddd7871d4df..638ba92e9ae8 100644 --- a/ui/ducks/bridge/utils.ts +++ b/ui/ducks/bridge/utils.ts @@ -2,6 +2,10 @@ import { Hex } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { getAddress } from 'ethers/lib/utils'; import { ContractMarketData } from '@metamask/assets-controllers'; +import { + AddNetworkFields, + NetworkConfiguration, +} from '@metamask/network-controller'; import { decGWEIToHexWEI } from '../../../shared/modules/conversion.utils'; import { Numeric } from '../../../shared/modules/Numeric'; import { TxData } from '../../pages/bridge/types'; @@ -133,3 +137,8 @@ export const exchangeRatesFromNativeAndCurrencyRates = ( : null, }; }; + +export const isNetworkAdded = ( + v: NetworkConfiguration | AddNetworkFields | undefined, +): v is NetworkConfiguration => + !v || 'networkClientId' in v.rpcEndpoints[v.defaultRpcEndpointIndex]; diff --git a/ui/hooks/useMultichainBalances.test.ts b/ui/hooks/useMultichainBalances.test.ts new file mode 100644 index 000000000000..7bcdc74e8418 --- /dev/null +++ b/ui/hooks/useMultichainBalances.test.ts @@ -0,0 +1,121 @@ +import { createBridgeMockStore } from '../../test/jest/mock-store'; +import { renderHookWithProvider } from '../../test/lib/render-helpers'; +import { useMultichainBalances } from './useMultichainBalances'; + +describe('useMultichainBalances', () => { + it('should return the native token of each imported network when no token balances are cached', () => { + const mockStore = createBridgeMockStore({ + metamaskStateOverrides: { + allTokens: {}, + }, + }); + const { result } = renderHookWithProvider( + () => useMultichainBalances(), + mockStore, + ); + + expect(result.current.assetsWithBalance).toHaveLength(2); + expect(result.current.assetsWithBalance).toStrictEqual( + expect.objectContaining([ + { + balance: '0.000000000000000014', + chainId: '0xe708', + decimals: 18, + image: './images/eth_logo.svg', + string: '0.000000000000000014', + symbol: 'ETH', + tokenFiatAmount: 3.53395e-14, + type: 'NATIVE', + }, + { + balance: '0.00000000000000001', + chainId: '0x1', + decimals: 18, + image: './images/eth_logo.svg', + string: '0.00000000000000001', + symbol: 'ETH', + tokenFiatAmount: 2.5242500000000003e-14, + type: 'NATIVE', + }, + ]), + ); + }); + + it('should return a list of assets with balances', () => { + const mockStore = createBridgeMockStore(); + const { result } = renderHookWithProvider( + () => useMultichainBalances(), + mockStore, + ); + + expect(result.current.assetsWithBalance).toHaveLength(5); + expect(result.current.assetsWithBalance).toStrictEqual( + expect.objectContaining([ + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + balance: '0.00184', + chainId: '0x1', + decimals: 6, + image: undefined, + isNative: false, + string: '0.00184', + tokenFiatAmount: 0.004232, + type: 'TOKEN', + }, + { + balance: '0.000000000000000014', + chainId: '0xe708', + decimals: 18, + image: './images/eth_logo.svg', + string: '0.000000000000000014', + symbol: 'ETH', + tokenFiatAmount: 3.53395e-14, + type: 'NATIVE', + }, + { + balance: '0.00000000000000001', + chainId: '0x1', + decimals: 18, + image: './images/eth_logo.svg', + string: '0.00000000000000001', + symbol: 'ETH', + tokenFiatAmount: 2.5242500000000003e-14, + type: 'NATIVE', + }, + { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + balance: '1', + chainId: '0x1', + image: undefined, + isNative: false, + string: '1', + tokenFiatAmount: null, + type: 'TOKEN', + }, + { + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + balance: '0', + chainId: '0xe708', + image: undefined, + isNative: false, + string: '0', + tokenFiatAmount: null, + type: 'TOKEN', + }, + ]), + ); + }); + + it('should return a mapping of chainId to balance', () => { + const mockStore = createBridgeMockStore(); + const { result } = renderHookWithProvider( + () => useMultichainBalances(), + mockStore, + ); + + expect(result.current.balanceByChainId).toStrictEqual({ + '0x1': 0.0042320000000252425, + '0xe708': 3.53395e-14, + }); + }); +}); diff --git a/ui/hooks/useMultichainBalances.ts b/ui/hooks/useMultichainBalances.ts new file mode 100644 index 000000000000..b953043f7851 --- /dev/null +++ b/ui/hooks/useMultichainBalances.ts @@ -0,0 +1,154 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { Hex } from '@metamask/utils'; +import { + getCurrencyRates, + getMarketData, + getSelectedAccount, + getSelectedAccountNativeTokenCachedBalanceByChainId, + getSelectedAccountTokensAcrossChains, + selectERC20TokensByChain, +} from '../selectors'; +import { + ChainAddressMarketData, + Token, +} from '../components/app/assets/token-list/token-list'; +import { calculateTokenFiatAmount } from '../components/app/assets/util/calculateTokenFiatAmount'; +import { calculateTokenBalance } from '../components/app/assets/util/calculateTokenBalance'; +import { + CHAIN_ID_TO_CURRENCY_SYMBOL_MAP, + CHAIN_ID_TOKEN_IMAGE_MAP, + TEST_CHAINS, +} from '../../shared/constants/network'; +import { + AssetWithDisplayData, + ERC20Asset, + NativeAsset, +} from '../components/multichain/asset-picker-amount/asset-picker-modal/types'; +import { AssetType } from '../../shared/constants/transaction'; +import { useTokenBalances } from './useTokenBalances'; + +const useFilteredAccountTokens = () => { + const selectedAccountTokensChains: Record = useSelector( + getSelectedAccountTokensAcrossChains, + ) as Record; + + const filteredAccountTokensChains = useMemo(() => { + return Object.fromEntries( + Object.entries(selectedAccountTokensChains).filter( + ([chainId]) => !(TEST_CHAINS as string[]).includes(chainId), + ), + ); + }, [selectedAccountTokensChains, TEST_CHAINS]); + + return filteredAccountTokensChains; +}; + +// This hook is used to get the balances of all tokens across all chains +// native balances are included, with fields isNative=true and address='' +export const useMultichainBalances = () => { + const selectedAccountTokensChains = useFilteredAccountTokens(); + const selectedAccount = useSelector(getSelectedAccount); + + const { tokenBalances } = useTokenBalances(); + const selectedAccountTokenBalancesAcrossChains = + tokenBalances[selectedAccount.address]; + + const marketData: ChainAddressMarketData = useSelector( + getMarketData, + ) as ChainAddressMarketData; + + const currencyRates = useSelector(getCurrencyRates); + const nativeBalances: Record = useSelector( + getSelectedAccountNativeTokenCachedBalanceByChainId, + ) as Record; + + const erc20TokensByChain = useSelector(selectERC20TokensByChain); + + const assetsWithBalance = useMemo(() => { + const tokensWithBalance: AssetWithDisplayData[] = + []; + + Object.entries(selectedAccountTokensChains).forEach( + ([stringChainKey, tokens]) => { + const chainId = stringChainKey as Hex; + tokens.forEach((token: Token) => { + const { isNative, address, decimals } = token; + const balance = + calculateTokenBalance({ + isNative, + chainId, + address, + decimals, + nativeBalances, + selectedAccountTokenBalancesAcrossChains, + }) || ''; + + const tokenFiatAmount = calculateTokenFiatAmount({ + token, + chainId, + balance, + marketData, + currencyRates, + }); + + // Append processed token with balance and fiat amount + const sharedFields = { + balance, + tokenFiatAmount, + chainId, + string: String(balance), + }; + if (token.isNative) { + tokensWithBalance.push({ + ...sharedFields, + type: AssetType.native, + image: + CHAIN_ID_TOKEN_IMAGE_MAP[ + chainId as keyof typeof CHAIN_ID_TOKEN_IMAGE_MAP + ], + symbol: + CHAIN_ID_TO_CURRENCY_SYMBOL_MAP[ + chainId as keyof typeof CHAIN_ID_TO_CURRENCY_SYMBOL_MAP + ], + decimals: token.decimals, + }); + } else { + tokensWithBalance.push({ + ...token, + ...sharedFields, + image: + token.image || + erc20TokensByChain?.[chainId]?.data?.[ + token.address.toLowerCase() + ]?.iconUrl, + address: token.address, + type: AssetType.token, + }); + } + }); + }, + ); + + return tokensWithBalance.sort( + (a, b) => (b.tokenFiatAmount ?? 0) - (a.tokenFiatAmount ?? 0), + ); + }, [JSON.stringify(selectedAccountTokensChains)]); + + const balanceByChainId = useMemo(() => { + return assetsWithBalance.reduce( + (acc: Record<`0x${string}`, number>, { chainId, tokenFiatAmount }) => { + if (!acc[chainId]) { + acc[chainId] = 0; + } + if (tokenFiatAmount) { + acc[chainId] += tokenFiatAmount; + } + return acc; + }, + {}, + ); + }, [assetsWithBalance]); + + return { assetsWithBalance, balanceByChainId }; +}; diff --git a/ui/hooks/useTokensWithFiltering.test.ts b/ui/hooks/useTokensWithFiltering.test.ts index f5ea05e02b8d..0a523b69bd74 100644 --- a/ui/hooks/useTokensWithFiltering.test.ts +++ b/ui/hooks/useTokensWithFiltering.test.ts @@ -61,6 +61,7 @@ describe('useTokensWithFiltering should return token list generator', () => { name: 'Ether', primaryLabel: 'ETH', rawFiat: '', + chainId: '0x1', rightPrimaryLabel: undefined, rightSecondaryLabel: '', secondaryLabel: 'Ether', @@ -74,6 +75,7 @@ describe('useTokensWithFiltering should return token list generator', () => { decimals: 18, erc20: true, erc721: false, + chainId: '0x1', iconUrl: 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x6b3595068778dd592e39a122f4f5a5cf09c90fe2.png', identiconAddress: null, @@ -121,6 +123,7 @@ describe('useTokensWithFiltering should return token list generator', () => { identiconAddress: null, image: './images/eth_logo.svg', name: 'Ether', + chainId: '0x1', primaryLabel: 'ETH', rawFiat: '0', rightPrimaryLabel: '0 ETH', @@ -141,6 +144,7 @@ describe('useTokensWithFiltering should return token list generator', () => { identiconAddress: null, image: 'images/contract/usdt.svg', name: 'Tether USD', + chainId: '0x1', primaryLabel: 'USDT', rawFiat: '', rightPrimaryLabel: undefined, diff --git a/ui/hooks/useTokensWithFiltering.ts b/ui/hooks/useTokensWithFiltering.ts index d729ce3c1fdc..ef155eb9ca1c 100644 --- a/ui/hooks/useTokensWithFiltering.ts +++ b/ui/hooks/useTokensWithFiltering.ts @@ -75,14 +75,20 @@ export const useTokensWithFiltering = ( ); const filteredTokenListGenerator = useCallback( - (shouldAddToken: (symbol: string, address?: string) => boolean) => { + ( + shouldAddToken: ( + symbol: string, + address?: string, + tokenChainId?: string, + ) => boolean, + ) => { const buildTokenData = ( token: SwapsTokenObject, ): | AssetWithDisplayData | AssetWithDisplayData | undefined => { - if (chainId && shouldAddToken(token.symbol, token.address)) { + if (chainId && shouldAddToken(token.symbol, token.address, chainId)) { return getRenderableTokenData( { ...token, @@ -90,6 +96,7 @@ export const useTokensWithFiltering = ( ? AssetType.native : AssetType.token, image: token.iconUrl, + chainId, }, tokenConversionRates, conversionRate, @@ -114,6 +121,7 @@ export const useTokensWithFiltering = ( numberOfDecimals: 4, toDenomination: EtherDenomination.ETH, }), + chainId, } : {}; const nativeToken = buildTokenData({ diff --git a/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap b/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap index d6e4c675f8e1..dc3cf4c408de 100644 --- a/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap +++ b/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap @@ -177,13 +177,14 @@ exports[`AssetPage should render a native asset 1`] = ` Your balance
@@ -502,13 +496,14 @@ exports[`AssetPage should render an ERC20 asset without prices 1`] = ` Your balance
@@ -1013,13 +1001,14 @@ exports[`AssetPage should render an ERC20 token with prices 1`] = ` Your balance
diff --git a/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap b/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap index 8c73ba3c82b2..b4873c7e1c89 100644 --- a/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap +++ b/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap @@ -142,11 +142,43 @@ exports[`PrepareBridgePage should render the component, with initial state 1`] = -

- Select token -

+
+
+
+ ? +
+
+
+ Ethereum Mainnet logo +
+
+
+
+
+

+

+
- $0.00 + $5,805.77
diff --git a/ui/pages/bridge/prepare/bridge-input-group.tsx b/ui/pages/bridge/prepare/bridge-input-group.tsx index 2627b9e04102..2f8ea8fda1c9 100644 --- a/ui/pages/bridge/prepare/bridge-input-group.tsx +++ b/ui/pages/bridge/prepare/bridge-input-group.tsx @@ -44,6 +44,7 @@ const generateAssetFromToken = ( image: tokenDetails.iconUrl, symbol: tokenDetails.symbol, address: tokenDetails.address, + chainId, }; } @@ -57,6 +58,7 @@ const generateAssetFromToken = ( CHAIN_ID_TO_CURRENCY_SYMBOL_MAP[ chainId as keyof typeof CHAIN_ID_TO_CURRENCY_SYMBOL_MAP ], + chainId, }; }; @@ -68,6 +70,7 @@ export const BridgeInputGroup = ({ onAmountChange, networkProps, customTokenListGenerator, + isMultiselectEnabled, amountFieldProps = {}, }: { className: string; @@ -79,7 +82,11 @@ export const BridgeInputGroup = ({ >; } & Pick< React.ComponentProps, - 'networkProps' | 'header' | 'customTokenListGenerator' | 'onAssetChange' + | 'networkProps' + | 'header' + | 'customTokenListGenerator' + | 'onAssetChange' + | 'isMultiselectEnabled' >) => { const t = useI18nContext(); @@ -123,6 +130,7 @@ export const BridgeInputGroup = ({ onAssetChange={onAssetChange} networkProps={networkProps} customTokenListGenerator={customTokenListGenerator} + isMultiselectEnabled={isMultiselectEnabled} /> { expect(container).toMatchSnapshot(); expect(getByRole('button', { name: /ETH/u })).toBeInTheDocument(); - expect(getByRole('button', { name: /Select token/u })).toBeInTheDocument(); expect(getByTestId('from-amount')).toBeInTheDocument(); expect(getByTestId('from-amount').closest('input')).not.toBeDisabled(); @@ -167,6 +166,7 @@ describe('PrepareBridgePage', () => { iconUrl: 'http://url', symbol: 'UNI', address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + decimals: 6, }, toChainId: CHAIN_IDS.LINEA_MAINNET, }, diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.tsx index 2cf97891189f..1533fc1a9c20 100644 --- a/ui/pages/bridge/prepare/prepare-bridge-page.tsx +++ b/ui/pages/bridge/prepare/prepare-bridge-page.tsx @@ -50,6 +50,7 @@ import { } from '../../../hooks/bridge/useCrossChainSwapsEventTracker'; import { useRequestProperties } from '../../../hooks/bridge/events/useRequestProperties'; import { MetaMetricsEventName } from '../../../../shared/constants/metametrics'; +import { isNetworkAdded } from '../../../ducks/bridge/utils'; import { BridgeInputGroup } from './bridge-input-group'; const PrepareBridgePage = () => { @@ -194,7 +195,7 @@ const PrepareBridgePage = () => { { dispatch(setFromTokenInputValue(e)); @@ -219,17 +220,20 @@ const PrepareBridgePage = () => { if (networkConfig.chainId === toChain?.chainId) { dispatch(setToChainId(null)); } - dispatch( - setActiveNetwork( - networkConfig.rpcEndpoints[ - networkConfig.defaultRpcEndpointIndex - ].networkClientId, - ), - ); + if (isNetworkAdded(networkConfig)) { + dispatch( + setActiveNetwork( + networkConfig.rpcEndpoints[ + networkConfig.defaultRpcEndpointIndex + ].networkClientId, + ), + ); + } dispatch(setFromChain(networkConfig.chainId)); dispatch(setFromToken(null)); dispatch(setFromTokenInputValue(null)); }, + header: t('bridgeFrom'), }} customTokenListGenerator={ fromTokens && fromTopAssets ? fromTokenListGenerator : undefined @@ -239,6 +243,7 @@ const PrepareBridgePage = () => { autoFocus: true, value: fromAmount || undefined, }} + isMultiselectEnabled={true} /> @@ -279,7 +284,7 @@ const PrepareBridgePage = () => { { token?.address && @@ -300,6 +305,7 @@ const PrepareBridgePage = () => { dispatch(setToChainId(networkConfig.chainId)); dispatch(setToChain(networkConfig.chainId)); }, + header: t('bridgeTo'), }} customTokenListGenerator={ toChain && toTokens && toTopAssets diff --git a/ui/pages/confirmations/components/confirm-page-container/confirm-page-container-header/__snapshots__/confirm-page-container-header.component.test.js.snap b/ui/pages/confirmations/components/confirm-page-container/confirm-page-container-header/__snapshots__/confirm-page-container-header.component.test.js.snap index 98fe98ec4138..78aa89d2bba3 100644 --- a/ui/pages/confirmations/components/confirm-page-container/confirm-page-container-header/__snapshots__/confirm-page-container-header.component.test.js.snap +++ b/ui/pages/confirmations/components/confirm-page-container/confirm-page-container-header/__snapshots__/confirm-page-container-header.component.test.js.snap @@ -32,12 +32,12 @@ exports[`Confirm Detail Row Component should match snapshot 1`] = ` > G
- Goerli - +

G - Goerli - +

G - Goerli - +

G - Goerli - +