Commit 485764fe authored by Nate Wienert's avatar Nate Wienert Committed by GitHub

feat: improve logic around hidden section of mini-portfolio balances spam tokens (#6988)

parent 51dc10b4
...@@ -18,7 +18,9 @@ describe('Token explore filter', () => { ...@@ -18,7 +18,9 @@ describe('Token explore filter', () => {
searchFor('dao') searchFor('dao')
cy.get('@filteredTokens').then((filteredTokens) => { cy.get('@filteredTokens').then((filteredTokens) => {
cy.get('[data-cy="token-name"]').should('deep.equal', filteredTokens) cy.get('[data-cy="token-name"]').then((tokens) => {
cy.wrap(Array.from(tokens)).should('deep.equal', Array.from(filteredTokens))
})
}) })
}) })
}) })
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -3,7 +3,7 @@ import { TraceEvent } from 'analytics' ...@@ -3,7 +3,7 @@ import { TraceEvent } from 'analytics'
import { useCachedPortfolioBalancesQuery } from 'components/AccountDrawer/PrefetchBalancesWrapper' import { useCachedPortfolioBalancesQuery } from 'components/AccountDrawer/PrefetchBalancesWrapper'
import Row from 'components/Row' import Row from 'components/Row'
import { formatDelta } from 'components/Tokens/TokenDetails/PriceChart' import { formatDelta } from 'components/Tokens/TokenDetails/PriceChart'
import { PortfolioBalancesQuery } from 'graphql/data/__generated__/types-and-hooks' import { TokenBalance } from 'graphql/data/__generated__/types-and-hooks'
import { getTokenDetailsURL, gqlToCurrency, logSentryErrorForUnsupportedChain } from 'graphql/data/util' import { getTokenDetailsURL, gqlToCurrency, logSentryErrorForUnsupportedChain } from 'graphql/data/util'
import { useAtomValue } from 'jotai/utils' import { useAtomValue } from 'jotai/utils'
import { EmptyWalletModule } from 'nft/components/profile/view/EmptyWalletContent' import { EmptyWalletModule } from 'nft/components/profile/view/EmptyWalletContent'
...@@ -12,6 +12,7 @@ import { useNavigate } from 'react-router-dom' ...@@ -12,6 +12,7 @@ import { useNavigate } from 'react-router-dom'
import styled from 'styled-components' import styled from 'styled-components'
import { EllipsisStyle, ThemedText } from 'theme' import { EllipsisStyle, ThemedText } from 'theme'
import { formatNumber, NumberType } from 'utils/formatNumbers' import { formatNumber, NumberType } from 'utils/formatNumbers'
import { splitHiddenTokens } from 'utils/splitHiddenTokens'
import { useToggleAccountDrawer } from '../..' import { useToggleAccountDrawer } from '../..'
import { PortfolioArrow } from '../../AuthenticatedHeader' import { PortfolioArrow } from '../../AuthenticatedHeader'
...@@ -20,12 +21,6 @@ import { ExpandoRow } from '../ExpandoRow' ...@@ -20,12 +21,6 @@ import { ExpandoRow } from '../ExpandoRow'
import { PortfolioLogo } from '../PortfolioLogo' import { PortfolioLogo } from '../PortfolioLogo'
import PortfolioRow, { PortfolioSkeleton, PortfolioTabWrapper } from '../PortfolioRow' import PortfolioRow, { PortfolioSkeleton, PortfolioTabWrapper } from '../PortfolioRow'
const HIDE_SMALL_USD_BALANCES_THRESHOLD = 1
function meetsThreshold(tokenBalance: TokenBalance, hideSmallBalances: boolean) {
return !hideSmallBalances || (tokenBalance.denominatedValue?.value ?? 0) > HIDE_SMALL_USD_BALANCES_THRESHOLD
}
export default function Tokens({ account }: { account: string }) { export default function Tokens({ account }: { account: string }) {
const toggleWalletDrawer = useToggleAccountDrawer() const toggleWalletDrawer = useToggleAccountDrawer()
const hideSmallBalances = useAtomValue(hideSmallBalancesAtom) const hideSmallBalances = useAtomValue(hideSmallBalancesAtom)
...@@ -33,27 +28,18 @@ export default function Tokens({ account }: { account: string }) { ...@@ -33,27 +28,18 @@ export default function Tokens({ account }: { account: string }) {
const { data } = useCachedPortfolioBalancesQuery({ account }) const { data } = useCachedPortfolioBalancesQuery({ account })
const visibleTokens = useMemo(() => { const tokenBalances = data?.portfolios?.[0].tokenBalances as TokenBalance[] | undefined
return !hideSmallBalances
? data?.portfolios?.[0].tokenBalances ?? []
: data?.portfolios?.[0].tokenBalances?.filter((tokenBalance) =>
meetsThreshold(tokenBalance, hideSmallBalances)
) ?? []
}, [data?.portfolios, hideSmallBalances])
const hiddenTokens = useMemo(() => { const { visibleTokens, hiddenTokens } = useMemo(
return !hideSmallBalances () => splitHiddenTokens(tokenBalances ?? [], { hideSmallBalances }),
? [] [hideSmallBalances, tokenBalances]
: data?.portfolios?.[0].tokenBalances?.filter( )
(tokenBalance) => !meetsThreshold(tokenBalance, hideSmallBalances)
) ?? []
}, [data?.portfolios, hideSmallBalances])
if (!data) { if (!data) {
return <PortfolioSkeleton /> return <PortfolioSkeleton />
} }
if (data?.portfolios?.[0].tokenBalances?.length === 0) { if (tokenBalances?.length === 0) {
// TODO: consider launching moonpay here instead of just closing the drawer // TODO: consider launching moonpay here instead of just closing the drawer
return <EmptyWalletModule type="token" onNavigateClick={toggleWalletDrawer} /> return <EmptyWalletModule type="token" onNavigateClick={toggleWalletDrawer} />
} }
...@@ -64,10 +50,7 @@ export default function Tokens({ account }: { account: string }) { ...@@ -64,10 +50,7 @@ export default function Tokens({ account }: { account: string }) {
<PortfolioTabWrapper> <PortfolioTabWrapper>
{visibleTokens.map( {visibleTokens.map(
(tokenBalance) => (tokenBalance) =>
tokenBalance.token && tokenBalance.token && <TokenRow key={tokenBalance.id} {...tokenBalance} token={tokenBalance.token} />
meetsThreshold(tokenBalance, hideSmallBalances) && (
<TokenRow key={tokenBalance.id} {...tokenBalance} token={tokenBalance.token} />
)
)} )}
<ExpandoRow isExpanded={showHiddenTokens} toggle={toggleHiddenTokens} numItems={hiddenTokens.length}> <ExpandoRow isExpanded={showHiddenTokens} toggle={toggleHiddenTokens} numItems={hiddenTokens.length}>
{hiddenTokens.map( {hiddenTokens.map(
...@@ -86,10 +69,6 @@ const TokenNameText = styled(ThemedText.SubHeader)` ...@@ -86,10 +69,6 @@ const TokenNameText = styled(ThemedText.SubHeader)`
${EllipsisStyle} ${EllipsisStyle}
` `
type TokenBalance = NonNullable<
NonNullable<NonNullable<PortfolioBalancesQuery['portfolios']>[number]>['tokenBalances']
>[number]
type PortfolioToken = NonNullable<TokenBalance['token']> type PortfolioToken = NonNullable<TokenBalance['token']>
function TokenRow({ token, quantity, denominatedValue, tokenProjectMarket }: TokenBalance & { token: PortfolioToken }) { function TokenRow({ token, quantity, denominatedValue, tokenProjectMarket }: TokenBalance & { token: PortfolioToken }) {
......
...@@ -35,6 +35,7 @@ gql` ...@@ -35,6 +35,7 @@ gql`
tokenProject { tokenProject {
id id
logoUrl logoUrl
isSpam
} }
} }
token { token {
......
import { Currency, TokenBalance } from 'graphql/data/__generated__/types-and-hooks'
import { splitHiddenTokens } from './splitHiddenTokens'
const tokens: TokenBalance[] = [
// low balance
{
id: 'low-balance',
ownerAddress: '',
__typename: 'TokenBalance',
denominatedValue: {
id: '',
value: 0.5,
},
tokenProjectMarket: {
id: '',
currency: Currency.Eth,
tokenProject: {
id: '',
tokens: [],
isSpam: false,
},
},
},
// spam
{
id: 'spam',
ownerAddress: '',
__typename: 'TokenBalance',
denominatedValue: {
id: '',
value: 100,
},
tokenProjectMarket: {
id: '',
currency: Currency.Eth,
tokenProject: {
id: '',
tokens: [],
isSpam: true,
},
},
},
// valid
{
id: 'valid',
ownerAddress: '',
__typename: 'TokenBalance',
denominatedValue: {
id: '',
value: 100,
},
tokenProjectMarket: {
id: '',
currency: Currency.Eth,
tokenProject: {
id: '',
tokens: [],
isSpam: false,
},
},
},
// empty value
{
id: 'undefined-value',
ownerAddress: '',
__typename: 'TokenBalance',
denominatedValue: {
id: '',
// @ts-ignore this is evidently possible but not represented in our types
value: undefined,
},
tokenProjectMarket: {
id: '',
currency: Currency.Eth,
tokenProject: {
id: '',
tokens: [],
isSpam: false,
},
},
},
]
describe('splitHiddenTokens', () => {
it('splits spam tokens into hidden but keeps small balances if hideSmallBalances = false', () => {
const { visibleTokens, hiddenTokens } = splitHiddenTokens(tokens, {
hideSmallBalances: false,
})
expect(hiddenTokens.length).toBe(1)
expect(hiddenTokens[0].id).toBe('spam')
expect(visibleTokens.length).toBe(3)
expect(visibleTokens[0].id).toBe('low-balance')
expect(visibleTokens[1].id).toBe('valid')
})
it('splits low balance into hidden by default', () => {
const { visibleTokens, hiddenTokens } = splitHiddenTokens(tokens)
expect(hiddenTokens.length).toBe(2)
expect(hiddenTokens[0].id).toBe('low-balance')
expect(hiddenTokens[1].id).toBe('spam')
expect(visibleTokens.length).toBe(2)
expect(visibleTokens[0].id).toBe('valid')
})
it('splits undefined value tokens into visible', () => {
const { visibleTokens } = splitHiddenTokens(tokens)
expect(visibleTokens.length).toBe(2)
expect(visibleTokens[0].id).toBe('valid')
expect(visibleTokens[1].id).toBe('undefined-value')
})
})
import { TokenBalance } from 'graphql/data/__generated__/types-and-hooks'
const HIDE_SMALL_USD_BALANCES_THRESHOLD = 1
export function splitHiddenTokens(
tokenBalances: TokenBalance[],
options?: {
hideSmallBalances?: boolean
}
) {
const visibleTokens: TokenBalance[] = []
const hiddenTokens: TokenBalance[] = []
for (const tokenBalance of tokenBalances) {
const isValidValue =
// if undefined we keep visible (see https://linear.app/uniswap/issue/WEB-1940/[mp]-update-how-we-handle-what-goes-in-hidden-token-section-of-mini)
typeof tokenBalance.denominatedValue?.value === 'undefined' ||
// if below $1
options?.hideSmallBalances === false ||
meetsThreshold(tokenBalance)
if (
isValidValue &&
// a spam token
!tokenBalance.tokenProjectMarket?.tokenProject?.isSpam
) {
visibleTokens.push(tokenBalance)
} else {
hiddenTokens.push(tokenBalance)
}
}
return { visibleTokens, hiddenTokens }
}
function meetsThreshold(tokenBalance: TokenBalance) {
const value = tokenBalance.denominatedValue?.value ?? 0
return value > HIDE_SMALL_USD_BALANCES_THRESHOLD
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment