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, +}