Skip to content

Commit

Permalink
feat: more account management wiring (#6780)
Browse files Browse the repository at this point in the history
* feat: auto-toggle account rows based on redux state

* feat: wire up account toggle to import accounts list

* feat: wire up imported accounts to portfolio slice

* chore: clean up return type of account management queries

* feat: load all accounts up to last enabled account, plus 1

* feat: wire up disabledAcountIds to portfolio selectors

* fix: tests

* chore: actioned code review feedback

* chore: rename disabledAccountIds to hiddenAccountIds

* chore: disabledProp with an s
  • Loading branch information
woodenfurniture authored May 3, 2024
1 parent 3dd73f2 commit 71d0bc7
Show file tree
Hide file tree
Showing 9 changed files with 176 additions and 49 deletions.
125 changes: 96 additions & 29 deletions src/components/ManageAccountsDrawer/components/ImportAccounts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from '@chakra-ui/react'
import type { ChainId } from '@shapeshiftoss/caip'
import { type AccountId, fromAccountId } from '@shapeshiftoss/caip'
import type { Asset } from '@shapeshiftoss/types'
import type { AccountMetadata, Asset } from '@shapeshiftoss/types'
import { useIsFetching, useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslate } from 'react-polyglot'
Expand All @@ -21,14 +21,18 @@ import { accountManagement } from 'react-queries/queries/accountManagement'
import { Amount } from 'components/Amount/Amount'
import { MiddleEllipsis } from 'components/MiddleEllipsis/MiddleEllipsis'
import { RawText } from 'components/Text'
import { useToggle } from 'hooks/useToggle/useToggle'
import { useWallet } from 'hooks/useWallet/useWallet'
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,
} from 'state/slices/selectors'
import { useAppSelector } from 'state/store'
import { useAppDispatch, useAppSelector } from 'state/store'

import { DrawerContentWrapper } from './DrawerContent'

Expand All @@ -45,18 +49,28 @@ type TableRowProps = {
accountId: AccountId
accountNumber: number
asset: Asset
toggleAccountId: (accountId: AccountId) => void
onAccountIdActiveChange: (accountId: AccountId, isActive: boolean) => void
}

const disabledProp = { opacity: 0.5, cursor: 'not-allowed', userSelect: 'none' }
const disabledProps = { opacity: 0.5, cursor: 'not-allowed', userSelect: 'none' }

const TableRow = forwardRef<TableRowProps, 'div'>(
({ asset, accountId, accountNumber, toggleAccountId }, ref) => {
({ asset, accountId, accountNumber, onAccountIdActiveChange }, ref) => {
const translate = useTranslate()
const handleChange = useCallback(() => toggleAccountId(accountId), [accountId, toggleAccountId])
const accountLabel = useMemo(() => accountIdToLabel(accountId), [accountId])
const balanceFilter = useMemo(() => ({ assetId: asset.assetId, accountId }), [asset, accountId])
const isAccountEnabledFilter = useMemo(() => ({ accountId }), [accountId])
const isAccountEnabledInRedux = useAppSelector(state =>
selectIsAccountIdEnabled(state, isAccountEnabledFilter),
)

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),
)
Expand All @@ -70,7 +84,7 @@ const TableRow = forwardRef<TableRowProps, 'div'>(
<RawText>{accountNumber}</RawText>
</Td>
<Td>
<Switch onChange={handleChange} />
<Switch isChecked={isAccountActive} onChange={toggleIsAccountActive} />
</Td>
<Td>
<Tooltip label={pubkey} isDisabled={isUtxoAccount}>
Expand Down Expand Up @@ -112,23 +126,33 @@ const LoadingRow = () => {

export const ImportAccounts = ({ chainId, onClose }: ImportAccountsProps) => {
const translate = useTranslate()
const wallet = useWallet().state.wallet
const dispatch = useAppDispatch()
const { wallet, deviceId: walletDeviceId } = useWallet().state
const asset = useAppSelector(state => selectFeeAssetByChainId(state, chainId))
const highestAccountNumberForChainIdFilter = useMemo(() => ({ chainId }), [chainId])
const highestAccountNumber = useAppSelector(state =>
selectHighestAccountNumberForChainId(state, highestAccountNumberForChainIdFilter),
)
const chainNamespaceDisplayName = asset?.networkName ?? ''
const [accounts, setAccounts] = useState<{ accountNumber: number; accountId: AccountId }[]>([])
const [accounts, setAccounts] = useState<
{ accountId: AccountId; accountMetadata: AccountMetadata; hasActivity: boolean }[]
>([])
const queryClient = useQueryClient()
const isLoading = useIsFetching({ queryKey: ['accountManagement'] }) > 0

// TODO:
// when "done" is clicked, all enabled accounts for this chain will be upserted into the portfolio
// all disabled ones will be removed.
// need to call dispatch(portfolioApi.getAccount({ accountId, upsertOnFetch: true }))
// which internally calls dispatch(portfolio.actions.upsertPortfolio(account)) and upserts assets
// But also need to remove accounts that were disabled.
const [accountIdActiveStateUpdate, setAccountIdActiveStateUpdate] = useState<
Record<string, boolean>
>({})

// initial fetch to detect the number of accounts based on the "first empty account" heuristic
const { data: allAccountIdsWithActivity } = useQuery(
accountManagement.allAccountIdsWithActivity(chainId, wallet, NUM_ADDITIONAL_EMPTY_ACCOUNTS),
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,
),
)

useEffect(() => {
Expand All @@ -139,31 +163,69 @@ export const ImportAccounts = ({ chainId, onClose }: ImportAccountsProps) => {
if (!wallet) return
const accountNumber = accounts.length
const accountResult = await queryClient.fetchQuery(
reactQueries.accountManagement.accountIdWithActivity(accountNumber, chainId, wallet),
reactQueries.accountManagement.accountIdWithActivityAndMetadata(
accountNumber,
chainId,
wallet,
walletDeviceId,
),
)
if (!accountResult) return
setAccounts(previousAccounts => {
const { accountId } = accountResult
return [...previousAccounts, { accountNumber, accountId }]
const { accountId, accountMetadata, hasActivity } = accountResult
return [...previousAccounts, { accountId, accountMetadata, hasActivity }]
})
}, [accounts, chainId, queryClient, wallet])
}, [accounts, chainId, queryClient, wallet, walletDeviceId])

const handleToggleAccountId = useCallback((accountId: AccountId) => {
console.log('handleToggleAccountId', accountId)
const handleAccountIdActiveChange = useCallback((accountId: AccountId, isActive: boolean) => {
setAccountIdActiveStateUpdate(previousState => {
return { ...previousState, [accountId]: isActive }
})
}, [])

// 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
await dispatch(portfolioApi.endpoints.getAccount.initiate({ accountId, upsertOnFetch: true }))
}

const accountMetadataByAccountId = accounts.reduce(
(accumulator, { accountId, accountMetadata }) => {
return { ...accumulator, [accountId]: accountMetadata }
},
{},
)

dispatch(
portfolio.actions.upsertAccountMetadata({
accountMetadataByAccountId,
walletId: walletDeviceId,
}),
)

const hiddenAccountIds = Object.entries(accountIdActiveStateUpdate)
.filter(([_, isActive]) => !isActive)
.map(([accountId]) => accountId)

dispatch(portfolio.actions.setHiddenAccountIds(hiddenAccountIds))

onClose()
}, [accountIdActiveStateUpdate, accounts, dispatch, onClose, walletDeviceId])

const accountRows = useMemo(() => {
if (!asset) return null
return accounts.map(({ accountId, accountNumber }) => (
return accounts.map(({ accountId }, accountNumber) => (
<TableRow
key={accountId}
accountId={accountId}
accountNumber={accountNumber}
asset={asset}
toggleAccountId={handleToggleAccountId}
onAccountIdActiveChange={handleAccountIdActiveChange}
/>
))
}, [accounts, asset, handleToggleAccountId])
}, [accounts, asset, handleAccountIdActiveChange])

if (!asset) {
console.error(`No fee asset found for chainId: ${chainId}`)
Expand All @@ -181,11 +243,16 @@ export const ImportAccounts = ({ chainId, onClose }: ImportAccountsProps) => {
mr={3}
onClick={onClose}
isDisabled={isLoading}
_disabled={disabledProp}
_disabled={disabledProps}
>
{translate('common.cancel')}
</Button>
<Button colorScheme='blue' isDisabled={isLoading} _disabled={disabledProp}>
<Button
colorScheme='blue'
onClick={handleDone}
isDisabled={isLoading}
_disabled={disabledProps}
>
{translate('common.done')}
</Button>
</>
Expand All @@ -204,7 +271,7 @@ export const ImportAccounts = ({ chainId, onClose }: ImportAccountsProps) => {
colorScheme='gray'
onClick={handleLoadMore}
isDisabled={isLoading}
_disabled={disabledProp}
_disabled={disabledProps}
>
{translate('common.loadMore')}
</Button>
Expand Down
51 changes: 32 additions & 19 deletions src/react-queries/queries/accountManagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,67 +2,80 @@ import { createQueryKeys } from '@lukemorales/query-key-factory'
import type { AccountId } from '@shapeshiftoss/caip'
import { type ChainId, fromAccountId } from '@shapeshiftoss/caip'
import type { HDWallet } from '@shapeshiftoss/hdwallet-core'
import type { AccountMetadata } from '@shapeshiftoss/types'
import { deriveAccountIdsAndMetadata } from 'lib/account/account'
import { assertGetChainAdapter } from 'lib/utils'
import { checkAccountHasActivity } from 'state/slices/portfolioSlice/utils'

const getAccountIdsWithActivity = async (
const getAccountIdsWithActivityAndMetadata = async (
accountNumber: number,
chainId: ChainId,
wallet: HDWallet,
) => {
const input = { accountNumber, chainIds: [chainId], wallet }
const accountIdsAndMetadata = await deriveAccountIdsAndMetadata(input)
const [accountId] = Object.keys(accountIdsAndMetadata)
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 { accountId, hasActivity }
return { accountId, accountMetadata, hasActivity }
}

export const accountManagement = createQueryKeys('accountManagement', {
accountIdWithActivity: (accountNumber: number, chainId: ChainId, wallet: HDWallet) => ({
queryKey: ['accountIdWithActivity', chainId, wallet.getDeviceID(), accountNumber],
accountIdWithActivityAndMetadata: (
accountNumber: number,
chainId: ChainId,
wallet: HDWallet,
walletDeviceId: string,
) => ({
queryKey: ['accountIdWithActivityAndMetadata', chainId, walletDeviceId, accountNumber],
queryFn: () => {
return getAccountIdsWithActivity(accountNumber, chainId, wallet)
return getAccountIdsWithActivityAndMetadata(accountNumber, chainId, wallet)
},
}),
allAccountIdsWithActivity: (
firstAccountIdsWithActivityAndMetadata: (
chainId: ChainId,
wallet: HDWallet | null,
numEmptyAccountsToInclude: number,
walletDeviceId: string,
accountNumberLimit: number,
) => ({
queryKey: [
'allAccountIdsWithActivity',
'firstAccountIdsWithActivityAndMetadata',
chainId,
wallet?.getDeviceID() ?? '',
numEmptyAccountsToInclude,
walletDeviceId,
accountNumberLimit,
],
queryFn: async () => {
let accountNumber = 0
let emptyAccountCount = 0
const accounts: { accountNumber: number; accountId: AccountId }[] = []

const accounts: {
accountId: AccountId
accountMetadata: AccountMetadata
hasActivity: boolean
}[] = []

if (!wallet) return []

while (true) {
try {
const accountResult = await getAccountIdsWithActivity(accountNumber, chainId, wallet)
const accountResult = await getAccountIdsWithActivityAndMetadata(
accountNumber,
chainId,
wallet,
)

if (!accountResult) break

const { accountId, hasActivity } = accountResult

if (!hasActivity) emptyAccountCount++
const { accountId, accountMetadata, hasActivity } = accountResult

if (!hasActivity && emptyAccountCount > numEmptyAccountsToInclude) {
if (accountNumber >= accountNumberLimit) {
break
}

accounts.push({ accountNumber, accountId })
accounts.push({ accountId, accountMetadata, hasActivity })
} catch (error) {
console.error(error)
break
Expand Down
1 change: 1 addition & 0 deletions src/state/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,5 @@ export const migrations = {
47: clearAssets,
48: clearAssets,
49: clearAssets,
50: clearPortfolio,
}
6 changes: 5 additions & 1 deletion src/state/slices/common-selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ export const selectWalletSupportedChainIds = (state: ReduxState) =>
export const selectWalletAccountIds = createDeepEqualOutputSelector(
selectWalletId,
(state: ReduxState) => state.portfolio.wallet.byId,
(walletId, walletById): AccountId[] => (walletId && walletById[walletId]) ?? [],
(state: ReduxState) => state.portfolio.hiddenAccountIds,
(walletId, walletById, hiddenAccountIds): AccountId[] => {
const walletAccountIds = (walletId && walletById[walletId]) ?? []
return walletAccountIds.filter(accountId => !hiddenAccountIds.includes(accountId))
},
)

export const selectWalletChainIds = createDeepEqualOutputSelector(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ exports[`portfolioSlice > reducers > upsertPortfolio > Bitcoin > should update s
"bip122:000000000019d6689c085ae165831e93:zpub6qk8s2NQsYG6X2Mm6iU2ii3yTAqDb2XqnMu9vo2WjvqwjSvjjiYQQveYXbPxrnRT5Yb5p0x934be745172066EDF795ffc5EA9F28f19b440c637BaBw1wowPwbS8fj7uCfj3UhqhD2LLbvY6Ni1w",
],
},
"hiddenAccountIds": [],
"wallet": {
"byId": {},
"ids": [],
Expand Down Expand Up @@ -76,6 +77,7 @@ exports[`portfolioSlice > reducers > upsertPortfolio > Bitcoin > should update s
"bip122:000000000019d6689c085ae165831e93:xpub6qk8s2NQsYG6X2Mm6iU2ii3yTAqDb2XqnMu9vo2WjvqwjSvjjiYQQveYXbPxrnRT5Yb5p0x934be745172066EDF795ffc5EA9F28f19b440c637BaBw1wowPwbS8fj7uCfj3UhqhD2LLbvY6Ni1w",
],
},
"hiddenAccountIds": [],
"wallet": {
"byId": {},
"ids": [],
Expand Down Expand Up @@ -146,6 +148,7 @@ exports[`portfolioSlice > reducers > upsertPortfolio > Ethereum and bitcoin > sh
"bip122:000000000019d6689c085ae165831e93:zpub6qk8s2NQsYG6X2Mm6iU2ii3yTAqDb2XqnMu9vo2WjvqwjSvjjiYQQveYXbPxrnRT5Yb5p0x934be745172066EDF795ffc5EA9F28f19b440c637BaBw1wowPwbS8fj7uCfj3UhqhD2LLbvY6Ni1w",
],
},
"hiddenAccountIds": [],
"wallet": {
"byId": {},
"ids": [],
Expand Down Expand Up @@ -201,6 +204,7 @@ exports[`portfolioSlice > reducers > upsertPortfolio > Ethereum and bitcoin > sh
"bip122:000000000019d6689c085ae165831e93:zpub6qk8s2NQsYG6X2Mm6iU2ii3yTAqDb2XqnMu9vo2WjvqwjSvjjiYQQveYXbPxrnRT5Yb5p0x934be745172066EDF795ffc5EA9F28f19b440c637BaBw1wowPwbS8fj7uCfj3UhqhD2LLbvY6Ni1w",
],
},
"hiddenAccountIds": [],
"wallet": {
"byId": {},
"ids": [],
Expand Down Expand Up @@ -239,6 +243,7 @@ exports[`portfolioSlice > reducers > upsertPortfolio > ethereum > should update
"eip155:1:0x9a2d593725045d1727d525dd07a396f9ff079bb1",
],
},
"hiddenAccountIds": [],
"wallet": {
"byId": {},
"ids": [],
Expand Down Expand Up @@ -290,6 +295,7 @@ exports[`portfolioSlice > reducers > upsertPortfolio > ethereum > should update
"eip155:1:0xea674fdde714fd979de3edf0f56aa9716b898ec8",
],
},
"hiddenAccountIds": [],
"wallet": {
"byId": {},
"ids": [],
Expand Down
3 changes: 3 additions & 0 deletions src/state/slices/portfolioSlice/portfolioSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ export const portfolio = createSlice({
// add the `action.meta[SHOULD_AUTOBATCH]` field the enhancer needs
prepare: prepareAutoBatched<Portfolio>(),
},
setHiddenAccountIds: (draftState, { payload }: { payload: AccountId[] }) => {
draftState.hiddenAccountIds = payload
},
},
extraReducers: builder => builder.addCase(PURGE, () => initialState),
})
Expand Down
Loading

0 comments on commit 71d0bc7

Please sign in to comment.