diff --git a/packages/components/src/components/form/Select/Select.tsx b/packages/components/src/components/form/Select/Select.tsx index d5a48d69048..8f0b3b847b0 100644 --- a/packages/components/src/components/form/Select/Select.tsx +++ b/packages/components/src/components/form/Select/Select.tsx @@ -119,6 +119,7 @@ type WrapperProps = TransientProps< $isWithPlaceholder: boolean; $hasBottomPadding: boolean; $elevation: Elevation; + $focusEnabled: boolean; }; const Wrapper = styled.div` @@ -162,9 +163,16 @@ const Wrapper = styled.div` } &:focus-within { - .${reactSelectClassNamePrefix}__dropdown-indicator { - transform: rotate(180deg); - } + ${({ $focusEnabled }) => + $focusEnabled + ? css` + .${reactSelectClassNamePrefix}__dropdown-indicator { + transform: rotate(180deg); + } + ` + : css` + border-color: transparent; + `} } } @@ -270,6 +278,7 @@ interface CommonProps extends Omit, 'onChange' | 'menuI * @description pass `null` if bottom text can be `undefined` */ bottomText?: ReactNode; + focusEnabled?: boolean; hasBottomPadding?: boolean; minValueWidth?: string; // TODO: should be probably removed inputState?: InputState; @@ -296,6 +305,7 @@ export const Select = ({ useKeyPressScroll, isSearchable = false, minValueWidth = 'initial', + focusEnabled = true, isMenuOpen, inputState, components, @@ -358,6 +368,7 @@ export const Select = ({ $minValueWidth={minValueWidth} $isDisabled={isDisabled} $isMenuOpen={isMenuOpen} + $focusEnabled={focusEnabled} $isWithLabel={!!label} $isWithPlaceholder={!!placeholder} $hasBottomPadding={hasBottomPadding === true && bottomText === null} diff --git a/packages/product-components/src/components/SelectAssetModal/AssetItemNotFound.tsx b/packages/product-components/src/components/SelectAssetModal/AssetItemNotFound.tsx index 6f8642c6394..319408b14e7 100644 --- a/packages/product-components/src/components/SelectAssetModal/AssetItemNotFound.tsx +++ b/packages/product-components/src/components/SelectAssetModal/AssetItemNotFound.tsx @@ -1,48 +1,58 @@ import { Column, Paragraph, Text } from '@trezor/components'; import { FormattedMessage } from 'react-intl'; -import { NetworkFilterCategory, SelectAssetSearchCategory } from './SelectAssetModal'; +import { + SelectAssetSearchCategory, + NetworkFilterCategories, + TokenFilterCategories, + NetworkFilterCategory, +} from './SelectAssetModal'; import { spacings } from '@trezor/theme'; interface AssetItemNotFoundProps { searchCategory: SelectAssetSearchCategory; - networkCategories: NetworkFilterCategory[]; + filterCategories: NetworkFilterCategories | TokenFilterCategories; listHeight: string; listMinHeight: number; } export const AssetItemNotFound = ({ searchCategory, - networkCategories, + filterCategories, listHeight, listMinHeight, }: AssetItemNotFoundProps) => { // TODO: resolve messages sharing https://github.com/trezor/trezor-suite/issues/5325 - const translations = searchCategory - ? { - heading: { - id: 'TR_TOKEN_NOT_FOUND_ON_NETWORK', - defaultMessage: 'Token not found on the {networkName} network', - values: { - networkName: networkCategories.find( - category => category.coingeckoId === searchCategory.coingeckoId, - )?.name, + + const isNetworkCategory = filterCategories?.categoriesType === 'networks'; + + const translations = + searchCategory && isNetworkCategory + ? { + heading: { + id: 'TR_TOKEN_NOT_FOUND_ON_NETWORK', + defaultMessage: 'Token not found on the {networkName} network', + values: { + networkName: filterCategories.categories.find( + (category: NetworkFilterCategory) => + category.coingeckoId === searchCategory.coingeckoId, + )?.name, + }, + }, + paragraph: { + id: 'TR_TOKEN_TRY_DIFFERENT_SEARCH_OR_SWITCH', + defaultMessage: 'Please try a different search or switch to another network.', + }, + } + : { + heading: { + id: 'TR_TOKEN_NOT_FOUND', + defaultMessage: 'Token not found', + }, + paragraph: { + id: 'TR_TOKEN_TRY_DIFFERENT_SEARCH', + defaultMessage: 'Please try a different search.', }, - }, - paragraph: { - id: 'TR_TOKEN_TRY_DIFFERENT_SEARCH_OR_SWITCH', - defaultMessage: 'Please try a different search or switch to another network.', - }, - } - : { - heading: { - id: 'TR_TOKEN_NOT_FOUND', - defaultMessage: 'Token not found', - }, - paragraph: { - id: 'TR_TOKEN_TRY_DIFFERENT_SEARCH', - defaultMessage: 'Please try a different search.', - }, - }; + }; return ( ) : ( diff --git a/packages/product-components/src/components/TokenIconSet/TokenIconSet.tsx b/packages/product-components/src/components/TokenIconSet/TokenIconSet.tsx index 29157d8d551..cde6bd2d0e3 100644 --- a/packages/product-components/src/components/TokenIconSet/TokenIconSet.tsx +++ b/packages/product-components/src/components/TokenIconSet/TokenIconSet.tsx @@ -1,7 +1,6 @@ import { AssetLogo, useElevation } from '@trezor/components'; import { getCoingeckoId, NetworkSymbol } from '@suite-common/wallet-config'; import { TokenInfo } from '@trezor/connect'; -import { getContractAddressForNetwork } from '@suite-common/wallet-utils'; import styled, { css } from 'styled-components'; import { borders, Elevation, mapElevationToBackground, mapElevationToBorder } from '@trezor/theme'; @@ -57,7 +56,7 @@ export const TokenIconSet = ({ network, tokens }: TokenIconSetProps) => { key={token.contract} size={20} coingeckoId={coingeckoId ?? ''} - contractAddress={getContractAddressForNetwork(network, token.contract)} + contractAddress={token.contract.toLowerCase()} placeholder={token.symbol?.toUpperCase() ?? ''} placeholderWithTooltip={false} /> diff --git a/packages/suite/src/components/suite/CoinBalance.tsx b/packages/suite/src/components/suite/CoinBalance.tsx index 9c19baf854f..be9c4c00042 100644 --- a/packages/suite/src/components/suite/CoinBalance.tsx +++ b/packages/suite/src/components/suite/CoinBalance.tsx @@ -3,7 +3,7 @@ import { FormattedCryptoAmount } from 'src/components/suite'; interface CoinBalanceProps { value: string; - symbol: Account['symbol']; + symbol: Account['symbol'] | (string & {}); } export const CoinBalance = ({ value, symbol }: CoinBalanceProps) => ( diff --git a/packages/suite/src/support/messages.ts b/packages/suite/src/support/messages.ts index e982b5e10f0..8f36738eff6 100644 --- a/packages/suite/src/support/messages.ts +++ b/packages/suite/src/support/messages.ts @@ -355,6 +355,10 @@ export default defineMessages({ defaultMessage: 'Search by name, symbol, network, or contract address', id: 'TR_SELECT_NAME_OR_ADDRESS', }, + TR_SEARCH_TOKEN_IN_SEND_FORM_MODAL: { + defaultMessage: 'Search by name, symbol, or contract address', + id: 'TR_SEARCH_TOKEN_IN_SEND_FORM_MODAL', + }, TR_TOKEN_NOT_FOUND: { defaultMessage: 'Token not found', id: 'TR_TOKEN_NOT_FOUND', @@ -9131,6 +9135,10 @@ export default defineMessages({ id: 'TR_PASSPHRASE_DESCRIPTION_ITEM3', defaultMessage: 'No one can recover it, not even Trezor Support', }, + TR_UNRECOGNIZED: { + id: 'TR_UNRECOGNIZED', + defaultMessage: 'Unrecognized', + }, TR_CONNECT_DEVICE_SEND_PROMO_TITLE: { id: 'TR_CONNECT_DEVICE_SEND_PROMO_TITLE', defaultMessage: "Your Trezor isn't connected", diff --git a/packages/suite/src/views/wallet/send/Outputs/Amount/Amount.tsx b/packages/suite/src/views/wallet/send/Outputs/Amount/Amount.tsx index dad5e078732..86c6edc403b 100644 --- a/packages/suite/src/views/wallet/send/Outputs/Amount/Amount.tsx +++ b/packages/suite/src/views/wallet/send/Outputs/Amount/Amount.tsx @@ -19,7 +19,7 @@ import { getFiatRateKey, } from '@suite-common/wallet-utils'; -import { FiatValue, Translation, NumberInput, HiddenPlaceholder } from 'src/components/suite'; +import { FiatValue, Translation, NumberInput } from 'src/components/suite'; import { useSendFormContext } from 'src/hooks/wallet'; import { useBitcoinAmountUnit } from 'src/hooks/wallet/useBitcoinAmountUnit'; import { useSelector, useTranslation } from 'src/hooks/suite'; @@ -29,8 +29,6 @@ import { validateMin, validateReserveOrBalance, } from 'src/utils/suite/validation'; -import { formatTokenSymbol } from 'src/utils/wallet/tokenUtils'; -import { TokenSelect } from './TokenSelect'; import { FiatInput } from './FiatInput'; import { SendMaxSwitch } from './SendMaxSwitch'; @@ -62,16 +60,6 @@ const Left = styled.div` flex: 1; `; -const TokenBalance = styled.div` - font-size: ${variables.FONT_SIZE.TINY}; - color: ${({ theme }) => theme.legacy.TYPE_LIGHT_GREY}; - height: 18px; -`; - -const TokenBalanceValue = styled.span` - font-weight: ${variables.FONT_WEIGHT.DEMI_BOLD}; -`; - // eslint-disable-next-line local-rules/no-override-ds-component const StyledTransferIcon = styled(Icon)` margin: 0 20px 46px; @@ -220,13 +208,6 @@ export const Amount = ({ output, outputId }: AmountProps) => { composeTransaction(amountName); }; - const isTokenSelected = !!token; - const tokenBalance = isTokenSelected ? ( - - {`${token.balance} ${formatTokenSymbol(token?.symbol || token.contract)}`} - - ) : undefined; - const getSendMaxSwitchComponent = ( hideOnLargeScreens: boolean | undefined, hideOnSmallScreens: boolean | undefined, @@ -255,16 +236,6 @@ export const Amount = ({ output, outputId }: AmountProps) => { labelLeft={ - {isTokenSelected && ( - - ( - - ) - - )} } bottomText={bottomText || null} @@ -276,8 +247,8 @@ export const Amount = ({ output, outputId }: AmountProps) => { rules={cryptoAmountRules} control={control} innerAddon={ - withTokens ? ( - + withTokens && token ? ( + {token?.symbol?.toUpperCase()} ) : ( {symbolToUse} ) diff --git a/packages/suite/src/views/wallet/send/Outputs/Outputs.tsx b/packages/suite/src/views/wallet/send/Outputs/Outputs.tsx index efae8549e88..97b07716501 100644 --- a/packages/suite/src/views/wallet/send/Outputs/Outputs.tsx +++ b/packages/suite/src/views/wallet/send/Outputs/Outputs.tsx @@ -5,15 +5,14 @@ import { motion } from 'framer-motion'; import { Card, motionEasing } from '@trezor/components'; import { motionEasingStrings } from '@trezor/components/src/config/motion'; import { spacingsPx } from '@trezor/theme'; -import { networks } from '@suite-common/wallet-config'; import { useSendFormContext } from 'src/hooks/wallet'; import { useLayoutSize } from 'src/hooks/suite'; -import { Translation } from 'src/components/suite'; import { Address } from './Address'; import { Amount } from './Amount/Amount'; import { OpReturn } from './OpReturn'; -import { CoinLogo } from '@trezor/product-components'; +import { TokenSelect } from './TokenSelect/TokenSelect'; +import { getNetworkFeatures } from '@suite-common/wallet-config'; const Container = styled.div<{ $height: number }>` height: ${({ $height }) => ($height ? `${$height}px` : 'auto')}; @@ -26,13 +25,6 @@ const Container = styled.div<{ $height: number }>` } `; -const StyledEvmExplanation = styled.div` - display: flex; - flex-direction: row; - align-items: center; - gap: ${spacingsPx.sm}; -`; - interface OutputsProps { disableAnim?: boolean; // used in tests, with animations enabled react-testing-library can't find output fields } @@ -45,8 +37,9 @@ export const Outputs = ({ disableAnim }: OutputsProps) => { const { outputs, formState: { errors }, - account, + account: { symbol }, } = useSendFormContext(); + const ref = useRef(null); useLayoutEffect(() => { @@ -61,6 +54,8 @@ export const Outputs = ({ disableAnim }: OutputsProps) => { } }, [outputs]); + const areTokensSupported = getNetworkFeatures(symbol).includes('tokens') ?? false; + return (
@@ -79,21 +74,8 @@ export const Outputs = ({ disableAnim }: OutputsProps) => { ease: motionEasing.transition, }} > - - - - - ) - } - > + {areTokensSupported && } + {output.type === 'opreturn' ? ( ) : ( diff --git a/packages/suite/src/views/wallet/send/Outputs/TokenSelect/TokenAddress.tsx b/packages/suite/src/views/wallet/send/Outputs/TokenSelect/TokenAddress.tsx new file mode 100644 index 00000000000..23d660c2059 --- /dev/null +++ b/packages/suite/src/views/wallet/send/Outputs/TokenSelect/TokenAddress.tsx @@ -0,0 +1,130 @@ +import { HiddenPlaceholder } from 'src/components/suite/HiddenPlaceholder'; +import { Icon, Link, Text, TextProps } from '@trezor/components'; +import { useMemo, useState } from 'react'; +import styled, { css, useTheme } from 'styled-components'; +import { borders, spacingsPx } from '@trezor/theme'; + +const IconWrapper = styled.div` + display: none; + padding: ${spacingsPx.xxxs}; + border-radius: ${borders.radii.xxxs}; + margin-left: ${spacingsPx.xxs}; + background-color: ${({ theme }) => theme.iconSubdued}; + height: 16px; + align-items: center; + justify-content: center; + + &:hover { + opacity: 0.7; + } +`; + +const onHoverTextOverflowContainerHover = css` + border-radius: ${borders.radii.xxxs}; + background-color: ${({ theme }) => theme.backgroundSurfaceElevation2}; + outline: ${borders.widths.large} solid ${({ theme }) => theme.backgroundSurfaceElevation2}; + z-index: 3; + + ${IconWrapper} { + display: flex; + } +`; + +const TextOverflowContainer = styled.div<{ $shouldAllowCopy?: boolean }>` + position: relative; + display: inline-flex; + align-items: center; + max-width: 100%; + overflow: hidden; + cursor: ${({ $shouldAllowCopy }) => ($shouldAllowCopy ? 'pointer' : 'cursor')}; + user-select: none; + + ${({ $shouldAllowCopy }) => + $shouldAllowCopy && + css` + @media (hover: none) { + ${onHoverTextOverflowContainerHover} + } + + &:hover, + &:focus { + ${onHoverTextOverflowContainerHover} + } + `} +`; + +interface TokenAddressProps { + tokenExplorerUrl?: string; + tokenContractAddress: string; + shouldAllowCopy?: boolean; + typographyStyle?: TextProps['typographyStyle']; + variant?: TextProps['variant']; + onCopy: () => void; +} + +// This is needed because icon interferes with pointer events of Select +const IconWithNoPointer = styled(Icon)` + pointer-events: none; +`; +export const TokenAddress = ({ + tokenContractAddress, + tokenExplorerUrl, + shouldAllowCopy = true, + typographyStyle = 'label', + variant = 'default', + onCopy, +}: TokenAddressProps) => { + const [isClicked, setIsClicked] = useState(false); + const theme = useTheme(); + + const copy = (e: React.MouseEvent) => { + e.stopPropagation(); + onCopy(); + }; + + const shortenAddress = useMemo(() => { + return `${tokenContractAddress.slice(0, 6)}...${tokenContractAddress.slice(-4)}`; + }, [tokenContractAddress]); + + // HiddenPlaceholder disableKeepingWidth: it isn't needed (no numbers to redact), but inline-block disrupts overflow behavior + return ( + + + setIsClicked(false)} + data-testid="@tx-detail/txid-value" + id={tokenContractAddress} + $shouldAllowCopy={shouldAllowCopy} + > + {shortenAddress} + {shouldAllowCopy ? ( + copy(e)}> + + + ) : null} + {tokenExplorerUrl ? ( + + e.stopPropagation()} + > + + + + ) : null} + + + + ); +}; diff --git a/packages/suite/src/views/wallet/send/Outputs/TokenSelect/TokenSelect.tsx b/packages/suite/src/views/wallet/send/Outputs/TokenSelect/TokenSelect.tsx new file mode 100644 index 00000000000..e463a3a09d1 --- /dev/null +++ b/packages/suite/src/views/wallet/send/Outputs/TokenSelect/TokenSelect.tsx @@ -0,0 +1,301 @@ +import { useMemo, useEffect, useState } from 'react'; +import { Controller } from 'react-hook-form'; +import { AssetLogo, Column, Row, Select, useElevation } from '@trezor/components'; +import { useSendFormContext } from 'src/hooks/wallet'; +import { useDispatch, useSelector, useTranslation } from 'src/hooks/suite'; +import { selectCurrentFiatRates, updateFiatRatesThunk } from '@suite-common/wallet-core'; +import { Timestamp, TokenAddress } from '@suite-common/wallet-types'; +import { enhanceTokensWithRates, sortTokensWithRates } from 'src/utils/wallet/tokenUtils'; +import { selectLocalCurrency } from 'src/reducers/wallet/settingsReducer'; +import { SUITE } from 'src/actions/suite/constants'; +import { + CoinLogo, + SelectAssetModal, + SelectAssetOptionCurrencyProps, +} from '@trezor/product-components'; +import { getContractAddressForNetwork } from '@suite-common/wallet-utils'; +import { borders, spacings } from '@trezor/theme'; +import { Text } from '@trezor/components'; +import { FiatValue, Translation } from 'src/components/suite'; +import { TokenAddress as TokenAddressComponent } from './TokenAddress'; +import { selectIsCopyAddressModalShown } from 'src/reducers/suite/suiteReducer'; +import { TokenFilterCategory } from '@trezor/product-components/src/components/SelectAssetModal/SelectAssetModal'; +import { selectCoinDefinitions } from '@suite-common/token-definitions'; +import { + TokenSelectContainer, + onCopyAddress, + buildTokenOptions, + sendTokenCategories, + TokenSelectProps, +} from './tokenSelectUtils'; +import { useTheme } from 'styled-components'; +import { NetworkSymbol } from '@suite-common/wallet-config'; +import { FiatCurrencyCode } from '@suite-common/suite-config'; + +export const TokenSelect = ({ outputId }: TokenSelectProps) => { + const { + account, + clearErrors, + control, + setAmount, + getValues, + getDefaultValue, + toggleOption, + composeTransaction, + watch, + setValue, + setDraftSaveRequest, + } = useSendFormContext(); + const [pickedSendTokenCategory, setPickedSendTokenCategory] = + useState('visibleWithBalance'); + const shouldShowCopyAddressModal = useSelector(selectIsCopyAddressModalShown); + const [isTokensModalActive, setIsTokensModalActive] = useState(false); + const coinDefinitions = useSelector(state => selectCoinDefinitions(state, account.symbol)); + const sendFormPrefill = useSelector(state => state.suite.prefillFields.sendForm); + const localCurrency = useSelector(selectLocalCurrency); + const fiatRates = useSelector(selectCurrentFiatRates); + const { elevation } = useElevation(); + const dispatch = useDispatch(); + const { translationString } = useTranslation(); + const theme = useTheme(); + + const tokensWithRates = enhanceTokensWithRates( + account.tokens, + localCurrency, + account.symbol, + fiatRates, + ); + + const sortedTokens = useMemo(() => { + return tokensWithRates.sort(sortTokensWithRates); + }, [tokensWithRates]); + const tokenInputName = `outputs.${outputId}.token` as const; + const amountInputName = `outputs.${outputId}.amount` as const; + const currencyInputName = `outputs.${outputId}.currency` as const; + const tokenContractAddress = watch(tokenInputName); + + const isSetMaxActive = getDefaultValue('setMaxOutputId') === outputId; + const dataEnabled = getDefaultValue('options', []).includes('ethereumData'); + const options = buildTokenOptions( + sortedTokens, + account.symbol, + coinDefinitions, + account.formattedBalance, + translationString, + ); + + let filteredOptions: SelectAssetOptionCurrencyProps[] = []; + + if (pickedSendTokenCategory === 'visibleWithBalance') { + filteredOptions = options.filter(option => !option.hidden && !option.unverified); + } else { + filteredOptions = options.filter(option => option.hidden || option.unverified); + } + + // Amount needs to be re-validated again AFTER token change propagation (decimal places, available balance) + // watch token change and use "useSendFormFields.setAmount" util for validation (if amount is set) + // if Amount is not valid 'react-hook-form' will set an error to it, and composeTransaction will be prevented + // N0TE: do this conditionally only for ETH and when set-max is not enabled + const tokenWatch = watch(tokenInputName, null); + const currencyValue = watch(currencyInputName); + + useEffect(() => { + if (account.networkType === 'ethereum' && !isSetMaxActive) { + const amountValue = getValues(`outputs.${outputId}.amount`) as string; + if (amountValue) setAmount(outputId, amountValue); + } + }, [outputId, tokenWatch, setAmount, getValues, account.networkType, isSetMaxActive]); + + useEffect(() => { + if (sendFormPrefill) { + setValue(tokenInputName, sendFormPrefill, { shouldValidate: true, shouldDirty: true }); + setDraftSaveRequest(true); + dispatch({ + type: SUITE.SET_SEND_FORM_PREFILL, + payload: '', + }); + } + }, [sendFormPrefill, setValue, tokenInputName, setDraftSaveRequest, dispatch]); + + const selectedOption = options.find(option => { + return option.type === 'currency' && option.contractAddress === tokenContractAddress; + }) as SelectAssetOptionCurrencyProps; + + const handleSelectChange = async (selectedAsset: SelectAssetOptionCurrencyProps) => { + const selectedOption = filteredOptions.find( + option => option.contractAddress === selectedAsset.contractAddress, + ); + if (!selectedOption) return; + setValue(tokenInputName, selectedAsset.contractAddress, { shouldDirty: true }); + + setIsTokensModalActive(false); + + await dispatch( + updateFiatRatesThunk({ + tickers: [ + { + symbol: account.symbol as NetworkSymbol, + tokenAddress: selectedOption.contractAddress as TokenAddress, + }, + ], + localCurrency: currencyValue.value as FiatCurrencyCode, + rateType: 'current', + fetchAttemptTimestamp: Date.now() as Timestamp, + }), + ); + // clear errors in Amount input + clearErrors(amountInputName); + // remove Amount if isSetMaxActive or ETH data options are enabled + if (isSetMaxActive || dataEnabled) setAmount(outputId, ''); + // remove ETH data option + if (dataEnabled) toggleOption('ethereumData'); + // compose (could be prevented because of Amount error from re-validation above) + composeTransaction(amountInputName); + }; + + return ( + <> + {isTokensModalActive && ( + setIsTokensModalActive(false)} + searchPlaceholderText={translationString('TR_SEARCH_TOKEN_IN_SEND_FORM_MODAL')} + pickedSendTokenCategory={pickedSendTokenCategory} + setPickedSendTokenCategory={setPickedSendTokenCategory} + selectAssetModalHeight="short" + /> + )} + ( + 1 + ? () => { + setIsTokensModalActive(true); + } + : () => {} + } + $isDisabled={options.length === 1} + > +