From 149ac8c6fe3beec87e105a22a25db2a693d60b4c Mon Sep 17 00:00:00 2001 From: kevin <35275952+kaladinlight@users.noreply.github.com> Date: Mon, 6 May 2024 16:31:36 -0600 Subject: [PATCH 01/10] fix: parse buy txid from midgard actions (#6813) --- .../swappers/ThorchainSwapper/endpoints.ts | 29 ++++++++----------- .../utils/parseThorBuyTxHash.ts | 21 ++++++-------- 2 files changed, 21 insertions(+), 29 deletions(-) diff --git a/src/lib/swapper/swappers/ThorchainSwapper/endpoints.ts b/src/lib/swapper/swappers/ThorchainSwapper/endpoints.ts index b4a5e59a43c..0b28ff61e62 100644 --- a/src/lib/swapper/swappers/ThorchainSwapper/endpoints.ts +++ b/src/lib/swapper/swappers/ThorchainSwapper/endpoints.ts @@ -35,7 +35,7 @@ import { isNativeEvmAsset } from '../utils/helpers/helpers' import { THORCHAIN_OUTBOUND_FEE_RUNE_THOR_UNIT } from './constants' import type { ThorEvmTradeQuote } from './getThorTradeQuote/getTradeQuote' import { getThorTradeQuote } from './getThorTradeQuote/getTradeQuote' -import { type MidgardActionsResponse, type ThornodeStatusResponse } from './types' +import type { ThornodeStatusResponse } from './types' import { checkOutboundTxConfirmations } from './utils/checkOutputTxConfirmations' import { getLatestThorTxStatusMessage } from './utils/getLatestThorTxStatusMessage' import { TradeType } from './utils/longTailHelpers' @@ -334,16 +334,11 @@ export const thorchainApi: SwapperApi = { const thorTxHash = txHash.replace(/^0x/, '') // not using monadic axios, this is intentional for simplicity in this non-monadic context - const [{ data: thorTxData }, { data: thorActionsData }] = await Promise.all([ - axios.get( - `${getConfig().REACT_APP_THORCHAIN_NODE_URL}/lcd/thorchain/tx/status/${thorTxHash}`, - ), - axios.get( - `${getConfig().REACT_APP_MIDGARD_URL}/actions?txid=${thorTxHash}`, - ), - ]) - - if ('error' in thorTxData) { + const { data } = await axios.get( + `${getConfig().REACT_APP_THORCHAIN_NODE_URL}/lcd/thorchain/tx/status/${thorTxHash}`, + ) + + if ('error' in data) { return { buyTxHash: undefined, status: TxStatus.Unknown, @@ -351,14 +346,14 @@ export const thorchainApi: SwapperApi = { } } - const outCoinAsset: string | undefined = thorActionsData.actions[0]?.out[0]?.coins[0]?.asset - const hasOutboundTx = outCoinAsset !== 'THOR.RUNE' + const latestOutTx = data.out_txs?.[data.out_txs.length - 1] + const hasOutboundTx = latestOutTx?.chain !== 'THOR' - const buyTxHash = parseThorBuyTxHash(txHash, thorActionsData) + const buyTxHash = parseThorBuyTxHash(txHash, data) - // if we have a buyTxHash, check if it's been confirmed on-chain + // if we have an outbound transaction (non rune) and associated buyTxHash, check if it's been confirmed on-chain if (hasOutboundTx && buyTxHash) { - const outboundTxConfirmations = await checkOutboundTxConfirmations(thorTxData, buyTxHash) + const outboundTxConfirmations = await checkOutboundTxConfirmations(data, buyTxHash) if (outboundTxConfirmations !== undefined && outboundTxConfirmations > 0) { return { @@ -369,7 +364,7 @@ export const thorchainApi: SwapperApi = { } } - const { message, status } = getLatestThorTxStatusMessage(thorTxData, hasOutboundTx) + const { message, status } = getLatestThorTxStatusMessage(data, hasOutboundTx) return { buyTxHash, diff --git a/src/lib/swapper/swappers/ThorchainSwapper/utils/parseThorBuyTxHash.ts b/src/lib/swapper/swappers/ThorchainSwapper/utils/parseThorBuyTxHash.ts index cfd9993b6ef..e107f8f72c7 100644 --- a/src/lib/swapper/swappers/ThorchainSwapper/utils/parseThorBuyTxHash.ts +++ b/src/lib/swapper/swappers/ThorchainSwapper/utils/parseThorBuyTxHash.ts @@ -1,22 +1,19 @@ -import type { MidgardActionsResponse } from '../types' +import type { ThorNodeStatusResponseSuccess } from '../types' const THORCHAIN_EVM_CHAINS = ['ETH', 'AVAX', 'BSC'] as const export const parseThorBuyTxHash = ( sellTxId: string, - thorActionsData: MidgardActionsResponse, + response: ThorNodeStatusResponseSuccess, ): string | undefined => { - const inCoinAsset: string | undefined = thorActionsData.actions[0]?.in[0]?.coins[0]?.asset - const outCoinAsset: string | undefined = thorActionsData.actions[0]?.out[0]?.coins[0]?.asset - const isDoubleSwap = outCoinAsset !== 'THOR.RUNE' && inCoinAsset !== 'THOR.RUNE' + const latestOutTx = response.out_txs?.[response.out_txs.length - 1] - // swaps into rune aren't double swaps so don't have a second tx (buy tx) - if (!isDoubleSwap) return sellTxId + if (!latestOutTx) return - const isEvmCoinAsset = THORCHAIN_EVM_CHAINS.some( - thorEvmChain => outCoinAsset?.startsWith(thorEvmChain), - ) + // outbound rune transactions do not have a txid as they are processed internally, use sell txid + if (latestOutTx.chain === 'THOR') return sellTxId - const buyTxId = thorActionsData.actions[0]?.out[0]?.txID - return isEvmCoinAsset && buyTxId ? `0x${buyTxId}` : buyTxId + const isEvmCoinAsset = THORCHAIN_EVM_CHAINS.some(chain => chain === latestOutTx.chain) + + return isEvmCoinAsset ? `0x${latestOutTx.id}` : latestOutTx.id } From 20f57fc30837941d2f801ce2109a4dd79263aa35 Mon Sep 17 00:00:00 2001 From: woody <125113430+woodenfurniture@users.noreply.github.com> Date: Tue, 7 May 2024 11:12:28 +1000 Subject: [PATCH 02/10] feat: account management wiring progression (#6798) * feat: initial utxo multi account rows for import accounts * fix: load native asset balances in import accounts * styles * fix: use updated query key for loading state * chore: actioned apojuice code review feedback * chore: actioned gome code review feedback --------- Co-authored-by: reallybeard <89934888+reallybeard@users.noreply.github.com> --- .../components/ImportAccounts.tsx | 172 +++++++++++------- .../components/SelectChain.tsx | 71 +++----- .../ManageAccounts/ManageAccountsModal.tsx | 23 +-- .../queries/accountManagement.ts | 42 +++-- src/state/slices/portfolioSlice/selectors.ts | 9 + 5 files changed, 174 insertions(+), 143 deletions(-) diff --git a/src/components/ManageAccountsDrawer/components/ImportAccounts.tsx b/src/components/ManageAccountsDrawer/components/ImportAccounts.tsx index 2b866ee4ce3..33922d6fdf8 100644 --- a/src/components/ManageAccountsDrawer/components/ImportAccounts.tsx +++ b/src/components/ManageAccountsDrawer/components/ImportAccounts.tsx @@ -23,14 +23,14 @@ import { MiddleEllipsis } from 'components/MiddleEllipsis/MiddleEllipsis' import { RawText } from 'components/Text' import { useToggle } from 'hooks/useToggle/useToggle' import { useWallet } from 'hooks/useWallet/useWallet' +import { fromBaseUnit } from 'lib/math' import { isUtxoAccountId } from 'lib/utils/utxo' import { portfolio, portfolioApi } from 'state/slices/portfolioSlice/portfolioSlice' import { accountIdToLabel } from 'state/slices/portfolioSlice/utils' import { selectFeeAssetByChainId, selectHighestAccountNumberForChainId, - selectIsAccountIdEnabled, - selectPortfolioCryptoPrecisionBalanceByFilter, + selectIsAnyAccountIdEnabled, } from 'state/slices/selectors' import { useAppDispatch, useAppSelector } from 'state/store' @@ -46,61 +46,88 @@ export type ImportAccountsProps = { } type TableRowProps = { - accountId: AccountId + accountIds: AccountId[] accountNumber: number asset: Asset - onAccountIdActiveChange: (accountId: AccountId, isActive: boolean) => void + onActiveAccountIdsChange: (accountIds: AccountId[], isActive: boolean) => void +} + +type TableRowAccountProps = { + accountId: AccountId + asset: Asset } const disabledProps = { opacity: 0.5, cursor: 'not-allowed', userSelect: 'none' } +const TableRowAccount = forwardRef(({ asset, accountId }, ref) => { + const accountLabel = useMemo(() => accountIdToLabel(accountId), [accountId]) + const pubkey = useMemo(() => fromAccountId(accountId).account, [accountId]) + const isUtxoAccount = useMemo(() => isUtxoAccountId(accountId), [accountId]) + + const { data: account, isLoading } = useQuery(accountManagement.getAccount(accountId)) + + const assetBalanceCryptoPrecision = useMemo(() => { + if (!account) return '0' + return fromBaseUnit(account.balance, asset.precision) + }, [account, asset.precision]) + + return ( + <> + + +
+ +
+
+ + + {isLoading ? ( + + ) : ( + + )} + + + ) +}) + const TableRow = forwardRef( - ({ asset, accountId, accountNumber, onAccountIdActiveChange }, ref) => { - const translate = useTranslate() - const accountLabel = useMemo(() => accountIdToLabel(accountId), [accountId]) - const balanceFilter = useMemo(() => ({ assetId: asset.assetId, accountId }), [asset, accountId]) - const isAccountEnabledFilter = useMemo(() => ({ accountId }), [accountId]) + ({ asset, accountNumber, accountIds, onActiveAccountIdsChange }, ref) => { const isAccountEnabledInRedux = useAppSelector(state => - selectIsAccountIdEnabled(state, isAccountEnabledFilter), + selectIsAnyAccountIdEnabled(state, accountIds), ) const [isAccountActive, toggleIsAccountActive] = useToggle(isAccountEnabledInRedux) useEffect(() => { - onAccountIdActiveChange(accountId, isAccountActive) - }, [accountId, isAccountActive, isAccountEnabledInRedux, onAccountIdActiveChange]) - - // TODO: Redux wont have this for new accounts and will be 0, so we'll need to fetch it - const assetBalancePrecision = useAppSelector(s => - selectPortfolioCryptoPrecisionBalanceByFilter(s, balanceFilter), - ) - const pubkey = useMemo(() => fromAccountId(accountId).account, [accountId]) + onActiveAccountIdsChange(accountIds, isAccountActive) + }, [accountIds, isAccountActive, isAccountEnabledInRedux, onActiveAccountIdsChange]) - const isUtxoAccount = useMemo(() => isUtxoAccountId(accountId), [accountId]) + const firstAccount = useMemo(() => accountIds[0], [accountIds]) + const otherAccountIds = useMemo(() => accountIds.slice(1), [accountIds]) + const otherAccounts = useMemo(() => { + return otherAccountIds.map(accountId => ( + + + + + )) + }, [asset, isAccountActive, otherAccountIds, ref]) return ( - - - {accountNumber} - - - - - - -
- {isUtxoAccount ? ( - {`${accountLabel} ${translate('common.account')}`} - ) : ( - - )} -
-
- - - - - + <> + + + + + + {accountNumber} + + + + + {otherAccounts} + ) }, ) @@ -135,10 +162,20 @@ export const ImportAccounts = ({ chainId, onClose }: ImportAccountsProps) => { ) const chainNamespaceDisplayName = asset?.networkName ?? '' const [accounts, setAccounts] = useState< - { accountId: AccountId; accountMetadata: AccountMetadata; hasActivity: boolean }[] + { accountId: AccountId; accountMetadata: AccountMetadata; hasActivity: boolean }[][] >([]) const queryClient = useQueryClient() - const isLoading = useIsFetching({ queryKey: ['accountManagement'] }) > 0 + const isLoading = + useIsFetching({ + predicate: query => { + return ( + query.queryKey[0] === 'accountManagement' && + ['accountIdWithActivityAndMetadata', 'firstAccountIdsWithActivityAndMetadata'].some( + str => str === query.queryKey[1], + ) + ) + }, + }) > 0 const [accountIdActiveStateUpdate, setAccountIdActiveStateUpdate] = useState< Record >({}) @@ -162,7 +199,7 @@ export const ImportAccounts = ({ chainId, onClose }: ImportAccountsProps) => { const handleLoadMore = useCallback(async () => { if (!wallet) return const accountNumber = accounts.length - const accountResult = await queryClient.fetchQuery( + const accountResults = await queryClient.fetchQuery( reactQueries.accountManagement.accountIdWithActivityAndMetadata( accountNumber, chainId, @@ -170,16 +207,18 @@ export const ImportAccounts = ({ chainId, onClose }: ImportAccountsProps) => { walletDeviceId, ), ) - if (!accountResult) return + if (!accountResults.length) return setAccounts(previousAccounts => { - const { accountId, accountMetadata, hasActivity } = accountResult - return [...previousAccounts, { accountId, accountMetadata, hasActivity }] + return [...previousAccounts, accountResults] }) }, [accounts, chainId, queryClient, wallet, walletDeviceId]) - const handleAccountIdActiveChange = useCallback((accountId: AccountId, isActive: boolean) => { + const handleAccountIdsActiveChange = useCallback((accountIds: AccountId[], isActive: boolean) => { setAccountIdActiveStateUpdate(previousState => { - return { ...previousState, [accountId]: isActive } + const stateUpdate = accountIds.reduce((accumulator, accountId) => { + return { ...accumulator, [accountId]: isActive } + }, {}) + return { ...previousState, ...stateUpdate } }) }, []) @@ -191,12 +230,12 @@ export const ImportAccounts = ({ chainId, onClose }: ImportAccountsProps) => { await dispatch(portfolioApi.endpoints.getAccount.initiate({ accountId, upsertOnFetch: true })) } - const accountMetadataByAccountId = accounts.reduce( - (accumulator, { accountId, accountMetadata }) => { - return { ...accumulator, [accountId]: accountMetadata } - }, - {}, - ) + const accountMetadataByAccountId = accounts.reduce((accumulator, accounts) => { + const obj = accounts.reduce((innerAccumulator, { accountId, accountMetadata }) => { + return { ...innerAccumulator, [accountId]: accountMetadata } + }, {}) + return { ...accumulator, ...obj } + }, {}) dispatch( portfolio.actions.upsertAccountMetadata({ @@ -216,16 +255,19 @@ export const ImportAccounts = ({ chainId, onClose }: ImportAccountsProps) => { const accountRows = useMemo(() => { if (!asset) return null - return accounts.map(({ accountId }, accountNumber) => ( - - )) - }, [accounts, asset, handleAccountIdActiveChange]) + return accounts.map((accountsForAccountNumber, accountNumber) => { + const accountIds = accountsForAccountNumber.map(({ accountId }) => accountId) + return ( + + ) + }) + }, [accounts, asset, handleAccountIdsActiveChange]) if (!asset) { console.error(`No fee asset found for chainId: ${chainId}`) diff --git a/src/components/ManageAccountsDrawer/components/SelectChain.tsx b/src/components/ManageAccountsDrawer/components/SelectChain.tsx index 65afc46f313..69ec1738f5c 100644 --- a/src/components/ManageAccountsDrawer/components/SelectChain.tsx +++ b/src/components/ManageAccountsDrawer/components/SelectChain.tsx @@ -1,19 +1,9 @@ -import { SearchIcon } from '@chakra-ui/icons' -import { - Box, - Button, - Input, - InputGroup, - InputLeftElement, - SimpleGrid, - VStack, -} from '@chakra-ui/react' +import { Button, SimpleGrid, Stack, VStack } from '@chakra-ui/react' import type { ChainId } from '@shapeshiftoss/caip' -import type { FormEvent } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' -import { useForm } from 'react-hook-form' import { useTranslate } from 'react-polyglot' import { LazyLoadAvatar } from 'components/LazyLoadAvatar' +import { GlobalFilter } from 'components/StakingVaults/GlobalFilter' import { RawText } from 'components/Text' import { assertGetChainAdapter, chainIdToFeeAssetId } from 'lib/utils' import { selectAssetById, selectWalletSupportedChainIds } from 'state/slices/selectors' @@ -22,6 +12,8 @@ import { useAppSelector } from 'state/store' import { filterChainIdsBySearchTerm } from '../helpers' import { DrawerContentWrapper } from './DrawerContent' +const inputGroupProps = { size: 'lg' } + export type SelectChainProps = { onSelectChainId: (chainId: ChainId) => void onClose: () => void @@ -57,33 +49,24 @@ const ChainButton = ({ export const SelectChain = ({ onSelectChainId, onClose }: SelectChainProps) => { const translate = useTranslate() const [searchTermChainIds, setSearchTermChainIds] = useState([]) + const [searchQuery, setSearchQuery] = useState('') const walletSupportedChainIds = useAppSelector(selectWalletSupportedChainIds) - const handleSubmit = useCallback((e: FormEvent) => e.preventDefault(), []) - - const { register, watch } = useForm<{ search: string }>({ - mode: 'onChange', - defaultValues: { - search: '', - }, - }) - const searchString = watch('search') - - const searching = useMemo(() => searchString.length > 0, [searchString]) + const isSearching = useMemo(() => searchQuery.length > 0, [searchQuery]) useEffect(() => { - if (!searching) return + if (!isSearching) return - setSearchTermChainIds(filterChainIdsBySearchTerm(searchString, walletSupportedChainIds)) - }, [searchString, searching, walletSupportedChainIds]) + setSearchTermChainIds(filterChainIdsBySearchTerm(searchQuery, walletSupportedChainIds)) + }, [searchQuery, isSearching, walletSupportedChainIds]) const chainButtons = useMemo(() => { - const listChainIds = searching ? searchTermChainIds : walletSupportedChainIds + const listChainIds = isSearching ? searchTermChainIds : walletSupportedChainIds return listChainIds.map(chainId => { return }) - }, [onSelectChainId, searchTermChainIds, searching, walletSupportedChainIds]) + }, [onSelectChainId, searchTermChainIds, isSearching, walletSupportedChainIds]) const footer = useMemo(() => { return ( @@ -97,31 +80,19 @@ export const SelectChain = ({ onSelectChainId, onClose }: SelectChainProps) => { const body = useMemo(() => { return ( - <> - - - {/* Override zIndex to prevent element displaying on overlay components */} - - - - - - - + + + {chainButtons} - + ) - }, [chainButtons, handleSubmit, register, translate]) + }, [chainButtons, searchQuery, translate]) return ( + ) @@ -115,12 +113,14 @@ export const ManageAccountsModal = ({ onClose={handleDrawerClose} chainId={selectedChainId} /> - + - - - {translate(title)} - + + + + {translate(title)} + + {translate('accountManagement.manageAccounts.description')} @@ -146,12 +146,13 @@ export const ManageAccountsModal = ({ colorScheme='blue' onClick={handleClickAddChain} width='full' + size='lg' isDisabled={disableAddChain} _disabled={disabledProp} > {translate('accountManagement.manageAccounts.addChain')} - diff --git a/src/react-queries/queries/accountManagement.ts b/src/react-queries/queries/accountManagement.ts index 79c1b4b0912..13ec8f57b4d 100644 --- a/src/react-queries/queries/accountManagement.ts +++ b/src/react-queries/queries/accountManagement.ts @@ -14,17 +14,29 @@ const getAccountIdsWithActivityAndMetadata = async ( ) => { const input = { accountNumber, chainIds: [chainId], wallet } const accountIdsAndMetadata = await deriveAccountIdsAndMetadata(input) - const [[accountId, accountMetadata]] = Object.entries(accountIdsAndMetadata) - const { account: pubkey } = fromAccountId(accountId) - const adapter = assertGetChainAdapter(chainId) - const account = await adapter.getAccount(pubkey) - const hasActivity = checkAccountHasActivity(account) + return Promise.all( + Object.entries(accountIdsAndMetadata).map(async ([accountId, accountMetadata]) => { + const { account: pubkey } = fromAccountId(accountId) + const adapter = assertGetChainAdapter(chainId) + const account = await adapter.getAccount(pubkey) + const hasActivity = checkAccountHasActivity(account) - return { accountId, accountMetadata, hasActivity } + return { accountId, accountMetadata, hasActivity } + }), + ) } export const accountManagement = createQueryKeys('accountManagement', { + getAccount: (accountId: AccountId) => ({ + queryKey: ['getAccount', accountId], + queryFn: async () => { + const { chainId, account: pubkey } = fromAccountId(accountId) + const adapter = assertGetChainAdapter(chainId) + const account = await adapter.getAccount(pubkey) + return account + }, + }), accountIdWithActivityAndMetadata: ( accountNumber: number, chainId: ChainId, @@ -55,27 +67,23 @@ export const accountManagement = createQueryKeys('accountManagement', { accountId: AccountId accountMetadata: AccountMetadata hasActivity: boolean - }[] = [] + }[][] = [] if (!wallet) return [] while (true) { try { - const accountResult = await getAccountIdsWithActivityAndMetadata( + if (accountNumber >= accountNumberLimit) { + break + } + + const accountResults = await getAccountIdsWithActivityAndMetadata( accountNumber, chainId, wallet, ) - if (!accountResult) break - - const { accountId, accountMetadata, hasActivity } = accountResult - - if (accountNumber >= accountNumberLimit) { - break - } - - accounts.push({ accountId, accountMetadata, hasActivity }) + accounts.push(accountResults) } catch (error) { console.error(error) break diff --git a/src/state/slices/portfolioSlice/selectors.ts b/src/state/slices/portfolioSlice/selectors.ts index b0a1a91a0b2..60d79f64f49 100644 --- a/src/state/slices/portfolioSlice/selectors.ts +++ b/src/state/slices/portfolioSlice/selectors.ts @@ -97,6 +97,15 @@ export const selectIsAccountIdEnabled = createCachedSelector( }, )((_s: ReduxState, filter) => filter?.accountId ?? 'accountId') +export const selectIsAnyAccountIdEnabled = createCachedSelector( + selectPortfolioAccounts, + (_state: ReduxState, accountIds: AccountId[]) => accountIds, + (accountsById, accountIds): boolean => { + if (accountIds.length === 0) return false + return accountIds.some(accountId => accountsById[accountId] !== undefined) + }, +)((_s: ReduxState, accountIds) => JSON.stringify(accountIds)) + export const selectPortfolioAssetIds = createDeepEqualOutputSelector( selectPortfolioAccountBalancesBaseUnit, (accountBalancesById): AssetId[] => { From 3eff90456079846dd72595b1f37c51b3f85a4a7f Mon Sep 17 00:00:00 2001 From: woody <125113430+woodenfurniture@users.noreply.github.com> Date: Tue, 7 May 2024 11:30:15 +1000 Subject: [PATCH 03/10] chore: make account management toggles use toggle functions instead of setters (#6816) chore: make account management toggles use toggle funtions instead of setters --- .../components/ImportAccounts.tsx | 68 ++++++++++--------- .../slices/portfolioSlice/portfolioSlice.ts | 13 +++- 2 files changed, 46 insertions(+), 35 deletions(-) diff --git a/src/components/ManageAccountsDrawer/components/ImportAccounts.tsx b/src/components/ManageAccountsDrawer/components/ImportAccounts.tsx index 33922d6fdf8..2ca36368503 100644 --- a/src/components/ManageAccountsDrawer/components/ImportAccounts.tsx +++ b/src/components/ManageAccountsDrawer/components/ImportAccounts.tsx @@ -32,7 +32,7 @@ import { selectHighestAccountNumberForChainId, selectIsAnyAccountIdEnabled, } from 'state/slices/selectors' -import { useAppDispatch, useAppSelector } from 'state/store' +import { store, useAppDispatch, useAppSelector } from 'state/store' import { DrawerContentWrapper } from './DrawerContent' @@ -49,7 +49,7 @@ type TableRowProps = { accountIds: AccountId[] accountNumber: number asset: Asset - onActiveAccountIdsChange: (accountIds: AccountId[], isActive: boolean) => void + onToggleAccountIds: (accountIds: AccountId[]) => void } type TableRowAccountProps = { @@ -92,24 +92,23 @@ const TableRowAccount = forwardRef(({ asset, accoun }) const TableRow = forwardRef( - ({ asset, accountNumber, accountIds, onActiveAccountIdsChange }, ref) => { - const isAccountEnabledInRedux = useAppSelector(state => - selectIsAnyAccountIdEnabled(state, accountIds), - ) + ({ asset, accountNumber, accountIds, onToggleAccountIds }, ref) => { + const isAccountEnabled = useAppSelector(state => selectIsAnyAccountIdEnabled(state, accountIds)) - const [isAccountActive, toggleIsAccountActive] = useToggle(isAccountEnabledInRedux) + const [isAccountActive, toggleIsAccountActive] = useToggle(isAccountEnabled) - useEffect(() => { - onActiveAccountIdsChange(accountIds, isAccountActive) - }, [accountIds, isAccountActive, isAccountEnabledInRedux, onActiveAccountIdsChange]) + const handleToggleIsAccountActive = useCallback(() => { + toggleIsAccountActive() + onToggleAccountIds(accountIds) + }, [accountIds, onToggleAccountIds, toggleIsAccountActive]) const firstAccount = useMemo(() => accountIds[0], [accountIds]) const otherAccountIds = useMemo(() => accountIds.slice(1), [accountIds]) const otherAccounts = useMemo(() => { return otherAccountIds.map(accountId => ( - - - + + + )) }, [asset, isAccountActive, otherAccountIds, ref]) @@ -118,7 +117,7 @@ const TableRow = forwardRef( <> - + {accountNumber} @@ -176,9 +175,7 @@ export const ImportAccounts = ({ chainId, onClose }: ImportAccountsProps) => { ) }, }) > 0 - const [accountIdActiveStateUpdate, setAccountIdActiveStateUpdate] = useState< - Record - >({}) + const [toggledActiveAccountIds, setToggledActiveAccountIds] = useState>(new Set()) // initial fetch to detect the number of accounts based on the "first empty account" heuristic const { data: allAccountIdsWithActivity } = useQuery( @@ -213,20 +210,27 @@ export const ImportAccounts = ({ chainId, onClose }: ImportAccountsProps) => { }) }, [accounts, chainId, queryClient, wallet, walletDeviceId]) - const handleAccountIdsActiveChange = useCallback((accountIds: AccountId[], isActive: boolean) => { - setAccountIdActiveStateUpdate(previousState => { - const stateUpdate = accountIds.reduce((accumulator, accountId) => { - return { ...accumulator, [accountId]: isActive } - }, {}) - return { ...previousState, ...stateUpdate } + const handleToggleAccountIds = useCallback((accountIds: AccountId[]) => { + setToggledActiveAccountIds(previousState => { + const updatedState = new Set(previousState) + for (const accountId of accountIds) { + if (updatedState.has(accountId)) { + updatedState.delete(accountId) + } else { + updatedState.add(accountId) + } + } + + return updatedState }) }, []) // TODO: Loading state const handleDone = useCallback(async () => { // for every new account that is active, fetch the account and upsert it into the redux state - for (const [accountId, isActive] of Object.entries(accountIdActiveStateUpdate)) { - if (!isActive) continue + for (const accountId of toggledActiveAccountIds) { + const isEnabled = selectIsAnyAccountIdEnabled(store.getState(), [accountId]) + if (isEnabled) continue await dispatch(portfolioApi.endpoints.getAccount.initiate({ accountId, upsertOnFetch: true })) } @@ -244,14 +248,12 @@ export const ImportAccounts = ({ chainId, onClose }: ImportAccountsProps) => { }), ) - const hiddenAccountIds = Object.entries(accountIdActiveStateUpdate) - .filter(([_, isActive]) => !isActive) - .map(([accountId]) => accountId) - - dispatch(portfolio.actions.setHiddenAccountIds(hiddenAccountIds)) + for (const accountId of toggledActiveAccountIds) { + dispatch(portfolio.actions.toggleAccountIdHidden(accountId)) + } onClose() - }, [accountIdActiveStateUpdate, accounts, dispatch, onClose, walletDeviceId]) + }, [toggledActiveAccountIds, accounts, dispatch, onClose, walletDeviceId]) const accountRows = useMemo(() => { if (!asset) return null @@ -263,11 +265,11 @@ export const ImportAccounts = ({ chainId, onClose }: ImportAccountsProps) => { accountNumber={accountNumber} accountIds={accountIds} asset={asset} - onActiveAccountIdsChange={handleAccountIdsActiveChange} + onToggleAccountIds={handleToggleAccountIds} /> ) }) - }, [accounts, asset, handleAccountIdsActiveChange]) + }, [accounts, asset, handleToggleAccountIds]) if (!asset) { console.error(`No fee asset found for chainId: ${chainId}`) diff --git a/src/state/slices/portfolioSlice/portfolioSlice.ts b/src/state/slices/portfolioSlice/portfolioSlice.ts index eb48302c4cb..9e3536ddfaa 100644 --- a/src/state/slices/portfolioSlice/portfolioSlice.ts +++ b/src/state/slices/portfolioSlice/portfolioSlice.ts @@ -137,8 +137,17 @@ export const portfolio = createSlice({ // add the `action.meta[SHOULD_AUTOBATCH]` field the enhancer needs prepare: prepareAutoBatched(), }, - setHiddenAccountIds: (draftState, { payload }: { payload: AccountId[] }) => { - draftState.hiddenAccountIds = payload + toggleAccountIdHidden: (draftState, { payload: accountId }: { payload: AccountId }) => { + const hiddenAccountIdsSet = new Set(draftState.hiddenAccountIds) + const isHidden = hiddenAccountIdsSet.has(accountId) + + if (!isHidden) { + hiddenAccountIdsSet.add(accountId) + } else { + hiddenAccountIdsSet.delete(accountId) + } + + draftState.hiddenAccountIds = Array.from(hiddenAccountIdsSet) }, }, extraReducers: builder => builder.addCase(PURGE, () => initialState), From 0c2f82144d6411c1875b17c1f261cd989b48aab6 Mon Sep 17 00:00:00 2001 From: woody <125113430+woodenfurniture@users.noreply.github.com> Date: Tue, 7 May 2024 11:42:03 +1000 Subject: [PATCH 04/10] fix: state corruption of import accounts drawer (#6817) * chore: migrate account import queries to useInfiniteQuery * fix: ensure import account component state is not shared across chains * fix: disable buttons when auto-fetching --- .../components/ImportAccounts.tsx | 133 +++++++++--------- .../ManageAccountsDrawer/helpers.ts | 28 +++- .../queries/accountManagement.ts | 84 +---------- 3 files changed, 95 insertions(+), 150 deletions(-) diff --git a/src/components/ManageAccountsDrawer/components/ImportAccounts.tsx b/src/components/ManageAccountsDrawer/components/ImportAccounts.tsx index 2ca36368503..d08754641c3 100644 --- a/src/components/ManageAccountsDrawer/components/ImportAccounts.tsx +++ b/src/components/ManageAccountsDrawer/components/ImportAccounts.tsx @@ -12,11 +12,10 @@ import { } from '@chakra-ui/react' import type { ChainId } from '@shapeshiftoss/caip' import { type AccountId, fromAccountId } from '@shapeshiftoss/caip' -import type { AccountMetadata, Asset } from '@shapeshiftoss/types' -import { useIsFetching, useQuery, useQueryClient } from '@tanstack/react-query' +import type { Asset } from '@shapeshiftoss/types' +import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslate } from 'react-polyglot' -import { reactQueries } from 'react-queries' import { accountManagement } from 'react-queries/queries/accountManagement' import { Amount } from 'components/Amount/Amount' import { MiddleEllipsis } from 'components/MiddleEllipsis/MiddleEllipsis' @@ -34,6 +33,7 @@ import { } from 'state/slices/selectors' import { store, useAppDispatch, useAppSelector } from 'state/store' +import { getAccountIdsWithActivityAndMetadata } from '../helpers' import { DrawerContentWrapper } from './DrawerContent' // The number of additional empty accounts to include in the initial fetch @@ -160,55 +160,58 @@ export const ImportAccounts = ({ chainId, onClose }: ImportAccountsProps) => { selectHighestAccountNumberForChainId(state, highestAccountNumberForChainIdFilter), ) const chainNamespaceDisplayName = asset?.networkName ?? '' - const [accounts, setAccounts] = useState< - { accountId: AccountId; accountMetadata: AccountMetadata; hasActivity: boolean }[][] - >([]) - const queryClient = useQueryClient() - const isLoading = - useIsFetching({ - predicate: query => { - return ( - query.queryKey[0] === 'accountManagement' && - ['accountIdWithActivityAndMetadata', 'firstAccountIdsWithActivityAndMetadata'].some( - str => str === query.queryKey[1], - ) - ) - }, - }) > 0 + const [autoFetching, setAutoFetching] = useState(true) const [toggledActiveAccountIds, setToggledActiveAccountIds] = useState>(new Set()) + // reset component state when chainId changes + useEffect(() => { + setAutoFetching(true) + setToggledActiveAccountIds(new Set()) + }, [chainId]) + // initial fetch to detect the number of accounts based on the "first empty account" heuristic - const { data: allAccountIdsWithActivity } = useQuery( - accountManagement.firstAccountIdsWithActivityAndMetadata( - chainId, - wallet, - walletDeviceId, - // Account numbers are 0-indexed, so we need to add 1 to the highest account number. - // Add additional empty accounts to show more accounts without having to load more. - highestAccountNumber + 1 + NUM_ADDITIONAL_EMPTY_ACCOUNTS, - ), - ) + const { + data: accounts, + fetchNextPage, + isLoading, + } = useInfiniteQuery({ + queryKey: ['accountIdWithActivityAndMetadata', chainId, walletDeviceId, wallet !== null], + queryFn: async ({ pageParam: accountNumber }) => { + return { + accountNumber, + accountIdWithActivityAndMetadata: await getAccountIdsWithActivityAndMetadata( + accountNumber, + chainId, + wallet, + ), + } + }, + initialPageParam: 0, + getNextPageParam: lastPage => { + return lastPage.accountNumber + 1 + }, + }) + // Handle initial automatic loading useEffect(() => { - setAccounts(allAccountIdsWithActivity ?? []) - }, [allAccountIdsWithActivity]) - - const handleLoadMore = useCallback(async () => { - if (!wallet) return - const accountNumber = accounts.length - const accountResults = await queryClient.fetchQuery( - reactQueries.accountManagement.accountIdWithActivityAndMetadata( - accountNumber, - chainId, - wallet, - walletDeviceId, - ), - ) - if (!accountResults.length) return - setAccounts(previousAccounts => { - return [...previousAccounts, accountResults] - }) - }, [accounts, chainId, queryClient, wallet, walletDeviceId]) + if (!autoFetching || !accounts) return + + // Account numbers are 0-indexed, so we need to add 1 to the highest account number. + // Add additional empty accounts to show more accounts without having to load more. + const numAccountsToLoad = highestAccountNumber + 1 + NUM_ADDITIONAL_EMPTY_ACCOUNTS + + if (accounts.pages.length < numAccountsToLoad) { + fetchNextPage() + } else { + // Stop auto-fetching and switch to manual mode + setAutoFetching(false) + } + }, [accounts, highestAccountNumber, fetchNextPage, autoFetching]) + + const handleLoadMore = useCallback(() => { + if (isLoading || autoFetching) return + fetchNextPage() + }, [autoFetching, isLoading, fetchNextPage]) const handleToggleAccountIds = useCallback((accountIds: AccountId[]) => { setToggledActiveAccountIds(previousState => { @@ -234,10 +237,15 @@ export const ImportAccounts = ({ chainId, onClose }: ImportAccountsProps) => { await dispatch(portfolioApi.endpoints.getAccount.initiate({ accountId, upsertOnFetch: true })) } - const accountMetadataByAccountId = accounts.reduce((accumulator, accounts) => { - const obj = accounts.reduce((innerAccumulator, { accountId, accountMetadata }) => { - return { ...innerAccumulator, [accountId]: accountMetadata } - }, {}) + if (!accounts) return + + const accountMetadataByAccountId = accounts.pages.reduce((accumulator, accounts) => { + const obj = accounts.accountIdWithActivityAndMetadata.reduce( + (innerAccumulator, { accountId, accountMetadata }) => { + return { ...innerAccumulator, [accountId]: accountMetadata } + }, + {}, + ) return { ...accumulator, ...obj } }, {}) @@ -256,12 +264,13 @@ export const ImportAccounts = ({ chainId, onClose }: ImportAccountsProps) => { }, [toggledActiveAccountIds, accounts, dispatch, onClose, walletDeviceId]) const accountRows = useMemo(() => { - if (!asset) return null - return accounts.map((accountsForAccountNumber, accountNumber) => { - const accountIds = accountsForAccountNumber.map(({ accountId }) => accountId) + if (!asset || !accounts) return null + return accounts.pages.map(({ accountIdWithActivityAndMetadata }, accountNumber) => { + const accountIds = accountIdWithActivityAndMetadata.map(({ accountId }) => accountId) + const key = accountIds.join('-') return ( { description={translate('accountManagement.importAccounts.description')} footer={ <> -