From df00a4d77f64aae70e0c24a1ceb844b1a6b22e16 Mon Sep 17 00:00:00 2001 From: Satish Ravi Date: Fri, 8 Mar 2024 10:06:08 -0800 Subject: [PATCH] refactor(tokens): break out AssetTabBar and AssetList into its own components (#5056) ### Description Break out AssetTabBar and AssetList from the Assets screen into its own components. This makes it easier to reason about and also would allow us to reuse components for the Wallets tab screen ### Test plan Unit and manual tested ### Related issues - Relates to ACT-1104 ### Backwards compatibility Yes ### Network scalability N/A --- src/navigator/types.tsx | 2 +- src/tokens/AssetList.test.tsx | 237 +++++++++++ src/tokens/AssetList.tsx | 337 +++++++++++++++ src/tokens/AssetTabBar.test.tsx | 63 +++ src/tokens/AssetTabBar.tsx | 87 ++++ src/tokens/Assets.test.tsx | 154 +------ src/tokens/Assets.tsx | 400 +----------------- ...setItem.test.tsx => PositionItem.test.tsx} | 43 +- .../{AssetItem.tsx => PositionItem.tsx} | 79 ---- src/tokens/types.ts | 6 + 10 files changed, 752 insertions(+), 656 deletions(-) create mode 100644 src/tokens/AssetList.test.tsx create mode 100644 src/tokens/AssetList.tsx create mode 100644 src/tokens/AssetTabBar.test.tsx create mode 100644 src/tokens/AssetTabBar.tsx rename src/tokens/{AssetItem.test.tsx => PositionItem.test.tsx} (66%) rename src/tokens/{AssetItem.tsx => PositionItem.tsx} (57%) diff --git a/src/navigator/types.tsx b/src/navigator/types.tsx index 3ba14475470..2816b84a12c 100644 --- a/src/navigator/types.tsx +++ b/src/navigator/types.tsx @@ -13,7 +13,7 @@ import { Screens } from 'src/navigator/Screens' import { Nft } from 'src/nfts/types' import { Recipient } from 'src/recipients/recipient' import { QrCode, TransactionDataInput } from 'src/send/types' -import { AssetTabType } from 'src/tokens/Assets' +import { AssetTabType } from 'src/tokens/types' import { NetworkId, TokenTransaction } from 'src/transactions/types' import { Currency } from 'src/utils/currencies' import { SerializableTransactionRequest } from 'src/viem/preparedTransactionSerialization' diff --git a/src/tokens/AssetList.test.tsx b/src/tokens/AssetList.test.tsx new file mode 100644 index 00000000000..e57f7768cff --- /dev/null +++ b/src/tokens/AssetList.test.tsx @@ -0,0 +1,237 @@ +import { fireEvent, render } from '@testing-library/react-native' +import * as React from 'react' +import { Provider } from 'react-redux' +import ValoraAnalytics from 'src/analytics/ValoraAnalytics' +import { navigate } from 'src/navigator/NavigationService' +import { Screens } from 'src/navigator/Screens' +import { fetchNfts } from 'src/nfts/slice' +import { getFeatureGate } from 'src/statsig' +import AssetList from 'src/tokens/AssetList' +import { AssetTabType } from 'src/tokens/types' +import { NetworkId } from 'src/transactions/types' +import { createMockStore } from 'test/utils' +import { + mockEthTokenId, + mockNftAllFields, + mockNftMinimumFields, + mockNftNullMetadata, + mockPoofTokenId, + mockPositions, + mockTokenBalances, +} from 'test/values' + +jest.mock('src/statsig', () => { + return { + getFeatureGate: jest.fn(), + getDynamicConfigParams: jest.fn(() => ({ + showBalances: ['celo-alfajores', 'ethereum-sepolia'], + })), + } +}) + +const storeWithAssets = { + tokens: { + tokenBalances: { + ...mockTokenBalances, + [mockEthTokenId]: { + tokenId: mockEthTokenId, + balance: '0', + priceUsd: '5', + networkId: NetworkId['ethereum-sepolia'], + showZeroBalance: true, + isNative: true, + symbol: 'ETH', + }, + ['token1']: { + tokenId: 'token1', + networkId: NetworkId['celo-alfajores'], + balance: '10', + symbol: 'TK1', + }, + ['token2']: { + tokenId: 'token2', + networkId: NetworkId['celo-alfajores'], + balance: '0', + symbol: 'TK2', + }, + ['token3']: { + tokenId: 'token3', + networkId: NetworkId['ethereum-sepolia'], + balance: '20', + symbol: 'TK3', + }, + }, + }, + positions: { + positions: mockPositions, + }, + nfts: { + nfts: [ + { ...mockNftAllFields, networkId: NetworkId['celo-alfajores'] }, + { ...mockNftMinimumFields, networkId: NetworkId['ethereum-sepolia'] }, + { ...mockNftNullMetadata, networkId: NetworkId['celo-alfajores'] }, + ], + nftsLoading: false, + nftsError: null, + }, +} + +describe('AssetList', () => { + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(getFeatureGate).mockReturnValue(true) + }) + + it('renders tokens in the expected order', () => { + const store = createMockStore(storeWithAssets) + + const { getAllByTestId, queryAllByTestId } = render( + + + + ) + + expect(getAllByTestId('TokenBalanceItem')).toHaveLength(6) + expect(queryAllByTestId('PositionItem')).toHaveLength(0) + expect(queryAllByTestId('NftItem')).toHaveLength(0) + ;['POOF', 'TK3', 'TK1', 'CELO', 'ETH', 'cUSD'].map((symbol, index) => { + expect(getAllByTestId('TokenBalanceItem')[index]).toHaveTextContent(symbol) + }) + }) + + it('renders collectibles', () => { + const store = createMockStore(storeWithAssets) + + const { queryAllByTestId } = render( + + + + ) + + expect(queryAllByTestId('TokenBalanceItem')).toHaveLength(0) + expect(queryAllByTestId('PositionItem')).toHaveLength(0) + expect(queryAllByTestId('NftItem')).toHaveLength(2) + }) + + it('renders collectibles error', () => { + const store = createMockStore({ + nfts: { + nftsLoading: false, + nfts: [], + nftsError: 'Error fetching nfts', + }, + }) + + const { getByTestId } = render( + + + + ) + + expect(getByTestId('Assets/NftsLoadError')).toBeTruthy() + }) + + it('renders no collectables text', () => { + const store = createMockStore({ + nfts: { + nftsLoading: false, + nfts: [], + nftsError: null, + }, + }) + + const { getByText } = render( + + + + ) + + expect(getByText('nftGallery.noNfts')).toBeTruthy() + }) + + it('renders dapp positions', () => { + const store = createMockStore(storeWithAssets) + + const { getAllByTestId, queryAllByTestId } = render( + + + + ) + + expect(getAllByTestId('PositionItem')).toHaveLength(3) + expect(queryAllByTestId('TokenBalanceItem')).toHaveLength(0) + expect(queryAllByTestId('NftItem')).toHaveLength(0) + }) + + it('clicking a token navigates to the token details screen and fires analytics event', () => { + const store = createMockStore(storeWithAssets) + + const { getAllByTestId } = render( + + + + ) + + expect(getAllByTestId('TokenBalanceItem')).toHaveLength(6) + + fireEvent.press(getAllByTestId('TokenBalanceItem')[0]) + expect(navigate).toHaveBeenCalledTimes(1) + expect(navigate).toHaveBeenCalledWith(Screens.TokenDetails, { tokenId: mockPoofTokenId }) + expect(ValoraAnalytics.track).toHaveBeenCalledTimes(1) + }) + + it('clicking an NFT navigates to the nfts info screen', async () => { + const store = createMockStore(storeWithAssets) + + const { getAllByTestId } = render( + + + + ) + + expect(getAllByTestId('NftItem')).toHaveLength(2) + + fireEvent.press(getAllByTestId('NftGallery/NftImage')[0]) + fireEvent.press(getAllByTestId('NftGallery/NftImage')[1]) + expect(navigate).toHaveBeenCalledTimes(2) + expect(navigate).toHaveBeenCalledWith(Screens.NftsInfoCarousel, { + nfts: [{ ...mockNftAllFields, networkId: NetworkId['celo-alfajores'] }], + networkId: NetworkId['celo-alfajores'], + }) + expect(navigate).toHaveBeenCalledWith(Screens.NftsInfoCarousel, { + nfts: [{ ...mockNftMinimumFields, networkId: NetworkId['ethereum-sepolia'] }], + networkId: NetworkId['ethereum-sepolia'], + }) + }) + + it('dispatches action to fetch nfts on load', () => { + const store = createMockStore(storeWithAssets) + + render( + + + + ) + expect(store.getActions()).toEqual([fetchNfts()]) + }) +}) diff --git a/src/tokens/AssetList.tsx b/src/tokens/AssetList.tsx new file mode 100644 index 00000000000..0b6a7a7a2aa --- /dev/null +++ b/src/tokens/AssetList.tsx @@ -0,0 +1,337 @@ +import React, { useEffect, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { + NativeScrollEvent, + NativeSyntheticEvent, + SectionList, + SectionListData, + SectionListProps, + StyleSheet, + Text, + View, +} from 'react-native' +import Animated from 'react-native-reanimated' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { AssetsEvents } from 'src/analytics/Events' +import ValoraAnalytics from 'src/analytics/ValoraAnalytics' +import Touchable from 'src/components/Touchable' +import ImageErrorIcon from 'src/icons/ImageErrorIcon' +import { navigate } from 'src/navigator/NavigationService' +import { Screens } from 'src/navigator/Screens' +import NftMedia from 'src/nfts/NftMedia' +import NftsLoadError from 'src/nfts/NftsLoadError' +import { + nftsErrorSelector, + nftsLoadingSelector, + nftsWithMetadataSelector, +} from 'src/nfts/selectors' +import { fetchNfts } from 'src/nfts/slice' +import { NftOrigin, NftWithNetworkId } from 'src/nfts/types' +import { positionsSelector } from 'src/positions/selectors' +import { Position } from 'src/positions/types' +import { useDispatch, useSelector } from 'src/redux/hooks' +import Colors from 'src/styles/colors' +import { typeScale } from 'src/styles/fonts' +import { Spacing } from 'src/styles/styles' +import variables from 'src/styles/variables' +import { PositionItem } from 'src/tokens/PositionItem' +import { TokenBalanceItem } from 'src/tokens/TokenBalanceItem' +import { sortedTokensWithBalanceOrShowZeroBalanceSelector } from 'src/tokens/selectors' +import { TokenBalance } from 'src/tokens/slice' +import { AssetTabType } from 'src/tokens/types' +import { getSupportedNetworkIdsForTokenBalances, getTokenAnalyticsProps } from 'src/tokens/utils' + +interface SectionData { + appName?: string +} + +const AnimatedSectionList = + Animated.createAnimatedComponent< + SectionListProps + >(SectionList) + +const assetIsPosition = (asset: Position | TokenBalance | NftWithNetworkId[]): asset is Position => + 'type' in asset && (asset.type === 'app-token' || asset.type === 'contract-position') + +/** + * Helper function to group an array into chunks of size n + * Used with Nfts to group them for use in the section list + */ +const groupArrayByN = (arr: any[], n: number) => { + return arr.reduce((result, item, index) => { + if (index % n === 0) { + result.push([item]) + } else { + result[Math.floor(index / n)].push(item) + } + return result + }, []) +} + +const NUM_OF_NFTS_PER_ROW = 2 + +const nftImageSize = + (variables.width - Spacing.Thick24 * 2 - Spacing.Regular16 * (NUM_OF_NFTS_PER_ROW - 1)) / + NUM_OF_NFTS_PER_ROW + +export default function AssetList({ + activeTab, + listHeaderHeight, + handleScroll, +}: { + activeTab: AssetTabType + listHeaderHeight: number + handleScroll: (event: NativeSyntheticEvent) => void +}) { + const dispatch = useDispatch() + const { t } = useTranslation() + const insets = useSafeAreaInsets() + + const supportedNetworkIds = getSupportedNetworkIdsForTokenBalances() + const tokens = useSelector((state) => + sortedTokensWithBalanceOrShowZeroBalanceSelector(state, supportedNetworkIds) + ) + + const positions = useSelector(positionsSelector) + const positionSections = useMemo(() => { + const positionsByDapp = new Map() + positions.forEach((position) => { + if (positionsByDapp.has(position.appName)) { + positionsByDapp.get(position.appName)?.push(position) + } else { + positionsByDapp.set(position.appName, [position]) + } + }) + + const sections: SectionListData[] = + [] + positionsByDapp.forEach((positions, appName) => { + sections.push({ + data: positions, + appName, + }) + }) + return sections + }, [positions]) + + // NFT Selectors + const nftsError = useSelector(nftsErrorSelector) + const nftsLoading = useSelector(nftsLoadingSelector) + const nfts = useSelector(nftsWithMetadataSelector) + // Group nfts for use in the section list + const nftsGrouped = groupArrayByN(nfts, NUM_OF_NFTS_PER_ROW) + + useEffect(() => { + dispatch(fetchNfts()) + }, []) + + const sections = + activeTab === AssetTabType.Tokens + ? [{ data: tokens }] + : activeTab === AssetTabType.Positions + ? positionSections + : nfts.length + ? [{ data: nftsGrouped }] + : [] + + const renderSectionHeader = ({ + section, + }: { + section: SectionListData + }) => { + if (section.appName) { + return ( + + + {section.appName.toLocaleUpperCase()} + + + ) + } + return null + } + + const keyExtractor = (item: TokenBalance | Position | NftWithNetworkId[], index: number) => { + if (assetIsPosition(item)) { + // Ideally we wouldn't need the index here, but we need to differentiate + // between positions with the same address (e.g. Uniswap V3 pool NFTs) + // We may want to consider adding a unique identifier to the position type. + return `${activeTab}-${item.appId}-${item.network}-${item.address}-${index}` + } else if ('balance' in item) { + return `${activeTab}-${item.tokenId}` + } else { + return `${activeTab}-${item[0]!.networkId}-${item[0]!.contractAddress}-${item[0]!.tokenId}` + } + } + + const NftItem = ({ item }: { item: NftWithNetworkId }) => { + return ( + + + navigate(Screens.NftsInfoCarousel, { nfts: [item], networkId: item.networkId }) + } + style={styles.nftsTouchableIcon} + > + + + {item.metadata?.name && ( + + {item.metadata.name} + + )} + + } + origin={NftOrigin.Assets} + borderRadius={Spacing.Regular16} + mediaType="image" + /> + + + ) + } + + const NftGroup = ({ item }: { item: NftWithNetworkId[] }) => { + return ( + + {item.map((nft, index) => ( + + ))} + + ) + } + + const renderAssetItem = ({ + item, + index, + }: { + item: TokenBalance | Position | NftWithNetworkId[] + index: number + }) => { + if (assetIsPosition(item)) { + return + } else if ('balance' in item) { + return ( + { + navigate(Screens.TokenDetails, { tokenId: item.tokenId }) + ValoraAnalytics.track(AssetsEvents.tap_asset, { + ...getTokenAnalyticsProps(item), + title: item.symbol, + description: item.name, + assetType: 'token', + }) + }} + /> + ) + } else { + return + } + } + + const renderEmptyState = () => { + switch (activeTab) { + case AssetTabType.Tokens: + case AssetTabType.Positions: + return null + case AssetTabType.Collectibles: + if (nftsError) return + else if (nftsLoading) return null + else + return ( + + {t('nftGallery.noNfts')} + + ) + } + } + + return ( + 0 ? 1 : 0, + }, + activeTab === AssetTabType.Collectibles && + !nftsError && + nfts.length > 0 && + styles.nftsContentContainer, + ]} + // ensure header is above the scrollbar on ios overscroll + scrollIndicatorInsets={{ top: listHeaderHeight }} + // @ts-ignore can't get the SectionList to accept a union type :( + sections={sections} + renderItem={renderAssetItem} + renderSectionHeader={renderSectionHeader} + keyExtractor={keyExtractor} + onScroll={handleScroll} + scrollEventThrottle={16} + ItemSeparatorComponent={() => + activeTab === AssetTabType.Collectibles ? ( + + ) : null + } + ListHeaderComponent={} + ListEmptyComponent={renderEmptyState} + /> + ) +} + +const styles = StyleSheet.create({ + positionSectionHeaderContainer: { + padding: Spacing.Thick24, + paddingTop: Spacing.Regular16, + }, + positionSectionHeaderText: { + ...typeScale.labelXXSmall, + color: Colors.gray5, + }, + nftsPairingContainer: { + flexDirection: 'row', + gap: Spacing.Regular16, + }, + nftsContentContainer: { + alignItems: 'flex-start', + paddingHorizontal: Spacing.Thick24, + }, + nftsErrorView: { + width: nftImageSize, + height: nftImageSize, + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: Colors.gray2, + borderRadius: Spacing.Regular16, + }, + nftsNoMetadataText: { + ...typeScale.labelSmall, + textAlign: 'center', + }, + nftsTouchableContainer: { + overflow: 'hidden', + borderRadius: Spacing.Regular16, + }, + nftsTouchableIcon: { + borderRadius: Spacing.Regular16, + }, + noNftsText: { + ...typeScale.bodySmall, + color: Colors.gray3, + textAlign: 'center', + }, + noNftsTextContainer: { + paddingHorizontal: Spacing.Thick24, + }, +}) diff --git a/src/tokens/AssetTabBar.test.tsx b/src/tokens/AssetTabBar.test.tsx new file mode 100644 index 00000000000..bf0651ea0d2 --- /dev/null +++ b/src/tokens/AssetTabBar.test.tsx @@ -0,0 +1,63 @@ +import { fireEvent, render } from '@testing-library/react-native' +import React from 'react' +import { AssetsEvents } from 'src/analytics/Events' +import ValoraAnalytics from 'src/analytics/ValoraAnalytics' +import Colors from 'src/styles/colors' +import AssetTabBar from 'src/tokens/AssetTabBar' +import { AssetTabType } from 'src/tokens/types' + +describe('AssetTabBar', () => { + const onChange = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders all items if positions is enabled', () => { + const { getAllByTestId } = render( + + ) + + const tabItems = getAllByTestId('Assets/TabBarItem') + expect(tabItems).toHaveLength(3) + expect(tabItems[0]).toHaveTextContent('tokens') + expect(tabItems[0].children[0]).toHaveStyle({ color: Colors.black }) + expect(tabItems[1]).toHaveTextContent('collectibles') + expect(tabItems[1].children[0]).toHaveStyle({ color: Colors.gray4 }) + expect(tabItems[2]).toHaveTextContent('dappPositions') + expect(tabItems[2].children[0]).toHaveStyle({ color: Colors.gray4 }) + }) + + it('does not render positions if disabled', () => { + const { getAllByTestId } = render( + + ) + + const tabItems = getAllByTestId('Assets/TabBarItem') + expect(tabItems).toHaveLength(2) + expect(tabItems[0]).toHaveTextContent('tokens') + expect(tabItems[0].children[0]).toHaveStyle({ color: Colors.gray4 }) + expect(tabItems[1]).toHaveTextContent('collectibles') + expect(tabItems[1].children[0]).toHaveStyle({ color: Colors.black }) + }) + + it.each([ + { tab: AssetTabType.Tokens, event: AssetsEvents.view_wallet_assets }, + { tab: AssetTabType.Collectibles, event: AssetsEvents.view_collectibles }, + { tab: AssetTabType.Positions, event: AssetsEvents.view_dapp_positions }, + ])('selecting tab $tab fires analytics events and invokes on change', ({ tab, event }) => { + const { getAllByTestId } = render( + + ) + + fireEvent.press(getAllByTestId('Assets/TabBarItem')[tab]) + expect(ValoraAnalytics.track).toHaveBeenCalledTimes(1) + expect(ValoraAnalytics.track).toHaveBeenCalledWith(event) + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith(tab) + }) +}) diff --git a/src/tokens/AssetTabBar.tsx b/src/tokens/AssetTabBar.tsx new file mode 100644 index 00000000000..f66af465977 --- /dev/null +++ b/src/tokens/AssetTabBar.tsx @@ -0,0 +1,87 @@ +import React, { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { StyleSheet, Text, View } from 'react-native' +import { AssetsEvents } from 'src/analytics/Events' +import ValoraAnalytics from 'src/analytics/ValoraAnalytics' +import Touchable from 'src/components/Touchable' +import Colors from 'src/styles/colors' +import { typeScale } from 'src/styles/fonts' +import { vibrateInformative } from 'src/styles/hapticFeedback' +import { Spacing } from 'src/styles/styles' +import variables from 'src/styles/variables' +import { AssetTabType } from 'src/tokens/types' + +const DEVICE_WIDTH_BREAKPOINT = 340 + +export default function TabBar({ + activeTab, + onChange, + displayPositions, +}: { + activeTab: AssetTabType + onChange: (selectedTab: AssetTabType) => void + displayPositions: boolean +}) { + const { t } = useTranslation() + + const items = useMemo(() => { + const items = [t('assets.tabBar.tokens'), t('assets.tabBar.collectibles')] + if (displayPositions) { + items.push(t('assets.tabBar.dappPositions')) + } + return items + }, [t, displayPositions]) + + const handleSelectOption = (index: AssetTabType) => () => { + ValoraAnalytics.track( + [ + AssetsEvents.view_wallet_assets, + AssetsEvents.view_collectibles, + AssetsEvents.view_dapp_positions, + ][index] + ) + onChange(index) + vibrateInformative() + } + + // On a smaller device, if there are more than two tabs, use smaller gaps + // between tabs + const gap = + items.length > 2 && variables.width < DEVICE_WIDTH_BREAKPOINT + ? Spacing.Smallest8 + : Spacing.Regular16 + + return ( + + {items.map((value, index) => ( + + + {value} + + + ))} + + ) +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + }, + touchable: { + flexShrink: 1, + }, + item: { + ...typeScale.bodyMedium, + color: Colors.gray4, + }, + itemSelected: { + ...typeScale.labelMedium, + color: Colors.black, + }, +}) diff --git a/src/tokens/Assets.test.tsx b/src/tokens/Assets.test.tsx index 99c65d8faf4..fc84566dd52 100644 --- a/src/tokens/Assets.test.tsx +++ b/src/tokens/Assets.test.tsx @@ -1,11 +1,9 @@ import { fireEvent, render } from '@testing-library/react-native' import * as React from 'react' import { Provider } from 'react-redux' -import ValoraAnalytics from 'src/analytics/ValoraAnalytics' import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' -import { fetchNfts } from 'src/nfts/slice' -import { getDynamicConfigParams, getFeatureGate } from 'src/statsig' +import { getFeatureGate } from 'src/statsig' import { StatsigFeatureGates } from 'src/statsig/types' import AssetsScreen from 'src/tokens/Assets' import { NetworkId } from 'src/transactions/types' @@ -21,7 +19,6 @@ import { mockNftNullMetadata, mockPositions, mockShortcuts, - mockTokenBalances, } from 'test/values' jest.mock('src/statsig', () => { @@ -33,8 +30,6 @@ jest.mock('src/statsig', () => { } }) -const ethTokenId = 'ethereum-sepolia:native' - const storeWithTokenBalances = { tokens: { tokenBalances: { @@ -203,44 +198,6 @@ describe('AssetsScreen', () => { expect(queryAllByTestId('NftItem')).toHaveLength(2) }) - it('renders collectibles error', () => { - const store = createMockStore({ - nfts: { - nftsLoading: false, - nfts: [], - nftsError: 'Error fetching nfts', - }, - }) - - const { getByText, getByTestId } = render( - - - - ) - - fireEvent.press(getByText('assets.tabBar.collectibles')) - expect(getByTestId('Assets/NftsLoadError')).toBeTruthy() - }) - - it('renders no collectables text', () => { - const store = createMockStore({ - nfts: { - nftsLoading: false, - nfts: [], - nftsError: null, - }, - }) - - const { getByText } = render( - - - - ) - - fireEvent.press(getByText('assets.tabBar.collectibles')) - expect(getByText('nftGallery.noNfts')).toBeTruthy() - }) - it('renders dapp positions on selecting the tab', () => { jest.mocked(getFeatureGate).mockReturnValue(true) const store = createMockStore(storeWithPositions) @@ -265,51 +222,6 @@ describe('AssetsScreen', () => { expect(queryAllByTestId('PositionItem')).toHaveLength(0) }) - it('clicking a token navigates to the token details screen and fires analytics event', () => { - jest.mocked(getFeatureGate).mockReturnValue(false) - const store = createMockStore(storeWithPositions) - - const { getAllByTestId } = render( - - - - ) - - expect(getAllByTestId('TokenBalanceItem')).toHaveLength(2) - - fireEvent.press(getAllByTestId('TokenBalanceItem')[0]) - expect(navigate).toHaveBeenCalledTimes(1) - expect(navigate).toHaveBeenCalledWith(Screens.TokenDetails, { tokenId: mockCusdTokenId }) - expect(ValoraAnalytics.track).toHaveBeenCalledTimes(1) - }) - - it('clicking an NFT navigates to the nfts info screen', async () => { - jest.mocked(getFeatureGate).mockReturnValue(false) - const store = createMockStore(storeWithNfts) - - const { getAllByTestId, getByText } = render( - - - - ) - - fireEvent.press(getByText('assets.tabBar.collectibles')) - - expect(getAllByTestId('NftItem')).toHaveLength(2) - - fireEvent.press(getAllByTestId('NftGallery/NftImage')[0]) - fireEvent.press(getAllByTestId('NftGallery/NftImage')[1]) - expect(navigate).toHaveBeenCalledTimes(2) - expect(navigate).toHaveBeenCalledWith(Screens.NftsInfoCarousel, { - nfts: [{ ...mockNftAllFields, networkId: NetworkId['celo-alfajores'] }], - networkId: NetworkId['celo-alfajores'], - }) - expect(navigate).toHaveBeenCalledWith(Screens.NftsInfoCarousel, { - nfts: [{ ...mockNftMinimumFields, networkId: NetworkId['ethereum-sepolia'] }], - networkId: NetworkId['ethereum-sepolia'], - }) - }) - it('hides claim rewards if feature gate is false', () => { jest .mocked(getFeatureGate) @@ -404,68 +316,4 @@ describe('AssetsScreen', () => { expect(navigate).toHaveBeenCalledWith(Screens.TokenImport) }) - - it('displays tokens with balance and ones marked with showZeroBalance in the expected order', () => { - jest.mocked(getDynamicConfigParams).mockReturnValueOnce({ - showBalances: [NetworkId['celo-alfajores'], NetworkId['ethereum-sepolia']], - }) - const store = createMockStore({ - tokens: { - tokenBalances: { - ...mockTokenBalances, - [ethTokenId]: { - tokenId: ethTokenId, - balance: '0', - priceUsd: '5', - networkId: NetworkId['ethereum-sepolia'], - showZeroBalance: true, - isNative: true, - symbol: 'ETH', - }, - ['token1']: { - tokenId: 'token1', - networkId: NetworkId['celo-alfajores'], - balance: '10', - symbol: 'TK1', - }, - ['token2']: { - tokenId: 'token2', - networkId: NetworkId['celo-alfajores'], - balance: '0', - symbol: 'TK2', - }, - ['token3']: { - tokenId: 'token3', - networkId: NetworkId['ethereum-sepolia'], - balance: '20', - symbol: 'TK3', - }, - }, - }, - }) - - const { getAllByTestId } = render( - - - - ) - - expect(getAllByTestId('TokenBalanceItem')).toHaveLength(6) - ;['POOF', 'TK3', 'TK1', 'CELO', 'ETH', 'cUSD'].map((symbol, index) => { - expect(getAllByTestId('TokenBalanceItem')[index]).toHaveTextContent(symbol) - }) - }) - - it('dispatches action to fetch nfts on load', () => { - const store = createMockStore(storeWithPositions) - - const { getByTestId } = render( - - - - ) - - expect(getByTestId('AssetsTokenBalance')).toBeTruthy() - expect(store.getActions()).toEqual([fetchNfts()]) - }) }) diff --git a/src/tokens/Assets.tsx b/src/tokens/Assets.tsx index db5ac6298d5..dece772dca7 100644 --- a/src/tokens/Assets.tsx +++ b/src/tokens/Assets.tsx @@ -1,16 +1,8 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' import BigNumber from 'bignumber.js' -import React, { useEffect, useLayoutEffect, useMemo, useState } from 'react' +import React, { useLayoutEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - LayoutChangeEvent, - SectionList, - SectionListData, - SectionListProps, - StyleSheet, - Text, - View, -} from 'react-native' +import { LayoutChangeEvent, StyleSheet, View } from 'react-native' import Animated, { interpolateColor, useAnimatedScrollHandler, @@ -22,8 +14,6 @@ import { AssetsEvents } from 'src/analytics/Events' import ValoraAnalytics from 'src/analytics/ValoraAnalytics' import Button, { BtnSizes, BtnTypes } from 'src/components/Button' import { AssetsTokenBalance } from 'src/components/TokenBalance' -import Touchable from 'src/components/Touchable' -import ImageErrorIcon from 'src/icons/ImageErrorIcon' import { useDollarsToLocalAmount } from 'src/localCurrency/hooks' import { getLocalCurrencySymbol } from 'src/localCurrency/selectors' import { headerWithBackButton } from 'src/navigator/Headers' @@ -32,76 +22,24 @@ import { Screens } from 'src/navigator/Screens' import useScrollAwareHeader from 'src/navigator/ScrollAwareHeader' import { TopBarTextButton } from 'src/navigator/TopBarButton' import { StackParamList } from 'src/navigator/types' -import NftMedia from 'src/nfts/NftMedia' -import NftsLoadError from 'src/nfts/NftsLoadError' -import { - nftsErrorSelector, - nftsLoadingSelector, - nftsWithMetadataSelector, -} from 'src/nfts/selectors' -import { fetchNfts } from 'src/nfts/slice' -import { NftOrigin, NftWithNetworkId } from 'src/nfts/types' import { positionsSelector, positionsWithClaimableRewardsSelector, totalPositionsBalanceUsdSelector, } from 'src/positions/selectors' -import { Position } from 'src/positions/types' -import { useDispatch, useSelector } from 'src/redux/hooks' +import { useSelector } from 'src/redux/hooks' import { getFeatureGate } from 'src/statsig' import { StatsigFeatureGates } from 'src/statsig/types' import Colors from 'src/styles/colors' import { typeScale } from 'src/styles/fonts' -import { vibrateInformative } from 'src/styles/hapticFeedback' import { Shadow, Spacing, getShadowStyle } from 'src/styles/styles' -import variables from 'src/styles/variables' -import { PositionItem } from 'src/tokens/AssetItem' -import { TokenBalanceItem } from 'src/tokens/TokenBalanceItem' +import AssetList from 'src/tokens/AssetList' +import AssetTabBar from 'src/tokens/AssetTabBar' import { useTokenPricesAreStale, useTotalTokenBalance } from 'src/tokens/hooks' -import { sortedTokensWithBalanceOrShowZeroBalanceSelector } from 'src/tokens/selectors' -import { TokenBalance } from 'src/tokens/slice' -import { getSupportedNetworkIdsForTokenBalances, getTokenAnalyticsProps } from 'src/tokens/utils' - -const DEVICE_WIDTH_BREAKPOINT = 340 -const NUM_OF_NFTS_PER_ROW = 2 - -const nftImageSize = - (variables.width - Spacing.Thick24 * 2 - Spacing.Regular16 * (NUM_OF_NFTS_PER_ROW - 1)) / - NUM_OF_NFTS_PER_ROW +import { AssetTabType } from 'src/tokens/types' +import { getSupportedNetworkIdsForTokenBalances } from 'src/tokens/utils' type Props = NativeStackScreenProps -interface SectionData { - appName?: string -} - -const AnimatedSectionList = - Animated.createAnimatedComponent< - SectionListProps - >(SectionList) - -const assetIsPosition = (asset: Position | TokenBalance | NftWithNetworkId[]): asset is Position => - 'type' in asset && (asset.type === 'app-token' || asset.type === 'contract-position') - -/** - * Helper function to group an array into chunks of size n - * Used with Nfts to group them for use in the section list - */ -const groupArrayByN = (arr: any[], n: number) => { - return arr.reduce((result, item, index) => { - if (index % n === 0) { - result.push([item]) - } else { - result[Math.floor(index / n)].push(item) - } - return result - }, []) -} - -export enum AssetTabType { - Tokens = 0, - Collectibles = 1, - Positions = 2, -} // offset relative to the bottom of the non sticky header component, where the // screen header opacity animation starts @@ -111,14 +49,10 @@ const HEADER_OPACITY_ANIMATION_DISTANCE = 20 function AssetsScreen({ navigation, route }: Props) { const { t } = useTranslation() - const dispatch = useDispatch() const activeTab = route.params?.activeTab ?? AssetTabType.Tokens const supportedNetworkIds = getSupportedNetworkIdsForTokenBalances() - const tokens = useSelector((state) => - sortedTokensWithBalanceOrShowZeroBalanceSelector(state, supportedNetworkIds) - ) const localCurrencySymbol = useSelector(getLocalCurrencySymbol) const totalTokenBalanceLocal = useTotalTokenBalance() ?? new BigNumber(0) @@ -143,17 +77,6 @@ function AssetsScreen({ navigation, route }: Props) { const totalPositionsBalanceLocal = useDollarsToLocalAmount(totalPositionsBalanceUsd) const totalBalanceLocal = totalTokenBalanceLocal?.plus(totalPositionsBalanceLocal ?? 0) - // NFT Selectors - const nftsError = useSelector(nftsErrorSelector) - const nftsLoading = useSelector(nftsLoadingSelector) - const nfts = useSelector(nftsWithMetadataSelector) - // Group nfts for use in the section list - const nftsGrouped = groupArrayByN(nfts, NUM_OF_NFTS_PER_ROW) - - useEffect(() => { - dispatch(fetchNfts()) - }, []) - const [nonStickyHeaderHeight, setNonStickyHeaderHeight] = useState(0) const [listHeaderHeight, setListHeaderHeight] = useState(0) const [listFooterHeight, setListFooterHeight] = useState(0) @@ -271,178 +194,10 @@ function AssetsScreen({ navigation, route }: Props) { setListFooterHeight(event.nativeEvent.layout.height) } - const handleChangeActiveView = (_: string, index: number) => { - navigation.setParams({ activeTab: index }) - ValoraAnalytics.track( - [ - AssetsEvents.view_wallet_assets, - AssetsEvents.view_collectibles, - AssetsEvents.view_dapp_positions, - ][index] - ) - } - - const positionSections = useMemo(() => { - const positionsByDapp = new Map() - positions.forEach((position) => { - if (positionsByDapp.has(position.appName)) { - positionsByDapp.get(position.appName)?.push(position) - } else { - positionsByDapp.set(position.appName, [position]) - } - }) - - const sections: SectionListData[] = - [] - positionsByDapp.forEach((positions, appName) => { - sections.push({ - data: positions, - appName, - }) - }) - return sections - }, [positions]) - - const sections = - activeTab === AssetTabType.Tokens - ? [{ data: tokens }] - : activeTab === AssetTabType.Positions - ? positionSections - : nfts.length - ? [{ data: nftsGrouped }] - : [] - - const renderSectionHeader = ({ - section, - }: { - section: SectionListData - }) => { - if (section.appName) { - return ( - - - {section.appName.toLocaleUpperCase()} - - - ) - } - return null - } - - const keyExtractor = (item: TokenBalance | Position | NftWithNetworkId[], index: number) => { - if (assetIsPosition(item)) { - // Ideally we wouldn't need the index here, but we need to differentiate - // between positions with the same address (e.g. Uniswap V3 pool NFTs) - // We may want to consider adding a unique identifier to the position type. - return `${activeTab}-${item.appId}-${item.network}-${item.address}-${index}` - } else if ('balance' in item) { - return `${activeTab}-${item.tokenId}` - } else { - return `${activeTab}-${item[0]!.networkId}-${item[0]!.contractAddress}-${item[0]!.tokenId}` - } - } - - const NftItem = ({ item }: { item: NftWithNetworkId }) => { - return ( - - - navigate(Screens.NftsInfoCarousel, { nfts: [item], networkId: item.networkId }) - } - style={styles.nftsTouchableIcon} - > - - - {item.metadata?.name && ( - - {item.metadata.name} - - )} - - } - origin={NftOrigin.Assets} - borderRadius={Spacing.Regular16} - mediaType="image" - /> - - - ) - } - - const NftGroup = ({ item }: { item: NftWithNetworkId[] }) => { - return ( - - {item.map((nft, index) => ( - - ))} - - ) - } - - const renderAssetItem = ({ - item, - index, - }: { - item: TokenBalance | Position | NftWithNetworkId[] - index: number - }) => { - if (assetIsPosition(item)) { - return - } else if ('balance' in item) { - return ( - { - navigate(Screens.TokenDetails, { tokenId: item.tokenId }) - ValoraAnalytics.track(AssetsEvents.tap_asset, { - ...getTokenAnalyticsProps(item), - title: item.symbol, - description: item.name, - assetType: 'token', - }) - }} - /> - ) - } else { - return - } - } - - const renderEmptyState = () => { - switch (activeTab) { - case AssetTabType.Tokens: - case AssetTabType.Positions: - return null - case AssetTabType.Collectibles: - if (nftsError) return - else if (nftsLoading) return null - else - return ( - - {t('nftGallery.noNfts')} - - ) - } + const handleChangeActiveView = (selectedTab: AssetTabType) => { + navigation.setParams({ activeTab: selectedTab }) } - const tabBarItems = useMemo(() => { - const items = [t('assets.tabBar.tokens'), t('assets.tabBar.collectibles')] - if (displayPositions) { - items.push(t('assets.tabBar.dappPositions')) - } - return items - }, [t, displayPositions]) - return ( <> - + - 0 ? 1 : 0, - }, - activeTab === AssetTabType.Collectibles && - !nftsError && - nfts.length > 0 && - styles.nftsContentContainer, - ]} - // ensure header is above the scrollbar on ios overscroll - scrollIndicatorInsets={{ top: listHeaderHeight }} - // @ts-ignore can't get the SectionList to accept a union type :( - sections={sections} - renderItem={renderAssetItem} - renderSectionHeader={renderSectionHeader} - keyExtractor={keyExtractor} - onScroll={handleScroll} - scrollEventThrottle={16} - ItemSeparatorComponent={() => - activeTab === AssetTabType.Collectibles ? ( - - ) : null - } - ListHeaderComponent={} - ListEmptyComponent={renderEmptyState} + {showClaimRewards && ( void -}) { - const handleSelectOption = (item: string, index: number) => () => { - onChange(item, index) - vibrateInformative() - } - - // On a smaller device, if there are more than two tabs, use smaller gaps - // between tabs - const gap = - items.length > 2 && variables.width < DEVICE_WIDTH_BREAKPOINT - ? Spacing.Smallest8 - : Spacing.Regular16 - - return ( - - {items.map((value, index) => ( - - - {value} - - - ))} - - ) -} - const styles = StyleSheet.create({ listHeaderContainer: { ...getShadowStyle(Shadow.SoftLight), @@ -566,14 +260,6 @@ const styles = StyleSheet.create({ width: '100%', zIndex: 1, }, - positionSectionHeaderContainer: { - padding: Spacing.Thick24, - paddingTop: Spacing.Regular16, - }, - positionSectionHeaderText: { - ...typeScale.labelXXSmall, - color: Colors.gray5, - }, nonStickyHeaderContainer: { zIndex: 1, paddingBottom: Spacing.Thick24, @@ -587,60 +273,10 @@ const styles = StyleSheet.create({ paddingHorizontal: Spacing.Thick24 - 10, paddingTop: Spacing.Regular16, }, - tabBarContainer: { - flexDirection: 'row', - }, - tabBarTouchable: { - flexShrink: 1, - }, - tabBarItem: { - ...typeScale.bodyMedium, - color: Colors.gray4, - }, - tabBarItemSelected: { - ...typeScale.labelMedium, - color: Colors.black, - }, topBarTextButton: { ...typeScale.bodyMedium, paddingRight: Spacing.Smallest8, }, - nftsPairingContainer: { - flexDirection: 'row', - gap: Spacing.Regular16, - }, - nftsContentContainer: { - alignItems: 'flex-start', - paddingHorizontal: Spacing.Thick24, - }, - nftsErrorView: { - width: nftImageSize, - height: nftImageSize, - flex: 1, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: Colors.gray2, - borderRadius: Spacing.Regular16, - }, - nftsNoMetadataText: { - ...typeScale.labelSmall, - textAlign: 'center', - }, - nftsTouchableContainer: { - overflow: 'hidden', - borderRadius: Spacing.Regular16, - }, - nftsTouchableIcon: { - borderRadius: Spacing.Regular16, - }, - noNftsText: { - ...typeScale.bodySmall, - color: Colors.gray3, - textAlign: 'center', - }, - noNftsTextContainer: { - paddingHorizontal: Spacing.Thick24, - }, }) export default AssetsScreen diff --git a/src/tokens/AssetItem.test.tsx b/src/tokens/PositionItem.test.tsx similarity index 66% rename from src/tokens/AssetItem.test.tsx rename to src/tokens/PositionItem.test.tsx index efff1477df1..52d9ba20e18 100644 --- a/src/tokens/AssetItem.test.tsx +++ b/src/tokens/PositionItem.test.tsx @@ -1,14 +1,12 @@ import { fireEvent, render } from '@testing-library/react-native' -import BigNumber from 'bignumber.js' import React from 'react' import { Provider } from 'react-redux' import { AssetsEvents } from 'src/analytics/Events' import ValoraAnalytics from 'src/analytics/ValoraAnalytics' import { AppTokenPosition } from 'src/positions/types' -import { PositionItem, TokenBalanceItem } from 'src/tokens/AssetItem' -import { NetworkId } from 'src/transactions/types' +import { PositionItem } from 'src/tokens/PositionItem' import { createMockStore } from 'test/utils' -import { mockCusdAddress, mockCusdTokenId, mockPositions } from 'test/values' +import { mockPositions } from 'test/values' beforeEach(() => { jest.clearAllMocks() @@ -77,40 +75,3 @@ describe('PositionItem', () => { expect(getByText('11.90')).toBeTruthy() }) }) - -describe('TokenBalanceItem', () => { - const mockTokenInfo = { - balance: new BigNumber('10'), - tokenId: mockCusdTokenId, - priceUsd: new BigNumber('1'), - networkId: NetworkId['celo-alfajores'], - lastKnownPriceUsd: new BigNumber('1'), - symbol: 'cUSD', - address: mockCusdAddress, - isFeeCurrency: true, - canTransferWithComment: true, - priceFetchedAt: Date.now(), - decimals: 18, - name: 'Celo Dollar', - imageUrl: '', - } - - it('tracks data about the asset when tapped', () => { - const { getByText } = render( - - - - ) - - fireEvent.press(getByText('cUSD')) - - expect(ValoraAnalytics.track).toHaveBeenCalledTimes(1) - expect(ValoraAnalytics.track).toHaveBeenCalledWith(AssetsEvents.tap_asset, { - address: mockCusdAddress, - assetType: 'token', - balanceUsd: 10, - description: 'Celo Dollar', - title: 'cUSD', - }) - }) -}) diff --git a/src/tokens/AssetItem.tsx b/src/tokens/PositionItem.tsx similarity index 57% rename from src/tokens/AssetItem.tsx rename to src/tokens/PositionItem.tsx index b473bd5c303..97e7aaaae81 100644 --- a/src/tokens/AssetItem.tsx +++ b/src/tokens/PositionItem.tsx @@ -5,14 +5,10 @@ import { TouchableWithoutFeedback } from 'react-native-gesture-handler' import { AssetsEvents } from 'src/analytics/Events' import ValoraAnalytics from 'src/analytics/ValoraAnalytics' import LegacyTokenDisplay from 'src/components/LegacyTokenDisplay' -import PercentageIndicator from 'src/components/PercentageIndicator' -import TokenDisplay from 'src/components/TokenDisplay' import { Position } from 'src/positions/types' import Colors from 'src/styles/colors' import fontStyles from 'src/styles/fonts' import { Spacing } from 'src/styles/styles' -import { TokenBalance } from 'src/tokens/slice' -import { isHistoricalPriceUpdated } from 'src/tokens/utils' import { Currency } from 'src/utils/currencies' export const PositionItem = ({ position }: { position: Position }) => { @@ -77,70 +73,6 @@ export const PositionItem = ({ position }: { position: Position }) => { ) } -export const TokenBalanceItem = ({ - token, - showPriceChangeIndicatorInBalances, -}: { - token: TokenBalance - showPriceChangeIndicatorInBalances: boolean -}) => { - const onPress = () => { - ValoraAnalytics.track(AssetsEvents.tap_asset, { - assetType: 'token', - address: token.address, - title: token.symbol, - description: token.name, - balanceUsd: token.balance.multipliedBy(token.priceUsd ?? 0).toNumber(), - }) - } - - return ( - - - - - {token.symbol} - {token.name} - - - - - {token.priceUsd?.gt(0) && ( - - {showPriceChangeIndicatorInBalances && - token.historicalPricesUsd && - isHistoricalPriceUpdated(token) && ( - - )} - - - )} - - - ) -} - const styles = StyleSheet.create({ tokenImg: { width: 32, @@ -148,23 +80,12 @@ const styles = StyleSheet.create({ borderRadius: 20, marginRight: Spacing.Regular16, }, - container: { - flexDirection: 'row', - paddingHorizontal: Spacing.Thick24, - paddingBottom: Spacing.Large32, - justifyContent: 'space-between', - }, positionsContainer: { flexDirection: 'row', paddingHorizontal: Spacing.Thick24, paddingBottom: Spacing.Thick24, justifyContent: 'space-between', }, - tokenContainer: { - flexDirection: 'row', - flexWrap: 'wrap', - justifyContent: 'flex-end', - }, tokenLabels: { flexShrink: 1, flexDirection: 'column', diff --git a/src/tokens/types.ts b/src/tokens/types.ts index c641010adb8..0186fbd4694 100644 --- a/src/tokens/types.ts +++ b/src/tokens/types.ts @@ -16,3 +16,9 @@ export interface TokenDetailsAction { onPress: () => void visible: boolean } + +export enum AssetTabType { + Tokens = 0, + Collectibles = 1, + Positions = 2, +}