import { ArrowDropDown, Edit } from '@mui/icons-material' import clsx from 'clsx' import { useCallback, useMemo, useState } from 'react' import { FieldValues, Path } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { HugeDecimal } from '@dao-dao/math' import { PopupTriggerCustomComponent, TokenInputOption, TokenInputProps, } from '@dao-dao/types' import { getDisplayNameForChainId, getFallbackImage, toAccessibleImageUrl, tokensEqual, transformIpfsUrlToHttpsIfNecessary, validateNonNegative, validatePositive, validateRequired, } from '@dao-dao/utils' import { useUpdatingRef } from '../../hooks' import { ChainLogo } from '../chain/ChainLogo' import { IconButton } from '../icon_buttons' import { FilterableItem, FilterableItemPopup } from '../popup' import { Tooltip } from '../tooltip' import { NumericInput } from './NumericInput' import { TextInput } from './TextInput' /** * A component for specifying an amount and a token. This should be used * whenever an amount of a variable choice token is needed. See example usage in * the Spend action component. */ export const TokenInput = < T extends TokenInputOption, FV extends FieldValues = FieldValues, FieldName extends Path = Path, >({ amount: amountField, tokens, hideTokens, onSelectToken, selectedToken: _selectedToken, tokenFallback, disabled, readOnly, required = true, containerClassName, showChainImage, containerRef, allowCustomToken, onCustomTokenChange, }: TokenInputProps) => { const { t } = useTranslation() const selectedToken = tokens.loading || !_selectedToken ? undefined : tokens.data.find((token) => tokensEqual(token, _selectedToken)) const amount = HugeDecimal.fromHumanReadable( amountField?.watch(amountField.fieldName) || '0', selectedToken?.decimals ?? 0 ) // All tokens from same chain. const allTokensOnSameChain = !tokens.loading && tokens.data.every((token) => token.chainId === tokens.data[0].chainId) const selectedTokenDisplay = useMemo( () => selectedToken ? (
{showChainImage && ( )}

{readOnly && amountField && amount.toFormattedString({ decimals: selectedToken.decimals, }) + (amountField.unit ? amountField.unit : '') + ' $'} {selectedToken.symbol}

) : ( (tokenFallback ?? (

{readOnly ? t('info.token', { // Plural if amount is not 1. count: amount.eq(1) ? 1 : 2, }) : disabled ? t('info.noTokenSelected') : t('button.selectToken')}

)) ), [ amount, amountField, disabled, readOnly, selectedToken, showChainImage, t, tokenFallback, ] ) // Disable if there is only one token to choose from and the currently // selected token is equal to it. const selectDisabled = disabled || (!tokens.loading && tokens.data.length === 1 && selectedToken && tokensEqual(tokens.data[0], selectedToken)) const [customSelected, setCustomSelected] = useState(false) const items: ( | (FilterableItem & T & { _custom?: false }) | (FilterableItem & { _custom: true }) )[] = tokens.loading ? [] : [ ...(allowCustomToken ? [ { key: '_custom', label: t('info.enterCustomToken'), Icon: Edit, _custom: true as const, iconClassName: 'ml-1 mb-1', contentContainerClassName: '!gap-3', }, ] : []), ...tokens.data .filter( (token) => !hideTokens?.some((hidden) => tokensEqual(hidden, token)) ) .map((token, index) => ({ key: index + token.denomOrAddress, label: token.symbol, iconUrl: transformIpfsUrlToHttpsIfNecessary( token.imageUrl || getFallbackImage(token.denomOrAddress) ), ...token, rightNode: (

{allTokensOnSameChain ? token.denomOrAddress : getDisplayNameForChainId(token.chainId)}

), iconClassName: '!h-8 !w-8', contentContainerClassName: '!gap-3', })), ] // Memoize reference so renderer never changes. const onCustomTokenChangeRef = useUpdatingRef(onCustomTokenChange) const CustomInputRenderer: PopupTriggerCustomComponent = useCallback( ({ onClick }) => { // eslint-disable-next-line react-hooks/rules-of-hooks const [customInput, setCustomInput] = useState('') return (
{ const { value } = event.target as HTMLInputElement setCustomInput(value) onCustomTokenChangeRef.current?.(value) }} // eslint-disable-next-line i18next/no-literal-string placeholder="udenom..." spellCheck={false} value={customInput} />
) }, [onCustomTokenChangeRef] ) return (
{readOnly ? ( selectedTokenDisplay ) : ( <> {amountField && ( amountField.setValue(fieldName, value as any, options) } validation={[ HugeDecimal.from(amountField.min || 0).isZero() ? validateNonNegative : validatePositive, ...(required ? [validateRequired] : []), ...(amountField.validations ?? []), ]} /> )} { if (allowCustomToken) { onSelectToken(token._custom ? undefined : token) setCustomSelected(!!token._custom) if (token._custom) { onCustomTokenChange('') } // Type-check. It shouldn't be possible to select a custom token // if `allowCustomToken` is false, but just in case. Do nothing // if a custom token is somehow selected when not allowed. } else if (!token._custom) { onSelectToken(token) } }} searchPlaceholder={t('info.searchForToken')} trigger={ allowCustomToken && customSelected ? { type: 'custom', Renderer: CustomInputRenderer, } : { type: 'button', props: { className: 'min-w-[10rem] grow basis-[10rem]', contentContainerClassName: 'justify-between text-icon-primary !gap-4', disabled: selectDisabled, loading: tokens.loading, size: 'lg', variant: 'ghost_outline', children: ( <> {selectedTokenDisplay} {!selectDisabled && ( )} ), }, } } /> )}
) } const FILTERABLE_KEYS = ['key', 'label', 'description']