diff --git a/icons/collection.svg b/icons/collection.svg new file mode 100644 index 0000000000..981040af5a --- /dev/null +++ b/icons/collection.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/api/resources.ts b/lib/api/resources.ts index d317d6d5ac..c1ddac5826 100644 --- a/lib/api/resources.ts +++ b/lib/api/resources.ts @@ -26,6 +26,9 @@ import type { AddressTokensFilter, AddressTokensResponse, AddressWithdrawalsResponse, + AddressNFTsResponse, + AddressCollectionsResponse, + AddressNFTTokensFilter, } from 'types/api/address'; import type { AddressesResponse } from 'types/api/addresses'; import type { BlocksResponse, BlockTransactionsResponse, Block, BlockFilters, BlockWithdrawalsResponse } from 'types/api/block'; @@ -51,6 +54,7 @@ import type { TokenInstance, TokenInstanceTransfersCount, TokenVerifiedInfo, + TokenInventoryFilters, } from 'types/api/token'; import type { TokensResponse, TokensFilters, TokensSorting, TokenInstanceTransferResponse, TokensBridgedFilters } from 'types/api/tokens'; import type { TokenTransferResponse, TokenTransferFilters } from 'types/api/tokenTransfer'; @@ -305,6 +309,16 @@ export const RESOURCES = { pathParams: [ 'hash' as const ], filterFields: [ 'type' as const ], }, + address_nfts: { + path: '/api/v2/addresses/:hash/nft', + pathParams: [ 'hash' as const ], + filterFields: [ 'type' as const ], + }, + address_collections: { + path: '/api/v2/addresses/:hash/nft/collections', + pathParams: [ 'hash' as const ], + filterFields: [ 'type' as const ], + }, address_withdrawals: { path: '/api/v2/addresses/:hash/withdrawals', pathParams: [ 'hash' as const ], @@ -384,7 +398,7 @@ export const RESOURCES = { token_inventory: { path: '/api/v2/tokens/:hash/instances', pathParams: [ 'hash' as const ], - filterFields: [], + filterFields: [ 'holder_address_hash' as const ], }, tokens: { path: '/api/v2/tokens', @@ -580,7 +594,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' | 'addresses' | 'address_txs' | 'address_internal_txs' | 'address_token_transfers' | 'address_blocks_validated' | 'address_coin_balance' | 'search' | -'address_logs' | 'address_tokens' | +'address_logs' | 'address_tokens' | 'address_nfts' | 'address_collections' | 'token_transfers' | 'token_holders' | 'token_inventory' | 'tokens' | 'tokens_bridged' | 'token_instance_transfers' | 'token_instance_holders' | 'verified_contracts' | @@ -642,6 +656,8 @@ Q extends 'address_coin_balance' ? AddressCoinBalanceHistoryResponse : Q extends 'address_coin_balance_chart' ? AddressCoinBalanceHistoryChart : Q extends 'address_logs' ? LogsResponseAddress : Q extends 'address_tokens' ? AddressTokensResponse : +Q extends 'address_nfts' ? AddressNFTsResponse : +Q extends 'address_collections' ? AddressCollectionsResponse : Q extends 'address_withdrawals' ? AddressWithdrawalsResponse : Q extends 'token' ? TokenInfo : Q extends 'token_verified_info' ? TokenVerifiedInfo : @@ -695,7 +711,10 @@ Q extends 'token_transfers' ? TokenTransferFilters : Q extends 'address_txs' | 'address_internal_txs' ? AddressTxsFilters : Q extends 'address_token_transfers' ? AddressTokenTransferFilters : Q extends 'address_tokens' ? AddressTokensFilter : +Q extends 'address_nfts' ? AddressNFTTokensFilter : +Q extends 'address_collections' ? AddressNFTTokensFilter : Q extends 'search' ? SearchResultFilters : +Q extends 'token_inventory' ? TokenInventoryFilters : Q extends 'tokens' ? TokensFilters : Q extends 'tokens_bridged' ? TokensBridgedFilters : Q extends 'verified_contracts' ? VerifiedContractsFilters : diff --git a/lib/cookies.ts b/lib/cookies.ts index 9634ebfc15..df61547187 100644 --- a/lib/cookies.ts +++ b/lib/cookies.ts @@ -12,6 +12,7 @@ export enum NAMES { INDEXING_ALERT='indexing_alert', ADBLOCK_DETECTED='adblock_detected', MIXPANEL_DEBUG='_mixpanel_debug', + ADDRESS_NFT_DISPLAY_TYPE='address_nft_display_type' } export function get(name?: NAMES | undefined | null, serverCookie?: string) { diff --git a/lib/token/tokenTypes.ts b/lib/token/tokenTypes.ts index c8f258b4fb..5246fc2418 100644 --- a/lib/token/tokenTypes.ts +++ b/lib/token/tokenTypes.ts @@ -1,9 +1,14 @@ -import type { TokenType } from 'types/api/token'; +import type { NFTTokenType, TokenType } from 'types/api/token'; -export const TOKEN_TYPES: Array<{ title: string; id: TokenType }> = [ - { title: 'ERC-20', id: 'ERC-20' }, +export const NFT_TOKEN_TYPES: Array<{ title: string; id: NFTTokenType }> = [ { title: 'ERC-721', id: 'ERC-721' }, { title: 'ERC-1155', id: 'ERC-1155' }, ]; +export const TOKEN_TYPES: Array<{ title: string; id: TokenType }> = [ + { title: 'ERC-20', id: 'ERC-20' }, + ...NFT_TOKEN_TYPES, +]; + +export const NFT_TOKEN_TYPE_IDS = NFT_TOKEN_TYPES.map(i => i.id); export const TOKEN_TYPE_IDS = TOKEN_TYPES.map(i => i.id); diff --git a/mocks/address/tokens.ts b/mocks/address/tokens.ts index 5f2299cee5..c8c7386134 100644 --- a/mocks/address/tokens.ts +++ b/mocks/address/tokens.ts @@ -1,4 +1,4 @@ -import type { AddressTokenBalance } from 'types/api/address'; +import type { AddressCollectionsResponse, AddressNFTsResponse, AddressTokenBalance } from 'types/api/address'; import * as tokens from 'mocks/tokens/tokenInfo'; import * as tokenInstance from 'mocks/tokens/tokenInstance'; @@ -117,3 +117,51 @@ export const erc1155List = { erc1155b, ], }; + +export const nfts: AddressNFTsResponse = { + items: [ + { + ...tokenInstance.base, + token: tokens.tokenInfoERC1155a, + token_type: 'ERC-1155', + value: '11', + }, + { + ...tokenInstance.unique, + token: tokens.tokenInfoERC721a, + token_type: 'ERC-721', + value: '1', + }, + ], + next_page_params: null, +}; + +const nftInstance = { + ...tokenInstance.base, + token_type: 'ERC-1155', + value: '11', +}; + +export const collections: AddressCollectionsResponse = { + items: [ + { + token: tokens.tokenInfoERC1155a, + amount: '100', + token_instances: Array(5).fill(nftInstance), + }, + { + token: tokens.tokenInfoERC20LongSymbol, + amount: '100', + token_instances: Array(5).fill(nftInstance), + }, + { + token: tokens.tokenInfoERC1155WithoutName, + amount: '1', + token_instances: [ nftInstance ], + }, + ], + next_page_params: { + token_contract_address_hash: '123', + token_type: 'ERC-1155', + }, +}; diff --git a/stubs/address.ts b/stubs/address.ts index f4c3ec2f52..c2e0ffd7c6 100644 --- a/stubs/address.ts +++ b/stubs/address.ts @@ -1,4 +1,12 @@ -import type { Address, AddressCoinBalanceHistoryItem, AddressCounters, AddressTabsCounters, AddressTokenBalance } from 'types/api/address'; +import type { + Address, + AddressCoinBalanceHistoryItem, + AddressCollection, + AddressCounters, + AddressNFT, + AddressTabsCounters, + AddressTokenBalance, +} from 'types/api/address'; import type { AddressesItem } from 'types/api/addresses'; import { ADDRESS_HASH } from './addressParams'; @@ -80,16 +88,22 @@ export const ADDRESS_TOKEN_BALANCE_ERC_20: AddressTokenBalance = { value: '1000000000000000000000000', }; -export const ADDRESS_TOKEN_BALANCE_ERC_721: AddressTokenBalance = { +export const ADDRESS_NFT_721: AddressNFT = { + token_type: 'ERC-721', token: TOKEN_INFO_ERC_721, - token_id: null, - token_instance: null, - value: '176', + value: '1', + ...TOKEN_INSTANCE, +}; + +export const ADDRESS_NFT_1155: AddressNFT = { + token_type: 'ERC-1155', + token: TOKEN_INFO_ERC_1155, + value: '10', + ...TOKEN_INSTANCE, }; -export const ADDRESS_TOKEN_BALANCE_ERC_1155: AddressTokenBalance = { +export const ADDRESS_COLLECTION: AddressCollection = { token: TOKEN_INFO_ERC_1155, - token_id: '188882', - token_instance: TOKEN_INSTANCE, - value: '176', + amount: '4', + token_instances: Array(4).fill(TOKEN_INSTANCE), }; diff --git a/types/api/address.ts b/types/api/address.ts index 10dc6aa757..7edd55659e 100644 --- a/types/api/address.ts +++ b/types/api/address.ts @@ -3,7 +3,7 @@ import type { Transaction } from 'types/api/transaction'; import type { UserTags } from './addressParams'; import type { Block } from './block'; import type { InternalTransaction } from './internalTransaction'; -import type { TokenInfo, TokenInstance, TokenType } from './token'; +import type { NFTTokenType, TokenInfo, TokenInstance, TokenType } from './token'; import type { TokenTransfer, TokenTransferPagination } from './tokenTransfer'; export interface Address extends UserTags { @@ -49,17 +49,47 @@ export interface AddressTokenBalance { token_instance: TokenInstance | null; } +export type AddressNFT = TokenInstance & { + token: TokenInfo; + token_type: Omit; + value: string; +} + +export type AddressCollection = { + token: TokenInfo; + amount: string; + token_instances: Array>; +} + export interface AddressTokensResponse { items: Array; next_page_params: { items_count: number; - token_name: 'string' | null; + token_name: string | null; token_type: TokenType; value: number; fiat_value: string | null; } | null; } +export interface AddressNFTsResponse { + items: Array; + next_page_params: { + items_count: number; + token_id: string; + token_type: TokenType; + token_contract_address_hash: string; + } | null; +} + +export interface AddressCollectionsResponse { + items: Array; + next_page_params: { + token_contract_address_hash: string; + token_type: TokenType; + } | null; +} + export interface AddressTokensBalancesSocketMessage { overflow: boolean; token_balances: Array; @@ -97,6 +127,10 @@ export type AddressTokensFilter = { type: TokenType; } +export type AddressNFTTokensFilter = { + type: Array | undefined; +} + export interface AddressCoinBalanceHistoryItem { block_number: number; block_timestamp: string; diff --git a/types/api/token.ts b/types/api/token.ts index 681dde2075..417c74aab6 100644 --- a/types/api/token.ts +++ b/types/api/token.ts @@ -1,7 +1,8 @@ import type { TokenInfoApplication } from './account'; import type { AddressParam } from './addressParams'; -export type TokenType = 'ERC-20' | 'ERC-721' | 'ERC-1155'; +export type NFTTokenType = 'ERC-721' | 'ERC-1155'; +export type TokenType = 'ERC-20' | NFTTokenType; export interface TokenInfo { address: string; @@ -77,3 +78,7 @@ export type TokenInventoryPagination = { } export type TokenVerifiedInfo = Omit; + +export type TokenInventoryFilters = { + holder_address_hash?: string; +} diff --git a/ui/address/AddressTokenTransfers.tsx b/ui/address/AddressTokenTransfers.tsx index 0355618015..0ebba17f8e 100644 --- a/ui/address/AddressTokenTransfers.tsx +++ b/ui/address/AddressTokenTransfers.tsx @@ -1,4 +1,4 @@ -import { Flex, Hide, Icon, Show, Text, Tooltip, useColorModeValue } from '@chakra-ui/react'; +import { Flex, Hide, Show, Text } from '@chakra-ui/react'; import { useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/router'; import React from 'react'; @@ -9,7 +9,6 @@ import type { AddressFromToFilter, AddressTokenTransferResponse } from 'types/ap import type { TokenType } from 'types/api/token'; import type { TokenTransfer } from 'types/api/tokenTransfer'; -import crossIcon from 'icons/cross.svg'; import { getResourceKey } from 'lib/api/useApiQuery'; import getFilterValueFromQuery from 'lib/getFilterValueFromQuery'; import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery'; @@ -26,6 +25,7 @@ import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; import HashStringShorten from 'ui/shared/HashStringShorten'; import Pagination from 'ui/shared/pagination/Pagination'; import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages'; +import ResetIconButton from 'ui/shared/ResetIconButton'; import * as SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice'; import TokenTransferFilter from 'ui/shared/TokenTransfer/TokenTransferFilter'; import TokenTransferList from 'ui/shared/TokenTransfer/TokenTransferList'; @@ -115,9 +115,6 @@ const AddressTokenTransfers = ({ scrollRef, overloadCount = OVERLOAD_COUNT }: Pr onFilterChange({}); }, [ onFilterChange ]); - const resetTokenIconColor = useColorModeValue('blue.600', 'blue.300'); - const resetTokenIconHoverColor = useColorModeValue('blue.400', 'blue.200'); - const handleNewSocketMessage: SocketMessage.AddressTokenTransfer['handler'] = (payload) => { setSocketAlert(''); @@ -235,19 +232,7 @@ const AddressTokenTransfers = ({ scrollRef, overloadCount = OVERLOAD_COUNT }: Pr { isMobile ? : tokenFilter } - - - - - + ); diff --git a/ui/address/AddressTokens.pw.tsx b/ui/address/AddressTokens.pw.tsx index 3cf3b34e93..7ee5556986 100644 --- a/ui/address/AddressTokens.pw.tsx +++ b/ui/address/AddressTokens.pw.tsx @@ -13,6 +13,8 @@ import AddressTokens from './AddressTokens'; const ADDRESS_HASH = addressMock.withName.hash; const API_URL_ADDRESS = buildApiUrl('address', { hash: ADDRESS_HASH }); const API_URL_TOKENS = buildApiUrl('address_tokens', { hash: ADDRESS_HASH }); +const API_URL_NFT = buildApiUrl('address_nfts', { hash: ADDRESS_HASH }) + '?type='; +const API_URL_COLLECTIONS = buildApiUrl('address_collections', { hash: ADDRESS_HASH }) + '?type='; const nextPageParams = { items_count: 50, @@ -52,6 +54,14 @@ const test = base.extend({ status: 200, body: JSON.stringify(response1155), })); + await page.route(API_URL_NFT, (route) => route.fulfill({ + status: 200, + body: JSON.stringify(tokensMock.nfts), + })); + await page.route(API_URL_COLLECTIONS, (route) => route.fulfill({ + status: 200, + body: JSON.stringify(tokensMock.collections), + })); use(page); }, @@ -76,10 +86,10 @@ test('erc20 +@dark-mode', async({ mount }) => { await expect(component).toHaveScreenshot(); }); -test('erc721 +@dark-mode', async({ mount }) => { +test('collections +@dark-mode', async({ mount }) => { const hooksConfig = { router: { - query: { hash: ADDRESS_HASH, tab: 'tokens_erc721' }, + query: { hash: ADDRESS_HASH, tab: 'tokens_nfts' }, isReady: true, }, }; @@ -95,10 +105,10 @@ test('erc721 +@dark-mode', async({ mount }) => { await expect(component).toHaveScreenshot(); }); -test('erc1155 +@dark-mode', async({ mount }) => { +test('nfts +@dark-mode', async({ mount }) => { const hooksConfig = { router: { - query: { hash: ADDRESS_HASH, tab: 'tokens_erc1155' }, + query: { hash: ADDRESS_HASH, tab: 'tokens_nfts' }, isReady: true, }, }; @@ -111,6 +121,8 @@ test('erc1155 +@dark-mode', async({ mount }) => { { hooksConfig }, ); + await component.getByText('List').click(); + await expect(component).toHaveScreenshot(); }); @@ -136,10 +148,10 @@ test.describe('mobile', () => { await expect(component).toHaveScreenshot(); }); - test('erc721', async({ mount }) => { + test('nfts', async({ mount }) => { const hooksConfig = { router: { - query: { hash: ADDRESS_HASH, tab: 'tokens_erc721' }, + query: { hash: ADDRESS_HASH, tab: 'tokens_nfts' }, isReady: true, }, }; @@ -152,13 +164,15 @@ test.describe('mobile', () => { { hooksConfig }, ); + await component.getByLabel('list').click(); + await expect(component).toHaveScreenshot(); }); - test('erc1155', async({ mount }) => { + test('collections', async({ mount }) => { const hooksConfig = { router: { - query: { hash: ADDRESS_HASH, tab: 'tokens_erc1155' }, + query: { hash: ADDRESS_HASH, tab: 'tokens_nfts' }, isReady: true, }, }; diff --git a/ui/address/AddressTokens.tsx b/ui/address/AddressTokens.tsx index 1d693bd4d6..6aae805c47 100644 --- a/ui/address/AddressTokens.tsx +++ b/ui/address/AddressTokens.tsx @@ -1,47 +1,46 @@ -import { Box } from '@chakra-ui/react'; -import { useQueryClient } from '@tanstack/react-query'; +import { Box, HStack } from '@chakra-ui/react'; import { useRouter } from 'next/router'; import React from 'react'; -import type { SocketMessage } from 'lib/socket/types'; -import type { AddressTokenBalance, AddressTokensBalancesSocketMessage, AddressTokensResponse } from 'types/api/address'; -import type { TokenType } from 'types/api/token'; +import type { NFTTokenType } from 'types/api/token'; import type { PaginationParams } from 'ui/shared/pagination/types'; -import { getResourceKey } from 'lib/api/useApiQuery'; +import listIcon from 'icons/apps.svg'; +import collectionIcon from 'icons/collection.svg'; +import { useAppContext } from 'lib/contexts/app'; +import * as cookies from 'lib/cookies'; +import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery'; import useIsMobile from 'lib/hooks/useIsMobile'; import getQueryParamString from 'lib/router/getQueryParamString'; -import useSocketChannel from 'lib/socket/useSocketChannel'; -import useSocketMessage from 'lib/socket/useSocketMessage'; -import { ADDRESS_TOKEN_BALANCE_ERC_1155, ADDRESS_TOKEN_BALANCE_ERC_20, ADDRESS_TOKEN_BALANCE_ERC_721 } from 'stubs/address'; +import { NFT_TOKEN_TYPE_IDS } from 'lib/token/tokenTypes'; +import { ADDRESS_TOKEN_BALANCE_ERC_20, ADDRESS_NFT_1155, ADDRESS_COLLECTION } from 'stubs/address'; import { generateListStub } from 'stubs/utils'; -import { tokenTabsByType } from 'ui/pages/Address'; +import PopoverFilter from 'ui/shared/filters/PopoverFilter'; +import TokenTypeFilter from 'ui/shared/filters/TokenTypeFilter'; import Pagination from 'ui/shared/pagination/Pagination'; import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages'; +import RadioButtonGroup from 'ui/shared/radioButtonGroup/RadioButtonGroup'; import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; -import ERC1155Tokens from './tokens/ERC1155Tokens'; +import AddressCollections from './tokens/AddressCollections'; +import AddressNFTs from './tokens/AddressNFTs'; import ERC20Tokens from './tokens/ERC20Tokens'; -import ERC721Tokens from './tokens/ERC721Tokens'; import TokenBalances from './tokens/TokenBalances'; +type TNftDisplayType = 'collection' | 'list'; + const TAB_LIST_PROPS = { - marginBottom: 0, + my: 3, py: 5, - marginTop: 3, columnGap: 3, }; const TAB_LIST_PROPS_MOBILE = { - mt: 8, + my: 8, columnGap: 3, }; -const tokenBalanceItemIdentityFactory = (match: AddressTokenBalance) => (item: AddressTokenBalance) => (( - match.token.address === item.token.address && - match.token_id === item.token_id && - match.token_instance?.id === item.token_instance?.id -)); +const getTokenFilterValue = (getFilterValuesFromQuery).bind(null, NFT_TOKEN_TYPE_IDS); const AddressTokens = () => { const router = useRouter(); @@ -49,6 +48,10 @@ const AddressTokens = () => { const scrollRef = React.useRef(null); + const displayTypeCookie = cookies.get(cookies.NAMES.ADDRESS_NFT_DISPLAY_TYPE, useAppContext().cookies); + const [ nftDisplayType, setNftDisplayType ] = React.useState(displayTypeCookie === 'list' ? 'list' : 'collection'); + const [ tokenTypes, setTokenTypes ] = React.useState | undefined>(getTokenFilterValue(router.query.type) || []); + const tab = getQueryParamString(router.query.tab); const hash = getQueryParamString(router.query.hash); @@ -58,111 +61,100 @@ const AddressTokens = () => { filters: { type: 'ERC-20' }, scrollRef, options: { + enabled: !tab || tab === 'tokens_erc20', refetchOnMount: false, placeholderData: generateListStub<'address_tokens'>(ADDRESS_TOKEN_BALANCE_ERC_20, 10, { next_page_params: null }), }, }); - const erc721Query = useQueryWithPages({ - resourceName: 'address_tokens', + const collectionsQuery = useQueryWithPages({ + resourceName: 'address_collections', pathParams: { hash }, - filters: { type: 'ERC-721' }, scrollRef, options: { - refetchOnMount: false, - placeholderData: generateListStub<'address_tokens'>(ADDRESS_TOKEN_BALANCE_ERC_721, 10, { next_page_params: null }), + enabled: tab === 'tokens_nfts' && nftDisplayType === 'collection', + placeholderData: generateListStub<'address_collections'>(ADDRESS_COLLECTION, 10, { next_page_params: null }), }, + filters: { type: tokenTypes }, }); - const erc1155Query = useQueryWithPages({ - resourceName: 'address_tokens', + const nftsQuery = useQueryWithPages({ + resourceName: 'address_nfts', pathParams: { hash }, - filters: { type: 'ERC-1155' }, scrollRef, options: { - refetchOnMount: false, - placeholderData: generateListStub<'address_tokens'>(ADDRESS_TOKEN_BALANCE_ERC_1155, 10, { next_page_params: null }), + enabled: tab === 'tokens_nfts' && nftDisplayType === 'list', + placeholderData: generateListStub<'address_nfts'>(ADDRESS_NFT_1155, 10, { next_page_params: null }), }, + filters: { type: tokenTypes }, }); - const queryClient = useQueryClient(); - - const updateTokensData = React.useCallback((type: TokenType, payload: AddressTokensBalancesSocketMessage) => { - const queryKey = getResourceKey('address_tokens', { pathParams: { hash }, queryParams: { type } }); - - queryClient.setQueryData(queryKey, (prevData: AddressTokensResponse | undefined) => { - const items = prevData?.items.map((currentItem) => { - const updatedData = payload.token_balances.find(tokenBalanceItemIdentityFactory(currentItem)); - return updatedData ?? currentItem; - }) || []; - - const extraItems = prevData?.next_page_params ? - [] : - payload.token_balances.filter((socketItem) => !items.some(tokenBalanceItemIdentityFactory(socketItem))); - - if (!prevData) { - return { - items: extraItems, - next_page_params: null, - }; - } - - return { - items: items.concat(extraItems), - next_page_params: prevData.next_page_params, - }; - }); - }, [ hash, queryClient ]); - - const handleTokenBalancesErc20Message: SocketMessage.AddressTokenBalancesErc20['handler'] = React.useCallback((payload) => { - updateTokensData('ERC-20', payload); - }, [ updateTokensData ]); - - const handleTokenBalancesErc721Message: SocketMessage.AddressTokenBalancesErc721['handler'] = React.useCallback((payload) => { - updateTokensData('ERC-721', payload); - }, [ updateTokensData ]); - - const handleTokenBalancesErc1155Message: SocketMessage.AddressTokenBalancesErc1155['handler'] = React.useCallback((payload) => { - updateTokensData('ERC-1155', payload); - }, [ updateTokensData ]); - - const channel = useSocketChannel({ - topic: `addresses:${ hash.toLowerCase() }`, - isDisabled: erc20Query.isPlaceholderData || erc721Query.isPlaceholderData || erc1155Query.isPlaceholderData, - }); + const handleNFTsDisplayTypeChange = React.useCallback((val: TNftDisplayType) => { + cookies.set(cookies.NAMES.ADDRESS_NFT_DISPLAY_TYPE, val); + setNftDisplayType(val); + }, []); + + const handleTokenTypesChange = React.useCallback((value: Array) => { + nftsQuery.onFilterChange({ type: value }); + collectionsQuery.onFilterChange({ type: value }); + setTokenTypes(value); + }, [ nftsQuery, collectionsQuery ]); + + const nftTypeFilter = ( + 0 } contentProps={{ w: '200px' }} appliedFiltersNum={ tokenTypes?.length }> + nftOnly onChange={ handleTokenTypesChange } defaultValue={ tokenTypes }/> + + ); - useSocketMessage({ - channel, - event: 'updated_token_balances_erc_20', - handler: handleTokenBalancesErc20Message, - }); - useSocketMessage({ - channel, - event: 'updated_token_balances_erc_721', - handler: handleTokenBalancesErc721Message, - }); - useSocketMessage({ - channel, - event: 'updated_token_balances_erc_1155', - handler: handleTokenBalancesErc1155Message, - }); + const hasActiveFilters = Boolean(tokenTypes?.length); const tabs = [ - { id: tokenTabsByType['ERC-20'], title: 'ERC-20', component: }, - { id: tokenTabsByType['ERC-721'], title: 'ERC-721', component: }, - { id: tokenTabsByType['ERC-1155'], title: 'ERC-1155', component: }, + { id: 'tokens_erc20', title: 'ERC-20', component: }, + { + id: 'tokens_nfts', + title: 'NFTs', + component: nftDisplayType === 'list' ? + : + , + }, ]; + const nftDisplayTypeRadio = ( + + onChange={ handleNFTsDisplayTypeChange } + defaultValue={ nftDisplayType } + name="type" + options={ [ + { title: 'By collection', value: 'collection', icon: collectionIcon, onlyIcon: isMobile }, + { title: 'List', value: 'list', icon: listIcon, onlyIcon: isMobile }, + ] } + /> + ); + let pagination: PaginationParams | undefined; - if (tab === tokenTabsByType['ERC-1155']) { - pagination = erc1155Query.pagination; - } else if (tab === tokenTabsByType['ERC-721']) { - pagination = erc721Query.pagination; + if (tab === 'tokens_nfts') { + pagination = nftDisplayType === 'list' ? nftsQuery.pagination : collectionsQuery.pagination; } else { pagination = erc20Query.pagination; } + const hasNftData = + (!nftsQuery.isPlaceholderData && nftsQuery.data?.items.length) || + (!collectionsQuery.isPlaceholderData && collectionsQuery.data?.items.length); + + const isNftTab = tab !== 'tokens' && tab !== 'tokens_erc20'; + + const rightSlot = ( + <> + + { isNftTab && (hasNftData || hasActiveFilters) && nftDisplayTypeRadio } + { isNftTab && (hasNftData || hasActiveFilters) && nftTypeFilter } + + { pagination.isVisible && !isMobile && } + + ); + return ( <> @@ -174,7 +166,8 @@ const AddressTokens = () => { colorScheme="gray" size="sm" tabListProps={ isMobile ? TAB_LIST_PROPS_MOBILE : TAB_LIST_PROPS } - rightSlot={ pagination.isVisible && !isMobile ? : null } + rightSlot={ rightSlot } + rightSlotProps={ tab !== 'tokens_erc20' && !isMobile ? { flexGrow: 1, display: 'flex', justifyContent: 'space-between', ml: 8 } : {} } stickyEnabled={ !isMobile } /> diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_collections-dark-mode-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_collections-dark-mode-1.png new file mode 100644 index 0000000000..2cfbdaf3a7 Binary files /dev/null and b/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_collections-dark-mode-1.png differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_erc1155-dark-mode-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_erc1155-dark-mode-1.png deleted file mode 100644 index 8e21ba8758..0000000000 Binary files a/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_erc1155-dark-mode-1.png and /dev/null differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_erc20-dark-mode-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_erc20-dark-mode-1.png index 5c9933e717..988d310f09 100644 Binary files a/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_erc20-dark-mode-1.png and b/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_erc20-dark-mode-1.png differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_erc721-dark-mode-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_erc721-dark-mode-1.png deleted file mode 100644 index 4eca88970b..0000000000 Binary files a/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_erc721-dark-mode-1.png and /dev/null differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_nfts-dark-mode-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_nfts-dark-mode-1.png new file mode 100644 index 0000000000..ff3f91748f Binary files /dev/null and b/ui/address/__screenshots__/AddressTokens.pw.tsx_dark-color-mode_nfts-dark-mode-1.png differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_collections-dark-mode-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_collections-dark-mode-1.png new file mode 100644 index 0000000000..185fa05a5c Binary files /dev/null and b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_collections-dark-mode-1.png differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_erc1155-dark-mode-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_erc1155-dark-mode-1.png deleted file mode 100644 index 7cbc7815c5..0000000000 Binary files a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_erc1155-dark-mode-1.png and /dev/null differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_erc20-dark-mode-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_erc20-dark-mode-1.png index 405d4f9ef4..5cb20256d2 100644 Binary files a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_erc20-dark-mode-1.png and b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_erc20-dark-mode-1.png differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_erc721-dark-mode-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_erc721-dark-mode-1.png deleted file mode 100644 index ad6f89f6c1..0000000000 Binary files a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_erc721-dark-mode-1.png and /dev/null differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-collections-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-collections-1.png new file mode 100644 index 0000000000..d2aacbe810 Binary files /dev/null and b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-collections-1.png differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-erc1155-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-erc1155-1.png deleted file mode 100644 index 6c1d0b61ac..0000000000 Binary files a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-erc1155-1.png and /dev/null differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-erc20-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-erc20-1.png index 4be344fc59..9325e2a5b4 100644 Binary files a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-erc20-1.png and b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-erc20-1.png differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-erc721-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-erc721-1.png deleted file mode 100644 index 9af951e2e9..0000000000 Binary files a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-erc721-1.png and /dev/null differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-nfts-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-nfts-1.png new file mode 100644 index 0000000000..cb01e73aa2 Binary files /dev/null and b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_mobile-nfts-1.png differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_nfts-dark-mode-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_nfts-dark-mode-1.png new file mode 100644 index 0000000000..f4ca1946ec Binary files /dev/null and b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_nfts-dark-mode-1.png differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_update-balances-via-socket-1.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_update-balances-via-socket-1.png index 21adb1d539..fab130576d 100644 Binary files a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_update-balances-via-socket-1.png and b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_update-balances-via-socket-1.png differ diff --git a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_update-balances-via-socket-2.png b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_update-balances-via-socket-2.png index 1292dc8384..4ada42569e 100644 Binary files a/ui/address/__screenshots__/AddressTokens.pw.tsx_default_update-balances-via-socket-2.png and b/ui/address/__screenshots__/AddressTokens.pw.tsx_default_update-balances-via-socket-2.png differ diff --git a/ui/address/tokenSelect/TokenSelect.pw.tsx b/ui/address/tokenSelect/TokenSelect.pw.tsx index 28e607dcdd..d2a810c981 100644 --- a/ui/address/tokenSelect/TokenSelect.pw.tsx +++ b/ui/address/tokenSelect/TokenSelect.pw.tsx @@ -2,10 +2,8 @@ import { Flex } from '@chakra-ui/react'; import { test as base, expect, devices } from '@playwright/experimental-ct-react'; import React from 'react'; -import * as coinBalanceMock from 'mocks/address/coinBalanceHistory'; import * as tokensMock from 'mocks/address/tokens'; import { tokenInfoERC20a } from 'mocks/tokens/tokenInfo'; -import * as socketServer from 'playwright/fixtures/socketServer'; import TestApp from 'playwright/TestApp'; import buildApiUrl from 'playwright/utils/buildApiUrl'; import MockAddressPage from 'ui/address/testUtils/MockAddressPage'; @@ -175,76 +173,3 @@ base('long values', async({ mount, page }) => { await expect(page).toHaveScreenshot({ clip: CLIPPING_AREA }); }); - -test.describe('socket', () => { - const testWithSocket = test.extend({ - createSocket: socketServer.createSocket, - }); - testWithSocket.describe.configure({ mode: 'serial' }); - - testWithSocket('new item after token balance update', async({ page, mount, createSocket }) => { - await mount( - - - - - - - , - { hooksConfig }, - ); - - await page.route(TOKENS_ERC20_API_URL, async(route) => route.fulfill({ - status: 200, - body: JSON.stringify({ - items: [ - ...tokensMock.erc20List.items, - tokensMock.erc20d, - ], - }), - }), { times: 1 }); - - const socket = await createSocket(); - const channel = await socketServer.joinChannel(socket, 'addresses:1'); - socketServer.sendMessage(socket, channel, 'token_balance', { - block_number: 1, - }); - - const button = page.getByRole('button', { name: /select/i }); - const text = await button.innerText(); - expect(text).toContain('10'); - }); - - testWithSocket('new item after coin balance update', async({ page, mount, createSocket }) => { - await mount( - - - - - - - , - { hooksConfig }, - ); - - await page.route(TOKENS_ERC20_API_URL, async(route) => route.fulfill({ - status: 200, - body: JSON.stringify({ - items: [ - ...tokensMock.erc20List.items, - tokensMock.erc20d, - ], - }), - }), { times: 1 }); - - const socket = await createSocket(); - const channel = await socketServer.joinChannel(socket, 'addresses:1'); - socketServer.sendMessage(socket, channel, 'coin_balance', { - coin_balance: coinBalanceMock.base, - }); - - const button = page.getByRole('button', { name: /select/i }); - const text = await button.innerText(); - expect(text).toContain('10'); - }); -}); diff --git a/ui/address/tokenSelect/TokenSelect.tsx b/ui/address/tokenSelect/TokenSelect.tsx index 11b358e3f7..ca03f06879 100644 --- a/ui/address/tokenSelect/TokenSelect.tsx +++ b/ui/address/tokenSelect/TokenSelect.tsx @@ -5,7 +5,6 @@ import NextLink from 'next/link'; import { useRouter } from 'next/router'; import React from 'react'; -import type { SocketMessage } from 'lib/socket/types'; import type { Address } from 'types/api/address'; import walletIcon from 'icons/wallet.svg'; @@ -13,8 +12,6 @@ import { getResourceKey } from 'lib/api/useApiQuery'; import useIsMobile from 'lib/hooks/useIsMobile'; import * as mixpanel from 'lib/mixpanel/index'; import getQueryParamString from 'lib/router/getQueryParamString'; -import useSocketChannel from 'lib/socket/useSocketChannel'; -import useSocketMessage from 'lib/socket/useSocketMessage'; import useFetchTokens from '../utils/useFetchTokens'; import TokenSelectDesktop from './TokenSelectDesktop'; @@ -28,14 +25,13 @@ const TokenSelect = ({ onClick }: Props) => { const router = useRouter(); const isMobile = useIsMobile(); const queryClient = useQueryClient(); - const [ blockNumber, setBlockNumber ] = React.useState(); const addressHash = getQueryParamString(router.query.hash); const addressResourceKey = getResourceKey('address', { pathParams: { hash: addressHash } }); const addressQueryData = queryClient.getQueryData
(addressResourceKey); - const { data, isError, isPending, refetch } = useFetchTokens({ hash: addressQueryData?.hash }); + const { data, isError, isPending } = useFetchTokens({ hash: addressQueryData?.hash }); const tokensResourceKey = getResourceKey('address_tokens', { pathParams: { hash: addressQueryData?.hash }, queryParams: { type: 'ERC-20' } }); const tokensIsFetching = useIsFetching({ queryKey: tokensResourceKey }); @@ -44,34 +40,6 @@ const TokenSelect = ({ onClick }: Props) => { onClick?.(); }, [ onClick ]); - const handleTokenBalanceMessage: SocketMessage.AddressTokenBalance['handler'] = React.useCallback((payload) => { - if (payload.block_number !== blockNumber) { - refetch(); - setBlockNumber(payload.block_number); - } - }, [ blockNumber, refetch ]); - const handleCoinBalanceMessage: SocketMessage.AddressCoinBalance['handler'] = React.useCallback((payload) => { - if (payload.coin_balance.block_number !== blockNumber) { - refetch(); - setBlockNumber(payload.coin_balance.block_number); - } - }, [ blockNumber, refetch ]); - - const channel = useSocketChannel({ - topic: `addresses:${ addressQueryData?.hash.toLowerCase() }`, - isDisabled: !addressQueryData, - }); - useSocketMessage({ - channel, - event: 'coin_balance', - handler: handleCoinBalanceMessage, - }); - useSocketMessage({ - channel, - event: 'token_balance', - handler: handleTokenBalanceMessage, - }); - if (isPending) { return ( diff --git a/ui/address/tokens/AddressCollections.tsx b/ui/address/tokens/AddressCollections.tsx new file mode 100644 index 0000000000..96b8326116 --- /dev/null +++ b/ui/address/tokens/AddressCollections.tsx @@ -0,0 +1,116 @@ +import { Box, Flex, Text, Grid, HStack, Skeleton } from '@chakra-ui/react'; +import React from 'react'; + +import { route } from 'nextjs-routes'; + +import useIsMobile from 'lib/hooks/useIsMobile'; +import { apos } from 'lib/html-entities'; +import ActionBar from 'ui/shared/ActionBar'; +import DataListDisplay from 'ui/shared/DataListDisplay'; +import TokenEntity from 'ui/shared/entities/token/TokenEntity'; +import LinkInternal from 'ui/shared/LinkInternal'; +import NftFallback from 'ui/shared/nft/NftFallback'; +import Pagination from 'ui/shared/pagination/Pagination'; +import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages'; + +import NFTItem from './NFTItem'; +import NFTItemContainer from './NFTItemContainer'; + +type Props = { + collectionsQuery: QueryWithPagesResult<'address_collections'>; + address: string; + hasActiveFilters: boolean; +} + +const AddressCollections = ({ collectionsQuery, address, hasActiveFilters }: Props) => { + const isMobile = useIsMobile(); + + const { isError, isPlaceholderData, data, pagination } = collectionsQuery; + + const actionBar = isMobile && pagination.isVisible && ( + + + + ); + + const content = data?.items ? data?.items.map((item, index) => { + const collectionUrl = route({ + pathname: '/token/[hash]', + query: { + hash: item.token.address, + tab: 'inventory', + holder_address_hash: address, + scroll_to_tabs: 'true', + }, + }); + const hasOverload = Number(item.amount) > item.token_instances.length; + return ( + + + + + { ` - ${ Number(item.amount).toLocaleString() } item${ Number(item.amount) > 1 ? 's' : '' }` } + + + View in collection + + + + { item.token_instances.map((instance, index) => { + const key = item.token.address + '_' + (instance.id && !isPlaceholderData ? `id_${ instance.id }` : `index_${ index }`); + + return ( + + ); + }) } + { hasOverload && ( + + + + + + + + View all NFTs + + + ) } + + + ); + }) : null; + + return ( + + ); +}; + +export default AddressCollections; diff --git a/ui/address/tokens/ERC1155Tokens.tsx b/ui/address/tokens/AddressNFTs.tsx similarity index 72% rename from ui/address/tokens/ERC1155Tokens.tsx rename to ui/address/tokens/AddressNFTs.tsx index 245fbec635..1c559e8d08 100644 --- a/ui/address/tokens/ERC1155Tokens.tsx +++ b/ui/address/tokens/AddressNFTs.tsx @@ -2,6 +2,7 @@ import { Grid } from '@chakra-ui/react'; import React from 'react'; import useIsMobile from 'lib/hooks/useIsMobile'; +import { apos } from 'lib/html-entities'; import ActionBar from 'ui/shared/ActionBar'; import DataListDisplay from 'ui/shared/DataListDisplay'; import Pagination from 'ui/shared/pagination/Pagination'; @@ -10,10 +11,11 @@ import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPage import NFTItem from './NFTItem'; type Props = { - tokensQuery: QueryWithPagesResult<'address_tokens'>; + tokensQuery: QueryWithPagesResult<'address_nfts'>; + hasActiveFilters: boolean; } -const ERC1155Tokens = ({ tokensQuery }: Props) => { +const AddressNFTs = ({ tokensQuery, hasActiveFilters }: Props) => { const isMobile = useIsMobile(); const { isError, isPlaceholderData, data, pagination } = tokensQuery; @@ -32,13 +34,14 @@ const ERC1155Tokens = ({ tokensQuery }: Props) => { gridTemplateColumns={{ base: 'repeat(2, calc((100% - 12px)/2))', lg: 'repeat(auto-fill, minmax(210px, 1fr))' }} > { data.items.map((item, index) => { - const key = item.token.address + '_' + (item.token_instance?.id && !isPlaceholderData ? `id_${ item.token_instance?.id }` : `index_${ index }`); + const key = item.token.address + '_' + (item.id && !isPlaceholderData ? `id_${ item.id }` : `index_${ index }`); return ( ); }) } @@ -52,8 +55,12 @@ const ERC1155Tokens = ({ tokensQuery }: Props) => { emptyText="There are no tokens of selected type." content={ content } actionBar={ actionBar } + filterProps={{ + emptyFilteredText: `Couldn${ apos }t find any token that matches your query.`, + hasActiveFilters, + }} /> ); }; -export default ERC1155Tokens; +export default AddressNFTs; diff --git a/ui/address/tokens/ERC721Tokens.tsx b/ui/address/tokens/ERC721Tokens.tsx deleted file mode 100644 index 8877703844..0000000000 --- a/ui/address/tokens/ERC721Tokens.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Show, Hide } from '@chakra-ui/react'; -import React from 'react'; - -import useIsMobile from 'lib/hooks/useIsMobile'; -import ActionBar from 'ui/shared/ActionBar'; -import DataListDisplay from 'ui/shared/DataListDisplay'; -import Pagination from 'ui/shared/pagination/Pagination'; -import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages'; - -import ERC721TokensListItem from './ERC721TokensListItem'; -import ERC721TokensTable from './ERC721TokensTable'; - -type Props = { - tokensQuery: QueryWithPagesResult<'address_tokens'>; -} - -const ERC721Tokens = ({ tokensQuery }: Props) => { - const isMobile = useIsMobile(); - - const { isError, isPlaceholderData, data, pagination } = tokensQuery; - - const actionBar = isMobile && pagination.isVisible && ( - - - - ); - - const content = data?.items ? ( - <> - - { data.items.map((item, index) => ( - - )) } - ) : null; - - return ( - - ); - -}; - -export default ERC721Tokens; diff --git a/ui/address/tokens/ERC721TokensListItem.tsx b/ui/address/tokens/ERC721TokensListItem.tsx deleted file mode 100644 index 66cf9e3911..0000000000 --- a/ui/address/tokens/ERC721TokensListItem.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Flex, HStack, Skeleton } from '@chakra-ui/react'; -import { useRouter } from 'next/router'; -import React from 'react'; - -import type { AddressTokenBalance } from 'types/api/address'; - -import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet'; -import AddressEntity from 'ui/shared/entities/address/AddressEntity'; -import TokenEntityWithAddressFilter from 'ui/shared/entities/token/TokenEntityWithAddressFilter'; -import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; - -type Props = AddressTokenBalance & { isLoading: boolean}; - -const ERC721TokensListItem = ({ token, value, isLoading }: Props) => { - const router = useRouter(); - - const hash = router.query.hash?.toString() || ''; - - return ( - - - - - - - - Quantity - - { value } - - - - ); -}; - -export default ERC721TokensListItem; diff --git a/ui/address/tokens/ERC721TokensTable.tsx b/ui/address/tokens/ERC721TokensTable.tsx deleted file mode 100644 index 9490e2cb58..0000000000 --- a/ui/address/tokens/ERC721TokensTable.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Table, Tbody, Tr, Th } from '@chakra-ui/react'; -import React from 'react'; - -import type { AddressTokenBalance } from 'types/api/address'; - -import { default as Thead } from 'ui/shared/TheadSticky'; - -import ERC721TokensTableItem from './ERC721TokensTableItem'; - -interface Props { - data: Array; - top: number; - isLoading: boolean; -} - -const ERC721TokensTable = ({ data, top, isLoading }: Props) => { - return ( - - - - - - - - - - { data.map((item, index) => ( - - )) } - -
AssetContract addressQuantity
- ); -}; - -export default ERC721TokensTable; diff --git a/ui/address/tokens/ERC721TokensTableItem.tsx b/ui/address/tokens/ERC721TokensTableItem.tsx deleted file mode 100644 index fdb8c04611..0000000000 --- a/ui/address/tokens/ERC721TokensTableItem.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Tr, Td, Flex, Skeleton } from '@chakra-ui/react'; -import { useRouter } from 'next/router'; -import React from 'react'; - -import type { AddressTokenBalance } from 'types/api/address'; - -import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet'; -import AddressEntity from 'ui/shared/entities/address/AddressEntity'; -import TokenEntityWithAddressFilter from 'ui/shared/entities/token/TokenEntityWithAddressFilter'; - -type Props = AddressTokenBalance & { isLoading: boolean}; - -const ERC721TokensTableItem = ({ - token, - value, - isLoading, -}: Props) => { - const router = useRouter(); - - const hash = router.query.hash?.toString() || ''; - - return ( - - - - - - - - - - - - - { value } - - - - ); -}; - -export default React.memo(ERC721TokensTableItem); diff --git a/ui/address/tokens/NFTItem.tsx b/ui/address/tokens/NFTItem.tsx index b4439d7f66..50a33ab73b 100644 --- a/ui/address/tokens/NFTItem.tsx +++ b/ui/address/tokens/NFTItem.tsx @@ -1,7 +1,7 @@ -import { Box, Flex, Text, Link, useColorModeValue } from '@chakra-ui/react'; +import { Tag, Flex, Text, Link, Skeleton, LightMode } from '@chakra-ui/react'; import React from 'react'; -import type { AddressTokenBalance } from 'types/api/address'; +import type { AddressNFT } from 'types/api/address'; import { route } from 'nextjs-routes'; @@ -9,22 +9,20 @@ import NftEntity from 'ui/shared/entities/nft/NftEntity'; import TokenEntity from 'ui/shared/entities/token/TokenEntity'; import NftMedia from 'ui/shared/nft/NftMedia'; -type Props = AddressTokenBalance & { isLoading: boolean }; +import NFTItemContainer from './NFTItemContainer'; -const NFTItem = ({ token, token_id: tokenId, token_instance: tokenInstance, isLoading }: Props) => { - const tokenInstanceLink = tokenId ? route({ pathname: '/token/[hash]/instance/[id]', query: { hash: token.address, id: tokenId } }) : undefined; +type Props = AddressNFT & { isLoading: boolean; withTokenLink?: boolean }; + +const NFTItem = ({ token, value, isLoading, withTokenLink, ...tokenInstance }: Props) => { + const tokenInstanceLink = tokenInstance.id ? + route({ pathname: '/token/[hash]/instance/[id]', query: { hash: token.address, id: tokenInstance.id } }) : + undefined; return ( - + + + { token.type } + - { tokenId && ( - + + ID# - + + + { Number(value) > 1 && Qty { value } } + + + { withTokenLink && ( + ) } - - + ); }; diff --git a/ui/address/tokens/NFTItemContainer.tsx b/ui/address/tokens/NFTItemContainer.tsx new file mode 100644 index 0000000000..fc3d906d3e --- /dev/null +++ b/ui/address/tokens/NFTItemContainer.tsx @@ -0,0 +1,27 @@ +import { Box, useColorModeValue, chakra } from '@chakra-ui/react'; +import React from 'react'; + +type Props = { + children: React.ReactNode; + className?: string; +}; + +const NFTItemContainer = ({ children, className }: Props) => { + return ( + + { children } + + ); +}; + +export default chakra(NFTItemContainer); diff --git a/ui/address/utils/useFetchTokens.ts b/ui/address/utils/useFetchTokens.ts index 9bc003373a..9b5bcda583 100644 --- a/ui/address/utils/useFetchTokens.ts +++ b/ui/address/utils/useFetchTokens.ts @@ -1,13 +1,25 @@ +import { useQueryClient } from '@tanstack/react-query'; import React from 'react'; -import useApiQuery from 'lib/api/useApiQuery'; +import type { SocketMessage } from 'lib/socket/types'; +import type { AddressTokenBalance, AddressTokensBalancesSocketMessage, AddressTokensResponse } from 'types/api/address'; +import type { TokenType } from 'types/api/token'; -import { calculateUsdValue } from './tokenUtils'; +import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery'; +import useSocketChannel from 'lib/socket/useSocketChannel'; +import useSocketMessage from 'lib/socket/useSocketMessage'; +import { calculateUsdValue } from './tokenUtils'; interface Props { hash?: string; } +const tokenBalanceItemIdentityFactory = (match: AddressTokenBalance) => (item: AddressTokenBalance) => (( + match.token.address === item.token.address && + match.token_id === item.token_id && + match.token_instance?.id === item.token_instance?.id +)); + export default function useFetchTokens({ hash }: Props) { const erc20query = useApiQuery('address_tokens', { pathParams: { hash }, @@ -25,11 +37,67 @@ export default function useFetchTokens({ hash }: Props) { queryOptions: { enabled: Boolean(hash), refetchOnMount: false }, }); - const refetch = React.useCallback(() => { - erc20query.refetch(); - erc721query.refetch(); - erc1155query.refetch(); - }, [ erc1155query, erc20query, erc721query ]); + const queryClient = useQueryClient(); + + const updateTokensData = React.useCallback((type: TokenType, payload: AddressTokensBalancesSocketMessage) => { + const queryKey = getResourceKey('address_tokens', { pathParams: { hash }, queryParams: { type } }); + + queryClient.setQueryData(queryKey, (prevData: AddressTokensResponse | undefined) => { + const items = prevData?.items.map((currentItem) => { + const updatedData = payload.token_balances.find(tokenBalanceItemIdentityFactory(currentItem)); + return updatedData ?? currentItem; + }) || []; + + const extraItems = prevData?.next_page_params ? + [] : + payload.token_balances.filter((socketItem) => !items.some(tokenBalanceItemIdentityFactory(socketItem))); + + if (!prevData) { + return { + items: extraItems, + next_page_params: null, + }; + } + + return { + items: items.concat(extraItems), + next_page_params: prevData.next_page_params, + }; + }); + }, [ hash, queryClient ]); + + const handleTokenBalancesErc20Message: SocketMessage.AddressTokenBalancesErc20['handler'] = React.useCallback((payload) => { + updateTokensData('ERC-20', payload); + }, [ updateTokensData ]); + + const handleTokenBalancesErc721Message: SocketMessage.AddressTokenBalancesErc721['handler'] = React.useCallback((payload) => { + updateTokensData('ERC-721', payload); + }, [ updateTokensData ]); + + const handleTokenBalancesErc1155Message: SocketMessage.AddressTokenBalancesErc1155['handler'] = React.useCallback((payload) => { + updateTokensData('ERC-1155', payload); + }, [ updateTokensData ]); + + const channel = useSocketChannel({ + topic: `addresses:${ hash?.toLowerCase() }`, + isDisabled: Boolean(hash) && (erc20query.isPlaceholderData || erc721query.isPlaceholderData || erc1155query.isPlaceholderData), + }); + + useSocketMessage({ + channel, + event: 'updated_token_balances_erc_20', + handler: handleTokenBalancesErc20Message, + }); + useSocketMessage({ + channel, + event: 'updated_token_balances_erc_721', + handler: handleTokenBalancesErc721Message, + }); + useSocketMessage({ + channel, + event: 'updated_token_balances_erc_1155', + handler: handleTokenBalancesErc1155Message, + }); const data = React.useMemo(() => { return { @@ -52,6 +120,5 @@ export default function useFetchTokens({ hash }: Props) { isPending: erc20query.isPending || erc721query.isPending || erc1155query.isPending, isError: erc20query.isError || erc721query.isError || erc1155query.isError, data, - refetch, }; } diff --git a/ui/pages/Address.tsx b/ui/pages/Address.tsx index 51c58d493e..eb4b95dc36 100644 --- a/ui/pages/Address.tsx +++ b/ui/pages/Address.tsx @@ -2,7 +2,6 @@ import { Box, Flex, HStack, Icon } from '@chakra-ui/react'; import { useRouter } from 'next/router'; import React from 'react'; -import type { TokenType } from 'types/api/token'; import type { RoutedTab } from 'ui/shared/Tabs/types'; import config from 'configs/app'; @@ -36,13 +35,7 @@ import PageTitle from 'ui/shared/Page/PageTitle'; import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton'; -export const tokenTabsByType: Record = { - 'ERC-20': 'tokens_erc20', - 'ERC-721': 'tokens_erc721', - 'ERC-1155': 'tokens_erc1155', -} as const; - -const TOKEN_TABS = Object.values(tokenTabsByType); +const TOKEN_TABS = [ 'tokens_erc20', 'tokens_nfts', 'tokens_nfts_collection', 'tokens_nfts_list' ]; const AddressPageContent = () => { const router = useRouter(); diff --git a/ui/pages/Token.tsx b/ui/pages/Token.tsx index 18e9f2dd05..20a3cf5dfd 100644 --- a/ui/pages/Token.tsx +++ b/ui/pages/Token.tsx @@ -56,6 +56,7 @@ const TokenPageContent = () => { const hashString = getQueryParamString(router.query.hash); const tab = getQueryParamString(router.query.tab); + const ownerFilter = getQueryParamString(router.query.holder_address_hash) || undefined; const queryClient = useQueryClient(); @@ -140,6 +141,7 @@ const TokenPageContent = () => { const inventoryQuery = useQueryWithPages({ resourceName: 'token_inventory', pathParams: { hash: hashString }, + filters: ownerFilter ? { holder_address_hash: ownerFilter } : {}, scrollRef, options: { enabled: Boolean( @@ -150,7 +152,7 @@ const TokenPageContent = () => { tab === 'inventory' ), ), - placeholderData: generateListStub<'token_inventory'>(tokenStubs.TOKEN_INSTANCE, 50, { next_page_params: null }), + placeholderData: generateListStub<'token_inventory'>(tokenStubs.TOKEN_INSTANCE, 50, { next_page_params: { unique_token: 1 } }), }, }); @@ -173,9 +175,11 @@ const TokenPageContent = () => { const contractTabs = useContractTabs(contractQuery.data); const tabs: Array = [ - (tokenQuery.data?.type === 'ERC-1155' || tokenQuery.data?.type === 'ERC-721') ? - { id: 'inventory', title: 'Inventory', component: } : - undefined, + (tokenQuery.data?.type === 'ERC-1155' || tokenQuery.data?.type === 'ERC-721') ? { + id: 'inventory', + title: 'Inventory', + component: , + } : undefined, { id: 'token_transfers', title: 'Token transfers', component: }, { id: 'holders', title: 'Holders', component: }, contractQuery.data?.is_contract ? { diff --git a/ui/pages/Tokens.tsx b/ui/pages/Tokens.tsx index 2442470b37..1cac311216 100644 --- a/ui/pages/Tokens.tsx +++ b/ui/pages/Tokens.tsx @@ -105,7 +105,7 @@ const Tokens = () => { ) : ( 0 } contentProps={{ w: '200px' }} appliedFiltersNum={ tokenTypes?.length }> - + onChange={ handleTokenTypesChange } defaultValue={ tokenTypes } nftOnly={ false }/> ); diff --git a/ui/shared/ResetIconButton.tsx b/ui/shared/ResetIconButton.tsx new file mode 100644 index 0000000000..53519eb1d1 --- /dev/null +++ b/ui/shared/ResetIconButton.tsx @@ -0,0 +1,31 @@ +import { Tooltip, Flex, Icon, useColorModeValue } from '@chakra-ui/react'; +import React from 'react'; + +import crossIcon from 'icons/cross.svg'; + +type Props = { + onClick: () => void; +} + +const ResetIconButton = ({ onClick }: Props) => { + const resetTokenIconColor = useColorModeValue('blue.600', 'blue.300'); + const resetTokenIconHoverColor = useColorModeValue('blue.400', 'blue.200'); + + return ( + + + + + + ); +}; + +export default ResetIconButton; diff --git a/ui/shared/TokenTransfer/TokenTransferFilter.tsx b/ui/shared/TokenTransfer/TokenTransferFilter.tsx index c6bc95b713..137c545b34 100644 --- a/ui/shared/TokenTransfer/TokenTransferFilter.tsx +++ b/ui/shared/TokenTransfer/TokenTransferFilter.tsx @@ -56,7 +56,7 @@ const TokenTransferFilter = ({ ) } Type - + onChange={ onTypeFilterChange } defaultValue={ defaultTypeFilters } nftOnly={ false }/> ); }; diff --git a/ui/shared/filters/TokenTypeFilter.tsx b/ui/shared/filters/TokenTypeFilter.tsx index 3aec39fa48..9d80814284 100644 --- a/ui/shared/filters/TokenTypeFilter.tsx +++ b/ui/shared/filters/TokenTypeFilter.tsx @@ -1,16 +1,16 @@ import { CheckboxGroup, Checkbox, Text, Flex, Link, useCheckboxGroup } from '@chakra-ui/react'; import React from 'react'; -import type { TokenType } from 'types/api/token'; +import type { NFTTokenType, TokenType } from 'types/api/token'; -import { TOKEN_TYPES } from 'lib/token/tokenTypes'; +import { NFT_TOKEN_TYPES, TOKEN_TYPES } from 'lib/token/tokenTypes'; -type Props = { - onChange: (nextValue: Array) => void; - defaultValue?: Array; +type Props = { + onChange: (nextValue: Array) => void; + defaultValue?: Array; + nftOnly: T extends NFTTokenType ? true : false; } - -const TokenTypeFilter = ({ onChange, defaultValue }: Props) => { +const TokenTypeFilter = ({ nftOnly, onChange, defaultValue }: Props) => { const { value, setValue } = useCheckboxGroup({ defaultValue }); const handleReset = React.useCallback(() => { @@ -21,7 +21,7 @@ const TokenTypeFilter = ({ onChange, defaultValue }: Props) => { onChange([]); }, [ onChange, setValue, value.length ]); - const handleChange = React.useCallback((nextValue: Array) => { + const handleChange = React.useCallback((nextValue: Array) => { setValue(nextValue); onChange(nextValue); }, [ onChange, setValue ]); @@ -32,6 +32,7 @@ const TokenTypeFilter = ({ onChange, defaultValue }: Props) => { Type 0 ? 'pointer' : 'unset' } color={ value.length > 0 ? 'link' : 'text_secondary' } _hover={{ color: value.length > 0 ? 'link_hovered' : 'text_secondary', @@ -41,7 +42,7 @@ const TokenTypeFilter = ({ onChange, defaultValue }: Props) => {
- { TOKEN_TYPES.map(({ title, id }) => ( + { (nftOnly ? NFT_TOKEN_TYPES : TOKEN_TYPES).map(({ title, id }) => ( { title } diff --git a/ui/shared/radioButtonGroup/RadioButtonGroup.pw.tsx b/ui/shared/radioButtonGroup/RadioButtonGroup.pw.tsx new file mode 100644 index 0000000000..01e60fc43b --- /dev/null +++ b/ui/shared/radioButtonGroup/RadioButtonGroup.pw.tsx @@ -0,0 +1,19 @@ +import { Box } from '@chakra-ui/react'; +import { test, expect } from '@playwright/experimental-ct-react'; +import React from 'react'; + +import TestApp from 'playwright/TestApp'; + +import RadioButtonGroupTest from './specs/RadioButtonGroupTest'; + +test('radio button group', async({ mount }) => { + const component = await mount( + + + + + , + ); + + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/shared/radioButtonGroup/RadioButtonGroup.tsx b/ui/shared/radioButtonGroup/RadioButtonGroup.tsx new file mode 100644 index 0000000000..27a8d70fa3 --- /dev/null +++ b/ui/shared/radioButtonGroup/RadioButtonGroup.tsx @@ -0,0 +1,97 @@ +import { ButtonGroup, Button, Flex, Icon, useRadio, useRadioGroup, useColorModeValue } from '@chakra-ui/react'; +import type { UseRadioProps } from '@chakra-ui/react'; +import React from 'react'; + +type RadioItemProps = { + title: string; + icon?: React.FC>; + onlyIcon: false | undefined; +} | { + title: string; + icon: React.FC>; + onlyIcon: true; +} + +type RadioButtonProps = UseRadioProps & RadioItemProps; + +const RadioButton = (props: RadioButtonProps) => { + const { getInputProps, getRadioProps } = useRadio(props); + const buttonColor = useColorModeValue('blue.50', 'gray.800'); + const checkedTextColor = useColorModeValue('blue.700', 'gray.50'); + + const input = getInputProps(); + const checkbox = getRadioProps(); + + const styleProps = { + flex: 1, + variant: 'outline', + fontWeight: 500, + cursor: props.isChecked ? 'initial' : 'pointer', + borderColor: buttonColor, + backgroundColor: props.isChecked ? buttonColor : 'none', + _hover: { + borderColor: buttonColor, + ...(props.isChecked ? {} : { color: 'link_hovered' }), + }, + _active: { + backgroundColor: 'none', + }, + ...(props.isChecked ? { color: checkedTextColor } : {}), + }; + + if (props.onlyIcon) { + return ( + + ); + } + + return ( + + ); +}; + +type RadioButtonGroupProps = { + onChange: (value: T) => void; + name: string; + defaultValue: string; + options: Array<{ value: T } & RadioItemProps>; +} + +const RadioButtonGroup = ({ onChange, name, defaultValue, options }: RadioButtonGroupProps) => { + const { getRootProps, getRadioProps } = useRadioGroup({ name, defaultValue, onChange }); + + const group = getRootProps(); + + return ( + + { options.map((option) => { + const props = getRadioProps({ value: option.value }); + return ; + }) } + + ); +}; + +export default RadioButtonGroup; diff --git a/ui/shared/radioButtonGroup/__screenshots__/RadioButtonGroup.pw.tsx_default_radio-button-group-1.png b/ui/shared/radioButtonGroup/__screenshots__/RadioButtonGroup.pw.tsx_default_radio-button-group-1.png new file mode 100644 index 0000000000..212b0b3e89 Binary files /dev/null and b/ui/shared/radioButtonGroup/__screenshots__/RadioButtonGroup.pw.tsx_default_radio-button-group-1.png differ diff --git a/ui/shared/radioButtonGroup/specs/RadioButtonGroupTest.tsx b/ui/shared/radioButtonGroup/specs/RadioButtonGroupTest.tsx new file mode 100644 index 0000000000..e62adedcc2 --- /dev/null +++ b/ui/shared/radioButtonGroup/specs/RadioButtonGroupTest.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +import RadioButtonGroup from '../RadioButtonGroup'; + +const TestIcon = ({ className }: {className?: string}) => { + return ( + + { /* eslint-disable-next-line max-len */ } + + + ); +}; + +type Test = 'v1' | 'v2' | 'v3'; + +const RadioButtonGroupTest = () => { + return ( + + // eslint-disable-next-line react/jsx-no-bind + onChange={ () => {} } + defaultValue="v1" + name="test" + options={ [ + { value: 'v1', title: 'test option 1', icon: TestIcon, onlyIcon: false }, + { value: 'v2', title: 'test 2', onlyIcon: false }, + { value: 'v2', title: 'test 2', icon: TestIcon, onlyIcon: true }, + ] } + /> + ); +}; + +export default RadioButtonGroupTest; diff --git a/ui/token/TokenInventory.tsx b/ui/token/TokenInventory.tsx index 2a99a5e828..50153b466c 100644 --- a/ui/token/TokenInventory.tsx +++ b/ui/token/TokenInventory.tsx @@ -1,4 +1,4 @@ -import { Grid } from '@chakra-ui/react'; +import { Flex, Grid, Text } from '@chakra-ui/react'; import type { UseQueryResult } from '@tanstack/react-query'; import React from 'react'; @@ -8,23 +8,50 @@ import type { ResourceError } from 'lib/api/resources'; import useIsMobile from 'lib/hooks/useIsMobile'; import ActionBar from 'ui/shared/ActionBar'; import DataListDisplay from 'ui/shared/DataListDisplay'; +import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import Pagination from 'ui/shared/pagination/Pagination'; import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages'; +import ResetIconButton from 'ui/shared/ResetIconButton'; import TokenInventoryItem from './TokenInventoryItem'; type Props = { inventoryQuery: QueryWithPagesResult<'token_inventory'>; tokenQuery: UseQueryResult>; + ownerFilter?: string; } -const TokenInventory = ({ inventoryQuery, tokenQuery }: Props) => { +const TokenInventory = ({ inventoryQuery, tokenQuery, ownerFilter }: Props) => { const isMobile = useIsMobile(); - const actionBar = isMobile && inventoryQuery.pagination.isVisible && ( - - - + const resetOwnerFilter = React.useCallback(() => { + inventoryQuery.onFilterChange({}); + }, [ inventoryQuery ]); + + const isActionBarHidden = !ownerFilter && !inventoryQuery.data?.items.length; + + const ownerFilterComponent = ownerFilter && ( + + Filtered by owner + + + + + + ); + + const actionBar = !isActionBarHidden && ( + <> + { ownerFilterComponent } + + { isMobile && } + + ); const items = inventoryQuery.data?.items;