From 4b72ec9095ba178befcf2a564a5c8e964394cdfc Mon Sep 17 00:00:00 2001 From: Albina Nikiforova Date: Thu, 21 Nov 2024 12:21:27 +0100 Subject: [PATCH] feat(suite): nft section --- .../suite-desktop-ui/src/support/Router.tsx | 2 + packages/suite-web/src/support/Router.tsx | 1 + .../AccountTopPanel/AccountNavigation.tsx | 12 +++ packages/suite/src/support/messages.ts | 12 +++ packages/suite/src/utils/wallet/nftUtils.ts | 60 +++++++++++++ .../src/views/wallet/nfts/NftsNavigation.tsx | 85 +++++++++++++++++++ .../wallet/nfts/NftsTable/HiddenNftsTable.tsx | 58 +++++++++++++ .../views/wallet/nfts/NftsTable/NftsRow.tsx | 74 ++++++++++++++++ .../views/wallet/nfts/NftsTable/NftsTable.tsx | 61 +++++++++++++ .../suite/src/views/wallet/nfts/index.tsx | 63 ++++++++++++++ suite-common/suite-config/src/routes.ts | 14 +++ suite-common/wallet-utils/src/accountUtils.ts | 13 +++ .../wallet-utils/src/transactionUtils.ts | 7 ++ 13 files changed, 462 insertions(+) create mode 100644 packages/suite/src/utils/wallet/nftUtils.ts create mode 100644 packages/suite/src/views/wallet/nfts/NftsNavigation.tsx create mode 100644 packages/suite/src/views/wallet/nfts/NftsTable/HiddenNftsTable.tsx create mode 100644 packages/suite/src/views/wallet/nfts/NftsTable/NftsRow.tsx create mode 100644 packages/suite/src/views/wallet/nfts/NftsTable/NftsTable.tsx create mode 100644 packages/suite/src/views/wallet/nfts/index.tsx diff --git a/packages/suite-desktop-ui/src/support/Router.tsx b/packages/suite-desktop-ui/src/support/Router.tsx index 4f954558a955..dd7e7a928d52 100644 --- a/packages/suite-desktop-ui/src/support/Router.tsx +++ b/packages/suite-desktop-ui/src/support/Router.tsx @@ -33,6 +33,7 @@ import { SettingsCoins } from 'src/views/settings/SettingsCoins/SettingsCoins'; import { SettingsDebug } from 'src/views/settings/SettingsDebug/SettingsDebug'; import { SettingsDevice } from 'src/views/settings/SettingsDevice/SettingsDevice'; import { Tokens } from 'src/views/wallet/tokens'; +import { Nfts } from 'src/views/wallet/nfts'; import PasswordManager from 'src/views/password-manager'; const components: { [key: string]: ComponentType } = { @@ -47,6 +48,7 @@ const components: { [key: string]: ComponentType } = { 'wallet-sign-verify': WalletSignVerify, 'wallet-anonymize': WalletAnonymize, 'wallet-tokens': Tokens, + 'wallet-nfts': Nfts, 'wallet-coinmarket-buy': CoinmarketBuyForm, 'wallet-coinmarket-buy-detail': CoinmarketBuyDetail, 'wallet-coinmarket-buy-offers': CoinmarketBuyOffers, diff --git a/packages/suite-web/src/support/Router.tsx b/packages/suite-web/src/support/Router.tsx index 5e09600f4b35..77f67f69128f 100644 --- a/packages/suite-web/src/support/Router.tsx +++ b/packages/suite-web/src/support/Router.tsx @@ -31,6 +31,7 @@ const components: Record>> = { () => import(/* webpackChunkName: "wallet" */ 'src/views/wallet/details'), ), 'wallet-tokens': lazy(() => import(/* webpackChunkName: "wallet" */ 'src/views/wallet/tokens')), + 'wallet-nfts': lazy(() => import(/* webpackChunkName: "wallet" */ 'src/views/wallet/nfts')), 'wallet-send': lazy(() => import(/* webpackChunkName: "wallet" */ 'src/views/wallet/send')), 'wallet-staking': lazy(() => import(/* webpackChunkName: "wallet" */ 'src/views/wallet/staking/WalletStaking').then( diff --git a/packages/suite/src/components/wallet/WalletLayout/AccountTopPanel/AccountNavigation.tsx b/packages/suite/src/components/wallet/WalletLayout/AccountTopPanel/AccountNavigation.tsx index 9a61238f7b9e..609ef8d5b206 100644 --- a/packages/suite/src/components/wallet/WalletLayout/AccountTopPanel/AccountNavigation.tsx +++ b/packages/suite/src/components/wallet/WalletLayout/AccountTopPanel/AccountNavigation.tsx @@ -13,6 +13,8 @@ export const ACCOUNT_TABS = [ 'wallet-index', 'wallet-details', 'wallet-tokens', + 'wallet-nfts', + 'wallet-nfts-hidden', 'wallet-tokens-hidden', 'wallet-staking', ]; @@ -54,6 +56,16 @@ export const AccountNavigation = () => { activeRoutes: ['wallet-tokens', 'wallet-tokens-hidden'], 'data-testid': '@wallet/menu/wallet-tokens', }, + { + id: 'wallet-nfts', + callback: () => { + goToWithAnalytics('wallet-nfts', { preserveParams: true }); + }, + title: , + isHidden: !['ethereum'].includes(networkType), + activeRoutes: ['wallet-nfts', 'wallet-nfts-hidden'], + 'data-testid': '@wallet/menu/wallet-nfts', + }, { id: 'wallet-staking', callback: () => { diff --git a/packages/suite/src/support/messages.ts b/packages/suite/src/support/messages.ts index 6d1360b65a00..39dcacc69d0d 100644 --- a/packages/suite/src/support/messages.ts +++ b/packages/suite/src/support/messages.ts @@ -2721,6 +2721,10 @@ const messages = defineMessagesWithTypeCheck({ defaultMessage: 'Tokens', id: 'TR_NAV_TOKENS', }, + TR_NAV_NFTS: { + defaultMessage: 'NFT', + id: 'TR_NAV_NFTS', + }, TR_NAV_SIGN_AND_VERIFY: { defaultMessage: 'Sign & Verify', description: @@ -5281,6 +5285,14 @@ const messages = defineMessagesWithTypeCheck({ id: 'TR_TOKENS', defaultMessage: 'Tokens', }, + TR_COLLECTION: { + id: 'TR_COLLECTION', + defaultMessage: 'Collection', + }, + TR_NFT: { + id: 'TR_NFT', + defaultMessage: 'NFT', + }, TR_TOKENS_EMPTY: { id: 'TR_TOKENS_EMPTY', defaultMessage: 'No tokens... yet.', diff --git a/packages/suite/src/utils/wallet/nftUtils.ts b/packages/suite/src/utils/wallet/nftUtils.ts new file mode 100644 index 000000000000..d5a56325d1e4 --- /dev/null +++ b/packages/suite/src/utils/wallet/nftUtils.ts @@ -0,0 +1,60 @@ +import { isTokenDefinitionKnown, TokenDefinition } from '@suite-common/token-definitions'; +import { isNftMatchesSearch, filterNftTokens } from '@suite-common/wallet-utils'; +import { NetworkSymbol, getNetworkFeatures } from '@suite-common/wallet-config'; +import { Token } from '@trezor/blockchain-link-types/src/blockbook-api'; + +type GetNfts = { + tokens: Token[]; + symbol: NetworkSymbol; + nftDefinitions?: TokenDefinition; + searchQuery?: string; +}; + +export type NftType = 'ERC721' | 'ERC1155'; + +export const getNfts = ({ tokens, symbol, nftDefinitions, searchQuery }: GetNfts) => { + // filter out NFT tokens until we implement them + const nfts = filterNftTokens(tokens); + + const hasNftDefinitions = getNetworkFeatures(symbol).includes('nft-definitions'); + + const shownVerified: Token[] = []; + const shownUnverified: Token[] = []; + const hiddenVerified: Token[] = []; + const hiddenUnverified: Token[] = []; + + nfts.forEach(token => { + const isKnown = isTokenDefinitionKnown(nftDefinitions?.data, symbol, token.contract || ''); + const isHidden = nftDefinitions?.hide.includes(token.contract || ''); + const isShown = nftDefinitions?.show.includes(token.contract || ''); + + const query = searchQuery ? searchQuery.trim().toLowerCase() : ''; + + if (searchQuery && !isNftMatchesSearch(token, query)) return; + + const pushToArray = (arrayVerified: Token[], arrayUnverified: Token[]) => { + if (isKnown) { + arrayVerified.push(token); + } else { + arrayUnverified.push(token); + } + }; + + if (isShown) { + pushToArray(shownVerified, shownUnverified); + } else if (hasNftDefinitions && !isKnown) { + pushToArray(hiddenVerified, hiddenUnverified); + } else if (isHidden) { + pushToArray(hiddenVerified, hiddenUnverified); + } else { + pushToArray(shownVerified, shownUnverified); + } + }); + + return { + shownVerified, + shownUnverified, + hiddenVerified, + hiddenUnverified, + }; +}; diff --git a/packages/suite/src/views/wallet/nfts/NftsNavigation.tsx b/packages/suite/src/views/wallet/nfts/NftsNavigation.tsx new file mode 100644 index 000000000000..b172164b77c9 --- /dev/null +++ b/packages/suite/src/views/wallet/nfts/NftsNavigation.tsx @@ -0,0 +1,85 @@ +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; + +import { SelectedAccountLoaded } from '@suite-common/wallet-types'; +import { selectNftDefinitions } from '@suite-common/token-definitions'; +import { spacings } from '@trezor/theme'; +import { Row } from '@trezor/components'; + +import { useSelector } from 'src/hooks/suite'; +import { NavigationItem } from 'src/components/suite/layouts/SuiteLayout/Sidebar/NavigationItem'; +import { getNfts } from 'src/utils/wallet/nftUtils'; +import { selectRouteName } from 'src/reducers/suite/routerReducer'; +import { SearchAction } from 'src/components/wallet/SearchAction'; +import { filterNftTokens } from '@suite-common/wallet-utils'; + +interface NftsNavigationProps { + selectedAccount: SelectedAccountLoaded; + searchQuery: string; + setSearchQuery: Dispatch>; +} + +export const NftsNavigation = ({ + selectedAccount, + searchQuery, + setSearchQuery, +}: NftsNavigationProps) => { + const { account } = selectedAccount; + + const [isExpanded, setExpanded] = useState(false); + + const routeName = useSelector(selectRouteName); + + const nftDefinitions = useSelector(state => + selectNftDefinitions(state, selectedAccount.account.symbol), + ); + + const filteredTokens = filterNftTokens(account.tokens || []); + + const nfts = getNfts({ tokens: filteredTokens, symbol: account.symbol, nftDefinitions }); + + useEffect(() => { + setSearchQuery(''); + setExpanded(false); + }, [account.symbol, account.index, account.accountType, setSearchQuery]); + + return ( + + + + + + + + + + ); +}; diff --git a/packages/suite/src/views/wallet/nfts/NftsTable/HiddenNftsTable.tsx b/packages/suite/src/views/wallet/nfts/NftsTable/HiddenNftsTable.tsx new file mode 100644 index 000000000000..78be2643355e --- /dev/null +++ b/packages/suite/src/views/wallet/nfts/NftsTable/HiddenNftsTable.tsx @@ -0,0 +1,58 @@ +import { Card, Table } from '@trezor/components'; +import { SelectedAccountLoaded } from '@suite-common/wallet-types'; +import { Translation } from 'src/components/suite'; +import { filterNftTokens } from '@suite-common/wallet-utils'; +import { selectNftDefinitions } from '@suite-common/token-definitions'; +import NftsRow from './NftsRow'; +import { useSelector } from 'src/hooks/suite'; +import { getNfts, NftType } from 'src/utils/wallet/nftUtils'; +import { getNetwork } from '@suite-common/wallet-config'; + +type NftsTableProps = { + selectedAccount: SelectedAccountLoaded; + searchQuery: string; + type?: NftType; + verified?: boolean; +}; + +const HiddenNftsTable = ({ selectedAccount, searchQuery, type, verified }: NftsTableProps) => { + const { account } = selectedAccount; + const network = getNetwork(account.symbol); + const nftDefinitions = useSelector(state => selectNftDefinitions(state, account.symbol)); + const filteredTokens = filterNftTokens(account?.tokens || []); + const nfts = getNfts({ tokens: filteredTokens, symbol: account.symbol, nftDefinitions }); + + const hiddenNfts = verified + ? nfts.hiddenVerified + : [...nfts.hiddenVerified, ...nfts.hiddenUnverified]; + + return ( + + + + + + + + + + + + + + {hiddenNfts.map(nft => ( + +
+
+ ); +}; + +export default HiddenNftsTable; diff --git a/packages/suite/src/views/wallet/nfts/NftsTable/NftsRow.tsx b/packages/suite/src/views/wallet/nfts/NftsTable/NftsRow.tsx new file mode 100644 index 000000000000..0fc7a2637642 --- /dev/null +++ b/packages/suite/src/views/wallet/nfts/NftsTable/NftsRow.tsx @@ -0,0 +1,74 @@ +import { Network } from '@suite-common/wallet-config'; +import { DefinitionType, tokenDefinitionsActions } from '@suite-common/token-definitions'; +import { TokenManagementAction } from '@suite-common/token-definitions'; +import { Token } from '@trezor/blockchain-link-types/src/blockbook-api'; +import { Button, Table } from '@trezor/components'; +import { Translation } from 'src/components/suite'; + +import { NftType } from 'src/utils/wallet/nftUtils'; + +import { useDispatch } from 'src/hooks/suite'; + +type NftsRowProps = { + nft: Token; + type: NftType; + hidden?: boolean; + network: Network; +}; + +const NftsRow = ({ nft, type, hidden, network }: NftsRowProps) => { + const dispatch = useDispatch(); + + return ( + + {nft.name} + + {(type === 'ERC721' && nft.ids?.map(id => id.toString()).join(', ')) || ''} + {(type === 'ERC1155' && + nft.multiTokenValues?.map(value => value.id?.toString()).join(', ')) || + ''} + + + {hidden ? ( + + ) : ( + + )} + + + ); +}; + +export default NftsRow; diff --git a/packages/suite/src/views/wallet/nfts/NftsTable/NftsTable.tsx b/packages/suite/src/views/wallet/nfts/NftsTable/NftsTable.tsx new file mode 100644 index 000000000000..e0e070924235 --- /dev/null +++ b/packages/suite/src/views/wallet/nfts/NftsTable/NftsTable.tsx @@ -0,0 +1,61 @@ +import { SelectedAccountLoaded } from '@suite-common/wallet-types'; +import { Card, Column, Table, Text } from '@trezor/components'; +import { selectNftDefinitions } from '@suite-common/token-definitions'; + +import { Translation } from 'src/components/suite/Translation'; +import { useSelector } from 'src/hooks/suite'; +import { getNfts } from 'src/utils/wallet/nftUtils'; + +import NftsRow from './NftsRow'; +import { filterNftTokens } from '@suite-common/wallet-utils'; +import { Token } from '@trezor/blockchain-link-types/src/blockbook-api'; +import { getNetwork } from '@suite-common/wallet-config'; + +type NftsTableProps = { + selectedAccount: SelectedAccountLoaded; + searchQuery: string; + type: 'ERC721' | 'ERC1155'; +}; + +const NftsTable = ({ selectedAccount, searchQuery, type }: NftsTableProps) => { + const { account } = selectedAccount; + const nftDefinitions = useSelector(state => selectNftDefinitions(state, account.symbol)); + const filteredTokens = filterNftTokens(account?.tokens || []); + const nfts = getNfts({ tokens: filteredTokens, symbol: account.symbol, nftDefinitions }); + const network = getNetwork(account.symbol); + + const filterNftsByType = (nfts: Token[]) => { + return nfts.filter(nft => nft.type === type); + }; + + const shownNfts = filterNftsByType([...nfts.shownVerified, ...nfts.shownUnverified]); + + return ( + + + {type} + + + + + + + + + + + + + + + {shownNfts.map(nft => ( + + ))} + +
+
+
+ ); +}; + +export default NftsTable; diff --git a/packages/suite/src/views/wallet/nfts/index.tsx b/packages/suite/src/views/wallet/nfts/index.tsx new file mode 100644 index 000000000000..4906208073c9 --- /dev/null +++ b/packages/suite/src/views/wallet/nfts/index.tsx @@ -0,0 +1,63 @@ +import { useState, useEffect } from 'react'; +import { Route, Switch } from 'react-router-dom'; + +import { WalletLayout } from 'src/components/wallet'; +import { useDispatch, useSelector } from 'src/hooks/suite'; +import { goto } from 'src/actions/suite/routerActions'; + +import { NftsNavigation } from './NftsNavigation'; +import NftsTable from './NftsTable/NftsTable'; +import HiddenNftsTable from './NftsTable/HiddenNftsTable'; +import { Column } from '@trezor/components'; + +export const Nfts = () => { + const [searchQuery, setSearchQuery] = useState(''); + + const { selectedAccount } = useSelector(state => state.wallet); + + const dispatch = useDispatch(); + + useEffect(() => { + if ( + selectedAccount.status === 'loaded' && + !selectedAccount.network?.features.includes('tokens') + ) { + dispatch(goto('wallet-index', { preserveParams: true })); + } + }, [selectedAccount, dispatch]); + + if (selectedAccount.status !== 'loaded') { + return ; + } + + return ( + + + + + + + + + + + + + + + ); +}; + +export default Nfts; diff --git a/suite-common/suite-config/src/routes.ts b/suite-common/suite-config/src/routes.ts index e420bca15a4a..8eb04dd51809 100644 --- a/suite-common/suite-config/src/routes.ts +++ b/suite-common/suite-config/src/routes.ts @@ -276,6 +276,20 @@ export const routes = [ params: walletParams, isNestedRoute: true, }, + { + name: 'wallet-nfts', + pattern: '/accounts/nfts', + app: 'wallet', + params: walletParams, + }, + { + name: 'wallet-nfts-hidden', + pattern: '/accounts/nfts/hidden', + app: 'wallet', + params: walletParams, + isNestedRoute: true, + }, + { name: 'wallet-anonymize', pattern: '/accounts/anonymize', diff --git a/suite-common/wallet-utils/src/accountUtils.ts b/suite-common/wallet-utils/src/accountUtils.ts index 64e58943e966..169521935bd5 100644 --- a/suite-common/wallet-utils/src/accountUtils.ts +++ b/suite-common/wallet-utils/src/accountUtils.ts @@ -44,6 +44,7 @@ import { toFiatCurrency } from './fiatConverterUtils'; import { getFiatRateKey } from './fiatRatesUtils'; import { getAccountTotalStakingBalance } from './ethereumStakingUtils'; import { isRbfTransaction } from './transactionUtils'; +import { Token } from '@trezor/blockchain-link-types/src/blockbook-api'; export const isUtxoBased = (account: Account) => account.networkType === 'bitcoin' || account.networkType === 'cardano'; @@ -1175,3 +1176,15 @@ export const isTokenMatchesSearch = (token: TokenInfo, search: string) => { token.policyId?.toLowerCase().includes(search) ); }; + +export const isNftMatchesSearch = (token: Token, search: string) => { + return ( + token.symbol?.toLowerCase().includes(search) || + token.name?.toLowerCase().includes(search) || + token.contract?.toLowerCase().includes(search) || + token.ids + ?.map(id => id.toString()) + .join(', ') + .includes(search) + ); +}; diff --git a/suite-common/wallet-utils/src/transactionUtils.ts b/suite-common/wallet-utils/src/transactionUtils.ts index 12b0be8287e9..cbcd7012662c 100644 --- a/suite-common/wallet-utils/src/transactionUtils.ts +++ b/suite-common/wallet-utils/src/transactionUtils.ts @@ -20,6 +20,7 @@ import { AccountTransaction, TokenTransfer, InternalTransfer, + TokenInfo, } from '@trezor/connect'; import { SignOperator } from '@suite-common/suite-types'; import { arrayPartition } from '@trezor/utils'; @@ -29,6 +30,7 @@ import { FiatCurrencyCode } from '@suite-common/suite-config'; import { formatAmount, formatNetworkAmount, isTokenMatchesSearch } from './accountUtils'; import { toFiatCurrency } from '../src/fiatConverterUtils'; import { getFiatRateKey, roundTimestampToNearestPastHour } from './fiatRatesUtils'; +import { Token } from '@trezor/blockchain-link-types/src/blockbook-api'; export const sortByBlockHeight = (a: { blockHeight?: number }, b: { blockHeight?: number }) => { // if both are missing the blockHeight don't change their order @@ -484,6 +486,11 @@ export const getNftTokenId = (transfer: TokenTransfer) => ? transfer.multiTokenValues[0].id : transfer.amount; +export const isNftToken = (token: Token) => token.type === 'ERC721' || token.type === 'ERC1155'; + +export const filterNftTokens = (tokens: Token[] | TokenInfo[]) => + tokens.filter(token => isNftToken(token as Token)) as Token[]; + export const getTxIcon = (txType: WalletAccountTransaction['type']) => { switch (txType) { case 'recv':