diff --git a/development/circular-deps.jsonc b/development/circular-deps.jsonc index 55065e10bb59..ac89a9532415 100644 --- a/development/circular-deps.jsonc +++ b/development/circular-deps.jsonc @@ -43,14 +43,6 @@ "ui/components/app/assets/asset-list/native-token/native-token.tsx", "ui/components/app/assets/asset-list/native-token/use-native-token-balance.ts" ], - [ - "ui/components/app/assets/token-list/token-list.tsx", - "ui/components/app/assets/util/calculateTokenBalance.ts" - ], - [ - "ui/components/app/assets/token-list/token-list.tsx", - "ui/components/app/assets/util/calculateTokenFiatAmount.ts" - ], [ "ui/components/app/name/name-details/name-details.tsx", "ui/components/app/name/name.tsx" diff --git a/ui/components/app/assets/asset-list/asset-list-funding-modals/asset-list-funding-modals.tsx b/ui/components/app/assets/asset-list/asset-list-funding-modals/asset-list-funding-modals.tsx new file mode 100644 index 000000000000..7041b3534443 --- /dev/null +++ b/ui/components/app/assets/asset-list/asset-list-funding-modals/asset-list-funding-modals.tsx @@ -0,0 +1,74 @@ +import React, { useState } from 'react'; +import { useSelector } from 'react-redux'; +import { ReceiveModal } from '../../../../multichain'; +import { FundingMethodModal } from '../../../../multichain/funding-method-modal/funding-method-modal'; +import { useI18nContext } from '../../../../../hooks/useI18nContext'; +import { getSelectedAccount } from '../../../../../selectors'; +///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) +import { + getMultichainIsBitcoin, + getMultichainSelectedAccountCachedBalanceIsZero, +} from '../../../../../selectors/multichain'; +import { getIsNativeTokenBuyable } from '../../../../../ducks/ramps'; +import { RampsCard } from '../../../../multichain/ramps-card'; +import { RAMPS_CARD_VARIANT_TYPES } from '../../../../multichain/ramps-card/ramps-card'; +///: END:ONLY_INCLUDE_IF + +const AssetListFundingModals = () => { + const t = useI18nContext(); + const selectedAccount = useSelector(getSelectedAccount); + + const [showFundingMethodModal, setShowFundingMethodModal] = useState(false); + const [showReceiveModal, setShowReceiveModal] = useState(false); + + const onClickReceive = () => { + setShowFundingMethodModal(false); + setShowReceiveModal(true); + }; + + ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) + const balanceIsZero = useSelector( + getMultichainSelectedAccountCachedBalanceIsZero, + ); + const isBuyableChain = useSelector(getIsNativeTokenBuyable); + const shouldShowBuy = isBuyableChain && balanceIsZero; + const isBtc = useSelector(getMultichainIsBitcoin); + ///: END:ONLY_INCLUDE_IF + + return ( + <> + { + ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) + shouldShowBuy ? ( + setShowFundingMethodModal(true) + } + /> + ) : null + ///: END:ONLY_INCLUDE_IF + } + {showReceiveModal && selectedAccount?.address && ( + setShowReceiveModal(false)} + /> + )} + {showFundingMethodModal && ( + setShowFundingMethodModal(false)} + title={t('fundingMethod')} + onClickReceive={onClickReceive} + /> + )} + + ); +}; + +export default AssetListFundingModals; diff --git a/ui/components/app/assets/asset-list/asset-list-funding-modals/index.ts b/ui/components/app/assets/asset-list/asset-list-funding-modals/index.ts new file mode 100644 index 000000000000..4ddad869fde1 --- /dev/null +++ b/ui/components/app/assets/asset-list/asset-list-funding-modals/index.ts @@ -0,0 +1 @@ +export { default } from './asset-list-funding-modals'; diff --git a/ui/components/app/assets/asset-list/asset-list.tsx b/ui/components/app/assets/asset-list/asset-list.tsx index 5ac7a84e0b29..18d42334ab48 100644 --- a/ui/components/app/assets/asset-list/asset-list.tsx +++ b/ui/components/app/assets/asset-list/asset-list.tsx @@ -1,56 +1,18 @@ -import React, { useContext, useEffect, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { Token } from '@metamask/assets-controllers'; -import { NetworkConfiguration } from '@metamask/network-controller'; +import React, { useContext } from 'react'; +import { useSelector } from 'react-redux'; import TokenList from '../token-list'; -import { PRIMARY } from '../../../../helpers/constants/common'; -import { useUserPreferencedCurrency } from '../../../../hooks/useUserPreferencedCurrency'; -import { - getAllDetectedTokensForSelectedAddress, - getDetectedTokensInCurrentNetwork, - getIsTokenNetworkFilterEqualCurrentNetwork, - getSelectedAccount, - getSelectedAddress, - getUseTokenDetection, -} from '../../../../selectors'; -import { - getMultichainIsEvm, - getMultichainSelectedAccountCachedBalance, - ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) - getMultichainIsBitcoin, - getMultichainSelectedAccountCachedBalanceIsZero, - ///: END:ONLY_INCLUDE_IF -} from '../../../../selectors/multichain'; -import { useCurrencyDisplay } from '../../../../hooks/useCurrencyDisplay'; +import { getMultichainIsEvm } from '../../../../selectors/multichain'; import { MetaMetricsContext } from '../../../../contexts/metametrics'; import { MetaMetricsEventCategory, MetaMetricsEventName, - MetaMetricsTokenEventSource, } from '../../../../../shared/constants/metametrics'; import DetectedToken from '../../detected-token/detected-token'; -import { ReceiveModal } from '../../../multichain'; -import { useI18nContext } from '../../../../hooks/useI18nContext'; -import { FundingMethodModal } from '../../../multichain/funding-method-modal/funding-method-modal'; -///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) -import { - RAMPS_CARD_VARIANT_TYPES, - RampsCard, -} from '../../../multichain/ramps-card/ramps-card'; -import { getIsNativeTokenBuyable } from '../../../../ducks/ramps'; -///: END:ONLY_INCLUDE_IF -import { - getCurrentChainId, - getNetworkConfigurationsByChainId, - getSelectedNetworkClientId, -} from '../../../../../shared/modules/selectors/networks'; -import { addImportedTokens } from '../../../../store/actions'; -import { - AssetType, - TokenStandard, -} from '../../../../../shared/constants/transaction'; +import useAssetListTokenDetection from '../hooks/useAssetListTokenDetection'; +import usePrimaryCurrencyProperties from '../hooks/usePrimaryCurrencyProperties'; import AssetListControlBar from './asset-list-control-bar'; import NativeToken from './native-token'; +import AssetListFundingModals from './asset-list-funding-modals'; export type TokenWithBalance = { address: string; @@ -68,142 +30,17 @@ export type AssetListProps = { }; const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { - const dispatch = useDispatch(); - const [showDetectedTokens, setShowDetectedTokens] = useState(false); - const selectedAccount = useSelector(getSelectedAccount); - const t = useI18nContext(); + const { showDetectedTokens, setShowDetectedTokens } = + useAssetListTokenDetection(); const trackEvent = useContext(MetaMetricsContext); - const balance = useSelector(getMultichainSelectedAccountCachedBalance); - - const { - currency: primaryCurrency, - numberOfDecimals: primaryNumberOfDecimals, - } = useUserPreferencedCurrency(PRIMARY, { - ethNumberOfDecimals: 4, - shouldCheckShowNativeToken: true, - }); - - const [, primaryCurrencyProperties] = useCurrencyDisplay(balance, { - numberOfDecimals: primaryNumberOfDecimals, - currency: primaryCurrency, - }); - - const detectedTokens = useSelector(getDetectedTokensInCurrentNetwork) || []; - - const isTokenNetworkFilterEqualCurrentNetwork = useSelector( - getIsTokenNetworkFilterEqualCurrentNetwork, - ); - - const allNetworks: Record<`0x${string}`, NetworkConfiguration> = useSelector( - getNetworkConfigurationsByChainId, - ); - const networkClientId = useSelector(getSelectedNetworkClientId); - const selectedAddress = useSelector(getSelectedAddress); - const useTokenDetection = useSelector(getUseTokenDetection); - const currentChainId = useSelector(getCurrentChainId); - - const [showFundingMethodModal, setShowFundingMethodModal] = useState(false); - const [showReceiveModal, setShowReceiveModal] = useState(false); - const onClickReceive = () => { - setShowFundingMethodModal(false); - setShowReceiveModal(true); - }; - - ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) - const balanceIsZero = useSelector( - getMultichainSelectedAccountCachedBalanceIsZero, - ); - const isBuyableChain = useSelector(getIsNativeTokenBuyable); - const shouldShowBuy = isBuyableChain && balanceIsZero; - const isBtc = useSelector(getMultichainIsBitcoin); - ///: END:ONLY_INCLUDE_IF + const { primaryCurrencyProperties } = usePrimaryCurrencyProperties(); const isEvm = useSelector(getMultichainIsEvm); // NOTE: Since we can parametrize it now, we keep the original behavior // for EVM assets const shouldShowTokensLinks = showTokensLinks ?? isEvm; - const detectedTokensMultichain: { - [key: `0x${string}`]: Token[]; - } = useSelector(getAllDetectedTokensForSelectedAddress); - - const multichainDetectedTokensLength = Object.values( - detectedTokensMultichain || {}, - ).reduce((acc, tokens) => acc + tokens.length, 0); - - // Add detected tokens to sate - useEffect(() => { - const importAllDetectedTokens = async () => { - // If autodetect tokens toggle is OFF, return - if (!useTokenDetection) { - return; - } - // TODO add event for MetaMetricsEventName.TokenAdded - - if ( - process.env.PORTFOLIO_VIEW && - !isTokenNetworkFilterEqualCurrentNetwork - ) { - const importPromises = Object.entries(detectedTokensMultichain).map( - async ([networkId, tokens]) => { - const chainConfig = allNetworks[networkId as `0x${string}`]; - const { defaultRpcEndpointIndex } = chainConfig; - const { networkClientId: networkInstanceId } = - chainConfig.rpcEndpoints[defaultRpcEndpointIndex]; - - await dispatch( - addImportedTokens(tokens as Token[], networkInstanceId), - ); - tokens.forEach((importedToken) => { - trackEvent({ - event: MetaMetricsEventName.TokenAdded, - category: MetaMetricsEventCategory.Wallet, - sensitiveProperties: { - token_symbol: importedToken.symbol, - token_contract_address: importedToken.address, - token_decimal_precision: importedToken.decimals, - source: MetaMetricsTokenEventSource.Detected, - token_standard: TokenStandard.ERC20, - asset_type: AssetType.token, - token_added_type: 'detected', - chain_id: chainConfig.chainId, - }, - }); - }); - }, - ); - - await Promise.all(importPromises); - } else if (detectedTokens.length > 0) { - await dispatch(addImportedTokens(detectedTokens, networkClientId)); - detectedTokens.forEach((importedToken: Token) => { - trackEvent({ - event: MetaMetricsEventName.TokenAdded, - category: MetaMetricsEventCategory.Wallet, - sensitiveProperties: { - token_symbol: importedToken.symbol, - token_contract_address: importedToken.address, - token_decimal_precision: importedToken.decimals, - source: MetaMetricsTokenEventSource.Detected, - token_standard: TokenStandard.ERC20, - asset_type: AssetType.token, - token_added_type: 'detected', - chain_id: currentChainId, - }, - }); - }); - } - }; - importAllDetectedTokens(); - }, [ - isTokenNetworkFilterEqualCurrentNetwork, - selectedAddress, - networkClientId, - detectedTokens.length, - multichainDetectedTokensLength, - ]); - return ( <> @@ -223,39 +60,10 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { }); }} /> - { - ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) - shouldShowBuy ? ( - setShowFundingMethodModal(true) - } - /> - ) : null - ///: END:ONLY_INCLUDE_IF - } {showDetectedTokens && ( )} - {showReceiveModal && selectedAccount?.address && ( - setShowReceiveModal(false)} - /> - )} - {showFundingMethodModal && ( - setShowFundingMethodModal(false)} - title={t('fundingMethod')} - onClickReceive={onClickReceive} - /> - )} + ); }; diff --git a/ui/components/app/assets/asset-list/import-control/import-control.tsx b/ui/components/app/assets/asset-list/import-control/import-control.tsx index 6ac4a6b1fce1..1b17bb6414c5 100644 --- a/ui/components/app/assets/asset-list/import-control/import-control.tsx +++ b/ui/components/app/assets/asset-list/import-control/import-control.tsx @@ -9,7 +9,6 @@ import { BackgroundColor, TextColor, } from '../../../../../helpers/constants/design-system'; - import { getMultichainIsEvm } from '../../../../../selectors/multichain'; type AssetListControlBarProps = { diff --git a/ui/components/app/assets/auto-detect-token/index.scss b/ui/components/app/assets/auto-detect-token/index.scss deleted file mode 100644 index 5714d957f0f9..000000000000 --- a/ui/components/app/assets/auto-detect-token/index.scss +++ /dev/null @@ -1,10 +0,0 @@ -.auto-detect-in-modal { - &__benefit { - flex: 1; - } - - &__dialog { - background-position: -80px 16px; - background-repeat: no-repeat; - } -} diff --git a/ui/components/app/assets/hooks/useAssetListTokenDetection.tsx b/ui/components/app/assets/hooks/useAssetListTokenDetection.tsx new file mode 100644 index 000000000000..6a12def1bd71 --- /dev/null +++ b/ui/components/app/assets/hooks/useAssetListTokenDetection.tsx @@ -0,0 +1,105 @@ +import { useContext, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { NetworkConfiguration } from '@metamask/network-controller'; +import { Token } from '../types'; +import { + getAllDetectedTokensForSelectedAddress, + getDetectedTokensInCurrentNetwork, + getIsTokenNetworkFilterEqualCurrentNetwork, + getSelectedAddress, + getUseTokenDetection, +} from '../../../../selectors'; +import { importAllDetectedTokens } from '../util/importAllDetectedTokens'; +import { + getCurrentChainId, + getNetworkConfigurationsByChainId, + getSelectedNetworkClientId, +} from '../../../../../shared/modules/selectors/networks'; +import { MetaMetricsContext } from '../../../../contexts/metametrics'; +import { + MetaMetricsEventCategory, + MetaMetricsEventName, + MetaMetricsTokenEventSource, +} from '../../../../../shared/constants/metametrics'; +import { + AssetType, + TokenStandard, +} from '../../../../../shared/constants/transaction'; +import { addImportedTokens } from '../../../../store/actions'; + +const useAssetListTokenDetection = () => { + const trackEvent = useContext(MetaMetricsContext); + const dispatch = useDispatch(); + const detectedTokensMultichain: { + [key: string]: Token[]; + } = useSelector(getAllDetectedTokensForSelectedAddress); + const networkClientId = useSelector(getSelectedNetworkClientId); + const selectedAddress = useSelector(getSelectedAddress); + const useTokenDetection = useSelector(getUseTokenDetection); + const currentChainId = useSelector(getCurrentChainId); + const isOnCurrentNetwork = useSelector( + getIsTokenNetworkFilterEqualCurrentNetwork, + ); + const detectedTokens = useSelector(getDetectedTokensInCurrentNetwork) || []; + const allNetworks: Record<`0x${string}`, NetworkConfiguration> = useSelector( + getNetworkConfigurationsByChainId, + ); + + const multichainDetectedTokensLength = Object.values( + detectedTokensMultichain || {}, + ).reduce((acc, tokens) => acc + tokens.length, 0); + + const [showDetectedTokens, setShowDetectedTokens] = useState(false); + + const handleAddImportedTokens = async ( + tokens: Token[], + networkClientIdProp: string, + ) => { + await dispatch(addImportedTokens(tokens as Token[], networkClientIdProp)); + }; + + const trackTokenAddedEvent = (importedToken: Token, chainId: string) => { + trackEvent({ + event: MetaMetricsEventName.TokenAdded, + category: MetaMetricsEventCategory.Wallet, + sensitiveProperties: { + token_symbol: importedToken.symbol, + token_contract_address: importedToken.address, + token_decimal_precision: importedToken.decimals, + source: MetaMetricsTokenEventSource.Detected, + token_standard: TokenStandard.ERC20, + asset_type: AssetType.token, + token_added_type: 'detected', + chain_id: chainId, + }, + }); + }; + + // Add detected tokens to sate + useEffect(() => { + if (!useTokenDetection) { + return; + } + + importAllDetectedTokens( + isOnCurrentNetwork, + detectedTokensMultichain, + allNetworks, + networkClientId, + currentChainId, + detectedTokens, + handleAddImportedTokens, + trackTokenAddedEvent, + ); + }, [ + isOnCurrentNetwork, + selectedAddress, + networkClientId, + detectedTokens.length, + multichainDetectedTokensLength, + ]); + + return { showDetectedTokens, setShowDetectedTokens }; +}; + +export default useAssetListTokenDetection; diff --git a/ui/components/app/assets/hooks/useNetworkFilter.tsx b/ui/components/app/assets/hooks/useNetworkFilter.tsx new file mode 100644 index 000000000000..1f8e352156a5 --- /dev/null +++ b/ui/components/app/assets/hooks/useNetworkFilter.tsx @@ -0,0 +1,30 @@ +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { setTokenNetworkFilter } from '../../../../store/actions'; +import { + getNetworkConfigurationIdByChainId, + getTokenNetworkFilter, +} from '../../../../selectors'; + +const useNetworkFilter = () => { + const dispatch = useDispatch(); + + const allNetworks = useSelector(getNetworkConfigurationIdByChainId); + const networkFilter = useSelector(getTokenNetworkFilter); + + useEffect(() => { + if (process.env.PORTFOLIO_VIEW) { + const allNetworkFilters = Object.fromEntries( + Object.keys(allNetworks).map((chainId) => [chainId, true]), + ); + + if (Object.keys(networkFilter).length > 1) { + dispatch(setTokenNetworkFilter(allNetworkFilters)); + } + } + }, [Object.keys(allNetworks).length, networkFilter, dispatch]); + + return { networkFilter }; +}; + +export default useNetworkFilter; diff --git a/ui/components/app/assets/hooks/usePrimaryCurrencyProperties.tsx b/ui/components/app/assets/hooks/usePrimaryCurrencyProperties.tsx new file mode 100644 index 000000000000..7f81c7568fe0 --- /dev/null +++ b/ui/components/app/assets/hooks/usePrimaryCurrencyProperties.tsx @@ -0,0 +1,27 @@ +import { useSelector } from 'react-redux'; +import { useUserPreferencedCurrency } from '../../../../hooks/useUserPreferencedCurrency'; +import { useCurrencyDisplay } from '../../../../hooks/useCurrencyDisplay'; +import { getMultichainSelectedAccountCachedBalance } from '../../../../selectors/multichain'; + +const usePrimaryCurrencyProperties = () => { + const balance = useSelector(getMultichainSelectedAccountCachedBalance); + + const { + currency: primaryCurrency, + numberOfDecimals: primaryNumberOfDecimals, + } = useUserPreferencedCurrency('PRIMARY', { + ethNumberOfDecimals: 4, + shouldCheckShowNativeToken: true, + }); + + const [, primaryCurrencyProperties] = useCurrencyDisplay(balance, { + numberOfDecimals: primaryNumberOfDecimals, + currency: primaryCurrency, + }); + + return { + primaryCurrencyProperties, + }; +}; + +export default usePrimaryCurrencyProperties; diff --git a/ui/components/app/assets/hooks/useShouldShowFiat.tsx b/ui/components/app/assets/hooks/useShouldShowFiat.tsx new file mode 100644 index 000000000000..22b04d3c677b --- /dev/null +++ b/ui/components/app/assets/hooks/useShouldShowFiat.tsx @@ -0,0 +1,24 @@ +import { useSelector } from 'react-redux'; +import { useMultichainSelector } from '../../../../hooks/useMultichainSelector'; +import { + getIsTestnet, + getSelectedAccount, + getShowFiatInTestnets, +} from '../../../../selectors'; +import { getMultichainShouldShowFiat } from '../../../../selectors/multichain'; + +const useShouldShowFiat = () => { + const isTestnet = useSelector(getIsTestnet); + const selectedAccount = useSelector(getSelectedAccount); + const shouldShowFiat = useMultichainSelector( + getMultichainShouldShowFiat, + selectedAccount, + ); + + const isMainnet = !isTestnet; + const showFiatInTestnets = useSelector(getShowFiatInTestnets); + + return shouldShowFiat && (isMainnet || (isTestnet && showFiatInTestnets)); +}; + +export default useShouldShowFiat; diff --git a/ui/components/app/assets/hooks/useSortedFilteredTokens.tsx b/ui/components/app/assets/hooks/useSortedFilteredTokens.tsx new file mode 100644 index 000000000000..a6e42739be82 --- /dev/null +++ b/ui/components/app/assets/hooks/useSortedFilteredTokens.tsx @@ -0,0 +1,69 @@ +import { useMemo } from 'react'; +import { shallowEqual, useSelector } from 'react-redux'; +import { sortAssets } from '../util/sort'; +import { filterAssets } from '../util/filter'; +import { + getCurrentNetwork, + getNewTokensImported, + getPreferences, + getSelectedAccount, + getTokenBalancesEvm, + getTokenExchangeRates, +} from '../../../../selectors'; +import { getConversionRate } from '../../../../ducks/metamask/metamask'; +import { TokenWithFiatAmount } from '../types'; +import useNetworkFilter from './useNetworkFilter'; + +const useSortedFilteredTokens = () => { + const currentNetwork = useSelector(getCurrentNetwork); + const { tokenSortConfig } = useSelector(getPreferences); + const selectedAccount = useSelector(getSelectedAccount); + const conversionRate = useSelector(getConversionRate); + const contractExchangeRates = useSelector( + getTokenExchangeRates, + shallowEqual, + ); + const newTokensImported = useSelector(getNewTokensImported); + const evmBalances = useSelector(getTokenBalancesEvm); // TODO: Make this chain agnostic + + // network filter to determine which tokens to show in list + const { networkFilter } = useNetworkFilter(); + + return useMemo(() => { + const filteredAssets = filterAssets(evmBalances, [ + { + key: 'chainId', + opts: networkFilter, + filterCallback: 'inclusive', + }, + ]); + + const { nativeTokens, nonNativeTokens } = filteredAssets.reduce<{ + nativeTokens: TokenWithFiatAmount[]; + nonNativeTokens: TokenWithFiatAmount[]; + }>( + (acc, token) => { + if (token.isNative) { + acc.nativeTokens.push(token); + } else { + acc.nonNativeTokens.push(token); + } + return acc; + }, + { nativeTokens: [], nonNativeTokens: [] }, + ); + + const assets = [...nativeTokens, ...nonNativeTokens]; + return sortAssets(assets, tokenSortConfig); + }, [ + tokenSortConfig, + networkFilter, + conversionRate, + contractExchangeRates, + currentNetwork, + selectedAccount, + newTokensImported, + ]); +}; + +export default useSortedFilteredTokens; diff --git a/ui/components/app/assets/token-list/token-list.tsx b/ui/components/app/assets/token-list/token-list.tsx index 5555c8782f8a..d84444f38d33 100644 --- a/ui/components/app/assets/token-list/token-list.tsx +++ b/ui/components/app/assets/token-list/token-list.tsx @@ -1,225 +1,32 @@ -import React, { ReactNode, useEffect, useMemo } from 'react'; -import { shallowEqual, useSelector, useDispatch } from 'react-redux'; +import React, { ReactNode, useEffect } from 'react'; +import { useSelector } from 'react-redux'; import { Hex } from '@metamask/utils'; import TokenCell from '../token-cell'; -import { TEST_CHAINS } from '../../../../../shared/constants/network'; -import { sortAssets } from '../util/sort'; -import { - getChainIdsToPoll, - getCurrencyRates, - getCurrentNetwork, - getIsTestnet, - getIsTokenNetworkFilterEqualCurrentNetwork, - getMarketData, - getNetworkConfigurationIdByChainId, - getNewTokensImported, - getPreferences, - getSelectedAccount, - getSelectedAccountNativeTokenCachedBalanceByChainId, - getSelectedAccountTokensAcrossChains, - getShowFiatInTestnets, - getTokenExchangeRates, - getTokenNetworkFilter, -} from '../../../../selectors'; -import { getConversionRate } from '../../../../ducks/metamask/metamask'; -import { filterAssets } from '../util/filter'; -import { calculateTokenBalance } from '../util/calculateTokenBalance'; -import { calculateTokenFiatAmount } from '../util/calculateTokenFiatAmount'; +import { getChainIdsToPoll, getPreferences } from '../../../../selectors'; import { endTrace, TraceName } from '../../../../../shared/lib/trace'; -import { useTokenBalances } from '../../../../hooks/useTokenBalances'; -import { setTokenNetworkFilter } from '../../../../store/actions'; -import { useMultichainSelector } from '../../../../hooks/useMultichainSelector'; -import { getMultichainShouldShowFiat } from '../../../../selectors/multichain'; +import { useTokenBalances as pollAndUpdateEvmBalances } from '../../../../hooks/useTokenBalances'; +import useSortedFilteredTokens from '../hooks/useSortedFilteredTokens'; +import useShouldShowFiat from '../hooks/useShouldShowFiat'; type TokenListProps = { onTokenClick: (chainId: string, address: string) => void; nativeToken?: ReactNode; }; -export type Token = { - address: Hex; - aggregators: string[]; - chainId: Hex; - decimals: number; - isNative: boolean; - symbol: string; - image: string; -}; - -export type TokenWithFiatAmount = Token & { - tokenFiatAmount: number | null; - balance?: string; - string: string; // needed for backwards compatability TODO: fix this -}; - -export type AddressBalanceMapping = Record>; -export type ChainAddressMarketData = Record< - Hex, - Record> ->; - -const useFilteredAccountTokens = (currentNetwork: { chainId: string }) => { - const isTestNetwork = useMemo(() => { - return (TEST_CHAINS as string[]).includes(currentNetwork.chainId); - }, [currentNetwork.chainId, TEST_CHAINS]); - - const selectedAccountTokensChains: Record = useSelector( - getSelectedAccountTokensAcrossChains, - ) as Record; - - const filteredAccountTokensChains = useMemo(() => { - return Object.fromEntries( - Object.entries(selectedAccountTokensChains).filter(([chainId]) => - isTestNetwork - ? (TEST_CHAINS as string[]).includes(chainId) - : !(TEST_CHAINS as string[]).includes(chainId), - ), - ); - }, [selectedAccountTokensChains, isTestNetwork, TEST_CHAINS]); - - return filteredAccountTokensChains; -}; - export default function TokenList({ onTokenClick, nativeToken, }: TokenListProps) { - const dispatch = useDispatch(); - const currentNetwork = useSelector(getCurrentNetwork); - const allNetworks = useSelector(getNetworkConfigurationIdByChainId); - const { tokenSortConfig, privacyMode, hideZeroBalanceTokens } = - useSelector(getPreferences); - const tokenNetworkFilter = useSelector(getTokenNetworkFilter); - const selectedAccount = useSelector(getSelectedAccount); - const conversionRate = useSelector(getConversionRate); + const { privacyMode } = useSelector(getPreferences); const chainIdsToPoll = useSelector(getChainIdsToPoll); - const contractExchangeRates = useSelector( - getTokenExchangeRates, - shallowEqual, - ); - const newTokensImported = useSelector(getNewTokensImported); - const selectedAccountTokensChains = useFilteredAccountTokens(currentNetwork); - const isOnCurrentNetwork = useSelector( - getIsTokenNetworkFilterEqualCurrentNetwork, - ); - const { tokenBalances } = useTokenBalances({ + // EVM specific tokenBalance polling, updates state via polling loop per chainId + pollAndUpdateEvmBalances({ chainIds: chainIdsToPoll as Hex[], }); - const selectedAccountTokenBalancesAcrossChains = - tokenBalances[selectedAccount.address]; - const marketData: ChainAddressMarketData = useSelector( - getMarketData, - ) as ChainAddressMarketData; - - const currencyRates = useSelector(getCurrencyRates); - const nativeBalances: Record = useSelector( - getSelectedAccountNativeTokenCachedBalanceByChainId, - ) as Record; - const isTestnet = useSelector(getIsTestnet); - // Ensure newly added networks are included in the tokenNetworkFilter - useEffect(() => { - if (process.env.PORTFOLIO_VIEW) { - const allNetworkFilters = Object.fromEntries( - Object.keys(allNetworks).map((chainId) => [chainId, true]), - ); - if (Object.keys(tokenNetworkFilter).length > 1) { - dispatch(setTokenNetworkFilter(allNetworkFilters)); - } - } - }, [Object.keys(allNetworks).length]); - - const consolidatedBalances = () => { - const tokensWithBalance: TokenWithFiatAmount[] = []; - 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, - }) || '0'; - - const tokenFiatAmount = calculateTokenFiatAmount({ - token, - chainId, - balance, - marketData, - currencyRates, - }); - - // Respect the "hide zero balance" setting (when true): - // - Native tokens should always display with zero balance when on the current network filter. - // - Native tokens should not display with zero balance when on all networks filter - // - ERC20 tokens with zero balances should respect the setting on both the current and all networks. - - // Respect the "hide zero balance" setting (when false): - // - Native tokens should always display with zero balance when on the current network filter. - // - Native tokens should always display with zero balance when on all networks filter - // - ERC20 tokens always display with zero balance on both the current and all networks filter. - if ( - !hideZeroBalanceTokens || - balance !== '0' || - (token.isNative && isOnCurrentNetwork) - ) { - tokensWithBalance.push({ - ...token, - balance, - tokenFiatAmount, - chainId, - string: String(balance), - }); - } - }); - }, - ); - - return tokensWithBalance; - }; - - const sortedFilteredTokens = useMemo(() => { - const consolidatedTokensWithBalances = consolidatedBalances(); - const filteredAssets = filterAssets(consolidatedTokensWithBalances, [ - { - key: 'chainId', - opts: tokenNetworkFilter, - filterCallback: 'inclusive', - }, - ]); - - const { nativeTokens, nonNativeTokens } = filteredAssets.reduce<{ - nativeTokens: TokenWithFiatAmount[]; - nonNativeTokens: TokenWithFiatAmount[]; - }>( - (acc, token) => { - if (token.isNative) { - acc.nativeTokens.push(token); - } else { - acc.nonNativeTokens.push(token); - } - return acc; - }, - { nativeTokens: [], nonNativeTokens: [] }, - ); - const assets = [...nativeTokens, ...nonNativeTokens]; - return sortAssets(assets, tokenSortConfig); - }, [ - tokenSortConfig, - tokenNetworkFilter, - conversionRate, - contractExchangeRates, - currentNetwork, - selectedAccount, - selectedAccountTokensChains, - newTokensImported, - ]); + const sortedFilteredTokens = useSortedFilteredTokens(); + const shouldShowFiat = useShouldShowFiat(); useEffect(() => { if (sortedFilteredTokens) { @@ -232,28 +39,18 @@ export default function TokenList({ return React.cloneElement(nativeToken as React.ReactElement); } - const shouldShowFiat = useMultichainSelector( - getMultichainShouldShowFiat, - selectedAccount, - ); - const isMainnet = !isTestnet; - // Check if show conversion is enabled - const showFiatInTestnets = useSelector(getShowFiatInTestnets); - const showFiat = - shouldShowFiat && (isMainnet || (isTestnet && showFiatInTestnets)); - return (
- {sortedFilteredTokens.map((tokenData) => ( + {sortedFilteredTokens.map((token) => ( diff --git a/ui/components/app/assets/types.ts b/ui/components/app/assets/types.ts new file mode 100644 index 000000000000..a9bf642b3900 --- /dev/null +++ b/ui/components/app/assets/types.ts @@ -0,0 +1,25 @@ +import { Hex } from '@metamask/utils'; + +export type Token = { + address: Hex; + aggregators: string[]; + chainId: Hex; + decimals: number; + isNative: boolean; + symbol: string; + image: string; +}; + +export type TokenWithFiatAmount = Token & { + tokenFiatAmount: number | null; + balance?: string; + string: string; // needed for backwards compatability TODO: fix this +}; + +export type AddressBalanceMapping = Record>; +export type ChainAddressMarketData = Record< + Hex, + Record> +>; + +export type SymbolCurrencyRateMapping = Record>; diff --git a/ui/components/app/assets/util/calculateTokenBalance.ts b/ui/components/app/assets/util/calculateTokenBalance.ts index 3eb1fc7373e7..dd19feaf5f6d 100644 --- a/ui/components/app/assets/util/calculateTokenBalance.ts +++ b/ui/components/app/assets/util/calculateTokenBalance.ts @@ -2,7 +2,7 @@ import BN from 'bn.js'; import { Hex } from '@metamask/utils'; import { stringifyBalance } from '../../../../hooks/useTokenBalances'; import { hexToDecimal } from '../../../../../shared/modules/conversion.utils'; -import { AddressBalanceMapping } from '../token-list/token-list'; +import { AddressBalanceMapping } from '../types'; type CalculateTokenBalanceParams = { isNative: boolean; diff --git a/ui/components/app/assets/util/calculateTokenFiatAmount.ts b/ui/components/app/assets/util/calculateTokenFiatAmount.ts index 279fae37f582..5917665e82cd 100644 --- a/ui/components/app/assets/util/calculateTokenFiatAmount.ts +++ b/ui/components/app/assets/util/calculateTokenFiatAmount.ts @@ -1,7 +1,9 @@ import { Hex } from '@metamask/utils'; -import { ChainAddressMarketData, Token } from '../token-list/token-list'; - -type SymbolCurrencyRateMapping = Record>; +import { + ChainAddressMarketData, + SymbolCurrencyRateMapping, + Token, +} from '../types'; type CalculateTokenFiatAmountParams = { token: Token; diff --git a/ui/components/app/assets/util/importAllDetectedTokens.ts b/ui/components/app/assets/util/importAllDetectedTokens.ts new file mode 100644 index 000000000000..0e47b856a9ce --- /dev/null +++ b/ui/components/app/assets/util/importAllDetectedTokens.ts @@ -0,0 +1,47 @@ +import { + NetworkClientId, + NetworkConfiguration, +} from '@metamask/network-controller'; +import { Token } from '../types'; + +export const importAllDetectedTokens = async ( + isOnCurrentNetwork: boolean, + detectedTokensMultichain: { + [key: string]: Token[]; + }, + allNetworks: Record, + networkClientId: NetworkClientId, + currentChainId: string, + detectedTokens: Token[], + addImportedTokens: (tokens: Token[], networkClientId: string) => void, + trackTokenAddedEvent: (importedToken: Token, chainId: string) => void, +) => { + // TODO add event for MetaMetricsEventName.TokenAdded + + if (process.env.PORTFOLIO_VIEW && !isOnCurrentNetwork) { + const importPromises = Object.entries(detectedTokensMultichain).map( + async ([networkId, tokens]) => { + const chainConfig = allNetworks[networkId]; + const { defaultRpcEndpointIndex } = chainConfig; + const { networkClientId: networkInstanceId } = + chainConfig.rpcEndpoints[defaultRpcEndpointIndex]; + + await addImportedTokens(tokens as Token[], networkInstanceId); + + tokens.forEach((importedToken) => { + // when multichain is fully integrated, we should change these event signatures for analytics + trackTokenAddedEvent(importedToken, chainConfig.chainId); + }); + }, + ); + + await Promise.all(importPromises); + } else if (detectedTokens.length > 0) { + await addImportedTokens(detectedTokens, networkClientId); + + detectedTokens.forEach((importedToken: Token) => { + // when multichain is fully integrated, we should change these event signatures for analytics + trackTokenAddedEvent(importedToken, currentChainId); + }); + } +}; diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/types.ts b/ui/components/multichain/asset-picker-amount/asset-picker-modal/types.ts index 00a16b72e1b8..6fb9f3f7bc39 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/types.ts +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/types.ts @@ -8,7 +8,7 @@ import { CHAIN_ID_TO_CURRENCY_SYMBOL_MAP, CHAIN_ID_TOKEN_IMAGE_MAP, } from '../../../../../shared/constants/network'; -import { TokenWithFiatAmount } from '../../../app/assets/token-list/token-list'; +import { TokenWithFiatAmount } from '../../../app/assets/types'; export type NFT = { address: string; diff --git a/ui/hooks/bridge/useTokensWithFiltering.ts b/ui/hooks/bridge/useTokensWithFiltering.ts index 118ab13555ad..eaa07ded3674 100644 --- a/ui/hooks/bridge/useTokensWithFiltering.ts +++ b/ui/hooks/bridge/useTokensWithFiltering.ts @@ -19,7 +19,7 @@ import { import { AssetType } from '../../../shared/constants/transaction'; import { isNativeAddress } from '../../pages/bridge/utils/quote'; import { CHAIN_ID_TOKEN_IMAGE_MAP } from '../../../shared/constants/network'; -import { Token } from '../../components/app/assets/token-list/token-list'; +import { Token } from '../../components/app/assets/types'; import { useMultichainBalances } from '../useMultichainBalances'; import { useAsyncResult } from '../useAsyncResult'; import { fetchTopAssetsList } from '../../pages/swaps/swaps.util'; diff --git a/ui/hooks/useMultichainBalances.ts b/ui/hooks/useMultichainBalances.ts index b953043f7851..dbb0af52775a 100644 --- a/ui/hooks/useMultichainBalances.ts +++ b/ui/hooks/useMultichainBalances.ts @@ -9,10 +9,7 @@ import { getSelectedAccountTokensAcrossChains, selectERC20TokensByChain, } from '../selectors'; -import { - ChainAddressMarketData, - Token, -} from '../components/app/assets/token-list/token-list'; +import { ChainAddressMarketData, Token } from '../components/app/assets/types'; import { calculateTokenFiatAmount } from '../components/app/assets/util/calculateTokenFiatAmount'; import { calculateTokenBalance } from '../components/app/assets/util/calculateTokenBalance'; import { diff --git a/ui/pages/asset/asset.tsx b/ui/pages/asset/asset.tsx index 19f487987110..f8efd94c8bf2 100644 --- a/ui/pages/asset/asset.tsx +++ b/ui/pages/asset/asset.tsx @@ -8,7 +8,7 @@ import NftDetails from '../../components/app/assets/nfts/nft-details/nft-details import { getSelectedAccountTokensAcrossChains } from '../../selectors'; import { getNFTsByChainId } from '../../ducks/metamask/metamask'; import { DEFAULT_ROUTE } from '../../helpers/constants/routes'; -import { Token } from '../../components/app/assets/token-list/token-list'; +import { Token } from '../../components/app/assets/types'; import TokenAsset from './components/token-asset'; import { findAssetByAddress } from './util'; import NativeAsset from './components/native-asset'; diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index cf169592151a..fda9e5bf7aec 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -109,6 +109,8 @@ import { hasTransactionData } from '../../shared/modules/transaction.utils'; import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils'; import { createDeepEqualSelector } from '../../shared/modules/selectors/util'; import { isSnapIgnoredInProd } from '../helpers/utils/snaps'; +import { calculateTokenBalance } from '../components/app/assets/util/calculateTokenBalance'; +import { calculateTokenFiatAmount } from '../components/app/assets/util/calculateTokenFiatAmount'; import { getAllUnapprovedTransactions, getCurrentNetworkTransactions, @@ -564,6 +566,7 @@ export function getCrossChainMetaMaskCachedBalances(state) { return acc; }, {}); } + /** * Based on the current account address, return the balance for the native token of all chain networks on that account * @@ -2977,3 +2980,90 @@ export function getKeyringSnapAccounts(state) { return keyringAccounts; } ///: END:ONLY_INCLUDE_IF + +export const getTokenBalancesEvm = createDeepEqualSelector( + getSelectedAccountTokensAcrossChains, + getSelectedAccountNativeTokenCachedBalanceByChainId, + (state) => state.metamask.tokenBalances, + getMarketData, + getCurrencyRates, + getPreferences, + getIsTokenNetworkFilterEqualCurrentNetwork, + getSelectedAccount, + getCurrentNetwork, + ( + selectedAccountTokensChains, + nativeBalances, + tokenBalances, + marketData, + currencyRates, + preferences, + isOnCurrentNetwork, + selectedAccount, + currentNetwork, + ) => { + const { hideZeroBalanceTokens } = preferences; + const selectedAccountTokenBalancesAcrossChains = + tokenBalances[selectedAccount.address]; + + // we need to filter Testnets + const isTestNetwork = TEST_CHAINS.includes(currentNetwork.chainId); + const filteredAccountTokensChains = Object.fromEntries( + Object.entries(selectedAccountTokensChains).filter(([chainId]) => + isTestNetwork + ? TEST_CHAINS.includes(chainId) + : !TEST_CHAINS.includes(chainId), + ), + ); + const tokensWithBalance = []; + Object.entries(filteredAccountTokensChains).forEach( + ([stringChainKey, tokens]) => { + const chainId = stringChainKey; + tokens.forEach((token) => { + const { isNative, address, decimals } = token; + const balance = + calculateTokenBalance({ + isNative, + chainId, + address, + decimals, + nativeBalances, + selectedAccountTokenBalancesAcrossChains, + }) || '0'; + + const tokenFiatAmount = calculateTokenFiatAmount({ + token, + chainId, + balance, + marketData, + currencyRates, + }); + + // Respect the "hide zero balance" setting (when true): + // - Native tokens should always display with zero balance when on the current network filter. + // - Native tokens should not display with zero balance when on all networks filter + // - ERC20 tokens with zero balances should respect the setting on both the current and all networks. + + // Respect the "hide zero balance" setting (when false): + // - Native tokens should always display with zero balance when on the current network filter. + // - Native tokens should always display with zero balance when on all networks filter + // - ERC20 tokens always display with zero balance on both the current and all networks filter. + if ( + !hideZeroBalanceTokens || + balance !== '0' || + (token.isNative && isOnCurrentNetwork) + ) { + tokensWithBalance.push({ + ...token, + balance, + tokenFiatAmount, + chainId, + string: String(balance), + }); + } + }); + }, + ); + return tokensWithBalance; + }, +);