diff --git a/src/Routes/Routes.tsx b/src/Routes/Routes.tsx index bfae8142182..80b56aeb570 100644 --- a/src/Routes/Routes.tsx +++ b/src/Routes/Routes.tsx @@ -1,5 +1,6 @@ import { LanguageTypeEnum } from 'constants/LanguageTypeEnum' -import { useEffect, useMemo, useState } from 'react' +import type { Location } from 'history' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { useDispatch } from 'react-redux' import { matchPath, Redirect, Route, Switch, useLocation } from 'react-router-dom' import { Layout } from 'components/Layout/Layout' @@ -16,16 +17,9 @@ import { selectSelectedLocale } from 'state/slices/selectors' import { useAppSelector } from 'state/store' import { PrivateRoute } from './PrivateRoute' - -function useLocationBackground() { - const location = useLocation<{ background: any }>() - const background = location.state && location.state.background - return { background, location } -} - -export const Routes = () => { +export const Routes = memo(() => { const dispatch = useDispatch() - const { background, location } = useLocationBackground() + const location = useLocation<{ background: Location }>() const { connectDemo, state } = useWallet() const { appRoutes } = useBrowserRouter() const hasWallet = Boolean(state.walletInfo?.deviceId) || state.isLoadingLocalWallet @@ -87,20 +81,20 @@ export const Routes = () => { [appRoutes, hasWallet, isUnstableRoute, location], ) + const locationProps = useMemo(() => location.state?.background || location, [location]) + + const renderRedirect = useCallback(() => { + return shouldRedirectDemoRoute ? ( + + ) : null + }, [matchDemoPath?.params.appRoute, shouldRedirectDemoRoute]) + return ( - - - {() => { - return shouldRedirectDemoRoute ? ( - - ) : null - }} - + + {renderRedirect} @@ -131,4 +125,4 @@ export const Routes = () => { ) -} +}) diff --git a/src/components/AccountDropdown/AccountDropdown.tsx b/src/components/AccountDropdown/AccountDropdown.tsx index ee05740db75..e0ea104b977 100644 --- a/src/components/AccountDropdown/AccountDropdown.tsx +++ b/src/components/AccountDropdown/AccountDropdown.tsx @@ -27,7 +27,7 @@ import { UtxoAccountType } from '@shapeshiftoss/types' import { chain } from 'lodash' import isEmpty from 'lodash/isEmpty' import sortBy from 'lodash/sortBy' -import React, { type FC, useCallback, useEffect, useMemo, useState } from 'react' +import React, { type FC, memo, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslate } from 'react-polyglot' import { useSelector } from 'react-redux' import { bnOrZero } from 'lib/bignumber/bignumber' @@ -76,254 +76,258 @@ const utxoAccountTypeToDisplayPriority = (accountType: UtxoAccountType | undefin } } -export const AccountDropdown: FC = ({ - assetId, - buttonProps, - onChange: handleChange, - disabled, - defaultAccountId, - listProps, - autoSelectHighestBalance, - boxProps, - showLabel = true, -}) => { - const { chainId } = fromAssetId(assetId) +export const AccountDropdown: FC = memo( + ({ + assetId, + buttonProps, + onChange: handleChange, + disabled, + defaultAccountId, + listProps, + autoSelectHighestBalance, + boxProps, + showLabel = true, + }) => { + const { chainId } = fromAssetId(assetId) - const color = useColorModeValue('black', 'white') - const labelColor = useColorModeValue('gray.600', 'gray.500') + const color = useColorModeValue('black', 'white') + const labelColor = useColorModeValue('gray.600', 'gray.500') - const filter = useMemo(() => ({ assetId }), [assetId]) - const accountIds = useAppSelector((s: ReduxState) => - selectPortfolioAccountIdsByAssetId(s, filter), - ) + const filter = useMemo(() => ({ assetId }), [assetId]) + const accountIds = useAppSelector((s: ReduxState) => + selectPortfolioAccountIdsByAssetId(s, filter), + ) - const translate = useTranslate() - const asset = useAppSelector((s: ReduxState) => selectAssetById(s, assetId)) + const translate = useTranslate() + const asset = useAppSelector((s: ReduxState) => selectAssetById(s, assetId)) - if (!asset) throw new Error(`AccountDropdown: no asset found for assetId ${assetId}!`) + if (!asset) throw new Error(`AccountDropdown: no asset found for assetId ${assetId}!`) - const accountBalances = useSelector(selectPortfolioAccountBalancesBaseUnit) - const accountMetadata = useSelector(selectPortfolioAccountMetadata) - const highestUserCurrencyBalanceAccountId = useAppSelector(state => - selectHighestUserCurrencyBalanceAccountByAssetId(state, { assetId }), - ) - const [selectedAccountId, setSelectedAccountId] = useState( - defaultAccountId, - ) + const accountBalances = useSelector(selectPortfolioAccountBalancesBaseUnit) + const accountMetadata = useSelector(selectPortfolioAccountMetadata) + const highestUserCurrencyBalanceAccountId = useAppSelector(state => + selectHighestUserCurrencyBalanceAccountByAssetId(state, { assetId }), + ) + const [selectedAccountId, setSelectedAccountId] = useState( + defaultAccountId, + ) - // very suspicious of this - // Poor man's componentDidUpdate until we figure out why this re-renders like crazy - const previousSelectedAccountId = usePrevious(selectedAccountId) - const isDropdownDisabled = disabled || accountIds.length <= 1 + // very suspicious of this + // Poor man's componentDidUpdate until we figure out why this re-renders like crazy + const previousSelectedAccountId = usePrevious(selectedAccountId) + const isDropdownDisabled = disabled || accountIds.length <= 1 - /** - * react on selectedAccountId change - */ - useEffect(() => { - if (isEmpty(accountMetadata)) return // not enough data to set an AccountId - if (!selectedAccountId || previousSelectedAccountId === selectedAccountId) return // no-op, this would fire onChange an infuriating amount of times - handleChange(selectedAccountId) - }, [accountMetadata, previousSelectedAccountId, selectedAccountId, handleChange]) - - /** - * react on accountIds on first render - */ - useEffect(() => { - if (!accountIds.length) return - const validatedAccountIdFromArgs = accountIds.find(accountId => accountId === defaultAccountId) - const firstAccountId = accountIds[0] - // Use the first accountId if we don't have a valid defaultAccountId - const preSelectedAccountId = - validatedAccountIdFromArgs ?? - (autoSelectHighestBalance ? highestUserCurrencyBalanceAccountId : undefined) ?? - firstAccountId /** - * assert asset the chainId of the accountId and assetId match + * react on selectedAccountId change */ - const accountIdChainId = fromAccountId(preSelectedAccountId).chainId - const assetIdChainId = fromAssetId(assetId).chainId - if (accountIdChainId !== assetIdChainId) { - throw new Error('AccountDropdown: chainId mismatch!') - } - setSelectedAccountId(preSelectedAccountId) - }, [ - assetId, - accountIds, - defaultAccountId, - highestUserCurrencyBalanceAccountId, - autoSelectHighestBalance, - ]) + useEffect(() => { + if (isEmpty(accountMetadata)) return // not enough data to set an AccountId + if (!selectedAccountId || previousSelectedAccountId === selectedAccountId) return // no-op, this would fire onChange an infuriating amount of times + handleChange(selectedAccountId) + }, [accountMetadata, previousSelectedAccountId, selectedAccountId, handleChange]) - const handleClick = useCallback((accountId: AccountId) => setSelectedAccountId(accountId), []) + /** + * react on accountIds on first render + */ + useEffect(() => { + if (!accountIds.length) return + const validatedAccountIdFromArgs = accountIds.find( + accountId => accountId === defaultAccountId, + ) + const firstAccountId = accountIds[0] + // Use the first accountId if we don't have a valid defaultAccountId + const preSelectedAccountId = + validatedAccountIdFromArgs ?? + (autoSelectHighestBalance ? highestUserCurrencyBalanceAccountId : undefined) ?? + firstAccountId + /** + * assert asset the chainId of the accountId and assetId match + */ + const accountIdChainId = fromAccountId(preSelectedAccountId).chainId + const assetIdChainId = fromAssetId(assetId).chainId + if (accountIdChainId !== assetIdChainId) { + throw new Error('AccountDropdown: chainId mismatch!') + } + setSelectedAccountId(preSelectedAccountId) + }, [ + assetId, + accountIds, + defaultAccountId, + highestUserCurrencyBalanceAccountId, + autoSelectHighestBalance, + ]) - /** - * memoized view bits and bobs - */ - const accountLabel = useMemo( - () => selectedAccountId && accountIdToLabel(selectedAccountId), - [selectedAccountId], - ) + const handleClick = useCallback((accountId: AccountId) => setSelectedAccountId(accountId), []) - const accountNumber: number | undefined = useMemo( - () => selectedAccountId && accountMetadata[selectedAccountId]?.bip44Params?.accountNumber, - [accountMetadata, selectedAccountId], - ) + /** + * memoized view bits and bobs + */ + const accountLabel = useMemo( + () => selectedAccountId && accountIdToLabel(selectedAccountId), + [selectedAccountId], + ) - const getAccountIdsSortedByUtxoAccountType = useCallback( - (accountIds: AccountId[]): AccountId[] => { - return sortBy(accountIds, accountId => - utxoAccountTypeToDisplayPriority(accountMetadata[accountId]?.accountType), - ) - }, - [accountMetadata], - ) + const accountNumber: number | undefined = useMemo( + () => selectedAccountId && accountMetadata[selectedAccountId]?.bip44Params?.accountNumber, + [accountMetadata, selectedAccountId], + ) - const getAccountIdsSortedByBalance = useCallback( - (accountIds: AccountId[]): AccountId[] => - chain(accountIds) - .sortBy(accountIds, accountId => - bnOrZero(accountBalances?.[accountId]?.[assetId] ?? 0).toNumber(), + const getAccountIdsSortedByUtxoAccountType = useCallback( + (accountIds: AccountId[]): AccountId[] => { + return sortBy(accountIds, accountId => + utxoAccountTypeToDisplayPriority(accountMetadata[accountId]?.accountType), ) - .reverse() - .value(), - [accountBalances, assetId], - ) + }, + [accountMetadata], + ) - const menuOptions = useMemo(() => { - const makeTitle = (accountId: AccountId): string => { - /** - * for UTXO chains, we want the title to be the account type - * for account-based chains, we want the title to be the asset name - */ - const { chainNamespace } = fromChainId(chainId) - switch (chainNamespace) { - case CHAIN_NAMESPACE.Utxo: { - return accountIdToLabel(accountId) - } - default: { - return asset?.name ?? '' + const getAccountIdsSortedByBalance = useCallback( + (accountIds: AccountId[]): AccountId[] => + chain(accountIds) + .sortBy(accountIds, accountId => + bnOrZero(accountBalances?.[accountId]?.[assetId] ?? 0).toNumber(), + ) + .reverse() + .value(), + [accountBalances, assetId], + ) + + const menuOptions = useMemo(() => { + const makeTitle = (accountId: AccountId): string => { + /** + * for UTXO chains, we want the title to be the account type + * for account-based chains, we want the title to be the asset name + */ + const { chainNamespace } = fromChainId(chainId) + switch (chainNamespace) { + case CHAIN_NAMESPACE.Utxo: { + return accountIdToLabel(accountId) + } + default: { + return asset?.name ?? '' + } } } - } - /** - * for UTXO-based chains, we can have many accounts for a single account number - * e.g. account 0 can have legacy, segwit, and segwit native - * - * this allows us to render the multiple account varieties and their balances for - * the native asset for UTXO chains, or a single row with the selected asset for - * account based chains that support tokens - */ - type AccountIdsByNumberAndType = { - [k: number]: AccountId[] - } - const initial: AccountIdsByNumberAndType = {} + /** + * for UTXO-based chains, we can have many accounts for a single account number + * e.g. account 0 can have legacy, segwit, and segwit native + * + * this allows us to render the multiple account varieties and their balances for + * the native asset for UTXO chains, or a single row with the selected asset for + * account based chains that support tokens + */ + type AccountIdsByNumberAndType = { + [k: number]: AccountId[] + } + const initial: AccountIdsByNumberAndType = {} - const accountIdsByNumberAndType = accountIds.reduce((acc, accountId) => { - const account = accountMetadata[accountId] - if (!account) return acc - const { accountNumber } = account.bip44Params - if (!acc[accountNumber]) acc[accountNumber] = [] - acc[accountNumber].push(accountId) - return acc - }, initial) + const accountIdsByNumberAndType = accountIds.reduce((acc, accountId) => { + const account = accountMetadata[accountId] + if (!account) return acc + const { accountNumber } = account.bip44Params + if (!acc[accountNumber]) acc[accountNumber] = [] + acc[accountNumber].push(accountId) + return acc + }, initial) - return Object.entries(accountIdsByNumberAndType).map(([accountNumber, accountIds]) => { - const sortedAccountIds = autoSelectHighestBalance - ? getAccountIdsSortedByBalance(accountIds) - : getAccountIdsSortedByUtxoAccountType(accountIds) - return ( - - - {sortedAccountIds.map((iterAccountId, index) => ( - handleClick(iterAccountId)} - isDisabled={disabled} - {...listProps} + return Object.entries(accountIdsByNumberAndType).map(([accountNumber, accountIds]) => { + const sortedAccountIds = autoSelectHighestBalance + ? getAccountIdsSortedByBalance(accountIds) + : getAccountIdsSortedByUtxoAccountType(accountIds) + return ( + + - ))} - - ) - }) - }, [ - accountIds, - chainId, - asset?.name, - asset?.precision, - asset?.symbol, - accountMetadata, - autoSelectHighestBalance, - getAccountIdsSortedByBalance, - getAccountIdsSortedByUtxoAccountType, - translate, - accountBalances, - assetId, - selectedAccountId, - disabled, - listProps, - handleClick, - ]) + {sortedAccountIds.map((iterAccountId, index) => ( + handleClick(iterAccountId)} + isDisabled={disabled} + {...listProps} + /> + ))} + + ) + }) + }, [ + accountIds, + chainId, + asset?.name, + asset?.precision, + asset?.symbol, + accountMetadata, + autoSelectHighestBalance, + getAccountIdsSortedByBalance, + getAccountIdsSortedByUtxoAccountType, + translate, + accountBalances, + assetId, + selectedAccountId, + disabled, + listProps, + handleClick, + ]) - /** - * do NOT remove these checks, this is not a visual thing, this is a safety check! - * - * this component is responsible for selecting the correct account for operations where - * we are sending funds, we need to be paranoid. - */ - if (!accountIds.length) return null - if (!isValidAccountNumber(accountNumber)) return null - if (!menuOptions.length) return null - if (!accountLabel) return null + /** + * do NOT remove these checks, this is not a visual thing, this is a safety check! + * + * this component is responsible for selecting the correct account for operations where + * we are sending funds, we need to be paranoid. + */ + if (!accountIds.length) return null + if (!isValidAccountNumber(accountNumber)) return null + if (!menuOptions.length) return null + if (!accountLabel) return null - return ( - - - } - variant='ghost' - color={color} - disabled={isDropdownDisabled} - {...buttonProps} - > - + + } + variant='ghost' + color={color} + disabled={isDropdownDisabled} + {...buttonProps} > - - {translate('accounts.accountNumber', { accountNumber })} - - {showLabel && ( - - {accountLabel} - - )} - - - - - - {menuOptions} - - - - - - ) -} + + + {translate('accounts.accountNumber', { accountNumber })} + + {showLabel && ( + + {accountLabel} + + )} + + + + + + {menuOptions} + + + + + + ) + }, +) diff --git a/src/components/AssetIcon.tsx b/src/components/AssetIcon.tsx index 215b590ccfe..b81b3ae3c7b 100644 --- a/src/components/AssetIcon.tsx +++ b/src/components/AssetIcon.tsx @@ -2,6 +2,7 @@ import type { AvatarProps } from '@chakra-ui/react' import { Avatar, Circle, Flex, useColorModeValue, useMultiStyleConfig } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' import { fromAssetId } from '@shapeshiftoss/caip' +import { memo } from 'react' import { getChainAdapterManager } from 'context/PluginProvider/chainAdapterSingleton' import { selectAssetById, selectFeeAssetById } from 'state/slices/selectors' import { useAppSelector } from 'state/store' @@ -24,6 +25,19 @@ type AssetWithNetworkProps = { assetId: AssetId } & AvatarProps +const before = { + content: '""', + width: '115%', + height: '115%', + backgroundColor: 'var(--chakra-colors-chakra-body-bg)', + borderRadius: 'full', + position: 'absolute', + left: '50%', + top: '50%', + transform: 'translate(-50%, -50%)', + zIndex: -1, +} + const AssetWithNetwork: React.FC = ({ assetId, icon, src, ...rest }) => { const asset = useAppSelector(state => selectAssetById(state, assetId ?? '')) const feeAsset = useAppSelector(state => selectFeeAssetById(state, assetId)) @@ -44,25 +58,14 @@ const AssetWithNetwork: React.FC = ({ assetId, icon, src, bg='none' fontSize='inherit' src={feeAsset?.networkIcon ?? feeAsset?.icon} - _before={{ - content: '""', - width: '115%', - height: '115%', - backgroundColor: 'var(--chakra-colors-chakra-body-bg)', - borderRadius: 'full', - position: 'absolute', - left: '50%', - top: '50%', - transform: 'translate(-50%, -50%)', - zIndex: -1, - }} + _before={before} /> )} ) } -export const AssetIcon = ({ assetId, showNetworkIcon, src, ...rest }: AssetIconProps) => { +export const AssetIcon = memo(({ assetId, showNetworkIcon, src, ...rest }: AssetIconProps) => { const asset = useAppSelector(state => selectAssetById(state, assetId ?? '')) const assetIconBg = useColorModeValue('gray.200', 'gray.700') const assetIconColor = useColorModeValue('gray.500', 'gray.500') @@ -117,7 +120,7 @@ export const AssetIcon = ({ assetId, showNetworkIcon, src, ...rest }: AssetIconP {...rest} /> ) -} +}) type WrappedIconProps = { wrapColor?: string diff --git a/src/components/AssetSearch/AssetList.tsx b/src/components/AssetSearch/AssetList.tsx index cde32db0902..74d6d5291bb 100644 --- a/src/components/AssetSearch/AssetList.tsx +++ b/src/components/AssetSearch/AssetList.tsx @@ -1,7 +1,8 @@ import type { ListProps } from '@chakra-ui/react' import { Center } from '@chakra-ui/react' import type { FC } from 'react' -import { useEffect, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' +import type { Size } from 'react-virtualized-auto-sizer' import AutoSizer from 'react-virtualized-auto-sizer' import { FixedSizeList } from 'react-window' import { Text } from 'components/Text' @@ -54,33 +55,47 @@ export const AssetList: FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [assets]) - return ( - - {({ height }) => - assets?.length === 0 ? ( + const itemData = useMemo( + () => ({ + assets, + handleClick, + disableUnsupported, + hideZeroBalanceAmounts, + }), + [assets, disableUnsupported, handleClick, hideZeroBalanceAmounts], + ) + + const renderContent = useCallback( + ({ height }: Size) => { + if (assets?.length === 0) { + return (
- ) : ( - - {AssetRow} - ) } + + return ( + + {AssetRow} + + ) + }, + [assets.length, itemData], + ) + + return ( + + {renderContent} ) } diff --git a/src/components/AssetSearch/AssetRow.tsx b/src/components/AssetSearch/AssetRow.tsx index 446c48a7938..41cebf6273c 100644 --- a/src/components/AssetSearch/AssetRow.tsx +++ b/src/components/AssetSearch/AssetRow.tsx @@ -1,6 +1,6 @@ import { Box, Button, Flex, Text, useColorModeValue } from '@chakra-ui/react' import type { FC } from 'react' -import { useMemo } from 'react' +import { memo, useCallback, useMemo } from 'react' import type { ListChildComponentProps } from 'react-window' import { Amount } from 'components/Amount/Amount' import { AssetIcon } from 'components/AssetIcon' @@ -17,74 +17,79 @@ import { } from 'state/slices/selectors' import { useAppSelector } from 'state/store' -export const AssetRow: FC> = ({ - data: { handleClick, disableUnsupported, assets, hideZeroBalanceAmounts }, - index, - style, -}) => { - const color = useColorModeValue('gray.500', 'whiteAlpha.500') - const { - state: { isConnected, isDemoWallet, wallet }, - } = useWallet() - const asset: Asset | undefined = assets[index] - const assetId = asset?.assetId - const filter = useMemo(() => ({ assetId }), [assetId]) - const isSupported = wallet && isAssetSupportedByWallet(assetId, wallet) - const cryptoHumanBalance = useAppSelector(s => - selectPortfolioCryptoPrecisionBalanceByFilter(s, filter), - ) - const userCurrencyBalance = - useAppSelector(s => selectPortfolioUserCurrencyBalanceByAssetId(s, filter)) ?? '0' - if (!asset) return null +const focus = { + shadow: 'outline-inset', +} + +export const AssetRow: FC> = memo( + ({ data: { handleClick, disableUnsupported, assets, hideZeroBalanceAmounts }, index, style }) => { + const color = useColorModeValue('gray.500', 'whiteAlpha.500') + const { + state: { isConnected, isDemoWallet, wallet }, + } = useWallet() + const asset: Asset | undefined = assets[index] + const assetId = asset?.assetId + const filter = useMemo(() => ({ assetId }), [assetId]) + const isSupported = wallet && isAssetSupportedByWallet(assetId, wallet) + const cryptoHumanBalance = useAppSelector(s => + selectPortfolioCryptoPrecisionBalanceByFilter(s, filter), + ) + const userCurrencyBalance = + useAppSelector(s => selectPortfolioUserCurrencyBalanceByAssetId(s, filter)) ?? '0' + const handleOnClick = useCallback(() => handleClick(asset), [asset, handleClick]) + + if (!asset) return null - const hideAssetBalance = !!(hideZeroBalanceAmounts && bnOrZero(cryptoHumanBalance).isZero()) + const hideAssetBalance = !!(hideZeroBalanceAmounts && bnOrZero(cryptoHumanBalance).isZero()) - return ( - - ) -} + {isConnected && !isDemoWallet && !hideAssetBalance && ( + + + + + )} + + ) + }, +) diff --git a/src/components/AssetSearch/AssetSearch.tsx b/src/components/AssetSearch/AssetSearch.tsx index acee0db01db..16f0c6bb05c 100644 --- a/src/components/AssetSearch/AssetSearch.tsx +++ b/src/components/AssetSearch/AssetSearch.tsx @@ -1,9 +1,8 @@ import { SearchIcon } from '@chakra-ui/icons' import type { BoxProps, InputProps } from '@chakra-ui/react' -import { Box, Input, InputGroup, InputLeftElement, SlideFade } from '@chakra-ui/react' +import { Box, Input, InputGroup, InputLeftElement } from '@chakra-ui/react' import type { ChainId } from '@shapeshiftoss/caip' import { isNft } from '@shapeshiftoss/caip' -import { debounce } from 'lodash' import intersection from 'lodash/intersection' import uniq from 'lodash/uniq' import type { FC, FormEvent } from 'react' @@ -12,7 +11,6 @@ import { useForm } from 'react-hook-form' import { useTranslate } from 'react-polyglot' import { useSelector } from 'react-redux' import { useHistory } from 'react-router' -import { Card } from 'components/Card/Card' import type { Asset } from 'lib/asset-service' import { selectAssetsSortedByMarketCapUserCurrencyBalanceAndName, @@ -23,33 +21,25 @@ import { useAppSelector } from 'state/store' import { AssetList } from './AssetList' import { ChainList } from './Chains/ChainList' import { filterAssetsBySearchTerm } from './helpers/filterAssetsBySearchTerm/filterAssetsBySearchTerm' - export type AssetSearchProps = { assets?: Asset[] onClick?: (asset: Asset) => void disableUnsupported?: boolean - assetListAsDropdown?: boolean - hideZeroBalanceAmounts?: boolean formProps?: BoxProps } - export const AssetSearch: FC = ({ assets: selectedAssets, onClick, disableUnsupported, - assetListAsDropdown, - hideZeroBalanceAmounts, formProps, }) => { const translate = useTranslate() const history = useHistory() - const chainIdsByMarketCap = useSelector(selectChainIdsByMarketCap) const [activeChain, setActiveChain] = useState('All') const assets = useAppSelector( state => selectedAssets ?? selectAssetsSortedByMarketCapUserCurrencyBalanceAndName(state), ) - /** * assets filtered by selected chain ids */ @@ -60,21 +50,17 @@ export const AssetSearch: FC = ({ : assets.filter(a => a.chainId === activeChain && !isNft(a.assetId)), [activeChain, assets], ) - - const [isFocused, setIsFocused] = useState(false) - const debounceBlur = debounce(() => setIsFocused(false), 150) - // If a custom click handler isn't provided navigate to the asset's page - const defaultClickHandler = (asset: Asset) => { - // AssetId has a `/` separator so the router will have to parse 2 variables - // e.g., /assets/:chainId/:assetSubId - const url = `/assets/${asset.assetId}` - history.push(url) - setIsFocused(false) - } - + const defaultClickHandler = useCallback( + (asset: Asset) => { + // AssetId has a `/` separator so the router will have to parse 2 variables + // e.g., /assets/:chainId/:assetSubId + const url = `/assets/${asset.assetId}` + history.push(url) + }, + [history], + ) const handleClick = onClick ?? defaultClickHandler - const [searchTermAssets, setSearchTermAssets] = useState([]) const { register, watch } = useForm<{ search: string }>({ mode: 'onChange', @@ -82,10 +68,8 @@ export const AssetSearch: FC = ({ search: '', }, }) - const searchString = watch('search') const searching = useMemo(() => searchString.length > 0, [searchString]) - useEffect(() => { if (filteredAssets) { setSearchTermAssets( @@ -93,9 +77,7 @@ export const AssetSearch: FC = ({ ) } }, [searchString, searching, filteredAssets]) - const listAssets = searching ? searchTermAssets : filteredAssets - /** * display a list of chain icon filters, based on a unique list of chain ids, * derived from the output of the filterBy function, sorted by market cap @@ -104,20 +86,18 @@ export const AssetSearch: FC = ({ () => intersection(chainIdsByMarketCap, uniq(assets.map(a => a.chainId))), [chainIdsByMarketCap, assets], ) - - const inputProps: InputProps = { - ...register('search'), - type: 'text', - placeholder: translate('common.searchAsset'), - pl: 10, - variant: 'filled', - autoComplete: 'off', - ...(() => - assetListAsDropdown - ? { onBlur: debounceBlur, onFocus: () => setIsFocused(true) } - : { autoFocus: true })(), - } - + const inputProps: InputProps = useMemo( + () => ({ + ...register('search'), + type: 'text', + placeholder: translate('common.searchAsset'), + pl: 10, + variant: 'filled', + autoComplete: 'off', + autoFocus: true, + }), + [register, translate], + ) const handleChainClick = useCallback( (e: React.MouseEvent) => (chainId: ChainId | 'All') => { e.preventDefault() @@ -126,33 +106,24 @@ export const AssetSearch: FC = ({ [], ) - const searchElement: JSX.Element = ( - ) => e.preventDefault()} - {...formProps} - > - - {/* Override zIndex to prevent element displaying on overlay components */} - - - - - - - ) + const handleSubmit = useCallback((e: FormEvent) => e.preventDefault(), []) - const assetSearchWithAssetList: JSX.Element = ( + return ( <> - {searchElement} + + + {/* Override zIndex to prevent element displaying on overlay components */} + + + + + + {listAssets && ( = ({ )} ) - - const assetSearchWithAssetDropdown: JSX.Element = ( - - {searchElement} - {isFocused && ( - - - - - - - - - - )} - - ) - - return assetListAsDropdown ? assetSearchWithAssetDropdown : assetSearchWithAssetList } diff --git a/src/components/AssetSearch/Chains/ChainDropdown.tsx b/src/components/AssetSearch/Chains/ChainDropdown.tsx index 4c0341074c7..2a32491c73b 100644 --- a/src/components/AssetSearch/Chains/ChainDropdown.tsx +++ b/src/components/AssetSearch/Chains/ChainDropdown.tsx @@ -10,7 +10,7 @@ import { MenuOptionGroup, } from '@chakra-ui/react' import type { ChainId } from '@shapeshiftoss/caip' -import { useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' import { Amount } from 'components/Amount/Amount' import { IconCircle } from 'components/IconCircle' @@ -29,6 +29,8 @@ type ChainDropdownProps = { buttonProps?: ButtonProps } & Omit +const width = { base: 'full', md: 'auto' } + export const ChainDropdown: React.FC = ({ chainIds, chainId, @@ -42,6 +44,7 @@ export const ChainDropdown: React.FC = ({ selectPortfolioTotalUserCurrencyBalanceExcludeEarnDupes, ) const translate = useTranslate() + const renderChains = useMemo(() => { return chainIds.map(chainId => ( @@ -50,18 +53,15 @@ export const ChainDropdown: React.FC = ({ )) }, [chainIds, includeBalance]) + const onChange = useCallback((value: string | string[]) => onClick(value as ChainId), [onClick]) + return ( - } - {...buttonProps} - > + } {...buttonProps}> {chainId ? : translate('common.allChains')} - onClick(value as ChainId)}> + {showAll && ( diff --git a/src/components/FilterGroup.tsx b/src/components/FilterGroup.tsx index 40aa5ac48fd..30fcf8f63c4 100644 --- a/src/components/FilterGroup.tsx +++ b/src/components/FilterGroup.tsx @@ -17,7 +17,7 @@ import { IoIosArrowDown, IoIosArrowUp } from 'react-icons/io' import { Text } from 'components/Text' import { useToggle } from 'hooks/useToggle/useToggle' -type Option = [string, string, ReactChild?] +export type Option = [string, string, ReactChild?] export const FilterGroup = ({ title, diff --git a/src/components/Graph/Graph.tsx b/src/components/Graph/Graph.tsx index b000ab89ac1..eee88f99a11 100644 --- a/src/components/Graph/Graph.tsx +++ b/src/components/Graph/Graph.tsx @@ -1,7 +1,8 @@ import { Center, Fade } from '@chakra-ui/react' import { ParentSize } from '@visx/responsive' +import type { ParentSizeProvidedProps } from '@visx/responsive/lib/components/ParentSize' import { isEmpty } from 'lodash' -import { useMemo } from 'react' +import { useCallback } from 'react' import type { BalanceChartData } from 'hooks/useBalanceChartData/useBalanceChartData' import { GraphLoading } from './GraphLoading' @@ -16,38 +17,38 @@ type GraphProps = { isRainbowChart?: boolean } +const margin = { + top: 16, + right: 0, + bottom: 32, + left: 0, +} + export const Graph: React.FC = ({ data, isLoaded, loading, color, isRainbowChart }) => { - return useMemo(() => { - const { total, rainbow } = data - return ( - - {parent => { - const primaryChartProps = { - height: parent.height, - width: parent.width, - color, - margin: { - top: 16, - right: 0, - bottom: 32, - left: 0, - }, - } - return loading || !isLoaded ? ( - -
- -
-
- ) : !isEmpty(data) ? ( - isRainbowChart ? ( - - ) : ( - - ) - ) : null - }} -
- ) - }, [color, data, isLoaded, loading, isRainbowChart]) + const { total, rainbow } = data + const renderGraph = useCallback( + ({ height, width }: ParentSizeProvidedProps) => { + return loading || !isLoaded ? ( + +
+ +
+
+ ) : !isEmpty(data) ? ( + isRainbowChart ? ( + + ) : ( + + ) + ) : null + }, + [color, data, isLoaded, isRainbowChart, loading, rainbow, total], + ) + return {renderGraph} } diff --git a/src/components/Graph/PrimaryChart/PrimaryChart.tsx b/src/components/Graph/PrimaryChart/PrimaryChart.tsx index 011070c72e6..dd7f170b031 100644 --- a/src/components/Graph/PrimaryChart/PrimaryChart.tsx +++ b/src/components/Graph/PrimaryChart/PrimaryChart.tsx @@ -6,10 +6,11 @@ import { LinearGradient } from '@visx/gradient' import { ScaleSVG } from '@visx/responsive' import { scaleLinear } from '@visx/scale' import { AnimatedAreaSeries, AnimatedAxis, Tooltip, XYChart } from '@visx/xychart' +import type { RenderTooltipParams } from '@visx/xychart/lib/components/Tooltip' import type { Numeric } from 'd3-array' import { extent, max, min } from 'd3-array' import dayjs from 'dayjs' -import { useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { Amount } from 'components/Amount/Amount' import { RawText } from 'components/Text' import { useLocaleFormatter } from 'hooks/useLocaleFormatter/useLocaleFormatter' @@ -24,19 +25,34 @@ export interface PrimaryChartProps { data: HistoryData[] width: number height: number - margin?: { top: number; right: number; bottom: number; left: number } + margin: { top: number; right: number; bottom: number; left: number } color?: string } // accessors const getStockValue = (d: HistoryData) => d?.price || 0 +const verticalCrosshairStyle = { + stroke: colors.blue[500], + strokeWidth: 2, + opacity: 0.5, + strokeDasharray: '5,2', + pointerEvents: 'none', +} + +const tooltipStyle = { zIndex: 10 } // render over swapper TokenButton component + +const accessors = { + xAccessor: (d: HistoryData) => d.date, + yAccessor: (d: HistoryData) => d.price, +} + export const PrimaryChart = ({ data, width = 10, height, color = 'green.500', - margin = { top: 0, right: 0, bottom: 0, left: 0 }, + margin, }: PrimaryChartProps) => { const selectedLocale = useAppSelector(selectSelectedLocale) @@ -50,24 +66,30 @@ export const PrimaryChart = ({ const tooltipColor = useColorModeValue(colors.gray[800], 'white') // bounds - const xMax = Math.max(width - margin.left - margin.right, 0) - const yMax = Math.max(height - margin.top - margin.bottom, 0) + const xMax = useMemo( + () => Math.max(width - margin.left - margin.right, 0), + [margin.left, margin.right, width], + ) + const yMax = useMemo( + () => Math.max(height - margin.top - margin.bottom, 0), + [height, margin.bottom, margin.top], + ) - const minPrice = Math.min(...data.map(getStockValue)) - const maxPrice = Math.max(...data.map(getStockValue)) + const minPrice = useMemo(() => Math.min(...data.map(getStockValue)), [data]) + const maxPrice = useMemo(() => Math.max(...data.map(getStockValue)), [data]) const priceScale = useMemo(() => { return scaleLinear({ range: [yMax + margin.top - 32, margin.top + 32], domain: [min(data, getStockValue) || 0, max(data, getStockValue) || 0], }) - // - }, [margin.top, yMax, data]) + }, [yMax, margin.top, data]) + + const xyChartMargin = useMemo( + () => ({ top: 0, bottom: margin.bottom, left: 0, right: 0 }), + [margin], + ) - const accessors = { - xAccessor: (d: HistoryData) => d.date, - yAccessor: (d: HistoryData) => d.price, - } const labelColor = useColorModeValue(colors.gray[300], colors.gray[600]) const tickLabelProps = useMemo( () => ({ @@ -98,21 +120,43 @@ export const PrimaryChart = ({ [yMax, margin.top, margin.bottom, minPrice, maxPrice], ) + const renderTooltip = useCallback( + ({ tooltipData }: RenderTooltipParams) => { + const { datum } = tooltipData?.nearestDatum! + const { date, price } = datum as HistoryData + return ( + + + + {dayjs(date).locale(selectedLocale).format('LLL')} + + + ) + }, + [selectedLocale, tooltipBg, tooltipBorder, tooltipColor], + ) + + const lineProps = useMemo(() => ({ stroke: chartColor }), [chartColor]) + const tickLabelPropsFn = useCallback(() => tickLabelProps, [tickLabelProps]) + return ( - + tickLabelProps} + tickLabelProps={tickLabelPropsFn} numTicks={5} labelOffset={16} /> @@ -121,45 +165,19 @@ export const PrimaryChart = ({ data={data} fill='url(#area-gradient)' fillOpacity={0.1} - lineProps={{ stroke: chartColor }} + lineProps={lineProps} offset={16} {...accessors} /> { - const { datum } = tooltipData?.nearestDatum! - const { date, price } = datum as HistoryData - return ( - - - - {dayjs(date).locale(selectedLocale).format('LLL')} - - - ) - }} + renderTooltip={renderTooltip} /> { - const options = Object.freeze([ - { value: HistoryTimeframe.HOUR, label: 'graph.timeControls.1H' }, - { value: HistoryTimeframe.DAY, label: 'graph.timeControls.24H' }, - { value: HistoryTimeframe.WEEK, label: 'graph.timeControls.1W' }, - { value: HistoryTimeframe.MONTH, label: 'graph.timeControls.1M' }, - { value: HistoryTimeframe.YEAR, label: 'graph.timeControls.1Y' }, - { value: HistoryTimeframe.ALL, label: 'graph.timeControls.all' }, - ]) - return ( - - ) -} +export const TimeControls = memo( + ({ onChange, defaultTime, buttonGroupProps }: TimeControlsProps) => { + const options = Object.freeze([ + { value: HistoryTimeframe.HOUR, label: 'graph.timeControls.1H' }, + { value: HistoryTimeframe.DAY, label: 'graph.timeControls.24H' }, + { value: HistoryTimeframe.WEEK, label: 'graph.timeControls.1W' }, + { value: HistoryTimeframe.MONTH, label: 'graph.timeControls.1M' }, + { value: HistoryTimeframe.YEAR, label: 'graph.timeControls.1Y' }, + { value: HistoryTimeframe.ALL, label: 'graph.timeControls.all' }, + ]) + return ( + + ) + }, +) diff --git a/src/components/Layout/Header/GlobalSearch/GlobalSearchButton.tsx b/src/components/Layout/Header/GlobalSearch/GlobalSearchButton.tsx index 8fc22d3f9dc..cf0bd9713fc 100644 --- a/src/components/Layout/Header/GlobalSearch/GlobalSearchButton.tsx +++ b/src/components/Layout/Header/GlobalSearch/GlobalSearchButton.tsx @@ -74,7 +74,7 @@ export const GlobalSeachButton = () => { const [assetResults, stakingResults, lpResults, txResults] = results const flatResults = useMemo(() => [...results, sendResults].flat(), [results, sendResults]) const resultsCount = flatResults.length - const isMac = /Mac/.test(navigator.userAgent) + const isMac = useMemo(() => /Mac/.test(navigator.userAgent), []) const send = useModal('send') useEffect(() => { diff --git a/src/components/Layout/Header/Header.tsx b/src/components/Layout/Header/Header.tsx index aeacea56cf6..16900d1a168 100644 --- a/src/components/Layout/Header/Header.tsx +++ b/src/components/Layout/Header/Header.tsx @@ -12,7 +12,7 @@ import { } from '@chakra-ui/react' import { useScroll } from 'framer-motion' import { WalletConnectToDappsHeaderButton } from 'plugins/walletConnectToDapps/components/header/WalletConnectToDappsHeaderButton' -import { useCallback, useEffect, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useSelector } from 'react-redux' import { useHistory } from 'react-router-dom' import { Text } from 'components/Text' @@ -30,7 +30,7 @@ import { Notifications } from './NavBar/Notifications' import { UserMenu } from './NavBar/UserMenu' import { SideNavContent } from './SideNavContent' -export const Header = () => { +export const Header = memo(() => { const { onToggle, isOpen, onClose } = useDisclosure() const isDegradedState = useSelector(selectPortfolioLoadingStatus) === 'error' @@ -42,7 +42,7 @@ export const Header = () => { const bg = useColorModeValue('gray.100', 'gray.800') const ref = useRef(null) const [y, setY] = useState(0) - const { height = 0 } = ref.current?.getBoundingClientRect() ?? {} + const height = useMemo(() => ref.current?.getBoundingClientRect()?.height ?? 0, []) const { scrollY } = useScroll() useEffect(() => { return scrollY.onChange(() => setY(scrollY.get())) @@ -71,7 +71,10 @@ export const Header = () => { return () => document.removeEventListener('keydown', handleKeyPress) }, [handleKeyPress]) - const handleBannerClick = () => dispatch({ type: WalletActions.SET_WALLET_MODAL, payload: true }) + const handleBannerClick = useCallback( + () => dispatch({ type: WalletActions.SET_WALLET_MODAL, payload: true }), + [dispatch], + ) return ( <> @@ -159,4 +162,4 @@ export const Header = () => { ) -} +}) diff --git a/src/components/Layout/Header/NavBar/MainNavLink.tsx b/src/components/Layout/Header/NavBar/MainNavLink.tsx index 89dd681dd73..866c66ce78f 100644 --- a/src/components/Layout/Header/NavBar/MainNavLink.tsx +++ b/src/components/Layout/Header/NavBar/MainNavLink.tsx @@ -1,9 +1,8 @@ import type { ButtonProps } from '@chakra-ui/react' -import { Box, Button, forwardRef, Tag, Tooltip, useMediaQuery } from '@chakra-ui/react' -import { memo, useMemo } from 'react' +import { Box, Button, Tag, Tooltip, useMediaQuery } from '@chakra-ui/react' +import { memo, useCallback } from 'react' import { useTranslate } from 'react-polyglot' import type { NavLinkProps } from 'react-router-dom' -import { matchPath, useLocation } from 'react-router-dom' import { CircleIcon } from 'components/Icons/Circle' import { breakpoints } from 'theme/theme' @@ -14,22 +13,18 @@ type SidebarLinkProps = { to?: NavLinkProps['to'] isCompact?: boolean isNew?: boolean + isActive?: boolean } & ButtonProps export const MainNavLink = memo( - forwardRef(({ isCompact, ...rest }: SidebarLinkProps, ref) => { - const { href, label, isNew } = rest + ({ isCompact, onClick, isNew, label, isActive, ...rest }: SidebarLinkProps) => { const [isLargerThan2xl] = useMediaQuery(`(min-width: ${breakpoints['2xl']})`, { ssr: false }) const translate = useTranslate() - const location = useLocation() - const isActive = useMemo(() => { - const match = matchPath(location.pathname, { - path: href, - exact: false, - strict: false, - }) - return !!match - }, [href, location.pathname]) + const handleClick: React.MouseEventHandler = useCallback( + e => (isActive ? e.preventDefault() : onClick?.(e)), + [isActive, onClick], + ) + return ( ) - }), + }, ) diff --git a/src/components/Layout/Header/NavBar/MobileNavBar.tsx b/src/components/Layout/Header/NavBar/MobileNavBar.tsx index 1151d27daa2..27d3883bfaa 100644 --- a/src/components/Layout/Header/NavBar/MobileNavBar.tsx +++ b/src/components/Layout/Header/NavBar/MobileNavBar.tsx @@ -1,49 +1,18 @@ -import { Button, Flex } from '@chakra-ui/react' +import { Flex } from '@chakra-ui/react' import { union } from 'lodash' -import { useMemo } from 'react' -import { useTranslate } from 'react-polyglot' -import { Link as ReactRouterLink, useLocation } from 'react-router-dom' import { routes } from 'Routes/RoutesCommon' import { usePlugins } from 'context/PluginProvider/PluginProvider' import { bnOrZero } from 'lib/bignumber/bignumber' +import { MobileNavLink } from './MobileNavLink' + export const MobileNavBar = () => { const { routes: pluginRoutes } = usePlugins() - const translate = useTranslate() const allRoutes = union(routes, pluginRoutes) .filter(route => !route.disable && !route.hide && route.mobileNav) // route mobileNav discriminated union narrowing is lost by the Array.prototype.sort() call .sort((a, b) => bnOrZero(a.priority!).minus(b.priority!).toNumber()) - const location = useLocation() - const renderMenu = useMemo(() => { - return allRoutes.map(route => { - const isActive = location?.pathname.includes(route?.path ?? '') - return ( - - ) - }) - }, [allRoutes, location?.pathname, translate]) return ( { paddingBottom='calc(env(safe-area-inset-bottom, 16px) - 16px)' display={{ base: 'flex', md: 'none' }} > - {renderMenu} + {allRoutes.map(route => ( + + ))} ) } diff --git a/src/components/Layout/Header/NavBar/MobileNavLink.tsx b/src/components/Layout/Header/NavBar/MobileNavLink.tsx new file mode 100644 index 00000000000..7522ede4666 --- /dev/null +++ b/src/components/Layout/Header/NavBar/MobileNavLink.tsx @@ -0,0 +1,43 @@ +import { Button, Flex } from '@chakra-ui/react' +import { useMemo } from 'react' +import { useTranslate } from 'react-polyglot' +import { Link as ReactRouterLink, matchPath, useLocation } from 'react-router-dom' +import type { Route } from 'Routes/helpers' + +export const MobileNavLink = ({ label, shortLabel, path, icon }: Route) => { + const translate = useTranslate() + const location = useLocation() + const isActive = useMemo(() => { + const match = matchPath(location.pathname, { + path, + exact: false, + strict: false, + }) + return !!match + }, [path, location.pathname]) + + return ( + + ) +} diff --git a/src/components/Layout/Header/NavBar/NavBar.tsx b/src/components/Layout/Header/NavBar/NavBar.tsx index 91388d8f922..6ff61c16eb1 100644 --- a/src/components/Layout/Header/NavBar/NavBar.tsx +++ b/src/components/Layout/Header/NavBar/NavBar.tsx @@ -3,7 +3,7 @@ import { Divider, Stack, useColorModeValue, useMediaQuery } from '@chakra-ui/rea import { union } from 'lodash' import { useMemo } from 'react' import { useTranslate } from 'react-polyglot' -import { Link as ReactRouterLink } from 'react-router-dom' +import { Link as ReactRouterLink, matchPath, useLocation } from 'react-router-dom' import type { Route } from 'Routes/helpers' import { routes } from 'Routes/RoutesCommon' import { YatBanner } from 'components/Banners/YatBanner' @@ -26,6 +26,7 @@ export const NavBar = ({ isCompact, onClick, ...rest }: NavBarProps) => { const isYatFeatureEnabled = useFeatureFlag('Yat') const groupColor = useColorModeValue('gray.400', 'gray.600') const dividerColor = useColorModeValue('gray.200', 'whiteAlpha.100') + const { pathname } = useLocation() const navItemGroups = useMemo(() => { const allRoutes = union(routes, pluginRoutes).filter(route => @@ -75,12 +76,19 @@ export const NavBar = ({ isCompact, onClick, ...rest }: NavBarProps) => { label={translate(item.label)} aria-label={translate(item.label)} data-test={`navigation-${item.label.split('.')[1]}-button`} + isActive={ + !!matchPath(pathname, { + path: item.path, + exact: false, + strict: false, + }) + } /> ))} ) }) - }, [groupColor, isCompact, navItemGroups, onClick, translate]) + }, [groupColor, isCompact, navItemGroups, onClick, pathname, translate]) return ( void }> = ({ onClick }) => { if (isLocked) disconnect() const hasWallet = Boolean(walletInfo?.deviceId) - const handleConnect = () => { + const handleConnect = useCallback(() => { onClick && onClick() dispatch({ type: WalletActions.SET_WALLET_MODAL, payload: true }) - } + }, [dispatch, onClick]) return ( diff --git a/src/components/Layout/Header/NavBar/WalletConnectedMenu.tsx b/src/components/Layout/Header/NavBar/WalletConnectedMenu.tsx index 5769ad07588..308c4d23c71 100644 --- a/src/components/Layout/Header/NavBar/WalletConnectedMenu.tsx +++ b/src/components/Layout/Header/NavBar/WalletConnectedMenu.tsx @@ -1,7 +1,7 @@ import { ChevronRightIcon, CloseIcon, RepeatIcon, WarningTwoIcon } from '@chakra-ui/icons' import { Flex, MenuDivider, MenuGroup, MenuItem } from '@chakra-ui/react' import { AnimatePresence } from 'framer-motion' -import { useMemo } from 'react' +import { memo, useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' import { Route, Switch, useLocation } from 'react-router-dom' import { @@ -14,41 +14,39 @@ import { WalletImage } from 'components/Layout/Header/NavBar/WalletImage' import { RawText, Text } from 'components/Text' import { SUPPORTED_WALLETS } from 'context/WalletProvider/config' -export const WalletConnectedMenu = ({ - onDisconnect, - onSwitchProvider, - walletInfo, - isConnected, - type, -}: WalletConnectedProps) => { - const { navigateToRoute } = useMenuRoutes() - const location = useLocation() - const translate = useTranslate() - const connectedWalletMenuRoutes = useMemo( - () => type && SUPPORTED_WALLETS[type].connectedWalletMenuRoutes, - [type], - ) - const ConnectMenuComponent = useMemo( - () => type && SUPPORTED_WALLETS[type].connectedMenuComponent, - [type], - ) +const ConnectedMenu = memo( + ({ + connectedWalletMenuRoutes, + isConnected, + type, + walletInfo, + onDisconnect, + onSwitchProvider, + }: WalletConnectedProps & { + connectedWalletMenuRoutes: boolean + }) => { + const { navigateToRoute } = useMenuRoutes() + const translate = useTranslate() + const ConnectMenuComponent = useMemo( + () => type && SUPPORTED_WALLETS[type].connectedMenuComponent, + [type], + ) + + const handleClick = useCallback(() => { + if (!connectedWalletMenuRoutes) return + navigateToRoute( + (type && SUPPORTED_WALLETS[type])?.connectedWalletMenuInitialPath ?? + WalletConnectedRoutes.Connected, + ) + }, [connectedWalletMenuRoutes, navigateToRoute, type]) - const ConnectedMenu = () => { return ( {walletInfo ? ( - navigateToRoute( - (type && SUPPORTED_WALLETS[type])?.connectedWalletMenuInitialPath ?? - WalletConnectedRoutes.Connected, - ) - : undefined - } + onClick={handleClick} icon={} > @@ -78,21 +76,43 @@ export const WalletConnectedMenu = ({ ) - } + }, +) + +export const WalletConnectedMenu = ({ + onDisconnect, + onSwitchProvider, + walletInfo, + isConnected, + type, +}: WalletConnectedProps) => { + const location = useLocation() + + const connectedWalletMenuRoutes = useMemo( + () => type && SUPPORTED_WALLETS[type].connectedWalletMenuRoutes, + [type], + ) return ( - + - {connectedWalletMenuRoutes?.map(route => { + {connectedWalletMenuRoutes?.map((route, i) => { const Component = route.component return !Component ? null : ( } diff --git a/src/components/Layout/Header/SideNav.tsx b/src/components/Layout/Header/SideNav.tsx index 12c1bc847df..621c3272f63 100644 --- a/src/components/Layout/Header/SideNav.tsx +++ b/src/components/Layout/Header/SideNav.tsx @@ -1,9 +1,10 @@ import { chakra, Flex, useColorModeValue } from '@chakra-ui/react' +import { memo } from 'react' import { AppLoadingIcon } from './AppLoadingIcon' import { SideNavContent } from './SideNavContent' -export const SideNav = () => { +export const SideNav = memo(() => { const bgColor = useColorModeValue('white', 'blackAlpha.300') const shadow = useColorModeValue('lg', 'none') return ( @@ -30,4 +31,4 @@ export const SideNav = () => { ) -} +}) diff --git a/src/components/Layout/Header/SideNavContent.tsx b/src/components/Layout/Header/SideNavContent.tsx index 09082c96eda..a675c36f59e 100644 --- a/src/components/Layout/Header/SideNavContent.tsx +++ b/src/components/Layout/Header/SideNavContent.tsx @@ -2,7 +2,7 @@ import { ChatIcon, CloseIcon, SettingsIcon } from '@chakra-ui/icons' import type { FlexProps } from '@chakra-ui/react' import { Box, Flex, IconButton, Stack, useMediaQuery } from '@chakra-ui/react' import { WalletConnectToDappsHeaderButton } from 'plugins/walletConnectToDapps/components/header/WalletConnectToDappsHeaderButton' -import { useCallback } from 'react' +import { memo, useCallback } from 'react' import { useTranslate } from 'react-polyglot' import { useModal } from 'hooks/useModal/useModal' import { breakpoints } from 'theme/theme' @@ -18,7 +18,7 @@ type HeaderContentProps = { onClose?: () => void } & FlexProps -export const SideNavContent = ({ isCompact, onClose }: HeaderContentProps) => { +export const SideNavContent = memo(({ isCompact, onClose }: HeaderContentProps) => { const translate = useTranslate() const [isLargerThanMd] = useMediaQuery(`(min-width: ${breakpoints['md']})`, { ssr: false }) const settings = useModal('settings') @@ -28,13 +28,15 @@ export const SideNavContent = ({ isCompact, onClose }: HeaderContentProps) => { const isWalletConnectToDappsEnabled = isWalletConnectToDappsV1Enabled || isWalletConnectToDappsV2Enabled - const handleClick = useCallback( - (onClick?: () => void) => { - onClose && onClose() - onClick && onClick() - }, - [onClose], - ) + const handleClickSettings = useCallback(() => { + settings.open({}) + onClose && onClose() + }, [onClose, settings]) + + const handleClickSupport = useCallback(() => { + feedbackSupport.open({}) + onClose && onClose() + }, [onClose, feedbackSupport]) return ( { aria-label='Close Nav' variant='ghost' icon={} - onClick={() => handleClick()} + onClick={onClose} /> - handleClick()} /> + @@ -72,12 +74,12 @@ export const SideNavContent = ({ isCompact, onClose }: HeaderContentProps) => { )} - handleClick()} /> + handleClick(() => settings.open({}))} + onClick={handleClickSettings} label={translate('common.settings')} leftIcon={} data-test='navigation-settings-button' @@ -85,11 +87,11 @@ export const SideNavContent = ({ isCompact, onClose }: HeaderContentProps) => { handleClick(() => feedbackSupport.open({}))} + onClick={handleClickSupport} label={translate('common.feedbackAndSupport')} leftIcon={} /> ) -} +}) diff --git a/src/components/Layout/Main.tsx b/src/components/Layout/Main.tsx index 924123b026d..b9ebc78989c 100644 --- a/src/components/Layout/Main.tsx +++ b/src/components/Layout/Main.tsx @@ -2,7 +2,7 @@ import type { ContainerProps } from '@chakra-ui/react' import { Box, Container, HStack, Stack, useColorModeValue } from '@chakra-ui/react' import { useScroll } from 'framer-motion' import type { ReactNode } from 'react' -import { useEffect, useRef, useState } from 'react' +import { memo, useEffect, useRef, useState } from 'react' import { Breadcrumbs } from 'components/Breadcrumbs/Breadcrumbs' import { NestedMenu } from 'components/NestedMenu/NestedMenu' import { useBrowserRouter } from 'hooks/useBrowserRouter/useBrowserRouter' @@ -15,56 +15,52 @@ export type MainProps = { hideBreadcrumbs?: boolean } & ContainerProps -export const Main: React.FC = ({ - children, - titleComponent, - headerComponent, - hideBreadcrumbs = false, - ...rest -}) => { - const ref = useRef(null) - const { currentRoute } = useBrowserRouter() - const bg = useColorModeValue('white', 'gray.800') - const borderColor = useColorModeValue('gray.100', 'gray.750') - const [y, setY] = useState(0) - const { height = 0 } = ref.current?.getBoundingClientRect() ?? {} - const { scrollY } = useScroll() - useEffect(() => { - return scrollY.onChange(() => setY(scrollY.get())) - }, [scrollY]) - return ( - - {titleComponent && ( - height ? 'sm' : undefined} - > - <> - - - {!hideBreadcrumbs && ( - - - - )} +export const Main: React.FC = memo( + ({ children, titleComponent, headerComponent, hideBreadcrumbs = false, ...rest }) => { + const ref = useRef(null) + const { currentRoute } = useBrowserRouter() + const bg = useColorModeValue('white', 'gray.800') + const borderColor = useColorModeValue('gray.100', 'gray.750') + const [y, setY] = useState(0) + const { height = 0 } = ref.current?.getBoundingClientRect() ?? {} + const { scrollY } = useScroll() + useEffect(() => { + return scrollY.onChange(() => setY(scrollY.get())) + }, [scrollY]) + return ( + + {titleComponent && ( + height ? 'sm' : undefined} + > + <> + + + {!hideBreadcrumbs && ( + + + + )} - {titleComponent} - - - {currentRoute && } - - - )} - {headerComponent} - - {children} - - - ) -} + {titleComponent} + + + {currentRoute && } + + + )} + {headerComponent} + + {children} + + + ) + }, +) diff --git a/src/components/Layout/Seo.tsx b/src/components/Layout/Seo.tsx index e695d259769..e9672e65036 100644 --- a/src/components/Layout/Seo.tsx +++ b/src/components/Layout/Seo.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { memo } from 'react' import { Helmet } from 'react-helmet-async' import Thumb from 'assets/ss-thumb.jpg' @@ -9,31 +9,33 @@ type SeoProps = { name?: string } -export const SEO: React.FC = ({ - title, - description = 'ShapeShift DAO | Your Web3 & DeFi Portal', - type = 'website', - name = 'ShapeShift', -}) => { - const publicUrl = window.location.origin - return ( - - {/* Standard metadata tags */} - {title ? `${title} | ShapeShift` : 'ShapeShift'} - - {/* End standard metadata tags */} - {/* Facebook tags */} - - - - - {/* End Facebook tags */} - {/* Twitter tags */} - - - - - {/* End Twitter tags */} - - ) -} +export const SEO: React.FC = memo( + ({ + title, + description = 'ShapeShift DAO | Your Web3 & DeFi Portal', + type = 'website', + name = 'ShapeShift', + }) => { + const publicUrl = window.location.origin + return ( + + {/* Standard metadata tags */} + {title ? `${title} | ShapeShift` : 'ShapeShift'} + + {/* End standard metadata tags */} + {/* Facebook tags */} + + + + + {/* End Facebook tags */} + {/* Twitter tags */} + + + + + {/* End Twitter tags */} + + ) + }, +) diff --git a/src/components/MaybeChartUnavailable.tsx b/src/components/MaybeChartUnavailable.tsx index 32a87e4006b..304c4abe382 100644 --- a/src/components/MaybeChartUnavailable.tsx +++ b/src/components/MaybeChartUnavailable.tsx @@ -1,12 +1,13 @@ import { Alert, AlertIcon } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' +import { memo } from 'react' import { useTranslate } from 'react-polyglot' import { useUnavailableBalanceChartDataAssetNames } from 'hooks/useBalanceChartData/utils' type MaybeChartUnavailableProps = { assetIds: AssetId[] } -export const MaybeChartUnavailable: React.FC = ({ assetIds }) => { +export const MaybeChartUnavailable: React.FC = memo(({ assetIds }) => { const translate = useTranslate() const unavailableAssetNames = useUnavailableBalanceChartDataAssetNames(assetIds) if (!unavailableAssetNames) return null @@ -16,4 +17,4 @@ export const MaybeChartUnavailable: React.FC = ({ as {translate('common.chartUnavailable', { unavailableAssetNames })} ) -} +}) diff --git a/src/components/Modals/AssetSearch/AssetSearchModal.tsx b/src/components/Modals/AssetSearch/AssetSearchModal.tsx index d0e14ea3265..9bacd512609 100644 --- a/src/components/Modals/AssetSearch/AssetSearchModal.tsx +++ b/src/components/Modals/AssetSearch/AssetSearchModal.tsx @@ -8,7 +8,7 @@ import { useMediaQuery, } from '@chakra-ui/react' import type { FC } from 'react' -import { useCallback } from 'react' +import { memo, useCallback } from 'react' import { useTranslate } from 'react-polyglot' import type { AssetSearchProps } from 'components/AssetSearch/AssetSearch' import { AssetSearch } from 'components/AssetSearch/AssetSearch' @@ -70,17 +70,17 @@ export const AssetSearchModalBase: FC = ({ // multiple instances to prevent rerenders opening the modal in different parts of the app -export const AssetSearchModal: FC = props => { +export const AssetSearchModal: FC = memo(props => { const assetSearch = useModal('assetSearch') return -} +}) -export const SellAssetSearchModal: FC = props => { +export const SellAssetSearchModal: FC = memo(props => { const sellAssetSearch = useModal('sellAssetSearch') return -} +}) -export const BuyAssetSearchModal: FC = props => { +export const BuyAssetSearchModal: FC = memo(props => { const buyAssetSearch = useModal('buyAssetSearch') return -} +}) diff --git a/src/components/Modals/FeedbackSupport/FeedbackSupport.tsx b/src/components/Modals/FeedbackSupport/FeedbackSupport.tsx index 9e622400f84..76290b41296 100644 --- a/src/components/Modals/FeedbackSupport/FeedbackSupport.tsx +++ b/src/components/Modals/FeedbackSupport/FeedbackSupport.tsx @@ -45,6 +45,7 @@ export const FeedbackAndSupport = () => { )} { as={Link} size='sm' label={translate('common.submitFeatureRequest')} + // @ts-ignore isExternal href='https://shapeshift.canny.io/feature-requests' /> diff --git a/src/components/MultiHopTrade/MultiHopTrade.tsx b/src/components/MultiHopTrade/MultiHopTrade.tsx index 4f8eb75060c..d1dceb1db6d 100644 --- a/src/components/MultiHopTrade/MultiHopTrade.tsx +++ b/src/components/MultiHopTrade/MultiHopTrade.tsx @@ -1,7 +1,7 @@ import type { AssetId } from '@shapeshiftoss/caip' import { ethAssetId, foxAssetId } from '@shapeshiftoss/caip' import { AnimatePresence } from 'framer-motion' -import { useEffect } from 'react' +import { memo, useEffect } from 'react' import { FormProvider, useForm } from 'react-hook-form' import { MemoryRouter, Route, Switch, useLocation } from 'react-router-dom' import type { CardProps } from 'components/Card/Card' @@ -22,37 +22,39 @@ export type TradeCardProps = { defaultSellAssetId?: AssetId } & CardProps -export const MultiHopTrade = ({ - defaultBuyAssetId = foxAssetId, - defaultSellAssetId = ethAssetId, - ...cardProps -}: TradeCardProps) => { - const dispatch = useAppDispatch() - const methods = useForm({ mode: 'onChange' }) +export const MultiHopTrade = memo( + ({ + defaultBuyAssetId = foxAssetId, + defaultSellAssetId = ethAssetId, + ...cardProps + }: TradeCardProps) => { + const dispatch = useAppDispatch() + const methods = useForm({ mode: 'onChange' }) - const defaultBuyAsset = useAppSelector(state => selectAssetById(state, defaultBuyAssetId)) - const defaultSellAsset = useAppSelector(state => selectAssetById(state, defaultSellAssetId)) + const defaultBuyAsset = useAppSelector(state => selectAssetById(state, defaultBuyAssetId)) + const defaultSellAsset = useAppSelector(state => selectAssetById(state, defaultSellAssetId)) - useEffect(() => { - dispatch(swappers.actions.clear()) - if (defaultSellAsset) dispatch(swappers.actions.setSellAsset(defaultSellAsset)) - if (defaultBuyAsset) dispatch(swappers.actions.setBuyAsset(defaultBuyAsset)) - }, [defaultBuyAsset, defaultSellAsset, dispatch]) + useEffect(() => { + dispatch(swappers.actions.clear()) + if (defaultSellAsset) dispatch(swappers.actions.setSellAsset(defaultSellAsset)) + if (defaultBuyAsset) dispatch(swappers.actions.setBuyAsset(defaultBuyAsset)) + }, [defaultBuyAsset, defaultSellAsset, dispatch]) - return ( - - - - - - - - - - ) -} + return ( + + + + + + + + + + ) + }, +) -const MultiHopRoutes = () => { +const MultiHopRoutes = memo(() => { const location = useLocation() const dispatch = useAppDispatch() @@ -80,4 +82,4 @@ const MultiHopRoutes = () => { ) -} +}) diff --git a/src/components/MultiHopTrade/components/TradeInput/TradeInput.tsx b/src/components/MultiHopTrade/components/TradeInput/TradeInput.tsx index 11842aba189..78542e8816f 100644 --- a/src/components/MultiHopTrade/components/TradeInput/TradeInput.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/TradeInput.tsx @@ -1,4 +1,5 @@ import { ArrowDownIcon, ArrowForwardIcon, ArrowUpIcon } from '@chakra-ui/icons' +import type { ResponsiveValue } from '@chakra-ui/react' import { Button, Flex, @@ -10,7 +11,8 @@ import { } from '@chakra-ui/react' import { KeplrHDWallet } from '@shapeshiftoss/hdwallet-keplr/dist/keplr' import { getDefaultSlippagePercentageForSwapper } from 'constants/constants' -import { useCallback, useEffect, useMemo, useState } from 'react' +import type { Property } from 'csstype' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslate } from 'react-polyglot' import { useHistory } from 'react-router' @@ -68,7 +70,12 @@ import { useSupportedAssets } from '../../hooks/useSupportedAssets' import { SellAssetInput } from './components/SellAssetInput' import { TradeQuotes } from './components/TradeQuotes/TradeQuotes' -export const TradeInput = () => { +const flexDir: ResponsiveValue = { base: 'column', md: 'row' } +const marginHorizontal = { base: 0, md: -3 } +const marginVertical = { base: -3, md: 0 } +const percentOptions = [1] + +export const TradeInput = memo(() => { useGetTradeQuotes() const { state: { wallet }, @@ -195,12 +202,32 @@ export const TradeInput = () => { return quoteHasError || manualReceiveAddressIsValidating || isLoading || !isSellAmountEntered }, [isLoading, isSellAmountEntered, manualReceiveAddressIsValidating, quoteHasError]) + const rightRegion = useMemo( + () => + activeQuote ? ( + : } + aria-label='Expand Quotes' + onClick={toggleShowTradeQuotes} + /> + ) : ( + <> + ), + [activeQuote, showTradeQuotes, toggleShowTradeQuotes], + ) + + const tradeQuotes = useMemo( + () => , + [showTradeQuotes, sortedQuotes], + ) + return ( - + { { fiatAmount={ isSellAmountEntered ? positiveOrZero(buyAmountAfterFeesUserCurrency).toFixed() : '0' } - percentOptions={[1]} + percentOptions={percentOptions} showInputSkeleton={isLoading} showFiatSkeleton={isLoading} label={translate('trade.youGet')} - rightRegion={ - activeQuote ? ( - : } - aria-label='Expand Quotes' - onClick={toggleShowTradeQuotes} - /> - ) : ( - <> - ) - } + rightRegion={rightRegion} > - {Boolean(sortedQuotes.length) && ( - - )} + {tradeQuotes} { ) -} +}) diff --git a/src/components/MultiHopTrade/components/TradeInput/components/DonationCheckbox.tsx b/src/components/MultiHopTrade/components/TradeInput/components/DonationCheckbox.tsx index 662c0fa61cb..61a5be67b17 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/DonationCheckbox.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/components/DonationCheckbox.tsx @@ -1,6 +1,6 @@ import { Checkbox, Stack } from '@chakra-ui/react' import type { FC } from 'react' -import { useCallback, useMemo } from 'react' +import { memo, useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' import { HelperTooltip } from 'components/HelperTooltip/HelperTooltip' import { Row } from 'components/Row/Row' @@ -18,43 +18,45 @@ type DonationCheckboxProps = { isLoading: boolean } -export const DonationCheckbox: FC = ({ isLoading }): JSX.Element | null => { - const translate = useTranslate() - const dispatch = useAppDispatch() - const willDonate = useAppSelector(selectWillDonate) - const affiliateBps = useAppSelector(selectActiveQuoteDonationBps) +export const DonationCheckbox: FC = memo( + ({ isLoading }): JSX.Element | null => { + const translate = useTranslate() + const dispatch = useAppDispatch() + const willDonate = useAppSelector(selectWillDonate) + const affiliateBps = useAppSelector(selectActiveQuoteDonationBps) - const { - number: { toFiat }, - } = useLocaleFormatter() + const { + number: { toFiat }, + } = useLocaleFormatter() - const potentialDonationAmountFiat = useAppSelector(selectPotentialDonationAmountUserCurrency) + const potentialDonationAmountFiat = useAppSelector(selectPotentialDonationAmountUserCurrency) - const handleDonationToggle = useCallback(() => { - dispatch(swappers.actions.toggleWillDonate()) - }, [dispatch]) + const handleDonationToggle = useCallback(() => { + dispatch(swappers.actions.toggleWillDonate()) + }, [dispatch]) - const donationOption: JSX.Element = useMemo( - () => ( - - - - - - - - - - {toFiat(potentialDonationAmountFiat ?? '0')} - - - ), - [translate, willDonate, handleDonationToggle, isLoading, toFiat, potentialDonationAmountFiat], - ) + const donationOption: JSX.Element = useMemo( + () => ( + + + + + + + + + + {toFiat(potentialDonationAmountFiat ?? '0')} + + + ), + [translate, willDonate, handleDonationToggle, isLoading, toFiat, potentialDonationAmountFiat], + ) - return affiliateBps !== undefined ? donationOption : null -} + return affiliateBps !== undefined ? donationOption : null + }, +) diff --git a/src/components/MultiHopTrade/components/TradeInput/components/ManualAddressEntry.tsx b/src/components/MultiHopTrade/components/TradeInput/components/ManualAddressEntry.tsx index e7b11a2c093..2361bbbfb0f 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/ManualAddressEntry.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/components/ManualAddressEntry.tsx @@ -1,7 +1,7 @@ import { FormControl, FormLabel } from '@chakra-ui/react' import { ethChainId } from '@shapeshiftoss/caip' import type { FC } from 'react' -import { useEffect, useMemo } from 'react' +import { memo, useEffect, useMemo } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslate } from 'react-polyglot' import { AddressInput } from 'components/Modals/Send/AddressInput/AddressInput' @@ -15,7 +15,7 @@ import { selectBuyAsset, selectManualReceiveAddress } from 'state/slices/swapper import { swappers } from 'state/slices/swappersSlice/swappersSlice' import { useAppDispatch, useAppSelector } from 'state/store' -export const ManualAddressEntry: FC = (): JSX.Element | null => { +export const ManualAddressEntry: FC = memo((): JSX.Element | null => { const dispatch = useAppDispatch() const { @@ -58,6 +58,32 @@ export const ManualAddressEntry: FC = (): JSX.Element | null => { dispatch(swappers.actions.setManualReceiveAddressIsValidating(isValidating)) }, [dispatch, isValidating]) + const rules = useMemo( + () => ({ + required: true, + validate: { + validateAddress: async (rawInput: string) => { + dispatch(swappers.actions.setManualReceiveAddress(undefined)) + const value = rawInput.trim() // trim leading/trailing spaces + // this does not throw, everything inside is handled + const parseAddressInputWithChainIdArgs = { + assetId: buyAssetAssetId, + chainId: buyAssetChainId, + urlOrAddress: value, + disableUrlParsing: true, + } + const { address } = await parseAddressInputWithChainId(parseAddressInputWithChainIdArgs) + dispatch(swappers.actions.setManualReceiveAddress(address || undefined)) + const invalidMessage = isYatSupported + ? 'common.invalidAddressOrYat' + : 'common.invalidAddress' + return address ? true : invalidMessage + }, + }, + }), + [buyAssetAssetId, buyAssetChainId, dispatch, isYatSupported], + ) + const ManualReceiveAddressEntry: JSX.Element = useMemo(() => { return ( @@ -68,35 +94,12 @@ export const ManualAddressEntry: FC = (): JSX.Element | null => { {translate('trade.receiveAddressDescription', { chainName: buyAssetChainName })} { - dispatch(swappers.actions.setManualReceiveAddress(undefined)) - const value = rawInput.trim() // trim leading/trailing spaces - // this does not throw, everything inside is handled - const parseAddressInputWithChainIdArgs = { - assetId: buyAssetAssetId, - chainId: buyAssetChainId, - urlOrAddress: value, - disableUrlParsing: true, - } - const { address } = await parseAddressInputWithChainId( - parseAddressInputWithChainIdArgs, - ) - dispatch(swappers.actions.setManualReceiveAddress(address || undefined)) - const invalidMessage = isYatSupported - ? 'common.invalidAddressOrYat' - : 'common.invalidAddress' - return address ? true : invalidMessage - }, - }, - }} + rules={rules} placeholder={translate('trade.addressPlaceholder', { chainName: buyAssetChainName })} /> ) - }, [buyAssetAssetId, buyAssetChainId, buyAssetChainName, dispatch, isYatSupported, translate]) + }, [buyAssetChainName, rules, translate]) return shouldShowManualReceiveAddressInput ? ManualReceiveAddressEntry : null -} +}) diff --git a/src/components/MultiHopTrade/components/TradeInput/components/SellAssetInput.tsx b/src/components/MultiHopTrade/components/TradeInput/components/SellAssetInput.tsx index f95015cbc5c..e4332fcef0d 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/SellAssetInput.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/components/SellAssetInput.tsx @@ -1,5 +1,5 @@ import type { AccountId } from '@shapeshiftoss/caip' -import { useCallback, useEffect, useState } from 'react' +import { memo, useCallback, useEffect, useState } from 'react' import { TradeAssetInput } from 'components/Trade/Components/TradeAssetInput' import type { Asset } from 'lib/asset-service' import { bnOrZero, positiveOrZero } from 'lib/bignumber/bignumber' @@ -13,7 +13,9 @@ export type SellAssetInputProps = { asset: Asset } -export const SellAssetInput = ({ accountId, asset, label }: SellAssetInputProps) => { +const percentOptions = [1] + +export const SellAssetInput = memo(({ accountId, asset, label }: SellAssetInputProps) => { const [sellAmountUserCurrencyHuman, setSellAmountUserCurrencyHuman] = useState('0') const [sellAmountCryptoPrecision, setSellAmountCryptoPrecision] = useState('0') const dispatch = useAppDispatch() @@ -51,10 +53,10 @@ export const SellAssetInput = ({ accountId, asset, label }: SellAssetInputProps) fiatAmount={positiveOrZero(sellAmountUserCurrencyHuman).toString()} isSendMaxDisabled={false} onChange={handleSellAssetInputChange} - percentOptions={[1]} + percentOptions={percentOptions} showInputSkeleton={false} showFiatSkeleton={false} label={label} /> ) -} +}) diff --git a/src/components/MultiHopTrade/components/TradeInput/components/TradeQuotes/TradeQuote.tsx b/src/components/MultiHopTrade/components/TradeInput/components/TradeQuotes/TradeQuote.tsx index 319f4da7147..e0442ec4c7b 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/TradeQuotes/TradeQuote.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/components/TradeQuotes/TradeQuote.tsx @@ -151,13 +151,22 @@ export const TradeQuoteLoaded: React.FC = ({ return borderColor })() + const hoverProps = useMemo( + () => ({ borderColor: isActive ? activeSwapperColor : hoverColor }), + [activeSwapperColor, hoverColor, isActive], + ) + const activeProps = useMemo( + () => ({ borderColor: isActive ? activeSwapperColor : focusColor }), + [activeSwapperColor, focusColor, isActive], + ) + return totalReceiveAmountCryptoPrecision ? ( = ({ isOpen, sortedQuotes }) => { +export const TradeQuotes: React.FC = memo(({ isOpen, sortedQuotes }) => { const activeSwapperName = useAppSelector(selectActiveSwapperName) const bestQuoteData = sortedQuotes[0] - const quotes = sortedQuotes.map((quoteData, i) => { - const { quote, swapperName } = quoteData - - // TODO(woodenfurniture): we may want to display per-swapper errors here - if (!quote) return null - - // TODO(woodenfurniture): use quote ID when we want to support multiple quotes per swapper - const isActive = activeSwapperName === swapperName - - return ( - - ) - }) + const quotes = useMemo( + () => + sortedQuotes.map((quoteData, i) => { + const { quote, swapperName } = quoteData + + // TODO(woodenfurniture): we may want to display per-swapper errors here + if (!quote) return null + + // TODO(woodenfurniture): use quote ID when we want to support multiple quotes per swapper + const isActive = activeSwapperName === swapperName + + return ( + + ) + }), + [activeSwapperName, bestQuoteData, sortedQuotes], + ) return ( @@ -42,4 +47,4 @@ export const TradeQuotes: React.FC = ({ isOpen, sortedQuotes } ) -} +}) diff --git a/src/components/SelectAssets/SelectAssets.tsx b/src/components/SelectAssets/SelectAssets.tsx index 9e930ba55da..a9dd8828f24 100644 --- a/src/components/SelectAssets/SelectAssets.tsx +++ b/src/components/SelectAssets/SelectAssets.tsx @@ -1,17 +1,20 @@ import { ArrowBackIcon } from '@chakra-ui/icons' import { IconButton, ModalBody, ModalCloseButton, ModalHeader, Stack } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' +import { useCallback } from 'react' import { useTranslate } from 'react-polyglot' import { AssetSearch } from 'components/AssetSearch/AssetSearch' import { SlideTransition } from 'components/SlideTransition' +import type { Asset } from 'lib/asset-service' type SelectAssetsProps = { onClick(assetId: AssetId): void onBack?: () => void } -export const SelectAssets = ({ onClick: handleClick, onBack: handleBack }: SelectAssetsProps) => { +export const SelectAssets = ({ onClick, onBack: handleBack }: SelectAssetsProps) => { const translate = useTranslate() + const handleClick = useCallback((asset: Asset) => onClick(asset.assetId), [onClick]) return ( @@ -30,7 +33,7 @@ export const SelectAssets = ({ onClick: handleClick, onBack: handleBack }: Selec - handleClick(asset.assetId)} /> + ) diff --git a/src/components/Trade/Components/AssetSelection.tsx b/src/components/Trade/Components/AssetSelection.tsx index 5a7aab1b79b..1fc89c1d7af 100644 --- a/src/components/Trade/Components/AssetSelection.tsx +++ b/src/components/Trade/Components/AssetSelection.tsx @@ -7,6 +7,7 @@ import { useColorModeValue, } from '@chakra-ui/react' import type { AccountId, AssetId } from '@shapeshiftoss/caip' +import { memo, useMemo } from 'react' import type { AccountDropdownProps } from 'components/AccountDropdown/AccountDropdown' import { AccountDropdown } from 'components/AccountDropdown/AccountDropdown' import { AssetIcon } from 'components/AssetIcon' @@ -38,6 +39,18 @@ type TradeAssetSelectProps = { label: string } +const footerPadding = { padding: 0 } +const buttonProps = { + width: 'full', + borderTopRadius: 0, + px: 4, + fontSize: 'xs', + py: 4, + height: 'auto', +} +const boxProps = { m: 0, p: 0 } +const borderRadius = { base: 'xl' } + export const TradeAssetSelectWithAsset: React.FC = ({ onAccountIdChange: handleAccountIdChange, accountId, @@ -52,12 +65,16 @@ export const TradeAssetSelectWithAsset: React.FC = ({ const asset = useAppSelector(state => selectAssetById(state, assetId ?? '')) const feeAsset = useAppSelector(state => selectFeeAssetByChainId(state, asset?.chainId ?? '')) const networkName = feeAsset?.networkName || feeAsset?.name + + const hoverProps = useMemo(() => ({ bg: hoverBg }), [hoverBg]) + const activeProps = useMemo(() => ({ bg: focusBg }), [focusBg]) + return ( @@ -65,8 +82,8 @@ export const TradeAssetSelectWithAsset: React.FC = ({ display='flex' gap={1} flexDir='column' - _hover={{ bg: hoverBg }} - _active={{ bg: focusBg }} + _hover={hoverProps} + _active={activeProps} cursor='pointer' py={2} px={4} @@ -86,20 +103,13 @@ export const TradeAssetSelectWithAsset: React.FC = ({ {assetId && ( - + @@ -109,14 +119,12 @@ export const TradeAssetSelectWithAsset: React.FC = ({ ) } -export const TradeAssetSelect: React.FC = ({ - assetId, - accountId, - ...restAssetInputProps -}) => { - return assetId ? ( - - ) : ( - - ) -} +export const TradeAssetSelect: React.FC = memo( + ({ assetId, accountId, ...restAssetInputProps }) => { + return assetId ? ( + + ) : ( + + ) + }, +) diff --git a/src/components/Trade/Components/RateGasRow.tsx b/src/components/Trade/Components/RateGasRow.tsx index b870014faa5..1616461b99b 100644 --- a/src/components/Trade/Components/RateGasRow.tsx +++ b/src/components/Trade/Components/RateGasRow.tsx @@ -1,5 +1,5 @@ import { Stack } from '@chakra-ui/react' -import { type FC } from 'react' +import { type FC, memo } from 'react' import { FaGasPump } from 'react-icons/fa' import { useTranslate } from 'react-polyglot' import { Amount } from 'components/Amount/Amount' @@ -18,68 +18,63 @@ type RateGasRowProps = { isLoading?: boolean isError?: boolean } -export const RateGasRow: FC = ({ - sellSymbol, - buySymbol, - rate, - gasFee, - isLoading, - isError, -}) => { - const translate = useTranslate() - switch (true) { - case isLoading: - return ( - - - - - ) - case !rate || isError: - return ( - - - - - - ) - default: - return ( - - - - - - - - - - - - - - - - - - - - - ) - } -} +export const RateGasRow: FC = memo( + ({ sellSymbol, buySymbol, rate, gasFee, isLoading, isError }) => { + const translate = useTranslate() + switch (true) { + case isLoading: + return ( + + + + + ) + case !rate || isError: + return ( + + + + + + ) + default: + return ( + + + + + + + + + + + + + + + + + + + + + ) + } + }, +) diff --git a/src/components/Trade/Components/TradeAmountInput.tsx b/src/components/Trade/Components/TradeAmountInput.tsx index 6ab28c15e54..7f583d8d5d7 100644 --- a/src/components/Trade/Components/TradeAmountInput.tsx +++ b/src/components/Trade/Components/TradeAmountInput.tsx @@ -13,8 +13,9 @@ import { import type { AccountId, AssetId } from '@shapeshiftoss/caip' import { PairIcons } from 'features/defi/components/PairIcons/PairIcons' import type { FocusEvent, PropsWithChildren } from 'react' -import React, { useRef, useState } from 'react' +import React, { memo, useCallback, useMemo, useRef, useState } from 'react' import type { FieldError } from 'react-hook-form' +import type { NumberFormatValues } from 'react-number-format' import NumberFormat from 'react-number-format' import { useTranslate } from 'react-polyglot' import { Amount } from 'components/Amount/Amount' @@ -26,6 +27,8 @@ import { useToggle } from 'hooks/useToggle/useToggle' import { bnOrZero } from 'lib/bignumber/bignumber' import { colors } from 'theme/colors' +const cryptoInputStyle = { caretColor: colors.blue[200] } + const CryptoInput = (props: InputProps) => { const translate = useTranslate() return ( @@ -39,7 +42,7 @@ const CryptoInput = (props: InputProps) => { textAlign='left' variant='inline' placeholder={translate('common.enterAmount')} - style={{ caretColor: colors.blue[200] }} + style={cryptoInputStyle} autoComplete='off' {...props} /> @@ -72,156 +75,176 @@ export type TradeAmountInputProps = { labelPostFix?: JSX.Element } & PropsWithChildren -export const TradeAmountInput: React.FC = ({ - assetId, - assetSymbol, - onChange = () => {}, - onMaxClick, - onPercentOptionClick, - cryptoAmount, - isReadOnly, - isSendMaxDisabled, - fiatAmount, - showFiatAmount = '0', - balance, - fiatBalance, - errors, - percentOptions = [0.25, 0.5, 0.75, 1], - icons, - children, - showInputSkeleton, - showFiatSkeleton, - formControlProps, - label, - rightRegion, - labelPostFix, -}) => { - const { - number: { localeParts }, - } = useLocaleFormatter() - const translate = useTranslate() - const amountRef = useRef(null) - const [isFiat, toggleIsFiat] = useToggle(false) - const [isFocused, setIsFocused] = useState(false) - const borderColor = useColorModeValue('blackAlpha.100', 'whiteAlpha.100') - const bgColor = useColorModeValue('white', 'gray.850') - const focusBg = useColorModeValue('gray.50', 'gray.900') - const focusBorder = useColorModeValue('blue.500', 'blue.400') +const defaultPercentOptions = [0.25, 0.5, 0.75, 1] - // Lower the decimal places when the integer is greater than 8 significant digits for better UI - const cryptoAmountIntegerCount = bnOrZero(bnOrZero(cryptoAmount).toFixed(0)).precision(true) - const formattedCryptoAmount = bnOrZero(cryptoAmountIntegerCount).isLessThanOrEqualTo(8) - ? cryptoAmount - : bnOrZero(cryptoAmount).toFixed(3) +export const TradeAmountInput: React.FC = memo( + ({ + assetId, + assetSymbol, + onChange, + onMaxClick, + onPercentOptionClick, + cryptoAmount, + isReadOnly, + isSendMaxDisabled, + fiatAmount, + showFiatAmount = '0', + balance, + fiatBalance, + errors, + percentOptions = defaultPercentOptions, + icons, + children, + showInputSkeleton, + showFiatSkeleton, + formControlProps, + label, + rightRegion, + labelPostFix, + }) => { + const { + number: { localeParts }, + } = useLocaleFormatter() + const translate = useTranslate() + const amountRef = useRef(null) + const [isFiat, toggleIsFiat] = useToggle(false) + const [isFocused, setIsFocused] = useState(false) + const borderColor = useColorModeValue('blackAlpha.100', 'whiteAlpha.100') + const bgColor = useColorModeValue('white', 'gray.850') + const focusBg = useColorModeValue('gray.50', 'gray.900') + const focusBorder = useColorModeValue('blue.500', 'blue.400') - return ( - - - {label && ( - - - {label} - - {labelPostFix} - - )} + // Lower the decimal places when the integer is greater than 8 significant digits for better UI + const cryptoAmountIntegerCount = bnOrZero(bnOrZero(cryptoAmount).toFixed(0)).precision(true) + const formattedCryptoAmount = useMemo( + () => + bnOrZero(cryptoAmountIntegerCount).isLessThanOrEqualTo(8) + ? cryptoAmount + : bnOrZero(cryptoAmount).toFixed(3), + [cryptoAmount, cryptoAmountIntegerCount], + ) + + const handleOnChange = useCallback(() => { + // onChange will send us the formatted value + // To get around this we need to get the value from the onChange using a ref + // Now when the max buttons are clicked the onChange will not fire + onChange?.(amountRef.current ?? '', isFiat) + }, [isFiat, onChange]) + + const handleOnBlur = useCallback(() => setIsFocused(false), []) + const handleOnFocus = useCallback((e: FocusEvent) => { + setIsFocused(true) + e.target.select() + }, []) + + const hover = useMemo( + () => ({ bg: isReadOnly ? bgColor : focusBg }), + [bgColor, focusBg, isReadOnly], + ) + + const handleValueChange = useCallback((values: NumberFormatValues) => { + // This fires anytime value changes including setting it on max click + // Store the value in a ref to send when we actually want the onChange to fire + amountRef.current = values.value + }, []) - {showFiatAmount && ( - + )} + + + {icons ? ( + + ) : ( + + )} + + + - - )} - - - {icons ? ( - - ) : ( - - )} - - - { - // This fires anytime value changes including setting it on max click - // Store the value in a ref to send when we actually want the onChange to fire - amountRef.current = values.value - }} - onChange={() => { - // onChange will send us the formatted value - // To get around this we need to get the value from the onChange using a ref - // Now when the max buttons are clicked the onChange will not fire - onChange(amountRef.current ?? '', isFiat) - }} - onBlur={() => setIsFocused(false)} - onFocus={(e: FocusEvent) => { - setIsFocused(true) - e.target.select() - }} + {rightRegion} + + + + {balance && ( + - - {rightRegion} + )} + {onPercentOptionClick && ( + + )} - - - {balance && ( - - )} - {onPercentOptionClick && ( - - )} - - {errors && {errors?.message}} - {children} - - ) -} + {errors && {errors?.message}} + {children} + + ) + }, +) diff --git a/src/components/Trade/Components/TradeAssetInput.tsx b/src/components/Trade/Components/TradeAssetInput.tsx index 296b83ccd0b..1c00b63950b 100644 --- a/src/components/Trade/Components/TradeAssetInput.tsx +++ b/src/components/Trade/Components/TradeAssetInput.tsx @@ -1,6 +1,6 @@ import { Skeleton, SkeletonCircle, Stack, useColorModeValue } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' -import React, { useMemo } from 'react' +import React, { memo, useMemo } from 'react' import type { AssetInputProps } from 'components/DeFi/components/AssetInput' import { bnOrZero } from 'lib/bignumber/bignumber' import { @@ -55,14 +55,12 @@ export type TradeAssetInputProps = { assetId?: AssetId } & TradeAmountInputProps -export const TradeAssetInput: React.FC = ({ - assetId, - accountId, - ...restAssetInputProps -}) => { - return assetId ? ( - - ) : ( - - ) -} +export const TradeAssetInput: React.FC = memo( + ({ assetId, accountId, ...restAssetInputProps }) => { + return assetId ? ( + + ) : ( + + ) + }, +) diff --git a/src/components/Trade/SelectAsset.tsx b/src/components/Trade/SelectAsset.tsx index ec39fb30fbb..2b3fe90b9ee 100644 --- a/src/components/Trade/SelectAsset.tsx +++ b/src/components/Trade/SelectAsset.tsx @@ -1,3 +1,4 @@ +import { useCallback } from 'react' import type { RouteComponentProps } from 'react-router-dom' import { AssetSearch } from 'components/AssetSearch/AssetSearch' import { Card } from 'components/Card/Card' @@ -14,9 +15,9 @@ type SelectAssetProps = { } & RouteComponentProps export const SelectAsset: React.FC = ({ assets, onClick, history }) => { - const handleBack = () => { + const handleBack = useCallback(() => { history.push(TradeRoutePaths.Input) - } + }, [history]) return ( diff --git a/src/components/Trade/TradeConfirm/ReceiveSummary.tsx b/src/components/Trade/TradeConfirm/ReceiveSummary.tsx index 47ce56407d9..f34ce076eed 100644 --- a/src/components/Trade/TradeConfirm/ReceiveSummary.tsx +++ b/src/components/Trade/TradeConfirm/ReceiveSummary.tsx @@ -8,7 +8,7 @@ import { useDisclosure, } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' -import { type FC, useCallback, useMemo } from 'react' +import { type FC, memo, useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' import { Amount } from 'components/Amount/Amount' import { HelperTooltip } from 'components/HelperTooltip/HelperTooltip' @@ -34,233 +34,237 @@ type ReceiveSummaryProps = { donationAmount?: string } & RowProps -export const ReceiveSummary: FC = ({ - symbol, - amountCryptoPrecision, - intermediaryTransactionOutputs, - fiatAmount, - amountBeforeFeesCryptoPrecision, - protocolFees, - shapeShiftFee, - slippage, - swapperName, - isLoading, - donationAmount, - ...rest -}) => { - const translate = useTranslate() - const { isOpen, onToggle } = useDisclosure() - const summaryBg = useColorModeValue('gray.50', 'gray.800') - const borderColor = useColorModeValue('gray.100', 'gray.750') - const hoverColor = useColorModeValue('black', 'white') - const redColor = useColorModeValue('red.500', 'red.300') - const greenColor = useColorModeValue('green.600', 'green.200') - const textColor = useColorModeValue('gray.800', 'whiteAlpha.900') +export const ReceiveSummary: FC = memo( + ({ + symbol, + amountCryptoPrecision, + intermediaryTransactionOutputs, + fiatAmount, + amountBeforeFeesCryptoPrecision, + protocolFees, + shapeShiftFee, + slippage, + swapperName, + isLoading, + donationAmount, + ...rest + }) => { + const translate = useTranslate() + const { isOpen, onToggle } = useDisclosure() + const summaryBg = useColorModeValue('gray.50', 'gray.800') + const borderColor = useColorModeValue('gray.100', 'gray.750') + const hoverColor = useColorModeValue('black', 'white') + const redColor = useColorModeValue('red.500', 'red.300') + const greenColor = useColorModeValue('green.600', 'green.200') + const textColor = useColorModeValue('gray.800', 'whiteAlpha.900') - const slippageAsPercentageString = bnOrZero(slippage).times(100).toString() - const isAmountPositive = bnOrZero(amountCryptoPrecision).gt(0) + const slippageAsPercentageString = bnOrZero(slippage).times(100).toString() + const isAmountPositive = bnOrZero(amountCryptoPrecision).gt(0) - const parseAmountDisplayMeta = useCallback((items: AmountDisplayMeta[]) => { - return items - .filter(({ amountCryptoBaseUnit }) => bnOrZero(amountCryptoBaseUnit).gt(0)) - .map(({ amountCryptoBaseUnit, asset }: AmountDisplayMeta) => ({ - symbol: asset.symbol, - chainName: getChainAdapterManager().get(asset.chainId)?.getDisplayName(), - amountCryptoPrecision: fromBaseUnit(amountCryptoBaseUnit, asset.precision), - })) - }, []) + const parseAmountDisplayMeta = useCallback((items: AmountDisplayMeta[]) => { + return items + .filter(({ amountCryptoBaseUnit }) => bnOrZero(amountCryptoBaseUnit).gt(0)) + .map(({ amountCryptoBaseUnit, asset }: AmountDisplayMeta) => ({ + symbol: asset.symbol, + chainName: getChainAdapterManager().get(asset.chainId)?.getDisplayName(), + amountCryptoPrecision: fromBaseUnit(amountCryptoBaseUnit, asset.precision), + })) + }, []) - const protocolFeesParsed = useMemo( - () => (protocolFees ? parseAmountDisplayMeta(Object.values(protocolFees)) : undefined), - [protocolFees, parseAmountDisplayMeta], - ) + const protocolFeesParsed = useMemo( + () => (protocolFees ? parseAmountDisplayMeta(Object.values(protocolFees)) : undefined), + [protocolFees, parseAmountDisplayMeta], + ) - const intermediaryTransactionOutputsParsed = useMemo( - () => - intermediaryTransactionOutputs - ? parseAmountDisplayMeta(intermediaryTransactionOutputs) - : undefined, - [intermediaryTransactionOutputs, parseAmountDisplayMeta], - ) + const intermediaryTransactionOutputsParsed = useMemo( + () => + intermediaryTransactionOutputs + ? parseAmountDisplayMeta(intermediaryTransactionOutputs) + : undefined, + [intermediaryTransactionOutputs, parseAmountDisplayMeta], + ) - const hasProtocolFees = useMemo( - () => protocolFeesParsed && protocolFeesParsed.length > 0, - [protocolFeesParsed], - ) + const hasProtocolFees = useMemo( + () => protocolFeesParsed && protocolFeesParsed.length > 0, + [protocolFeesParsed], + ) - const hasIntermediaryTransactionOutputs = useMemo( - () => intermediaryTransactionOutputsParsed && intermediaryTransactionOutputsParsed.length > 0, - [intermediaryTransactionOutputsParsed], - ) + const hasIntermediaryTransactionOutputs = useMemo( + () => intermediaryTransactionOutputsParsed && intermediaryTransactionOutputsParsed.length > 0, + [intermediaryTransactionOutputsParsed], + ) - return ( - <> - - - - - {isOpen ? : } - - - - - - - - {fiatAmount && ( + return ( + <> + + + + + {isOpen ? : } + + + + - + - )} - - - - - - - - - - - - - - - {swapperName} - - - - - {amountBeforeFeesCryptoPrecision && ( - - - - - + {fiatAmount && ( - + - - - )} - {hasProtocolFees && ( + )} + + + + + - + - + - {protocolFeesParsed?.map(({ amountCryptoPrecision, symbol, chainName }) => ( - - - - ))} - - - )} - {shapeShiftFee && ( - - - + + {swapperName} + - - - - - - )} - {donationAmount && ( - - + {amountBeforeFeesCryptoPrecision && ( + - + - - - - - - - - )} - <> - - - - - - - + - + + + + )} + {hasProtocolFees && ( + + + + + + + + {protocolFeesParsed?.map(({ amountCryptoPrecision, symbol, chainName }) => ( + + + + ))} + + + )} + {shapeShiftFee && ( + + + + + + + + + - {isAmountPositive && - hasIntermediaryTransactionOutputs && - intermediaryTransactionOutputsParsed?.map( - ({ amountCryptoPrecision, symbol, chainName }) => ( - - - - ), - )} - - - - - - - - ) -} + + + )} + {donationAmount && ( + + + + + + + + + + + + + )} + <> + + + + + + + + + + + {isAmountPositive && + hasIntermediaryTransactionOutputs && + intermediaryTransactionOutputsParsed?.map( + ({ amountCryptoPrecision, symbol, chainName }) => ( + + + + ), + )} + + + + + + + + ) + }, +) diff --git a/src/components/TransactionHistory/TransactionHistoryList.tsx b/src/components/TransactionHistory/TransactionHistoryList.tsx index 0fd843c8528..8fef40b6f34 100644 --- a/src/components/TransactionHistory/TransactionHistoryList.tsx +++ b/src/components/TransactionHistory/TransactionHistoryList.tsx @@ -1,4 +1,5 @@ import { Center } from '@chakra-ui/react' +import { memo } from 'react' import InfiniteScroll from 'react-infinite-scroller' import { Card } from 'components/Card/Card' import { CircularProgress } from 'components/CircularProgress/CircularProgress' @@ -12,34 +13,33 @@ type TransactionHistoryListProps = { useCompactMode?: boolean } -export const TransactionHistoryList: React.FC = ({ - txIds, - useCompactMode = false, -}) => { - const { next, data, hasMore } = useInfiniteScroll(txIds) +export const TransactionHistoryList: React.FC = memo( + ({ txIds, useCompactMode = false }) => { + const { next, data, hasMore } = useInfiniteScroll(txIds) - return data.length ? ( - - - - - } - > - - - - ) : ( - - - - ) -} + return data.length ? ( + + + + + } + > + + + + ) : ( + + + + ) + }, +) diff --git a/src/components/TransactionHistory/TransactionsGroupByDate.tsx b/src/components/TransactionHistory/TransactionsGroupByDate.tsx index 97cf0eeb262..e3e0a5d2a08 100644 --- a/src/components/TransactionHistory/TransactionsGroupByDate.tsx +++ b/src/components/TransactionHistory/TransactionsGroupByDate.tsx @@ -1,6 +1,6 @@ import { Stack, StackDivider, useColorModeValue } from '@chakra-ui/react' import dayjs from 'dayjs' -import { useMemo } from 'react' +import { memo, useMemo } from 'react' import { TransactionDate } from 'components/TransactionHistoryRows/TransactionDate' import { TransactionRow } from 'components/TransactionHistoryRows/TransactionRow' import { useResizeObserver } from 'hooks/useResizeObserver/useResizeObserver' @@ -18,50 +18,49 @@ type TransactionGroup = { txIds: TxId[] } -export const TransactionsGroupByDate: React.FC = ({ - txIds, - useCompactMode = false, -}) => { - const { setNode, entry } = useResizeObserver() - const transactions = useAppSelector(state => selectTxDateByIds(state, txIds)) - const borderTopColor = useColorModeValue('gray.100', 'gray.750') - const txRows = useMemo(() => { - const groups: TransactionGroup[] = [] - for (let index = 0; index < transactions.length; index++) { - const transaction = transactions[index] - const transactionDate = dayjs(transaction.date * 1000) - .startOf('day') - .unix() - const group = groups.find(g => g.date === transactionDate) - if (group) { - group.txIds.push(transaction.txId) - } else { - groups.push({ date: transactionDate, txIds: [transaction.txId] }) +export const TransactionsGroupByDate: React.FC = memo( + ({ txIds, useCompactMode = false }) => { + const { setNode, entry } = useResizeObserver() + const transactions = useAppSelector(state => selectTxDateByIds(state, txIds)) + const borderTopColor = useColorModeValue('gray.100', 'gray.750') + const txRows = useMemo(() => { + const groups: TransactionGroup[] = [] + for (let index = 0; index < transactions.length; index++) { + const transaction = transactions[index] + const transactionDate = dayjs(transaction.date * 1000) + .startOf('day') + .unix() + const group = groups.find(g => g.date === transactionDate) + if (group) { + group.txIds.push(transaction.txId) + } else { + groups.push({ date: transactionDate, txIds: [transaction.txId] }) + } } - } - return groups - }, [transactions]) + return groups + }, [transactions]) - const renderTxRows = useMemo(() => { - return txRows.map((group: TransactionGroup) => ( - - {!useCompactMode && } - {group.txIds?.map((txId: TxId, index: number) => ( - - ))} - - )) - }, [entry?.contentRect.width, txRows, useCompactMode]) + const renderTxRows = useMemo(() => { + return txRows.map((group: TransactionGroup) => ( + + {!useCompactMode && } + {group.txIds?.map((txId: TxId, index: number) => ( + + ))} + + )) + }, [entry?.contentRect.width, txRows, useCompactMode]) - return ( - }> - {renderTxRows} - - ) -} + return ( + }> + {renderTxRows} + + ) + }, +) diff --git a/src/components/TransactionHistoryRows/TransactionRow.tsx b/src/components/TransactionHistoryRows/TransactionRow.tsx index e00634f19d8..d5784c305b5 100644 --- a/src/components/TransactionHistoryRows/TransactionRow.tsx +++ b/src/components/TransactionHistoryRows/TransactionRow.tsx @@ -3,7 +3,7 @@ import { Box, forwardRef, useColorModeValue } from '@chakra-ui/react' import { TradeType, TransferType } from '@shapeshiftoss/unchained-client' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' -import { useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { TransactionMethod } from 'components/TransactionHistoryRows/TransactionMethod' import { TransactionReceive } from 'components/TransactionHistoryRows/TransactionReceive' import { TransactionSend } from 'components/TransactionHistoryRows/TransactionSend' @@ -34,6 +34,48 @@ type TxRowProps = { disableCollapse?: boolean } & BoxProps +const TransactionType = ({ + txDetails, + showDateAndGuide, + useCompactMode, + isOpen, + parentWidth, + toggleOpen, +}: { + txDetails: TxDetails + showDateAndGuide: boolean + useCompactMode: boolean + isOpen: boolean + parentWidth: number + toggleOpen: () => void +}): JSX.Element => { + const props: TransactionRowProps = useMemo( + () => ({ + txDetails, + showDateAndGuide, + compactMode: useCompactMode, + toggleOpen, + isOpen, + parentWidth, + }), + [isOpen, parentWidth, showDateAndGuide, toggleOpen, txDetails, useCompactMode], + ) + + switch (txDetails.type) { + case TransferType.Send: + return + case TransferType.Receive: + return + case TradeType.Trade: + case TradeType.Refund: + return + case 'method': + return + default: + return + } +} + export const TransactionRow = forwardRef( ( { @@ -48,52 +90,36 @@ export const TransactionRow = forwardRef( ref, ) => { const [isOpen, setIsOpen] = useState(initOpen) - const toggleOpen = () => (disableCollapse ? null : setIsOpen(!isOpen)) + const toggleOpen = useCallback( + () => (disableCollapse ? null : setIsOpen(!isOpen)), + [disableCollapse, isOpen], + ) const rowHoverBg = useColorModeValue('gray.100', 'gray.750') const borderColor = useColorModeValue('blackAlpha.100', 'whiteAlpha.100') const txDetails = useTxDetails(txId) - const renderTransactionType = ( - txDetails: TxDetails, - showDateAndGuide: boolean, - useCompactMode: boolean, - ): JSX.Element => { - const props: TransactionRowProps = { - txDetails, - showDateAndGuide, - compactMode: useCompactMode, - toggleOpen, - isOpen, - parentWidth, - } + const backgroundProps = useMemo(() => ({ bg: rowHoverBg }), [rowHoverBg]) - switch (txDetails.type) { - case TransferType.Send: - return - case TransferType.Receive: - return - case TradeType.Trade: - case TradeType.Refund: - return - case 'method': - return - default: - return - } - } return ( - {renderTransactionType(txDetails, showDateAndGuide, useCompactMode)} + ) }, diff --git a/src/hooks/useBalanceChartData/useBalanceChartData.ts b/src/hooks/useBalanceChartData/useBalanceChartData.ts index 49cf491c152..0f01ca31bd8 100644 --- a/src/hooks/useBalanceChartData/useBalanceChartData.ts +++ b/src/hooks/useBalanceChartData/useBalanceChartData.ts @@ -5,13 +5,10 @@ import { HistoryTimeframe } from '@shapeshiftoss/types' import { TransferType, TxStatus } from '@shapeshiftoss/unchained-client' import type { BigNumber } from 'bignumber.js' import dayjs from 'dayjs' -import fill from 'lodash/fill' import head from 'lodash/head' import intersection from 'lodash/intersection' import isEmpty from 'lodash/isEmpty' import last from 'lodash/last' -import reduce from 'lodash/reduce' -import reverse from 'lodash/reverse' import values from 'lodash/values' import without from 'lodash/without' import { useEffect, useMemo, useState } from 'react' @@ -39,9 +36,7 @@ import { useAppSelector } from 'state/store' import { excludeTransaction } from './cosmosUtils' import { CHART_ASSET_ID_BLACKLIST, makeBalanceChartData } from './utils' -type BalanceByAssetId = { - [k: AssetId]: BigNumber // map of asset to base units -} +type BalanceByAssetId = Record // map of asset to base units type BucketBalance = { crypto: BalanceByAssetId @@ -73,14 +68,25 @@ type MakeBucketsArgs = { balances: AssetBalancesById } +// PERF: limit buckets to a limited number to prevent performance issues +const NUM_BUCKETS = 100 + // adjust this to give charts more or less granularity export const timeframeMap: Record = { - [HistoryTimeframe.HOUR]: { count: 60, duration: 1, unit: 'minute' }, - [HistoryTimeframe.DAY]: { count: 289, duration: 5, unit: 'minutes' }, - [HistoryTimeframe.WEEK]: { count: 168, duration: 1, unit: 'hours' }, - [HistoryTimeframe.MONTH]: { count: 362, duration: 2, unit: 'hours' }, - [HistoryTimeframe.YEAR]: { count: 365, duration: 1, unit: 'days' }, - [HistoryTimeframe.ALL]: { count: 262, duration: 1, unit: 'weeks' }, + [HistoryTimeframe.HOUR]: { count: NUM_BUCKETS, duration: 60 / NUM_BUCKETS, unit: 'minute' }, + [HistoryTimeframe.DAY]: { + count: NUM_BUCKETS, + duration: (24 * 60) / NUM_BUCKETS, + unit: 'minutes', + }, + [HistoryTimeframe.WEEK]: { count: NUM_BUCKETS, duration: (7 * 24) / NUM_BUCKETS, unit: 'hours' }, + [HistoryTimeframe.MONTH]: { + count: NUM_BUCKETS, + duration: (31 * 24) / NUM_BUCKETS, + unit: 'hours', + }, + [HistoryTimeframe.YEAR]: { count: NUM_BUCKETS, duration: 365 / NUM_BUCKETS, unit: 'days' }, + [HistoryTimeframe.ALL]: { count: NUM_BUCKETS, duration: 1, unit: 'weeks' }, } type MakeBuckets = (args: MakeBucketsArgs) => MakeBucketsReturn @@ -99,9 +105,14 @@ export const makeBuckets: MakeBuckets = args => { return acc }, {}) - const makeReducer = (duration: number, unit: dayjs.ManipulateType) => { - const now = dayjs() - return (acc: Bucket[], _cur: unknown, idx: number) => { + const now = dayjs() + + const meta = timeframeMap[timeframe] + const { count, duration, unit } = meta + + const buckets = Array(count) + .fill(undefined) + .map((_value, idx) => { const end = now.subtract(idx * duration, unit) const start = end.subtract(duration, unit).add(1, 'second') const txs: Tx[] = [] @@ -110,15 +121,10 @@ export const makeBuckets: MakeBuckets = args => { crypto: assetBalances, fiat: zeroAssetBalances, } - const bucket = { start, end, txs, rebases, balance } - acc.push(bucket) - return acc - } - } + return { start, end, txs, rebases, balance } + }) + .reverse() - const meta = timeframeMap[timeframe] - const { count, duration, unit } = meta - const buckets = reverse(reduce(fill(Array(count), 0), makeReducer(duration, unit), [])) return { buckets, meta } } diff --git a/src/pages/Dashboard/Dashboard.tsx b/src/pages/Dashboard/Dashboard.tsx index 11d88c5be30..615d8aed0a6 100644 --- a/src/pages/Dashboard/Dashboard.tsx +++ b/src/pages/Dashboard/Dashboard.tsx @@ -1,4 +1,6 @@ +import type { StackDirection } from '@chakra-ui/react' import { Stack } from '@chakra-ui/react' +import { memo } from 'react' import { useTranslate } from 'react-polyglot' import { Route, Switch, useRouteMatch } from 'react-router' import { Main } from 'components/Layout/Main' @@ -16,7 +18,10 @@ import { Portfolio } from './Portfolio' import { RewardsDashboard } from './RewardsDashboard' import { WalletDashboard } from './WalletDashboard' -export const Dashboard = () => { +const direction: StackDirection = { base: 'column', xl: 'row' } +const maxWidth = { base: 'full', xl: 'sm' } + +export const Dashboard = memo(() => { const translate = useTranslate() const isDefiDashboardEnabled = useFeatureFlag('DefiDashboard') const { path } = useRouteMatch() @@ -58,19 +63,14 @@ export const Dashboard = () => { return (
- + - +
) -} +}) diff --git a/src/pages/Dashboard/DashboardSidebar.tsx b/src/pages/Dashboard/DashboardSidebar.tsx index e4a6411b288..8a2d7085907 100644 --- a/src/pages/Dashboard/DashboardSidebar.tsx +++ b/src/pages/Dashboard/DashboardSidebar.tsx @@ -1,5 +1,6 @@ import { Flex, Image } from '@chakra-ui/react' import { btcAssetId, dogeAssetId, foxAssetId } from '@shapeshiftoss/caip' +import { memo } from 'react' import OnRamperLogo from 'assets/on-ramper.png' import SaversVaultTop from 'assets/savers-vault-top.png' import { AssetIcon } from 'components/AssetIcon' @@ -68,19 +69,17 @@ const promoData: PromoItem[] = [ }, ] -export const DashboardSidebar = () => { +const display = { base: 'none', xl: 'block' } + +export const DashboardSidebar = memo(() => { const { MultiHopTrades } = useAppSelector(selectFeatureFlags) return ( - {MultiHopTrades ? ( - - ) : ( - - )} + {MultiHopTrades ? : } ) -} +}) diff --git a/src/pages/Dashboard/EarnDashboard.tsx b/src/pages/Dashboard/EarnDashboard.tsx index 18a6bff1075..166d14c2a30 100644 --- a/src/pages/Dashboard/EarnDashboard.tsx +++ b/src/pages/Dashboard/EarnDashboard.tsx @@ -1,15 +1,19 @@ import { ArrowForwardIcon } from '@chakra-ui/icons' import { Button, Flex, Heading } from '@chakra-ui/react' +import { memo } from 'react' import { useTranslate } from 'react-polyglot' import { Link as NavLink } from 'react-router-dom' import { DeFiEarn } from 'components/StakingVaults/DeFiEarn' import { RawText } from 'components/Text' +const alignItems = { base: 'flex-start', md: 'center' } +const padding = { base: 4, xl: 0 } + const EarnHeader = () => { const translate = useTranslate() return ( - + {translate('defi.myPositions')} - )} - - -
- ) -} +export const RecentTransactions: React.FC = memo( + ({ limit = 10, viewMoreLink, ...rest }) => { + const recentTxIds = useSelector((state: ReduxState) => selectLastNTxIds(state, limit)) + const translate = useTranslate() + return ( + + + + + + {viewMoreLink && ( + + )} + + + + ) + }, +) diff --git a/src/pages/Dashboard/RewardsDashboard.tsx b/src/pages/Dashboard/RewardsDashboard.tsx index 166b712206a..9650f212ce3 100644 --- a/src/pages/Dashboard/RewardsDashboard.tsx +++ b/src/pages/Dashboard/RewardsDashboard.tsx @@ -1,14 +1,18 @@ import { ArrowForwardIcon } from '@chakra-ui/icons' import { Button, Flex, Heading } from '@chakra-ui/react' +import { memo } from 'react' import { useTranslate } from 'react-polyglot' import { Link as NavLink } from 'react-router-dom' import { DeFiEarn } from 'components/StakingVaults/DeFiEarn' import { RawText } from 'components/Text' +const alignItems = { base: 'flex-start', md: 'center' } +const padding = { base: 4, xl: 0 } + const RewardsHeader = () => { const translate = useTranslate() return ( - + {translate('defi.myRewards')} - } - onClick={e => { - e.stopPropagation() - onResetFilters() - }} - /> - - - - - - +export const TransactionHistoryFilter = memo( + ({ setFilters, resetFilters, hasAppliedFilter = false }: TransactionHistoryFilterProps) => { + const [isOpen, setIsOpen] = useState(false) + const popoverRef = useRef(null) + /** + * Popover default outside click detector didn't play well with + * react-datepicker, but making it controlled and + * passing a new detector to popover content, + * solved the problem. + */ + useOutsideClick({ + ref: popoverRef, + handler: () => { + setIsOpen(false) + }, + }) + const translate = useTranslate() + const { control, handleSubmit, watch, reset } = useForm({ mode: 'onChange' }) + const onSubmit = useCallback( + (values: FieldValues) => { + const { fromDate, toDate, dayRange, types } = values + let filterSet = { + fromDate, + toDate, + dayRange, + types, + } + if (!!dayRange && dayRange !== customRangeOption) { + const today = dayjs().endOf('day') + filterSet.fromDate = today.subtract(dayRange, 'day').unix() + filterSet.toDate = today.unix() + } else if (dayRange === customRangeOption) { + if (fromDate) { + filterSet.fromDate = dayjs(fromDate).startOf('day').unix() + } + if (toDate) { + filterSet.toDate = dayjs(toDate).endOf('day').unix() + } + } + setFilters(filterSet) + }, + [setFilters], + ) + const popoverContentBg = useColorModeValue('gray.100', 'gray.700') + const onResetFilters = useCallback(() => { + reset() + resetFilters() + }, [reset, resetFilters]) + const dayRangeSelectedOption = watch(FilterFormFields.DayRange) + const RangeCustomComponent = useCallback(() => { + return dayRangeSelectedOption === customRangeOption ? ( + + + + + + ) : null + }, [control, dayRangeSelectedOption]) + + const handleClose = useCallback(() => setIsOpen(false), [setIsOpen]) + const handleToggle = useCallback(() => setIsOpen(state => !state), [setIsOpen]) + + const handleResetStopPagination: React.MouseEventHandler = useCallback( + e => { + e.stopPropagation() + onResetFilters() + }, + [onResetFilters], + ) + + const dayRangeOptions: Option[] = useMemo( + () => [ + ['transactionHistory.filters.custom', customRangeOption, ], + ['transactionHistory.filters.10days', '10'], + ['transactionHistory.filters.30days', '30'], + ['transactionHistory.filters.90days', '90'], + ], + [RangeCustomComponent], + ) + + const categoriesOptions: Option[] = useMemo( + () => [ + ['transactionHistory.filters.send', TransferType.Send], + ['transactionHistory.filters.trade', TradeType.Trade], + ['transactionHistory.filters.receive', TransferType.Receive], + ], + [], + ) + + return ( + + <> + + - - -
- , - ], - ['transactionHistory.filters.10days', '10'], - ['transactionHistory.filters.30days', '30'], - ['transactionHistory.filters.90days', '90'], - ]} - /> - - } + onClick={handleResetStopPagination} /> - - - -
-
- - - ) -} + +
+ + + + + + + + + + + + ) + }, +) diff --git a/src/pages/TransactionHistory/TransactionHistorySearch.tsx b/src/pages/TransactionHistory/TransactionHistorySearch.tsx index 2112e223a4d..b8694bf88b5 100644 --- a/src/pages/TransactionHistory/TransactionHistorySearch.tsx +++ b/src/pages/TransactionHistory/TransactionHistorySearch.tsx @@ -1,20 +1,26 @@ import { SearchIcon } from '@chakra-ui/icons' import { Input, InputGroup, InputLeftElement, useColorModeValue } from '@chakra-ui/react' -import { forwardRef } from 'react' +import { forwardRef, useCallback } from 'react' import { useTranslate } from 'react-polyglot' +const inputGroupMargin = [2, 3, 6] + export const TransactionHistorySearch = forwardRef< HTMLInputElement, { handleInputChange: Function } >(({ handleInputChange }, ref) => { const translate = useTranslate() + const handleOnChange: React.ChangeEventHandler = useCallback( + e => handleInputChange(e.target.value), + [handleInputChange], + ) return ( - + handleInputChange(e.target.value)} + onChange={handleOnChange} type='text' placeholder={translate('common.search')} pl={10} diff --git a/src/pages/TransactionHistory/components/DatePicker/DatePicker.tsx b/src/pages/TransactionHistory/components/DatePicker/DatePicker.tsx index 567ddef1fa7..d518f920043 100644 --- a/src/pages/TransactionHistory/components/DatePicker/DatePicker.tsx +++ b/src/pages/TransactionHistory/components/DatePicker/DatePicker.tsx @@ -2,6 +2,7 @@ import 'react-datepicker/dist/react-datepicker.css' import './DatePicker.css' import { Input, InputGroup, InputLeftElement, useColorModeValue } from '@chakra-ui/react' +import { useCallback } from 'react' import ReactDatePicker from 'react-datepicker' import type { Control } from 'react-hook-form' import { useController } from 'react-hook-form' @@ -11,6 +12,7 @@ export const DatePicker = ({ control, name }: { control: Control; name: string } const { field: { onChange, value }, } = useController({ control, name }) + const handleFormatWeekDat = useCallback((day: string) => day.slice(0, 1), []) return ( @@ -25,7 +27,7 @@ export const DatePicker = ({ control, name }: { control: Control; name: string } name={name} placeholderText='00/00/0000' autoComplete='off' - formatWeekDay={(day: string) => day.slice(0, 1)} + formatWeekDay={handleFormatWeekDat} /> ) diff --git a/src/pages/TransactionHistory/hooks/useFilters.tsx b/src/pages/TransactionHistory/hooks/useFilters.tsx index 729f700238b..56fded496d2 100644 --- a/src/pages/TransactionHistory/hooks/useFilters.tsx +++ b/src/pages/TransactionHistory/hooks/useFilters.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useCallback, useState } from 'react' type TransactionFilterType = { fromDate: number | null @@ -15,7 +15,7 @@ const initialState = { export const useFilters = () => { const [filters, setFilters] = useState(initialState) - const resetFilters = () => setFilters(initialState) + const resetFilters = useCallback(() => setFilters(initialState), []) return { filters,