diff --git a/shared/constants/bridge.ts b/shared/constants/bridge.ts index b0676b7e66cd..505e62f6a99a 100644 --- a/shared/constants/bridge.ts +++ b/shared/constants/bridge.ts @@ -3,9 +3,14 @@ import { BRIDGE_DEV_API_BASE_URL, BRIDGE_PROD_API_BASE_URL, ChainId, + formatChainIdToCaip, } from '@metamask/bridge-controller'; import { MultichainNetworks } from './multichain/networks'; -import { CHAIN_IDS, NETWORK_TO_NAME_MAP } from './network'; +import { + CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP, + CHAIN_IDS, + NETWORK_TO_NAME_MAP, +} from './network'; const ALLOWED_MULTICHAIN_BRIDGE_CHAIN_IDS = [ MultichainNetworks.SOLANA, @@ -62,7 +67,18 @@ export const BRIDGE_API_BASE_URL = process.env.BRIDGE_USE_DEV_APIS ? BRIDGE_DEV_API_BASE_URL : BRIDGE_PROD_API_BASE_URL; -export const ETH_USDT_ADDRESS = '0xdac17f958d2ee523a2206206994597c13d831ec7'; +export const BRIDGE_CHAIN_ID_TO_NETWORK_IMAGE_MAP: Record< + (typeof ALLOWED_BRIDGE_CHAIN_IDS_IN_CAIP)[number], + string +> = ALLOWED_BRIDGE_CHAIN_IDS.reduce( + (acc, chainId) => { + acc[formatChainIdToCaip(chainId)] = + CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP[chainId]; + return acc; + }, + {} as Record<(typeof ALLOWED_BRIDGE_CHAIN_IDS_IN_CAIP)[number], string>, +); + export const NETWORK_TO_SHORT_NETWORK_NAME_MAP: Record< AllowedBridgeChainIds, string diff --git a/ui/ducks/bridge/actions.ts b/ui/ducks/bridge/actions.ts index 56fce0d2a3b8..20fc6f352206 100644 --- a/ui/ducks/bridge/actions.ts +++ b/ui/ducks/bridge/actions.ts @@ -143,7 +143,7 @@ export const setFromChain = ({ token = null, }: { chainId: Hex | CaipChainId; - token?: TokenPayload['payload']; + token?: TokenPayload['payload'] | null; }) => { return async ( dispatch: MetaMaskReduxDispatch, diff --git a/ui/ducks/bridge/types.ts b/ui/ducks/bridge/types.ts index c04477b4beb8..f783bbe05843 100644 --- a/ui/ducks/bridge/types.ts +++ b/ui/ducks/bridge/types.ts @@ -14,6 +14,7 @@ export type BridgeToken = { assetId?: CaipAssetType; symbol: string; image: string; + name: string; decimals: number; chainId: number | Hex | ChainId | CaipChainId; balance: string; // raw balance @@ -58,6 +59,7 @@ export type TokenPayload = { address: GenericQuoteRequest['srcTokenAddress']; symbol: string; decimals: number; + name?: string; chainId: Exclude; balance?: string; image?: string; @@ -66,5 +68,5 @@ export type TokenPayload = { assetId?: CaipAssetType; aggregators?: string[]; occurrences?: number; - } | null; + }; }; diff --git a/ui/ducks/bridge/utils.ts b/ui/ducks/bridge/utils.ts index 4d750631eb43..ee91b54fd0eb 100644 --- a/ui/ducks/bridge/utils.ts +++ b/ui/ducks/bridge/utils.ts @@ -289,13 +289,11 @@ const getTokenImage = (payload: TokenPayload['payload']) => { export const toBridgeToken = ( payload: TokenPayload['payload'], -): BridgeToken | null => { - if (!payload) { - return null; - } +): BridgeToken => { const caipChainId = formatChainIdToCaip(payload.chainId); return { ...payload, + name: payload.name ?? payload.symbol, balance: payload.balance ?? '0', chainId: payload.chainId, image: getTokenImage(payload), diff --git a/ui/pages/bridge/__snapshots__/index.test.tsx.snap b/ui/pages/bridge/__snapshots__/index.test.tsx.snap index d9e49483cf2b..bb8142e9c6ef 100644 --- a/ui/pages/bridge/__snapshots__/index.test.tsx.snap +++ b/ui/pages/bridge/__snapshots__/index.test.tsx.snap @@ -80,31 +80,38 @@ exports[`Bridge renders the component with initial props 1`] = ` /> @@ -187,31 +190,38 @@ exports[`Bridge renders the component with initial props 1`] = ` /> diff --git a/ui/pages/bridge/index.scss b/ui/pages/bridge/index.scss index a82d1b3a41cd..048ab310d606 100644 --- a/ui/pages/bridge/index.scss +++ b/ui/pages/bridge/index.scss @@ -2,6 +2,7 @@ @import 'prepare/index'; @import 'prepare/components/index'; +@import 'prepare/components/bridge-asset-picker/index'; @import 'quotes/index'; @import 'transaction-details/index'; @import 'awaiting-signatures/index'; diff --git a/ui/pages/bridge/layout/row.tsx b/ui/pages/bridge/layout/row.tsx index eeb94a7e06f7..aaff108a0e4c 100644 --- a/ui/pages/bridge/layout/row.tsx +++ b/ui/pages/bridge/layout/row.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Container, ContainerProps, + PolymorphicRef, } from '../../../components/component-library'; import { AlignItems, @@ -11,17 +12,23 @@ import { JustifyContent, } from '../../../helpers/constants/design-system'; -const Row = (props: ContainerProps<'div'>) => { - return ( - - ); -}; +const Row = React.forwardRef( + ( + props: ContainerProps, + ref?: PolymorphicRef, + ) => { + return ( + + ); + }, +); export default Row; 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 ac549f5c6f2f..b18ec3797397 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 @@ -27,31 +27,38 @@ exports[`PrepareBridgePage should render the component, with initial state 1`] = /> @@ -134,31 +137,38 @@ exports[`PrepareBridgePage should render the component, with initial state 1`] = /> @@ -252,31 +258,38 @@ exports[`PrepareBridgePage should render the component, with inputs set 1`] = ` /> @@ -359,31 +368,38 @@ exports[`PrepareBridgePage should render the component, with inputs set 1`] = ` /> diff --git a/ui/pages/bridge/prepare/bridge-input-group.tsx b/ui/pages/bridge/prepare/bridge-input-group.tsx index 02acb8e25aa5..f8375de5490c 100644 --- a/ui/pages/bridge/prepare/bridge-input-group.tsx +++ b/ui/pages/bridge/prepare/bridge-input-group.tsx @@ -10,8 +10,6 @@ import { TextField, TextFieldType, ButtonLink, - Button, - ButtonSize, } from '../../../components/component-library'; import { AssetPicker } from '../../../components/multichain/asset-picker-amount/asset-picker'; import { TabName } from '../../../components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-tabs'; @@ -47,7 +45,7 @@ import { import { formatBlockExplorerAddressUrl } from '../../../../shared/lib/multichain/networks'; import type { BridgeToken } from '../../../ducks/bridge/types'; import { getMultichainCurrentChainId } from '../../../selectors/multichain'; -import { BridgeAssetPickerButton } from './components/bridge-asset-picker-button'; +import { SelectedAssetButton } from './components/bridge-asset-picker/selected-asset-button'; export const BridgeInputGroup = ({ header, @@ -235,27 +233,15 @@ export const BridgeInputGroup = ({ isMultiselectEnabled={isMultiselectEnabled} isDestinationToken={isDestinationToken} > - {(onClickHandler, networkImageSrc) => - isAmountReadOnly && !token ? ( - - ) : ( - + ) : ( + <> ) } diff --git a/ui/pages/bridge/prepare/components/bridge-asset-picker-button.tsx b/ui/pages/bridge/prepare/components/bridge-asset-picker-button.tsx deleted file mode 100644 index 587b7fd149cb..000000000000 --- a/ui/pages/bridge/prepare/components/bridge-asset-picker-button.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import React from 'react'; -import { - SelectButtonProps, - SelectButtonSize, -} from '../../../../components/component-library/select-button/select-button.types'; -import { - AvatarNetwork, - AvatarNetworkSize, - AvatarToken, - BadgeWrapper, - IconName, - SelectButton, - Text, -} from '../../../../components/component-library'; -import { - AlignItems, - BackgroundColor, - BorderColor, - BorderRadius, - Display, - OverflowWrap, - TextVariant, -} from '../../../../helpers/constants/design-system'; -import { useI18nContext } from '../../../../hooks/useI18nContext'; -import { AssetPicker } from '../../../../components/multichain/asset-picker-amount/asset-picker'; -import { getNftImage } from '../../../../helpers/utils/nfts'; - -export const BridgeAssetPickerButton = ({ - asset, - networkProps, - networkImageSrc, - ...props -}: { - networkImageSrc?: string; -} & SelectButtonProps<'div'> & - Pick, 'asset' | 'networkProps'>) => { - const t = useI18nContext(); - - return ( - - {asset?.symbol ?? t('bridgeTo')} - - } - startAccessory={ - asset ? ( - - ) : undefined - } - > - {asset ? ( - - ) : undefined} - - ) : undefined - } - {...props} - /> - ); -}; diff --git a/ui/pages/bridge/prepare/components/bridge-asset-picker/asset.stories.tsx b/ui/pages/bridge/prepare/components/bridge-asset-picker/asset.stories.tsx new file mode 100644 index 000000000000..408e08400839 --- /dev/null +++ b/ui/pages/bridge/prepare/components/bridge-asset-picker/asset.stories.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { Provider, useSelector } from 'react-redux'; +import { AssetListItem } from './asset'; +import { getFromToken } from '../../../../../ducks/bridge/selectors'; +import { MultichainNetworks } from '../../../../../../shared/constants/multichain/networks'; +import { CHAIN_IDS } from '../../../../../../shared/constants/network'; +import { formatChainIdToCaip } from '@metamask/bridge-controller'; +import configureStore from '../../../../../store/store'; +import { createBridgeMockStore } from '../../../../../../test/data/bridge/mock-bridge-store'; + +const storybook = { + title: 'Pages/Bridge/AssetPicker', + component: AssetListItem, +}; + +const mockFeatureFlags = { + bridgeConfig: { + refreshRate: 30000, + priceImpactThreshold: { + normal: 1, + gasless: 2, + }, + maxRefreshCount: 5, + chainRanking: [ + { chainId: formatChainIdToCaip(CHAIN_IDS.MAINNET) }, + { chainId: formatChainIdToCaip(CHAIN_IDS.OPTIMISM) }, + { chainId: formatChainIdToCaip(CHAIN_IDS.POLYGON) }, + { chainId: MultichainNetworks.SOLANA }, + { chainId: MultichainNetworks.BITCOIN }, + { chainId: MultichainNetworks.TRON }, + ], + }, +}; +const mockBridgeSlice = { + toChainId: CHAIN_IDS.LINEA_MAINNET, + fromTokenInputValue: '1', +}; + +export const AssetListItemStory = () => { + const token = useSelector(getFromToken); + + return ( + token && ( + <> + + + + ) + ); +}; + +AssetListItemStory.storyName = 'AssetListItem'; +AssetListItemStory.decorators = [ + (Story) => ( + + + + ), +]; + +export default storybook; diff --git a/ui/pages/bridge/prepare/components/bridge-asset-picker/asset.tsx b/ui/pages/bridge/prepare/components/bridge-asset-picker/asset.tsx new file mode 100644 index 000000000000..98659d41e321 --- /dev/null +++ b/ui/pages/bridge/prepare/components/bridge-asset-picker/asset.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { + AvatarNetwork, + AvatarToken, + AvatarTokenSize, + BadgeWrapper, + Box, + BoxBackgroundColor, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react'; +import { formatChainIdToCaip } from '@metamask/bridge-controller'; +import { ContainerProps } from '../../../../../components/component-library'; +import { getCurrentCurrency } from '../../../../../ducks/metamask/metamask'; +import { getIntlLocale } from '../../../../../ducks/locale/locale'; +import { type BridgeToken } from '../../../../../ducks/bridge/types'; +import { + BRIDGE_CHAIN_ID_TO_NETWORK_IMAGE_MAP, + NETWORK_TO_SHORT_NETWORK_NAME_MAP, +} from '../../../../../../shared/constants/bridge'; +import { + AlignItems, + BackgroundColor, + BlockSize, + BorderRadius, +} from '../../../../../helpers/constants/design-system'; +import { Column, Row } from '../../../layout'; +import { formatCurrencyAmount, formatTokenAmount } from '../../../utils/quote'; + +export const AssetListItem = ({ + asset, + selected, + ...buttonProps +}: { + asset: BridgeToken; + selected: boolean; +} & ContainerProps<'button'>) => { + const currency = useSelector(getCurrentCurrency); + const locale = useSelector(getIntlLocale); + + return ( + + {selected && ( + + )} + + } + > + + + + + + {asset.symbol} + + {asset.tokenFiatAmount + ? formatCurrencyAmount( + asset.tokenFiatAmount.toString(), + currency, + 2, + ) + : ''} + + + + + + {asset.name ?? asset.symbol} + + + {asset.balance && asset.balance !== '0' + ? formatTokenAmount(locale, asset.balance, asset.symbol) + : ''} + + + + + ); +}; diff --git a/ui/pages/bridge/prepare/components/bridge-asset-picker/index.scss b/ui/pages/bridge/prepare/components/bridge-asset-picker/index.scss new file mode 100644 index 000000000000..7561c6d0bacb --- /dev/null +++ b/ui/pages/bridge/prepare/components/bridge-asset-picker/index.scss @@ -0,0 +1,14 @@ +// Hides the network avatar placeholder icon in the "All Networks" NetworkListItem +.bridge-network-list-popover { + .multichain-network-list-item:first-child { + .mm-avatar-base { + display: none; + } + } + .multichain-network-list-item { + .mm-avatar-base { + border-width: 0px; + border-radius: 8px; + } + } +} diff --git a/ui/pages/bridge/prepare/components/bridge-asset-picker/index.stories.tsx b/ui/pages/bridge/prepare/components/bridge-asset-picker/index.stories.tsx new file mode 100644 index 000000000000..92459d409faa --- /dev/null +++ b/ui/pages/bridge/prepare/components/bridge-asset-picker/index.stories.tsx @@ -0,0 +1,100 @@ +import React, { useState } from 'react'; +import { Provider, useDispatch, useSelector } from 'react-redux'; +import { SelectedAssetButton } from './selected-asset-button'; +import { + getFromAccount, + getFromChains, + getFromToken, +} from '../../../../../ducks/bridge/selectors'; +import { setFromToken } from '../../../../../ducks/bridge/actions'; +import { MultichainNetworks } from '../../../../../../shared/constants/multichain/networks'; +import { CHAIN_IDS } from '../../../../../../shared/constants/network'; +import { formatChainIdToCaip } from '@metamask/bridge-controller'; +import configureStore from '../../../../../store/store'; +import { createBridgeMockStore } from '../../../../../../test/data/bridge/mock-bridge-store'; +import { BridgeAssetPicker } from '.'; + +const storybook = { + title: 'Pages/Bridge/AssetPicker', + component: BridgeAssetPicker, +}; + +const mockFeatureFlags = { + bridgeConfig: { + refreshRate: 30000, + priceImpactThreshold: { + normal: 1, + gasless: 2, + }, + maxRefreshCount: 5, + chainRanking: [ + { chainId: formatChainIdToCaip(CHAIN_IDS.MAINNET) }, + { chainId: formatChainIdToCaip(CHAIN_IDS.OPTIMISM) }, + { chainId: formatChainIdToCaip(CHAIN_IDS.POLYGON) }, + { chainId: MultichainNetworks.SOLANA }, + { chainId: MultichainNetworks.BITCOIN }, + { chainId: MultichainNetworks.TRON }, + ], + }, +}; +const mockBridgeSlice = { + toChainId: CHAIN_IDS.LINEA_MAINNET, + fromTokenInputValue: '1', +}; + +export const BridgeAssetPickerStory = () => { + const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false); + const networks = useSelector(getFromChains); + const account = useSelector(getFromAccount); + const token = useSelector(getFromToken); + const dispatch = useDispatch(); + + if (!token || !account?.address) { + return null; + } + + return ( + <> + setIsAssetPickerOpen(false)} + onAssetChange={(asset) => { + dispatch(setFromToken(asset)); + }} + chainIds={networks.map((network) => + formatChainIdToCaip(network.chainId), + )} + accountAddress={account?.address} + /> + setIsAssetPickerOpen(true)} + asset={token} + data-testid={'test-id'} + /> + + ); +}; + +BridgeAssetPickerStory.storyName = 'BridgeAssetPicker'; +BridgeAssetPickerStory.decorators = [ + (Story) => ( + + + + ), +]; + +export default storybook; diff --git a/ui/pages/bridge/prepare/components/bridge-asset-picker/index.tsx b/ui/pages/bridge/prepare/components/bridge-asset-picker/index.tsx new file mode 100644 index 000000000000..8cfed31d01f5 --- /dev/null +++ b/ui/pages/bridge/prepare/components/bridge-asset-picker/index.tsx @@ -0,0 +1,248 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { + ButtonIconSize, + Icon, + IconColor, + IconName, + IconSize, +} from '@metamask/design-system-react'; +import { type CaipChainId } from '@metamask/utils'; +import { uniqBy } from 'lodash'; +import { formatChainIdToCaip } from '@metamask/bridge-controller'; +import { + BRIDGE_CHAIN_ID_TO_NETWORK_IMAGE_MAP, + NETWORK_TO_SHORT_NETWORK_NAME_MAP, +} from '../../../../../../shared/constants/bridge'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalBody, + ModalHeader, + PickerNetwork, + TextField, + ModalContentSize, +} from '../../../../../components/component-library'; +import { useI18nContext } from '../../../../../hooks/useI18nContext'; +import { + BackgroundColor, + BlockSize, + BorderColor, + BorderRadius, + Display, + FlexDirection, + TextVariant, +} from '../../../../../helpers/constants/design-system'; +import { getFromToken } from '../../../../../ducks/bridge/selectors'; +import { toBridgeToken } from '../../../../../ducks/bridge/utils'; +import { type BridgeToken } from '../../../../../ducks/bridge/types'; +import { NetworkPicker } from './network-picker'; +import { BridgeAssetList } from './lazy-asset-list'; + +export const BridgeAssetPicker = ({ + chainIds, + isOpen, + onClose, + onAssetChange, + header, + selectedAsset, + accountAddress, + ...assetListProps +}: { + isOpen: boolean; + accountAddress: string; + onClose: () => void; + header: string; + selectedAsset: BridgeToken; +} & Pick, 'chainIds'> & + Pick< + React.ComponentProps, + 'onAssetChange' | 'excludedAssetId' + >) => { + // TODO remove this when actual balances are provided + const fromToken = useSelector(getFromToken); + const assetsWithBalance = fromToken ? [fromToken] : []; + + const t = useI18nContext(); + + const networkPickerButtonRef = useRef(null); + const [isNetworkPickerOpen, setIsNetworkPickerOpen] = useState(false); + // This is the network that the user has selected from the dropdown + const [selectedChainId, setSelectedChainId] = useState( + null, + ); + + const chainIdsList = useMemo(() => { + return selectedChainId ? [selectedChainId] : chainIds; + }, [selectedChainId, chainIds]); + + const chainIdsSet = useMemo(() => { + return new Set(chainIdsList); + }, [chainIdsList]); + + const assetsToInclude = useMemo( + () => + uniqBy( + assetsWithBalance.concat(selectedAsset).filter((token) => { + const matchesChainIdFilter = chainIdsSet.has( + formatChainIdToCaip(token.chainId), + ); + + return matchesChainIdFilter; + }), + (a) => a.assetId?.toLowerCase(), + ), + // Ignore warnings about assetsWithBalance to prevent re-fetching token list excessively + [chainIdsSet, selectedAsset], + ); + + // TODO call usePopularTokens hook here + const popularTokensList = assetsToInclude.map(toBridgeToken); + const isPopularTokensLoading = false; + + const selectedNetworkName = selectedChainId + ? NETWORK_TO_SHORT_NETWORK_NAME_MAP[selectedChainId] + : t('allNetworks'); + + const [searchQuery, setSearchQuery] = useState(''); + const handleClose = useCallback(() => { + setSearchQuery(''); + setIsNetworkPickerOpen(false); + onClose(); + }, [onClose]); + + return ( + <> + + + + + {header} + + + + isNetworkPickerOpen + ? setIsNetworkPickerOpen(false) + : setIsNetworkPickerOpen(true) + } + data-testid="multichain-asset-picker__network" + marginInline={4} + paddingLeft={4} + paddingRight={4} + backgroundColor={BackgroundColor.backgroundMuted} + borderRadius={BorderRadius.XL} + width={BlockSize.Max} + style={{ minHeight: 32 }} + /> + { + setSelectedChainId(chainId); + setIsNetworkPickerOpen(false); + }} + onClose={() => setIsNetworkPickerOpen(false)} + /> + { + setSearchQuery(e.target.value); + }} + borderRadius={BorderRadius.XL} + borderWidth={1} + borderColor={BorderColor.borderMuted} + inputProps={{ + disableStateStyles: true, + textVariant: TextVariant.bodyMd, + paddingRight: 2, + borderColor: BorderColor.borderMuted, + }} + style={{ + minHeight: 48, + paddingRight: 8, + outline: 'none', + borderColor: BorderColor.borderMuted, + }} + marginInline={4} + startAccessory={ + + } + /> + + {!isNetworkPickerOpen && selectedAsset.assetId && ( + { + handleClose(); + onAssetChange(asset); + }} + {...assetListProps} + /> + )} + + + + + ); +}; diff --git a/ui/pages/bridge/prepare/components/bridge-asset-picker/lazy-asset-list.tsx b/ui/pages/bridge/prepare/components/bridge-asset-picker/lazy-asset-list.tsx new file mode 100644 index 000000000000..8fb75088d5c5 --- /dev/null +++ b/ui/pages/bridge/prepare/components/bridge-asset-picker/lazy-asset-list.tsx @@ -0,0 +1,105 @@ +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { type CaipAssetType } from '@metamask/utils'; +import { FontWeight, Text, TextColor } from '@metamask/design-system-react'; +import { type BridgeToken } from '../../../../../ducks/bridge/types'; +import { BackgroundColor } from '../../../../../helpers/constants/design-system'; +import { Column } from '../../../layout'; +import { AssetListItem } from './asset'; +import { LoadingSkeleton } from './loading-skeleton'; + +export const BridgeAssetList = ({ + popularTokensList, + isPopularTokensLoading, + onAssetChange, + selectedAssetId, + excludedAssetId, + searchQuery, +}: { + popularTokensList: BridgeToken[]; + isPopularTokensLoading: boolean; + assetsToInclude: BridgeToken[]; + onAssetChange: (asset: BridgeToken) => void; + selectedAssetId: CaipAssetType; + excludedAssetId?: CaipAssetType; + searchQuery: string; +} & React.ComponentProps) => { + // TODO call useTokenSearchResults hook here + const searchResults = popularTokensList; + const isSearchResultsLoading = false; + const hasMoreResults = true; + // eslint-disable-next-line no-empty-function + const onFetchMoreResults = (_: string) => {}; + + const loadingRef = useRef(null); + const handleObserver = useCallback( + (entries: IntersectionObserverEntry[]) => { + const target = entries[0]; + + if (target.isIntersecting && hasMoreResults && !isSearchResultsLoading) { + onFetchMoreResults(searchQuery); + } + }, + [hasMoreResults, searchQuery, isSearchResultsLoading, onFetchMoreResults], + ); + + useEffect(() => { + const observer = new IntersectionObserver(handleObserver, { + threshold: 0.1, + }); + if (loadingRef.current) { + observer.observe(loadingRef.current); + } + return () => { + observer.disconnect(); + }; + }, [handleObserver]); + + /** + * Whether to show the loading indicator + * When the indicator is visible, the next page of search results will be fetched + */ + const shouldShowLoadingIndicator = + isPopularTokensLoading || hasMoreResults || isSearchResultsLoading; + /** + * If there is a search query, use the search results, otherwise use the popular token list + * Filter out the excluded asset + */ + const filteredTokenList = useMemo( + () => + (searchQuery.length > 0 ? searchResults : popularTokensList).filter( + (token) => + token.assetId?.toLowerCase() !== excludedAssetId?.toLowerCase(), + ), + [searchQuery.length, searchResults, popularTokensList, excludedAssetId], + ); + + return ( + + {filteredTokenList.map((token) => ( + { + onAssetChange(token); + }} + selected={selectedAssetId === token.assetId} + /> + ))} + {filteredTokenList.length < 1 && !shouldShowLoadingIndicator ? ( + + No tokens match "{searchQuery}" + + ) : ( + + )} + + ); +}; diff --git a/ui/pages/bridge/prepare/components/bridge-asset-picker/loading-skeleton.stories.tsx b/ui/pages/bridge/prepare/components/bridge-asset-picker/loading-skeleton.stories.tsx new file mode 100644 index 000000000000..ec0c80f41c87 --- /dev/null +++ b/ui/pages/bridge/prepare/components/bridge-asset-picker/loading-skeleton.stories.tsx @@ -0,0 +1,69 @@ +import React, { useState } from 'react'; +import { Provider, useSelector } from 'react-redux'; +import { LoadingSkeleton } from './loading-skeleton'; +import { + getFromToken, +} from '../../../../../ducks/bridge/selectors'; +import { MultichainNetworks } from '../../../../../../shared/constants/multichain/networks'; +import { CHAIN_IDS } from '../../../../../../shared/constants/network'; +import { formatChainIdToCaip } from '@metamask/bridge-controller'; +import configureStore from '../../../../../store/store'; +import { createBridgeMockStore } from '../../../../../../test/data/bridge/mock-bridge-store'; + +const storybook = { + title: 'Pages/Bridge/AssetPicker', + component: LoadingSkeleton, +}; + +const mockFeatureFlags = { + bridgeConfig: { + refreshRate: 30000, + priceImpactThreshold: { + normal: 1, + gasless: 2, + }, + maxRefreshCount: 5, + chainRanking: [ + { chainId: formatChainIdToCaip(CHAIN_IDS.MAINNET) }, + { chainId: formatChainIdToCaip(CHAIN_IDS.OPTIMISM) }, + { chainId: formatChainIdToCaip(CHAIN_IDS.POLYGON) }, + { chainId: MultichainNetworks.SOLANA }, + { chainId: MultichainNetworks.BITCOIN }, + { chainId: MultichainNetworks.TRON }, + ], + }, +}; +const mockBridgeSlice = { + toChainId: CHAIN_IDS.LINEA_MAINNET, + fromTokenInputValue: '1', +}; + +export const LoadingSkeletonStory = () => { + return ( + <> + + + ); +}; + +LoadingSkeletonStory.storyName = 'LoadingSkeleton'; +// DefaultStory.decorators = [ +// (Story) => ( +// +// +// +// ), +// ]; + +export default storybook; diff --git a/ui/pages/bridge/prepare/components/bridge-asset-picker/loading-skeleton.tsx b/ui/pages/bridge/prepare/components/bridge-asset-picker/loading-skeleton.tsx new file mode 100644 index 000000000000..02d591b5d7dd --- /dev/null +++ b/ui/pages/bridge/prepare/components/bridge-asset-picker/loading-skeleton.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { + Skeleton, + SkeletonProps, +} from '../../../../../components/component-library/skeleton'; +import { + BlockSize, + BorderRadius, +} from '../../../../../helpers/constants/design-system'; +import { PolymorphicRef } from '../../../../../components/component-library'; +import { Column, Row } from '../../../layout'; + +export const LoadingSkeleton = React.forwardRef( + ( + props: SkeletonProps, + ref?: PolymorphicRef, + ) => { + return ( + + + + + + + + ); + }, +); diff --git a/ui/pages/bridge/prepare/components/bridge-asset-picker/network-picker.stories.tsx b/ui/pages/bridge/prepare/components/bridge-asset-picker/network-picker.stories.tsx new file mode 100644 index 000000000000..e98b3e328136 --- /dev/null +++ b/ui/pages/bridge/prepare/components/bridge-asset-picker/network-picker.stories.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { Provider, useSelector } from 'react-redux'; +import { NetworkPicker } from './network-picker'; +import { getFromChains } from '../../../../../ducks/bridge/selectors'; +import { MultichainNetworks } from '../../../../../../shared/constants/multichain/networks'; +import { CHAIN_IDS } from '../../../../../../shared/constants/network'; +import { formatChainIdToCaip } from '@metamask/bridge-controller'; +import configureStore from '../../../../../store/store'; +import { createBridgeMockStore } from '../../../../../../test/data/bridge/mock-bridge-store'; + +const storybook = { + title: 'Pages/Bridge/AssetPicker', + component: NetworkPicker, +}; + +const mockFeatureFlags = { + bridgeConfig: { + refreshRate: 30000, + priceImpactThreshold: { + normal: 1, + gasless: 2, + }, + maxRefreshCount: 5, + chainRanking: [ + { chainId: formatChainIdToCaip(CHAIN_IDS.MAINNET) }, + { chainId: formatChainIdToCaip(CHAIN_IDS.OPTIMISM) }, + { chainId: formatChainIdToCaip(CHAIN_IDS.POLYGON) }, + { chainId: MultichainNetworks.SOLANA }, + { chainId: MultichainNetworks.BITCOIN }, + { chainId: MultichainNetworks.TRON }, + ], + }, +}; +const mockBridgeSlice = { + toChainId: CHAIN_IDS.LINEA_MAINNET, + fromTokenInputValue: '1', +}; + +export const NetworkPickerStory = () => { + const networks = useSelector(getFromChains); + + return ( + networks && ( + + formatChainIdToCaip(network.chainId), + )} + selectedChainId={'eip155:1'} + onNetworkChange={() => {}} + buttonElement={null} + isOpen={true} + onClose={() => {}} + /> + ) + ); +}; + +NetworkPickerStory.storyName = 'NetworkPicker'; +NetworkPickerStory.decorators = [ + (Story) => ( + + + + ), +]; + +export default storybook; diff --git a/ui/pages/bridge/prepare/components/bridge-asset-picker/network-picker.tsx b/ui/pages/bridge/prepare/components/bridge-asset-picker/network-picker.tsx new file mode 100644 index 000000000000..01e41287b323 --- /dev/null +++ b/ui/pages/bridge/prepare/components/bridge-asset-picker/network-picker.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { + AvatarIcon, + AvatarIconSize, + IconColor, + IconName, +} from '@metamask/design-system-react'; +import { type CaipChainId } from '@metamask/utils'; +import { + BRIDGE_CHAIN_ID_TO_NETWORK_IMAGE_MAP, + NETWORK_TO_SHORT_NETWORK_NAME_MAP, +} from '../../../../../../shared/constants/bridge'; +import { Popover } from '../../../../../components/component-library'; +import { NetworkListItem } from '../../../../../components/multichain'; +import { useI18nContext } from '../../../../../hooks/useI18nContext'; +import { + BackgroundColor, + BorderRadius, +} from '../../../../../helpers/constants/design-system'; +import { Column } from '../../../layout'; + +export const NetworkPicker = ({ + chainIds, + selectedChainId, + onNetworkChange, + buttonElement, + isOpen, + onClose, +}: { + chainIds: CaipChainId[]; + selectedChainId: CaipChainId | null; + onNetworkChange: (chainId: CaipChainId | null) => void; + buttonElement: HTMLElement | null; + isOpen: boolean; + onClose: () => void; +}) => { + const t = useI18nContext(); + + return ( + <> + + + { + onNetworkChange(null); + }} + startAccessory={ + + } + /> + {chainIds.map((chainId) => ( + { + onNetworkChange(chainId); + }} + /> + ))} + + + + ); +}; diff --git a/ui/pages/bridge/prepare/components/bridge-asset-picker/selected-asset-button.tsx b/ui/pages/bridge/prepare/components/bridge-asset-picker/selected-asset-button.tsx new file mode 100644 index 000000000000..a3173d77451e --- /dev/null +++ b/ui/pages/bridge/prepare/components/bridge-asset-picker/selected-asset-button.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { formatChainIdToCaip } from '@metamask/bridge-controller'; +import { + AvatarNetwork, + AvatarNetworkSize, + AvatarToken, + BadgeWrapper, +} from '@metamask/design-system-react'; +import { + SelectButtonProps, + SelectButtonSize, +} from '../../../../../components/component-library/select-button/select-button.types'; +import { + AlignItems, + BackgroundColor, + BorderColor, + BorderRadius, + Display, + OverflowWrap, +} from '../../../../../helpers/constants/design-system'; +import { BridgeToken } from '../../../../../ducks/bridge/types'; +import { + IconName, + SelectButton, +} from '../../../../../components/component-library'; +import { + BRIDGE_CHAIN_ID_TO_NETWORK_IMAGE_MAP, + NETWORK_TO_SHORT_NETWORK_NAME_MAP, +} from '../../../../../../shared/constants/bridge'; + +export const SelectedAssetButton = ({ + asset, + ...props +}: { + asset: BridgeToken; +} & SelectButtonProps<'div'>) => { + const caipChainId = formatChainIdToCaip(asset.chainId); + + return ( + + } + > + + + } + {...props} + /> + ); +}; diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx index 4d1ba4560a5b..b3979c74ee1d 100644 --- a/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx +++ b/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx @@ -1,10 +1,12 @@ import React from 'react'; import type { Provider } from '@metamask/network-controller'; import { act } from '@testing-library/react'; +import { formatChainIdToCaip } from '@metamask/bridge-controller'; import * as reactRouterUtils from 'react-router-dom'; import { userEvent } from '@testing-library/user-event'; import { toEvmCaipChainId } from '@metamask/multichain-network-controller'; import { renderWithProvider } from '../../../../test/lib/render-helpers-navigate'; +import { toAssetId } from '../../../../shared/lib/asset-utils'; import configureStore from '../../../store/store'; import { createBridgeMockStore } from '../../../../test/data/bridge/mock-bridge-store'; import { CHAIN_IDS } from '../../../../shared/constants/network'; @@ -118,12 +120,21 @@ describe('PrepareBridgePage', () => { address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', decimals: 6, chainId: CHAIN_IDS.MAINNET, + assetId: toAssetId( + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + formatChainIdToCaip(CHAIN_IDS.MAINNET), + ), }, toToken: { iconUrl: 'http://url', symbol: 'UNI', address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', decimals: 6, + chainId: CHAIN_IDS.LINEA_MAINNET, + assetId: toAssetId( + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + formatChainIdToCaip(CHAIN_IDS.LINEA_MAINNET), + ), }, toChainId: toEvmCaipChainId(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 6b47d84650f6..3529ff6de421 100644 --- a/ui/pages/bridge/prepare/prepare-bridge-page.tsx +++ b/ui/pages/bridge/prepare/prepare-bridge-page.tsx @@ -215,6 +215,17 @@ const PrepareBridgePage = ({ const { openBuyCryptoInPdapp } = useRamps(); const { tokenAlert } = useTokenAlerts(); + const securityWarnings: string[] = useMemo(() => { + const warnings: string[] = []; + if (tokenAlert?.description) { + warnings.push(tokenAlert.description); + } + if (txAlert?.description) { + warnings.push(txAlert.description); + } + return warnings; + }, [tokenAlert?.description, txAlert?.description]); + const { selectedDestinationAccount, setSelectedDestinationAccount, @@ -380,9 +391,7 @@ const PrepareBridgePage = ({ token_symbol_destination: toToken?.symbol ?? '', // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention - security_warnings: [txAlert?.descriptionId, tokenAlert?.titleId].filter( - Boolean, - ) as string[], + security_warnings: securityWarnings, // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention usd_amount_source: fromAmountInCurrency.usd.toNumber(), @@ -471,9 +480,6 @@ const PrepareBridgePage = ({ }; dispatch(setFromToken(bridgeToken)); dispatch(setFromTokenInputValue(null)); - if (token.address === toToken?.address) { - dispatch(setToToken(null)); - } }} networkProps={{ // @ts-expect-error other network fields are not used by the asset picker @@ -571,52 +577,50 @@ const PrepareBridgePage = ({ } onClick={() => { dispatch(setSelectedQuote(null)); - if (!toChain) { + if (!toChain || !fromToken || !toToken) { return; } // Track the flip event - fromToken && - toToken && - dispatch( - trackUnifiedSwapBridgeEvent( - UnifiedSwapBridgeEventName.InputSourceDestinationSwitched, - { - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - token_symbol_source: toToken?.symbol ?? null, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - token_symbol_destination: fromToken?.symbol ?? null, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - token_address_source: - toToken?.assetId ?? - toAssetId( - toToken.address ?? '', - formatChainIdToCaip(toToken.chainId ?? ''), - ) ?? - getNativeAssetForChainId(toChain.chainId)?.assetId, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - token_address_destination: - toAssetId( - fromToken.address ?? '', - formatChainIdToCaip(fromToken.chainId ?? ''), - ) ?? null, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_id_source: formatChainIdToCaip(toChain.chainId), - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_id_destination: fromChain?.chainId - ? formatChainIdToCaip(fromChain?.chainId) - : null, - // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 - // eslint-disable-next-line @typescript-eslint/naming-convention - security_warnings: [], - }, - ), - ); + dispatch( + trackUnifiedSwapBridgeEvent( + UnifiedSwapBridgeEventName.InputSourceDestinationSwitched, + { + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + token_symbol_source: toToken?.symbol ?? null, + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + token_symbol_destination: fromToken?.symbol ?? null, + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + token_address_source: + toToken?.assetId ?? + toAssetId( + toToken.address ?? '', + formatChainIdToCaip(toToken.chainId ?? ''), + ) ?? + getNativeAssetForChainId(toChain.chainId)?.assetId, + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + token_address_destination: + toAssetId( + fromToken.address ?? '', + formatChainIdToCaip(fromToken.chainId ?? ''), + ) ?? null, + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_id_source: formatChainIdToCaip(toChain.chainId), + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_id_destination: fromChain?.chainId + ? formatChainIdToCaip(fromChain?.chainId) + : null, + // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 + // eslint-disable-next-line @typescript-eslint/naming-convention + security_warnings: securityWarnings, + }, + ), + ); setRotateSwitchTokens(!rotateSwitchTokens); @@ -756,7 +760,7 @@ const PrepareBridgePage = ({ token_symbol_destination: toToken?.symbol ?? '', // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention - security_warnings: [], // TODO populate security warnings + security_warnings: securityWarnings, // TODO: Fix in https://github.com/MetaMask/metamask-extension/issues/31860 // eslint-disable-next-line @typescript-eslint/naming-convention usd_amount_source: fromAmountInCurrency.usd.toNumber(), diff --git a/ui/pages/bridge/utils/slippage-service.test.ts b/ui/pages/bridge/utils/slippage-service.test.ts index 98310452e0c7..b808ce9e0099 100644 --- a/ui/pages/bridge/utils/slippage-service.test.ts +++ b/ui/pages/bridge/utils/slippage-service.test.ts @@ -16,6 +16,7 @@ describe('Slippage Service', () => { decimals: 6, image: '', balance: '0', + name: 'USDC', }; const mockUSDT: BridgeToken = { @@ -25,6 +26,7 @@ describe('Slippage Service', () => { decimals: 6, image: '', balance: '0', + name: 'USDT', }; const mockWETH: BridgeToken = { @@ -34,6 +36,7 @@ describe('Slippage Service', () => { decimals: 18, image: '', balance: '0', + name: 'WETH', }; const mockSolanaToken: BridgeToken = { @@ -43,6 +46,7 @@ describe('Slippage Service', () => { decimals: 9, image: '', balance: '0', + name: 'SOL', }; describe('calculateSlippage', () => {