import { PlusIcon } from "@radix-ui/react-icons"; import { useCallback, useMemo, useState } from "react"; import type { Token } from "../../../../../bridge/index.js"; import type { BridgeChain } from "../../../../../bridge/types/Chain.js"; import type { ThirdwebClient } from "../../../../../client/client.js"; import { isNativeTokenAddress } from "../../../../../constants/addresses.js"; import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js"; import { shortenAddress } from "../../../../../utils/address.js"; import { toTokens } from "../../../../../utils/units.js"; import { useCustomTheme } from "../../../../core/design-system/CustomThemeProvider.js"; import { fontSize, iconSize, radius, spacing, } from "../../../../core/design-system/index.js"; import { CoinsIcon } from "../../ConnectWallet/icons/CoinsIcon.js"; import { InfoIcon } from "../../ConnectWallet/icons/InfoIcon.js"; import { WalletDotIcon } from "../../ConnectWallet/icons/WalletDotIcon.js"; import { formatCurrencyAmount } from "../../ConnectWallet/screens/formatTokenBalance.js"; import { Container, Line, ModalHeader, noScrollBar, } from "../../components/basic.js"; import { Button, IconButton } from "../../components/buttons.js"; import { CopyIcon } from "../../components/CopyIcon.js"; import { Img } from "../../components/Img.js"; import { Skeleton } from "../../components/Skeleton.js"; import { Spacer } from "../../components/Spacer.js"; import { Spinner } from "../../components/Spinner.js"; import { Link, Text } from "../../components/text.js"; import { StyledDiv } from "../../design-system/elements.js"; import { useDebouncedValue } from "../../hooks/useDebouncedValue.js"; import { useIsMobile } from "../../hooks/useisMobile.js"; import { useTokenPrice } from "./hooks.js"; import { SearchInput } from "./SearchInput.js"; import { SelectChainButton } from "./SelectChainButton.js"; import { SelectBridgeChain } from "./select-chain.js"; import type { ActiveWalletInfo, TokenSelection } from "./types.js"; import { useBridgeChainsWithFilters } from "./use-bridge-chains.js"; import { type TokenBalance, useTokenBalances, useTokens, } from "./use-tokens.js"; import { tokenAmountFormatter } from "./utils.js"; /** * @internal */ type SelectTokenUIProps = { onClose: () => void; client: ThirdwebClient; selectedToken: TokenSelection | undefined; setSelectedToken: (token: TokenSelection) => void; activeWalletInfo: ActiveWalletInfo | undefined; type: "buy" | "sell"; selections: { buyChainId: number | undefined; sellChainId: number | undefined; }; currency: SupportedFiatCurrency; }; function findChain(chains: BridgeChain[], activeChainId: number | undefined) { if (!activeChainId) { return undefined; } return chains.find((chain) => chain.chainId === activeChainId); } const INITIAL_LIMIT = 100; /** * @internal */ export function SelectToken(props: SelectTokenUIProps) { const chainQuery = useBridgeChainsWithFilters({ client: props.client, type: props.type, buyChainId: props.selections.buyChainId, sellChainId: props.selections.sellChainId, }); const [search, _setSearch] = useState(""); const debouncedSearch = useDebouncedValue(search, 500); const [limit, setLimit] = useState(INITIAL_LIMIT); const setSearch = useCallback((search: string) => { _setSearch(search); setLimit(INITIAL_LIMIT); }, []); const [_selectedChain, setSelectedChain] = useState( undefined, ); const selectedChain = _selectedChain || (chainQuery.data ? findChain(chainQuery.data, props.selectedToken?.chainId) || findChain(chainQuery.data, props.activeWalletInfo?.activeChain.id) || findChain(chainQuery.data, 1) : undefined); // all tokens const tokensQuery = useTokens({ client: props.client, chainId: selectedChain?.chainId, search: debouncedSearch, limit, offset: 0, }); // owned tokens const ownedTokensQuery = useTokenBalances({ client: props.client, chainId: selectedChain?.chainId, limit, page: 1, walletAddress: props.activeWalletInfo?.activeAccount.address, }); const filteredOwnedTokens = useMemo(() => { return ownedTokensQuery.data?.tokens?.filter((token) => { return ( token.symbol.toLowerCase().includes(debouncedSearch.toLowerCase()) || token.name.toLowerCase().includes(debouncedSearch.toLowerCase()) || token.token_address .toLowerCase() .includes(debouncedSearch.toLowerCase()) ); }); }, [ownedTokensQuery.data?.tokens, debouncedSearch]); const isFetching = tokensQuery.isFetching || ownedTokensQuery.isFetching; return ( { setLimit(limit + INITIAL_LIMIT); } : undefined } /> ); } function SelectTokenUI( props: SelectTokenUIProps & { ownedTokens: TokenBalance[]; allTokens: Token[]; isFetching: boolean; selectedChain: BridgeChain | undefined; setSelectedChain: (chain: BridgeChain) => void; search: string; setSearch: (search: string) => void; selectedToken: TokenSelection | undefined; setSelectedToken: (token: TokenSelection) => void; showMore: (() => void) | undefined; type: "buy" | "sell"; selections: { buyChainId: number | undefined; sellChainId: number | undefined; }; }, ) { const isMobile = useIsMobile(); const [screen, setScreen] = useState<"select-chain" | "select-token">( "select-token", ); // show tokens with icons first const sortedOwnedTokens = useMemo(() => { return props.ownedTokens.sort((a, b) => { if (a.icon_uri && !b.icon_uri) { return -1; } if (!a.icon_uri && b.icon_uri) { return 1; } return 0; }); }, [props.ownedTokens]); const otherTokens = useMemo(() => { const ownedTokenSet = new Set( sortedOwnedTokens.map((t) => `${t.token_address}-${t.chain_id}`.toLowerCase(), ), ); return props.allTokens.filter( (token) => !ownedTokenSet.has(`${token.address}-${token.chainId}`.toLowerCase()), ); }, [props.allTokens, sortedOwnedTokens]); // show tokens with icons first const sortedOtherTokens = useMemo(() => { return otherTokens.sort((a, b) => { if (a.iconUri && !b.iconUri) { return -1; } if (!a.iconUri && b.iconUri) { return 1; } return 0; }); }, [otherTokens]); // desktop if (!isMobile) { return ( setScreen("select-token")} client={props.client} isMobile={false} onSelectChain={(chain) => { props.setSelectedChain(chain); setScreen("select-token"); }} selectedChain={props.selectedChain} /> { props.setSelectedToken(token); props.onClose(); }} isMobile={false} key={props.selectedChain?.chainId} selectedToken={props.selectedToken} isFetching={props.isFetching} ownedTokens={props.ownedTokens} otherTokens={sortedOtherTokens} showMore={props.showMore} selectedChain={props.selectedChain} onSelectChain={() => setScreen("select-chain")} client={props.client} search={props.search} setSearch={props.setSearch} currency={props.currency} /> ); } if (screen === "select-token") { return ( { props.setSelectedToken(token); props.onClose(); }} selectedToken={props.selectedToken} isFetching={props.isFetching} ownedTokens={props.ownedTokens} otherTokens={sortedOtherTokens} showMore={props.showMore} selectedChain={props.selectedChain} isMobile={true} onSelectChain={() => setScreen("select-chain")} client={props.client} search={props.search} setSearch={props.setSearch} currency={props.currency} /> ); } if (screen === "select-chain") { return ( setScreen("select-token")} client={props.client} onSelectChain={(chain) => { props.setSelectedChain(chain); setScreen("select-token"); }} selectedChain={props.selectedChain} type={props.type} selections={props.selections} /> ); } return null; } function TokenButtonSkeleton() { return (
); } function TokenButton(props: { token: TokenBalance | Token; client: ThirdwebClient; onSelect: (tokenWithPrices: TokenSelection) => void; onInfoClick: (tokenAddress: string, chainId: number) => void; isSelected: boolean; }) { const theme = useCustomTheme(); const tokenBalanceInUnits = "balance" in props.token ? toTokens(BigInt(props.token.balance), props.token.decimals) : undefined; const usdValue = "balance" in props.token ? props.token.price_data.price_usd * Number(tokenBalanceInUnits) : undefined; const tokenAddress = "balance" in props.token ? props.token.token_address : props.token.address; const chainId = "balance" in props.token ? props.token.chain_id : props.token.chainId; return ( ); } function TokenInfoScreen(props: { tokenAddress: string; chainId: number; client: ThirdwebClient; onBack: () => void; currency: SupportedFiatCurrency; }) { const theme = useCustomTheme(); const tokenQuery = useTokenPrice({ token: { tokenAddress: props.tokenAddress, chainId: props.chainId, }, client: props.client, }); const token = tokenQuery.data; const isNativeToken = isNativeTokenAddress(props.tokenAddress); const explorerLink = isNativeToken ? `https://thirdweb.com/${props.chainId}` : `https://thirdweb.com/${props.chainId}/${props.tokenAddress}`; return ( {/* Header */} {tokenQuery.isPending ? ( ) : !token ? ( Token not found ) : ( {/* name + icon */} Name } /> {token.name} {/* symbol */} {/* price */} {"prices" in token && ( )} {/* market cap */} {!!token.marketCapUsd && ( )} {/* volume 24h */} {!!token.volume24hUsd && ( )} {/* address + link */} Contract Address {!isNativeToken && ( )} {isNativeToken ? "Native Currency" : shortenAddress(props.tokenAddress)} )} ); } function TokenInfoRow(props: { label: string; value: string }) { return ( {props.label} {props.value} ); } function TokenSelectionScreen(props: { selectedChain: BridgeChain | undefined; isMobile: boolean; onSelectChain: () => void; client: ThirdwebClient; search: string; setSearch: (search: string) => void; isFetching: boolean; ownedTokens: TokenBalance[]; otherTokens: Token[]; showMore: (() => void) | undefined; selectedToken: TokenSelection | undefined; onSelectToken: (token: TokenSelection) => void; currency: SupportedFiatCurrency; }) { const [tokenInfoScreen, setTokenInfoScreen] = useState<{ tokenAddress: string; chainId: number; } | null>(null); const noTokensFound = !props.isFetching && props.otherTokens.length === 0 && props.ownedTokens.length === 0; if (tokenInfoScreen) { return ( setTokenInfoScreen(null)} currency={props.currency} /> ); } return ( Select Token Select a token from the list or use the search {!props.selectedChain && ( )} {props.selectedChain && ( <> {props.isMobile ? ( ) : ( )} {/* search */} {props.isFetching && new Array(20).fill(0).map((_, i) => ( // biome-ignore lint/suspicious/noArrayIndexKey: ok ))} {!props.isFetching && props.ownedTokens.length > 0 && ( Your Tokens )} {!props.isFetching && props.ownedTokens.map((token) => ( setTokenInfoScreen({ tokenAddress, chainId }) } isSelected={ !!props.selectedToken && props.selectedToken.tokenAddress.toLowerCase() === token.token_address.toLowerCase() && token.chain_id === props.selectedToken.chainId } /> ))} {!props.isFetching && props.ownedTokens.length > 0 && ( Other Tokens )} {!props.isFetching && props.otherTokens.map((token) => ( setTokenInfoScreen({ tokenAddress, chainId }) } isSelected={ !!props.selectedToken && props.selectedToken.tokenAddress.toLowerCase() === token.address.toLowerCase() && token.chainId === props.selectedToken.chainId } /> ))} {props.showMore && ( )} {noTokensFound && (
No Tokens Found
)}
)}
); } const LeftContainer = /* @__PURE__ */ StyledDiv((_) => { const theme = useCustomTheme(); return { display: "flex", flexDirection: "column", overflowY: "auto", ...noScrollBar, borderRight: `1px solid ${theme.colors.separatorLine}`, position: "relative", }; });