UNPKG

@reservoir0x/relay-kit-ui

Version:

Relay is the Fastest and Cheapest Way to Bridge and Transact Across Chains.

411 lines 24.1 kB
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; import { useCallback, useEffect, useMemo, useState } from 'react'; import { Flex, Text, Input, Box } from '../../primitives/index.js'; import { Modal } from '../Modal.js'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faMagnifyingGlass, faFolderOpen } from '@fortawesome/free-solid-svg-icons'; import {} from './ChainFilter.js'; import useRelayClient from '../../../hooks/useRelayClient.js'; import { isAddress } from 'viem'; import { useDebounceState, useDuneBalances } from '../../../hooks/index.js'; import { useMediaQuery } from 'usehooks-ts'; import { useTokenList } from '@reservoir0x/relay-kit-hooks'; import { EventNames } from '../../../constants/events.js'; import { UnverifiedTokenModal } from '../UnverifiedTokenModal.js'; import { useEnhancedTokensList } from '../../../hooks/useEnhancedTokensList.js'; import ChainFilter from './ChainFilter.js'; import { TokenList } from './TokenList.js'; import { UnsupportedDepositAddressChainIds } from '../../../constants/depositAddresses.js'; import { getRelayUiKitData } from '../../../utils/localStorage.js'; import { isValidAddress as isValidAddressUtil } from '../../../utils/address.js'; import { AccessibleList, AccessibleListItem } from '../../primitives/AccessibleList.js'; import { eclipse, solana } from '../../../utils/solana.js'; import { bitcoin } from '../../../utils/bitcoin.js'; import { ChainFilterSidebar } from './ChainFilterSidebar.js'; import { SuggestedTokens } from './SuggestedTokens.js'; import { convertApiCurrencyToToken, mergeTokenLists } from '../../../utils/tokens.js'; import { bitcoinDeadAddress, evmDeadAddress, solDeadAddress } from '@reservoir0x/relay-sdk'; import { getInitialChainFilter, sortChains } from '../../../utils/tokenSelector.js'; import { useInternalRelayChains } from '../../../hooks/index.js'; import { useTrendingCurrencies } from '@reservoir0x/relay-kit-hooks'; const TokenSelector = ({ token, trigger, chainIdsFilter, lockedChainIds, context, address, isValidAddress, multiWalletSupportEnabled = false, fromChainWalletVMSupported, supportedWalletVMs, popularChainIds, setToken, onAnalyticEvent }) => { const relayClient = useRelayClient(); const { chains: allRelayChains } = useInternalRelayChains(); const isDesktop = useMediaQuery('(min-width: 660px)'); const [open, setOpen] = useState(false); const [unverifiedTokenModalOpen, setUnverifiedTokenModalOpen] = useState(false); const [unverifiedToken, setUnverifiedToken] = useState(); const [chainFilter, setChainFilter] = useState({ id: undefined, name: 'All Chains' }); const { value: tokenSearchInput, debouncedValue: debouncedTokenSearchValue, setValue: setTokenSearchInput } = useDebounceState('', 500); const depositAddressOnly = context === 'from' ? chainFilter?.vmType ? !supportedWalletVMs?.includes(chainFilter.vmType) : !chainFilter.id ? false : !fromChainWalletVMSupported && chainFilter.id === token?.chainId : !fromChainWalletVMSupported; const isReceivingDepositAddress = depositAddressOnly && context === 'to'; // Configure chains const configuredChains = useMemo(() => { let chains = allRelayChains?.filter((chain) => relayClient?.chains?.find((relayChain) => relayChain.id === chain.id)) ?? []; if (!multiWalletSupportEnabled && context === 'from') { chains = chains.filter((chain) => chain.vmType === 'evm'); } if (isReceivingDepositAddress) { chains = chains.filter(({ id }) => !UnsupportedDepositAddressChainIds.includes(id)); } return sortChains(chains); }, [ allRelayChains, relayClient?.chains, multiWalletSupportEnabled, context, depositAddressOnly ]); const configuredChainIds = useMemo(() => { if (lockedChainIds) { return lockedChainIds; } let _chainIds = configuredChains.map((chain) => chain.id); if (chainIdsFilter) { _chainIds = _chainIds.filter((id) => !chainIdsFilter.includes(id)); } return _chainIds; }, [configuredChains, lockedChainIds, chainIdsFilter, depositAddressOnly]); const hasMultipleConfiguredChainIds = configuredChainIds.length > 1; const chainFilterOptions = context === 'from' ? configuredChains?.filter((chain) => (chain.vmType === 'evm' || chain.vmType === 'suivm' || chain.vmType === 'tvm' || chain.vmType === 'hypevm' || chain.id === solana.id || chain.id === eclipse.id || chain.id === bitcoin.id) && configuredChainIds.includes(chain.id)) : configuredChains?.filter((chain) => configuredChainIds.includes(chain.id)); const allChains = [ ...(isReceivingDepositAddress ? [] : [{ id: undefined, name: 'All Chains' }]), ...chainFilterOptions ]; const useDefaultTokenList = debouncedTokenSearchValue === ''; // Get user's token balances const { data: duneTokens, balanceMap: tokenBalances, isLoading: isLoadingBalances } = useDuneBalances(address && address !== evmDeadAddress && address !== solDeadAddress && address !== bitcoinDeadAddress && isValidAddress ? address : undefined, relayClient?.baseApiUrl?.includes('testnet') ? 'testnet' : 'mainnet', { staleTime: 60000, gcTime: 60000 }); // Filter dune token balances based on configured chains const filteredDuneTokenBalances = useMemo(() => { return duneTokens?.balances?.filter((balance) => configuredChainIds.includes(balance.chain_id)); }, [duneTokens?.balances, configuredChainIds]); const userTokensQuery = useMemo(() => { if (filteredDuneTokenBalances && filteredDuneTokenBalances.length > 0) { return filteredDuneTokenBalances.map((balance) => `${balance.chain_id}:${balance.address}`); } return undefined; }, [filteredDuneTokenBalances]); // Get user's tokens from currencies api const { data: userTokens, isLoading: isLoadingUserTokens } = useTokenList(relayClient?.baseApiUrl, userTokensQuery ? { tokens: userTokensQuery, limit: 100, depositAddressOnly, referrer: relayClient?.source } : undefined, { enabled: !!filteredDuneTokenBalances }); const isSearchTermValidAddress = isValidAddressUtil(chainFilter.vmType, debouncedTokenSearchValue, chainFilter.id); const { data: trendingTokens, isLoading: isLoadingTrendingTokens } = useTrendingCurrencies(relayClient?.baseApiUrl, { referrer: relayClient?.source }, { enabled: context === 'to' }); // Get main token list const { data: tokenList, isLoading: isLoadingTokenList } = useTokenList(relayClient?.baseApiUrl, { chainIds: chainFilter.id ? [chainFilter.id] : configuredChains.map((c) => c.id), address: isSearchTermValidAddress ? debouncedTokenSearchValue : undefined, term: !isSearchTermValidAddress ? debouncedTokenSearchValue : undefined, defaultList: useDefaultTokenList && !depositAddressOnly, limit: 12, depositAddressOnly, referrer: relayClient?.source }); // Get external token list for search const { data: externalTokenList, isLoading: isLoadingExternalList } = useTokenList(relayClient?.baseApiUrl, { chainIds: chainFilter.id ? [chainFilter.id] : configuredChains.map((c) => c.id), address: isSearchTermValidAddress ? debouncedTokenSearchValue : undefined, term: !isSearchTermValidAddress ? debouncedTokenSearchValue : undefined, defaultList: false, limit: 12, useExternalSearch: true, referrer: relayClient?.source }, { enabled: !!debouncedTokenSearchValue && !depositAddressOnly }); // Merge token lists when searching const combinedTokenList = useMemo(() => { if (!debouncedTokenSearchValue) return tokenList; return mergeTokenLists([tokenList, externalTokenList]); }, [tokenList, externalTokenList, debouncedTokenSearchValue]); const sortedUserTokens = useEnhancedTokensList(userTokens, tokenBalances, context, multiWalletSupportEnabled, chainFilter.id, true); const sortedTrendingTokens = useEnhancedTokensList(trendingTokens, tokenBalances, 'to', multiWalletSupportEnabled, undefined, false); const sortedCombinedTokens = useEnhancedTokensList(combinedTokenList, tokenBalances, context, multiWalletSupportEnabled, chainFilter.id, false); const [chainSearchInputElement, setChainSearchInputElement] = useState(null); const [tokenSearchInputElement, setTokenSearchInputElement] = useState(null); const inputElement = hasMultipleConfiguredChainIds ? chainSearchInputElement : tokenSearchInputElement; const resetState = useCallback(() => { setTokenSearchInput(''); setChainSearchInputElement(null); setTokenSearchInputElement(null); }, []); const onOpenChange = useCallback((openChange) => { let tokenCount = undefined; let usdcCount = 0; let usdtCount = 0; let ethCount = 0; try { if (!isLoadingBalances && tokenBalances) { tokenCount = Object.keys(tokenBalances).length; Object.values(tokenBalances).forEach((token) => { const tokenSymbol = token.symbol ? token.symbol.toLowerCase() : token.symbol; if (tokenSymbol === 'usdc') { usdcCount += 1; } else if (tokenSymbol === 'usdt') { usdtCount += 1; } else if (tokenSymbol === 'eth') { ethCount += 1; } }); } onAnalyticEvent?.(openChange ? EventNames.SWAP_START_TOKEN_SELECT : EventNames.SWAP_EXIT_TOKEN_SELECT, { direction: context === 'from' ? 'input' : 'output', ...(!openChange && { balanceData: { tokenCount, usdcCount, usdtCount, ethCount, balanceAddress: address } }) }); } catch (error) { console.error(error); } if (openChange) { // Set the initial chain filter before opening the modal const chainFilter = getInitialChainFilter(chainFilterOptions, context, depositAddressOnly, token); setChainFilter(chainFilter); } setOpen(openChange); }, [ tokenBalances, isLoadingBalances, context, address, onAnalyticEvent, setOpen, chainFilterOptions, depositAddressOnly, token ]); const handleTokenSelection = useCallback((selectedToken) => { const isVerified = selectedToken.verified; const direction = context === 'from' ? 'input' : 'output'; let position = undefined; // Track position for search results if (debouncedTokenSearchValue.length > 0) { position = sortedCombinedTokens.findIndex((token) => token.chainId === selectedToken.chainId && token.address?.toLowerCase() === selectedToken.address?.toLowerCase()); } if (!isVerified) { const relayUiKitData = getRelayUiKitData(); const tokenKey = `${selectedToken.chainId}:${selectedToken.address}`; const isAlreadyAccepted = relayUiKitData.acceptedUnverifiedTokens.includes(tokenKey); if (isAlreadyAccepted) { onAnalyticEvent?.(EventNames.SWAP_TOKEN_SELECT, { direction, token_symbol: selectedToken.symbol, chain_id: selectedToken.chainId, token_address: selectedToken.address, search_term: debouncedTokenSearchValue, position }); setToken(selectedToken); } else { setUnverifiedToken(selectedToken); setUnverifiedTokenModalOpen(true); return; } } else { onAnalyticEvent?.(EventNames.SWAP_TOKEN_SELECT, { direction, token_symbol: selectedToken.symbol, chain_id: selectedToken.chainId, token_address: selectedToken.address, search_term: debouncedTokenSearchValue, position }); setToken(selectedToken); } onOpenChange(false); }, [ setToken, onOpenChange, resetState, context, onAnalyticEvent, debouncedTokenSearchValue, sortedCombinedTokens ]); useEffect(() => { if (!open) { resetState(); } }, [open]); // Focus input element when modal opens useEffect(() => { if (open && inputElement && isDesktop) { inputElement.focus(); } }, [open, inputElement]); return (_jsxs(_Fragment, { children: [_jsx("div", { style: { position: 'relative' }, children: _jsx(Modal, { open: open, onOpenChange: onOpenChange, showCloseButton: true, trigger: trigger, css: { p: '4', display: 'flex', flexDirection: 'column', height: 'min(85vh, 600px)', '@media(min-width: 660px)': { minWidth: isDesktop ? hasMultipleConfiguredChainIds ? 660 : 408 : 400, maxWidth: isDesktop && hasMultipleConfiguredChainIds ? 660 : 408 } }, children: _jsxs(Flex, { direction: "column", css: { width: '100%', height: '100%', gap: '3', overflowY: 'hidden' }, children: [_jsx(Text, { style: "h6", children: "Select Token" }), _jsxs(Flex, { css: { flex: 1, gap: '3', overflow: 'hidden' }, children: [isDesktop && (!configuredChainIds || hasMultipleConfiguredChainIds) ? (_jsx(ChainFilterSidebar, { options: allChains, value: chainFilter, isOpen: open, onSelect: setChainFilter, onAnalyticEvent: onAnalyticEvent, onInputRef: setChainSearchInputElement, tokenSearchInputRef: tokenSearchInputElement, popularChainIds: popularChainIds, context: context })) : null, _jsxs(AccessibleList, { onSelect: (value) => { if (value === 'input') return; const [chainId, ...addressParts] = value.split(':'); const address = addressParts.join(':'); const allTokens = [ ...sortedUserTokens, ...sortedCombinedTokens, ...sortedTrendingTokens ]; const selectedToken = allTokens.find((token) => token.chainId === Number(chainId) && token.address?.toLowerCase() === address?.toLowerCase()); if (selectedToken) { handleTokenSelection(selectedToken); } }, css: { display: 'flex', flexDirection: 'column', width: '100%', minWidth: 0, height: '100%' }, children: [_jsxs(Flex, { direction: "column", align: "start", css: { width: '100%', gap: '2', background: 'modal-background' }, children: [_jsx(AccessibleListItem, { value: "input", asChild: true, children: _jsx(Input, { ref: setTokenSearchInputElement, placeholder: "Search for a token or paste address", icon: _jsx(Box, { css: { color: 'gray9' }, children: _jsx(FontAwesomeIcon, { icon: faMagnifyingGlass, width: 16, height: 16 }) }), containerCss: { width: '100%', height: 40, mb: isDesktop ? '1' : '0' }, css: { width: '100%', _placeholder_parent: { textOverflow: 'ellipsis' } }, value: tokenSearchInput, onChange: (e) => { const value = e.target.value; setTokenSearchInput(value); if (isValidAddressUtil(chainFilter.vmType, value)) { onAnalyticEvent?.(EventNames.TOKEN_SELECTOR_CONTRACT_SEARCH, { search_term: value, chain_filter: chainFilter.id }); } } }) }), !isDesktop && (!configuredChainIds || hasMultipleConfiguredChainIds) ? (_jsx(ChainFilter, { options: allChains, value: chainFilter, onSelect: setChainFilter, popularChainIds: popularChainIds })) : null] }), _jsxs(Flex, { direction: "column", css: { flex: 1, overflowY: 'auto', gap: '3', pt: '2', scrollbarColor: 'var(--relay-colors-gray5) transparent' }, children: [chainFilter.id && tokenSearchInput.length === 0 && !depositAddressOnly ? (_jsx(SuggestedTokens, { chainId: chainFilter.id, depositAddressOnly: depositAddressOnly, onSelect: (token) => { handleTokenSelection(token); } })) : null, tokenSearchInput.length > 0 ? (_jsx(TokenList, { title: "Results", tokens: sortedCombinedTokens, isLoading: isLoadingTokenList || tokenSearchInput !== debouncedTokenSearchValue, isLoadingBalances: isLoadingBalances, chainFilterId: chainFilter.id })) : (_jsx(Flex, { direction: "column", css: { gap: '3' }, children: [ { title: 'Your Tokens', tokens: sortedUserTokens, isLoading: isLoadingUserTokens, show: sortedUserTokens.length > 0 }, { title: 'Global 24H Volume', tokens: sortedCombinedTokens, isLoading: isLoadingTokenList, show: true }, { title: 'Relay 24H Volume', tokens: sortedTrendingTokens, isLoading: isLoadingTrendingTokens, show: context === 'to' && chainFilter.id === undefined, showMoreButton: true } ] .sort((a, b) => (context === 'to' ? -1 : 1)) // Reverse order depending on context .map(({ title, tokens, isLoading, show, showMoreButton }) => show && (_jsx(TokenList, { title: title, tokens: tokens, isLoading: isLoading, isLoadingBalances: isLoadingBalances, chainFilterId: chainFilter.id, showMoreButton: showMoreButton }, title))) })), !isLoadingTokenList && !isLoadingExternalList && tokenList?.length === 0 && externalTokenList?.length === 0 ? (_jsxs(Flex, { direction: "column", align: "center", css: { py: '5', maxWidth: 312, alignSelf: 'center' }, children: [!chainFilter?.id && isSearchTermValidAddress && (_jsx(Box, { css: { color: 'gray8', mb: '2' }, children: _jsx(FontAwesomeIcon, { icon: faFolderOpen, size: "xl", width: 27, height: 24 }) })), _jsx(Text, { color: "subtle", style: "body2", css: { textAlign: 'center' }, children: !chainFilter?.id && isSearchTermValidAddress ? 'No results. Switch to the desired chain to search by contract.' : 'No results.' })] })) : null] }, chainFilter.id ?? 'all')] })] })] }) }) }), unverifiedTokenModalOpen && (_jsx(UnverifiedTokenModal, { open: unverifiedTokenModalOpen, onOpenChange: setUnverifiedTokenModalOpen, data: unverifiedToken ? { token: unverifiedToken } : undefined, onAcceptToken: (token) => { if (token) { handleTokenSelection(token); } setUnverifiedTokenModalOpen(false); } }))] })); }; export default TokenSelector; //# sourceMappingURL=TokenSelector.js.map