From 407295f1ae9caee10dfbd3ac0d4cd0fad66bf464 Mon Sep 17 00:00:00 2001 From: Josh Leonard <30185185+josheleonard@users.noreply.github.com> Date: Wed, 5 Jun 2024 13:07:47 -0600 Subject: [PATCH] Fix(wallet): paginate portfolio NFTs page (#23816) * fix: paginate portfolio NFTs page * feat(wallet): allow hiding unowned NFTs * fix(wallet): remove group-by filter from NFTs portfolio view * fix(wallet): reset nfts portfolio page number when saving new filters * fix(wallet): update found portfolio NFTs counting and loading logic --- .../browser/brave_wallet_constants.h | 1 + .../common/async/base-query-cache.ts | 41 ++ .../common/constants/local-storage-keys.ts | 4 +- .../common/hooks/use-balances-fetcher.tsx | 5 +- .../common/slices/endpoints/nfts.endpoints.ts | 45 +- .../endpoints/token_balances.endpoints.ts | 66 +- .../filter-modals/portfolio-filters-modal.tsx | 49 +- .../nft-group-selector/nft-group-selector.tsx | 5 +- .../desktop/views/nfts/components/nfts.tsx | 647 ++++++++++-------- .../nft-grid-view/nft-grid-view-item.tsx | 4 +- .../views/portfolio/portfolio-overview.tsx | 89 +-- .../shared/pagination/pagination.stories.tsx | 31 + .../shared/pagination/pagination.styles.ts | 20 + .../shared/pagination/pagination.tsx | 128 ++++ components/brave_wallet_ui/constants/types.ts | 2 + components/brave_wallet_ui/stories/locale.ts | 1 + .../utils/local-storage-utils.ts | 30 + .../brave_wallet_ui/utils/routes-utils.ts | 14 +- components/resources/wallet_strings.grdp | 1 + ui/webui/resources/BUILD.gn | 2 + 20 files changed, 757 insertions(+), 428 deletions(-) create mode 100644 components/brave_wallet_ui/components/shared/pagination/pagination.stories.tsx create mode 100644 components/brave_wallet_ui/components/shared/pagination/pagination.styles.ts create mode 100644 components/brave_wallet_ui/components/shared/pagination/pagination.tsx diff --git a/components/brave_wallet/browser/brave_wallet_constants.h b/components/brave_wallet/browser/brave_wallet_constants.h index 6341457d5e8..c67321f75bb 100644 --- a/components/brave_wallet/browser/brave_wallet_constants.h +++ b/components/brave_wallet/browser/brave_wallet_constants.h @@ -46,6 +46,7 @@ inline constexpr char kSimpleHashBraveProxyUrl[] = "https://simplehash.wallet.brave.com"; inline constexpr webui::LocalizedString kLocalizedStrings[] = { + {"braveWalletHideNotOwnedNfTs", IDS_BRAVE_WALLET_HIDE_NOT_OWNED_NF_TS}, {"braveWalletNoRoutesFound", IDS_BRAVE_WALLET_NO_ROUTES_FOUND}, {"braveWalletPrivateKeyImportType", IDS_BRAVE_WALLET_PRIVATE_KEY_IMPORT_TYPE}, diff --git a/components/brave_wallet_ui/common/async/base-query-cache.ts b/components/brave_wallet_ui/common/async/base-query-cache.ts index 0237b82edeb..0bcdbb1c5bb 100644 --- a/components/brave_wallet_ui/common/async/base-query-cache.ts +++ b/components/brave_wallet_ui/common/async/base-query-cache.ts @@ -90,6 +90,10 @@ export class BaseQueryCache { private _nftMetadataRegistry: Record = {} public rewardsInfo: BraveRewardsInfo | undefined = undefined public balanceScannerSupportedChains: string[] | undefined = undefined + public spamNftsForAccountRegistry: Record< + string, // accountUniqueId + BraveWallet.BlockchainToken[] + > = {} getWalletInfo = async () => { if (!this.walletInfo) { @@ -483,6 +487,43 @@ export class BaseQueryCache { return this._nftMetadataRegistry[tokenId] } + getSpamNftsForAccountId = async (accountId: BraveWallet.AccountId) => { + if (!this.spamNftsForAccountRegistry[accountId.uniqueKey]) { + const { braveWalletService } = getAPIProxy() + const { address, coin } = accountId + const networksRegistry = await cache.getNetworksRegistry() + + const chainIds = networksRegistry.ids.map( + (network) => networksRegistry.entities[network]!.chainId + ) + + let currentCursor: string | null = null + const accountSpamNfts = [] + + do { + const { + tokens, + cursor + }: { + tokens: BraveWallet.BlockchainToken[] + cursor: string | null + } = await braveWalletService.getSimpleHashSpamNFTs( + address, + chainIds, + coin, + currentCursor + ) + + accountSpamNfts.push(...tokens) + currentCursor = cursor + } while (currentCursor) + + this.spamNftsForAccountRegistry[accountId.uniqueKey] = accountSpamNfts + } + + return this.spamNftsForAccountRegistry[accountId.uniqueKey] + } + // Brave Rewards getBraveRewardsInfo = async () => { if (!this.rewardsInfo) { diff --git a/components/brave_wallet_ui/common/constants/local-storage-keys.ts b/components/brave_wallet_ui/common/constants/local-storage-keys.ts index c849257b58e..8e2be3c3520 100644 --- a/components/brave_wallet_ui/common/constants/local-storage-keys.ts +++ b/components/brave_wallet_ui/common/constants/local-storage-keys.ts @@ -27,9 +27,11 @@ export const LOCAL_STORAGE_KEYS = { CURRENT_PANEL: 'BRAVE_WALLET_CURRENT_PANEL', LAST_VISITED_PANEL: 'BRAVE_WALLET_LAST_VISITED_PANEL', TOKEN_BALANCES: 'BRAVE_WALLET_TOKEN_BALANCES2', + SPAM_TOKEN_BALANCES: 'SPAM_TOKEN_BALANCES', SAVED_SESSION_ROUTE: 'BRAVE_WALLET_SAVED_SESSION_ROUTE', USER_HIDDEN_TOKEN_IDS: 'BRAVE_WALLET_USER_HIDDEN_TOKEN_IDS', - USER_DELETED_TOKEN_IDS: 'BRAVE_WALLET_USER_DELETED_TOKEN_IDS' + USER_DELETED_TOKEN_IDS: 'BRAVE_WALLET_USER_DELETED_TOKEN_IDS', + HIDE_UNOWNED_NFTS: 'HIDE_UNOWNED_NFTS' } as const const LOCAL_STORAGE_KEYS_DEPRECATED = { diff --git a/components/brave_wallet_ui/common/hooks/use-balances-fetcher.tsx b/components/brave_wallet_ui/common/hooks/use-balances-fetcher.tsx index 3f184aad140..dd355f56d22 100644 --- a/components/brave_wallet_ui/common/hooks/use-balances-fetcher.tsx +++ b/components/brave_wallet_ui/common/hooks/use-balances-fetcher.tsx @@ -16,7 +16,7 @@ import { GetTokenBalancesRegistryArg // } from '../slices/endpoints/token_balances.endpoints' -type Arg = Pick & { +type Arg = Pick & { accounts: BraveWallet.AccountInfo[] } @@ -45,7 +45,8 @@ export const useBalancesFetcher = (arg: Arg | typeof skipToken) => { supportedKeyrings }) ), - useAnkrBalancesFeature + useAnkrBalancesFeature, + isSpamRegistry: arg.isSpamRegistry } : skipToken, { diff --git a/components/brave_wallet_ui/common/slices/endpoints/nfts.endpoints.ts b/components/brave_wallet_ui/common/slices/endpoints/nfts.endpoints.ts index ab83961c130..566f2562b54 100644 --- a/components/brave_wallet_ui/common/slices/endpoints/nfts.endpoints.ts +++ b/components/brave_wallet_ui/common/slices/endpoints/nfts.endpoints.ts @@ -206,46 +206,25 @@ export const nftsEndpoints = ({ } } }), - getSimpleHashSpamNfts: query({ - queryFn: async (_arg, { endpoint }, _extraOptions, baseQuery) => { + + /** will get spam for all accounts if accounts arg is not provided */ + getSimpleHashSpamNfts: query< + BraveWallet.BlockchainToken[], + void | undefined | { accounts: BraveWallet.AccountInfo[] } + >({ + queryFn: async (arg, { endpoint }, _extraOptions, baseQuery) => { try { - const { data: api, cache } = baseQuery(undefined) - const { braveWalletService } = api + const { cache } = baseQuery(undefined) - const networksRegistry = await cache.getNetworksRegistry() + const lookupAccounts = + arg?.accounts ?? (await cache.getAllAccounts()).accounts - const chainIds = networksRegistry.ids.map( - (network) => networksRegistry.entities[network]!.chainId - ) - - const { accounts } = await cache.getAllAccounts() const spamNfts = ( await mapLimit( - accounts, + lookupAccounts, 10, async (account: BraveWallet.AccountInfo) => { - let currentCursor: string | null = null - const accountSpamNfts = [] - - do { - const { - tokens, - cursor - }: { - tokens: BraveWallet.BlockchainToken[] - cursor: string | null - } = await braveWalletService.getSimpleHashSpamNFTs( - account.address, - chainIds, - account.accountId.coin, - currentCursor - ) - - accountSpamNfts.push(...tokens) - currentCursor = cursor - } while (currentCursor) - - return accountSpamNfts + return await cache.getSpamNftsForAccountId(account.accountId) } ) ).flat(1) diff --git a/components/brave_wallet_ui/common/slices/endpoints/token_balances.endpoints.ts b/components/brave_wallet_ui/common/slices/endpoints/token_balances.endpoints.ts index 4f19383243b..6260f0bc347 100644 --- a/components/brave_wallet_ui/common/slices/endpoints/token_balances.endpoints.ts +++ b/components/brave_wallet_ui/common/slices/endpoints/token_balances.endpoints.ts @@ -40,10 +40,15 @@ import { baseQueryFunction } from '../../async/base-query-cache' import { + getPersistedPortfolioSpamTokenBalances, getPersistedPortfolioTokenBalances, + setPersistedPortfolioSpamTokenBalances, setPersistedPortfolioTokenBalances } from '../../../utils/local-storage-utils' import { getIsRewardsNetwork } from '../../../utils/rewards_utils' +import { + blockchainTokenEntityAdaptorInitialState // +} from '../entities/blockchain-token.entity' type BalanceNetwork = Pick< BraveWallet.NetworkInfo, @@ -92,6 +97,9 @@ export type GetTokenBalancesRegistryArg = { accountIds: BraveWallet.AccountId[] networks: BalanceNetwork[] useAnkrBalancesFeature: boolean + /** if true, only spam NFT balances will be fetched, if falsey, only user + * token balances will be fetched */ + isSpamRegistry?: boolean } function mergeTokenBalancesRegistry( @@ -222,8 +230,10 @@ export const tokenBalancesEndpoints = ({ TokenBalancesRegistry | null, GetTokenBalancesRegistryArg >({ - queryFn: function () { - const persistedBalances = getPersistedPortfolioTokenBalances() + queryFn: function (arg) { + const persistedBalances = arg.isSpamRegistry + ? getPersistedPortfolioSpamTokenBalances() + : getPersistedPortfolioTokenBalances() // return null so we can tell if we have data or not to start with return { @@ -357,7 +367,13 @@ export const tokenBalancesEndpoints = ({ networkSupportsAccount(network, accountId) ) - const userTokens = await cache.getUserTokensRegistry() + const userTokensRegistry = arg.isSpamRegistry + ? blockchainTokenEntityAdaptorInitialState + : await cache.getUserTokensRegistry() + + const spamTokens = arg.isSpamRegistry + ? await cache.getSpamNftsForAccountId(accountId) + : [] if (nonAnkrSupportedAccountNetworks.length) { await eachLimit( @@ -366,6 +382,22 @@ export const tokenBalancesEndpoints = ({ async (network: BraveWallet.NetworkInfo) => { assert(coinTypesMapping[network.coin] !== undefined) try { + const tokens = arg.isSpamRegistry + ? spamTokens.filter( + (token) => + token.coin === network.coin && + token.chainId === network.chainId + ) + : getEntitiesListFromEntityState( + userTokensRegistry, + userTokensRegistry.idsByChainId[ + networkEntityAdapter.selectId({ + coin: network.coin, + chainId: network.chainId + }) + ] + ) + await fetchTokenBalanceRegistryForAccountsAndChainIds({ args: network.coin === CoinTypes.SOL @@ -381,15 +413,7 @@ export const tokenBalancesEndpoints = ({ accountId, coin: coinTypesMapping[network.coin], chainId: network.chainId, - tokens: getEntitiesListFromEntityState( - userTokens, - userTokens.idsByChainId[ - networkEntityAdapter.selectId({ - coin: network.coin, - chainId: network.chainId - }) - ] - ) + tokens: tokens } ], cache, @@ -416,13 +440,19 @@ export const tokenBalancesEndpoints = ({ return tokenBalancesRegistry }) - const persistedBalances = getPersistedPortfolioTokenBalances() - setPersistedPortfolioTokenBalances( - mergeTokenBalancesRegistry( - persistedBalances, - tokenBalancesRegistry - ) + const persistedBalances = arg.isSpamRegistry + ? getPersistedPortfolioSpamTokenBalances() + : getPersistedPortfolioTokenBalances() + + const mergedRegistry = mergeTokenBalancesRegistry( + persistedBalances, + tokenBalancesRegistry ) + if (arg.isSpamRegistry) { + setPersistedPortfolioSpamTokenBalances(mergedRegistry) + } else { + setPersistedPortfolioTokenBalances(mergedRegistry) + } } catch (error) { handleEndpointError( 'getTokenBalancesRegistry.onCacheEntryAdded', diff --git a/components/brave_wallet_ui/components/desktop/popup-modals/filter-modals/portfolio-filters-modal.tsx b/components/brave_wallet_ui/components/desktop/popup-modals/filter-modals/portfolio-filters-modal.tsx index b2aa75c33cb..98b1f4fd9b3 100644 --- a/components/brave_wallet_ui/components/desktop/popup-modals/filter-modals/portfolio-filters-modal.tsx +++ b/components/brave_wallet_ui/components/desktop/popup-modals/filter-modals/portfolio-filters-modal.tsx @@ -66,11 +66,10 @@ import { ContentWrapper, ButtonRow } from './portfolio-filters-modal.style' interface Props { onClose: () => void + onSave?: () => void } -export const PortfolioFiltersModal = (props: Props) => { - const { onClose } = props - +export const PortfolioFiltersModal = ({ onClose, onSave }: Props) => { // routing const { pathname: currentRoute } = useLocation() @@ -107,6 +106,10 @@ export const PortfolioFiltersModal = (props: Props) => { LOCAL_STORAGE_KEYS.SHOW_NETWORK_LOGO_ON_NFTS, false ) + const [hideUnownedNfts, setHideUnownedNfts] = useSyncedLocalStorage( + LOCAL_STORAGE_KEYS.HIDE_UNOWNED_NFTS, + false + ) // queries const { data: defaultFiatCurrency = 'usd' } = useGetDefaultFiatCurrencyQuery() @@ -128,6 +131,8 @@ export const PortfolioFiltersModal = (props: Props) => { const [showNetworkLogo, setShowNetworkLogo] = React.useState( showNetworkLogoOnNfts ) + const [hideUnownedNftsToggle, setHideUnownedNftsToggle] = + React.useState(hideUnownedNfts) // Memos const hideSmallBalancesDescription = React.useMemo(() => { @@ -151,6 +156,8 @@ export const PortfolioFiltersModal = (props: Props) => { setSelectedAssetFilter(selectedAssetFilterOption) setHidePortfolioSmallBalances(hideSmallBalances) setShowNetworkLogoOnNfts(showNetworkLogo) + setHideUnownedNfts(hideUnownedNftsToggle) + onSave?.() onClose() }, [ setFilteredOutPortfolioNetworkKeys, @@ -165,6 +172,9 @@ export const PortfolioFiltersModal = (props: Props) => { hideSmallBalances, setShowNetworkLogoOnNfts, showNetworkLogo, + setHideUnownedNfts, + hideUnownedNftsToggle, + onSave, onClose ]) @@ -185,7 +195,7 @@ export const PortfolioFiltersModal = (props: Props) => { fullWidth={true} alignItems='flex-start' > - {showNftFilters && ( + {showNftFilters ? ( <> { setIsSelected={() => setShowNetworkLogo((prev) => !prev)} /> + setHideUnownedNftsToggle((prev) => !prev)} + /> + {/* Disabled until Spam NFTs feature is implemented in core */} {/* { } /> */} - )} - - - - {!showNftFilters && ( + ) : ( <> + void - tokenBalancesRegistry: TokenBalancesRegistry | undefined | null + accounts: BraveWallet.AccountInfo[] + tokenBalancesRegistry: TokenBalancesRegistry | null | undefined + networks: BraveWallet.NetworkInfo[] } const compareFn = ( @@ -95,24 +102,50 @@ const compareFn = ( b: BraveWallet.BlockchainToken ) => a.name.localeCompare(b.name) -export const Nfts = (props: Props) => { - const { - nftList, - accounts, - networks, - onShowPortfolioSettings, - tokenBalancesRegistry - } = props +const searchNfts = ( + searchValue: string, + items: BraveWallet.BlockchainToken[] +) => { + if (searchValue === '') { + return items + } - const { braveWalletP3A } = useApiProxy() + return items.filter((item) => { + const tokenId = new Amount(item.tokenId).toNumber().toString() + const searchValueLower = searchValue.toLowerCase() + return ( + item.name.toLocaleLowerCase().includes(searchValueLower) || + item.symbol.toLocaleLowerCase().includes(searchValueLower) || + tokenId.includes(searchValueLower) + ) + }) +} - // local-storage - const [selectedGroupAssetsByItem] = useLocalStorage( - LOCAL_STORAGE_KEYS.GROUP_PORTFOLIO_ASSETS_BY, - NoneGroupByOption.id - ) +const LIST_PAGE_ITEM_COUNT = 15 + +const scrollOptions: ScrollIntoViewOptions = { block: 'start' } + +const emptyTokenIdsList: string[] = [] + +export const Nfts = ({ + networks, + accounts, + onShowPortfolioSettings, + tokenBalancesRegistry +}: Props) => { + // routing + const history = useHistory() + const urlSearchParams = useQuery() + const tab = urlSearchParams.get('tab') + const currentPageNumber = Number(urlSearchParams.get('page')) || 1 + const selectedTab: NftDropdownOptionId = + tab === 'collected' || tab === 'hidden' ? tab : 'collected' + + // refs + const listScrollContainerRef = React.useRef(null) // redux + const dispatch = useDispatch() const isNftPinningFeatureEnabled = useSafeWalletSelector( WalletSelectors.isNftPinningFeatureEnabled ) @@ -124,6 +157,12 @@ export const Nfts = (props: Props) => { ) const isPanel = useSafeUISelector(UISelectors.isPanel) + // local-storage + const [hideUnownedNfts] = useSyncedLocalStorage( + LOCAL_STORAGE_KEYS.HIDE_UNOWNED_NFTS, + false + ) + // state const [searchValue, setSearchValue] = React.useState('') const [showAddNftModal, setShowAddNftModal] = React.useState(false) @@ -135,19 +174,18 @@ export const Nfts = (props: Props) => { ) const [showSearchBar, setShowSearchBar] = React.useState(false) - // hooks - const history = useHistory() - const dispatch = useDispatch() + // custom hooks + const { braveWalletP3A } = useApiProxy() const { isIpfsBannerVisible, onToggleShowIpfsBanner } = useNftPin() - const urlSearchParams = useQuery() - const tab = urlSearchParams.get('tab') - const selectedTab: NftDropdownOptionId = - tab === 'collected' || tab === 'hidden' ? tab : 'collected' // queries const { data: isNftAutoDiscoveryEnabled } = useGetNftDiscoveryEnabledStatusQuery() - const { data: simpleHashSpamNfts = [] } = useGetSimpleHashSpamNftsQuery() + const { data: simpleHashSpamNfts = [], isFetching: isLoadingSpamNfts } = + useGetSimpleHashSpamNftsQuery( + selectedTab === 'collected' || !accounts.length ? skipToken : { accounts } + ) + const { accounts: allAccounts } = useAccountsQuery() const { userTokensRegistry, hiddenNfts, visibleNfts } = useGetUserTokensRegistryQuery(undefined, { selectFromResult: (result) => ({ @@ -157,15 +195,227 @@ export const Nfts = (props: Props) => { }) }) + const shouldFetchSpamNftBalances = + selectedTab === 'hidden' && + !isLoadingSpamNfts && + !hideUnownedNfts && + accounts.length > 0 && + networks.length > 0 + + const { data: spamTokenBalancesRegistry } = useBalancesFetcher( + shouldFetchSpamNftBalances + ? { + accounts, + networks, + isSpamRegistry: true + } + : skipToken + ) + // mutations const [setNftDiscovery] = useSetNftDiscoveryEnabledMutation() + // memos & computed + const { visibleUserNonSpamNfts, visibleUserMarkedSpamNfts } = + React.useMemo(() => { + const results: { + visibleUserNonSpamNfts: BraveWallet.BlockchainToken[] + visibleUserMarkedSpamNfts: BraveWallet.BlockchainToken[] + } = { + visibleUserNonSpamNfts: [], + visibleUserMarkedSpamNfts: [] + } + for (const nft of visibleNfts) { + if (nft.isSpam) { + results.visibleUserMarkedSpamNfts.push(nft) + } else { + if (nft.visible) { + results.visibleUserNonSpamNfts.push(nft) + } + } + } + return results + }, [visibleNfts]) + + const hiddenNftsIds = + userTokensRegistry?.nonFungibleHiddenTokenIds ?? emptyTokenIdsList + const userNonSpamNftIds = + userTokensRegistry?.nonSpamTokenIds ?? emptyTokenIdsList + + const [allSpamNfts, allSpamNftsIds] = React.useMemo(() => { + // filter out NFTs user has marked not spam + // hidden NFTs, and deleted NFTs + const excludedNftIds = userNonSpamNftIds + .concat(hiddenNftsIds) + .concat(userTokensRegistry?.deletedTokenIds || []) + const simpleHashList = simpleHashSpamNfts.filter( + (nft) => !excludedNftIds.includes(getAssetIdKey(nft)) + ) + const simpleHashListIds = simpleHashList.map((nft) => getAssetIdKey(nft)) + // add NFTs user has marked as NFT if they are not in the list + // to avoid duplicates + const fullSpamList = [ + ...simpleHashList, + ...visibleUserMarkedSpamNfts.filter( + (nft) => !simpleHashListIds.includes(getAssetIdKey(nft)) + ) + ] + + return [fullSpamList, fullSpamList.map((nft) => getAssetIdKey(nft))] + }, [ + visibleUserMarkedSpamNfts, + simpleHashSpamNfts, + hiddenNftsIds, + userNonSpamNftIds, + userTokensRegistry + ]) + + const hiddenAndSpamNfts = React.useMemo(() => { + return hiddenNfts.concat(allSpamNfts) + }, [allSpamNfts, hiddenNfts]) + + const selectedNftList = + selectedTab === 'collected' ? visibleUserNonSpamNfts : hiddenAndSpamNfts + + const sortedSelectedNftList = React.useMemo(() => { + return selectedNftList.slice().sort(compareFn) + }, [selectedNftList]) + + // Filters the user's tokens based on the users + // filteredOutPortfolioNetworkKeys pref and visible networks. + const sortedSelectedNftListForChains = React.useMemo(() => { + return sortedSelectedNftList.filter((token) => + networks.some( + (net) => net.chainId === token.chainId && net.coin === token.coin + ) + ) + }, [sortedSelectedNftList, networks]) + + // apply accounts filter to selected nfts list + const sortedSelectedNftListForChainsAndAccounts = React.useMemo(() => { + if (hideUnownedNfts) { + return sortedSelectedNftListForChains.filter((token) => { + return accounts.some((account) => { + const balance = getBalance( + account.accountId, + token, + tokenBalancesRegistry + ) + const spamBalance = getBalance( + account.accountId, + token, + spamTokenBalancesRegistry + ) + return ( + (balance && balance !== '0') || (spamBalance && spamBalance !== '0') + ) + }) + }) + } + + // skip balance checks if all accounts are selected + if (accounts.length === allAccounts.length) { + return sortedSelectedNftListForChains + } + + return sortedSelectedNftListForChains.filter((token) => { + return ( + accounts.some((account) => { + const balance = getBalance( + account.accountId, + token, + tokenBalancesRegistry + ) + const spamBalance = getBalance( + account.accountId, + token, + spamTokenBalancesRegistry + ) + return ( + (balance && balance !== '0') || (spamBalance && spamBalance !== '0') + ) + }) || + // not owned by any account + !allAccounts.some((account) => { + const balance = getBalance( + account.accountId, + token, + tokenBalancesRegistry + ) + const spamBalance = getBalance( + account.accountId, + token, + spamTokenBalancesRegistry + ) + return ( + (balance && balance !== '0') || (spamBalance && spamBalance !== '0') + ) + }) + ) + }) + }, [ + accounts, + allAccounts, + hideUnownedNfts, + sortedSelectedNftListForChains, + spamTokenBalancesRegistry, + tokenBalancesRegistry + ]) + + const { searchResults, totalNftsFound } = React.useMemo(() => { + const searchResults = searchNfts( + searchValue, + sortedSelectedNftListForChainsAndAccounts + ) + return { + searchResults, + totalNftsFound: searchResults.length + } + }, [searchValue, sortedSelectedNftListForChainsAndAccounts]) + + const lastPageNumber = + Math.floor(searchResults.length / LIST_PAGE_ITEM_COUNT) + 1 + + /** label summary is shown only on the selected tab */ + const dropDownOptions: NftDropdownOption[] = React.useMemo(() => { + return [ + { + id: 'collected', + label: getLocale('braveNftsTabCollected'), + labelSummary: totalNftsFound + }, + { + id: 'hidden', + label: getLocale('braveNftsTabHidden'), + labelSummary: totalNftsFound + } + ] + }, [totalNftsFound]) + + const renderedListPage = React.useMemo(() => { + const pageStartItemIndex = + currentPageNumber * LIST_PAGE_ITEM_COUNT - LIST_PAGE_ITEM_COUNT + return searchResults.slice( + pageStartItemIndex, + pageStartItemIndex + LIST_PAGE_ITEM_COUNT + ) + }, [searchResults, currentPageNumber]) + + const isLoadingAssets = + !assetAutoDiscoveryCompleted || + (selectedTab === 'hidden' && + (isLoadingSpamNfts || + (shouldFetchSpamNftBalances && !spamTokenBalancesRegistry))) + // methods const onSearchValueChange = React.useCallback( (event: React.ChangeEvent) => { setSearchValue(event.target.value) + if (currentPageNumber !== 1) { + history.push(makePortfolioNftsRoute(selectedTab, 1)) + } }, - [] + [currentPageNumber, history, selectedTab] ) const onSelectAsset = React.useCallback( @@ -206,266 +456,28 @@ export const Nfts = (props: Props) => { const onSelectOption = React.useCallback( (selectedOption: NftDropdownOption) => { - history.push({ - pathname: WalletRoutes.PortfolioNFTs, - search: `?tab=${selectedOption.id}` - }) + history.push(makePortfolioNftsRoute(selectedOption.id, 1)) }, [history] ) - const searchNfts = React.useCallback( - (item: BraveWallet.BlockchainToken) => { - const tokenId = new Amount(item.tokenId).toNumber().toString() - - return ( - item.name.toLowerCase() === searchValue.toLowerCase() || - item.name.toLowerCase().includes(searchValue.toLowerCase()) || - item.symbol.toLocaleLowerCase() === searchValue.toLowerCase() || - item.symbol.toLowerCase().includes(searchValue.toLowerCase()) || - tokenId === searchValue.toLowerCase() || - tokenId.includes(searchValue.toLowerCase()) - ) - }, - [searchValue] - ) - const onCloseSearchBar = React.useCallback(() => { setShowSearchBar(false) setSearchValue('') }, []) - React.useEffect(() => { - braveWalletP3A.recordNFTGalleryView(nftList.length) - }, [braveWalletP3A, nftList]) - - // memos - const { userNonSpamNfts, userMarkedSpamNfts } = React.useMemo(() => { - const results: { - userNonSpamNfts: BraveWallet.BlockchainToken[] - userMarkedSpamNfts: BraveWallet.BlockchainToken[] - } = { - userNonSpamNfts: [], - userMarkedSpamNfts: [] - } - for (const nft of nftList) { - if (!nft.isSpam) { - if (nft.visible) { - results.userNonSpamNfts.push(nft) - } - } else { - results.userMarkedSpamNfts.push(nft) - } - } - return results - }, [nftList]) - - const [hiddenNftsIds, userNonSpamNftIds] = React.useMemo(() => { - if (!userTokensRegistry) { - return [[], []] - } - return [ - userTokensRegistry.nonFungibleHiddenTokenIds, - userTokensRegistry.nonSpamTokenIds - ] - }, [userTokensRegistry]) - - const [allSpamNfts, allSpamNftsIds] = React.useMemo(() => { - // filter out NFTs user has marked not spam - // hidden NFTs, - // and deleted NFTs - const excludedNftIds = userNonSpamNftIds - .concat(hiddenNftsIds) - .concat(userTokensRegistry?.deletedTokenIds || []) - const simpleHashList = simpleHashSpamNfts.filter( - (nft) => !excludedNftIds.includes(getAssetIdKey(nft)) - ) - const simpleHashListIds = simpleHashList.map((nft) => getAssetIdKey(nft)) - // add NFTs user has marked as NFT if they are not in the list - // to avoid duplicates - const fullSpamList = [ - ...simpleHashList, - ...userMarkedSpamNfts.filter( - (nft) => !simpleHashListIds.includes(getAssetIdKey(nft)) - ) - ] - - return [fullSpamList, fullSpamList.map((nft) => getAssetIdKey(nft))] - }, [ - userMarkedSpamNfts, - simpleHashSpamNfts, - hiddenNftsIds, - userNonSpamNftIds, - userTokensRegistry - ]) - - const [sortedNfts, sortedHiddenNfts, sortedSpamNfts] = React.useMemo(() => { - if (searchValue === '') { - return [ - userNonSpamNfts.slice().sort(compareFn), - hiddenNfts.slice().sort(compareFn), - allSpamNfts.slice().sort(compareFn) - ] - } - - return [ - userNonSpamNfts.filter(searchNfts).sort(compareFn), - hiddenNfts.filter(searchNfts).sort(compareFn), - allSpamNfts.filter(searchNfts).sort(compareFn) - ] - }, [searchValue, userNonSpamNfts, hiddenNfts, allSpamNfts, searchNfts]) - - const dropDownOptions: NftDropdownOption[] = React.useMemo(() => { - return [ - { - id: 'collected', - label: getLocale('braveNftsTabCollected'), - labelSummary: sortedNfts.length - }, - { - id: 'hidden', - label: getLocale('braveNftsTabHidden'), - labelSummary: sortedHiddenNfts.concat(sortedSpamNfts).length - } - ] - }, [sortedHiddenNfts, sortedSpamNfts, sortedNfts]) - - const renderedList = React.useMemo(() => { - switch (selectedTab) { - case 'collected': - return sortedNfts - case 'hidden': - return sortedHiddenNfts.concat(sortedSpamNfts) - default: - return sortedNfts - } - }, [selectedTab, sortedNfts, sortedHiddenNfts, sortedSpamNfts]) - - // Returns a list of assets based on provided account - const getFilteredNftsByAccount = React.useCallback( - (account: BraveWallet.AccountInfo) => { - return renderedList.filter( - (nft) => - nft.coin === account.accountId.coin && - new Amount( - getBalance(account.accountId, nft, tokenBalancesRegistry) - ).gte('1') - ) + const navigateToPage = React.useCallback( + (pageNumber: number) => { + history.push(makePortfolioNftsRoute(selectedTab, pageNumber)) + listScrollContainerRef.current?.scrollIntoView(scrollOptions) }, - [renderedList, tokenBalancesRegistry] + [history, selectedTab] ) - // Returns a list of assets based on provided network - const getAssetsByNetwork = React.useCallback( - (network: BraveWallet.NetworkInfo) => { - return renderedList.filter( - (asset) => - networkEntityAdapter - .selectId({ - chainId: asset.chainId, - coin: asset.coin - }) - .toString() === networkEntityAdapter.selectId(network).toString() - ) - }, - [renderedList] - ) - - const renderGridViewItem = React.useCallback( - (nft: BraveWallet.BlockchainToken) => { - const assetId = getAssetIdKey(nft) - const isSpam = allSpamNftsIds.includes(assetId) - - return ( - onSelectAsset(nft)} - isTokenHidden={ - userTokensRegistry?.nonFungibleHiddenTokenIds.includes(assetId) || - isSpam - } - isTokenSpam={isSpam} - /> - ) - }, - [userTokensRegistry, allSpamNftsIds, onSelectAsset] - ) - - const listUiByAccounts = React.useMemo(() => { - return accounts.map((account) => ( - - {getFilteredNftsByAccount(account).length !== 0 && ( - - - {getFilteredNftsByAccount(account).map(renderGridViewItem)} - {!assetAutoDiscoveryCompleted && } - - - )} - - )) - }, [ - accounts, - assetAutoDiscoveryCompleted, - getFilteredNftsByAccount, - renderGridViewItem - ]) - - const listUiByNetworks = React.useMemo(() => { - return networks?.map((network) => ( - - {getAssetsByNetwork(network).length !== 0 && ( - - - {getAssetsByNetwork(network).map(renderGridViewItem)} - {!assetAutoDiscoveryCompleted && } - - - )} - - )) - }, [ - assetAutoDiscoveryCompleted, - getAssetsByNetwork, - networks, - renderGridViewItem - ]) - - const listUi = React.useMemo(() => { - return selectedGroupAssetsByItem === NetworksGroupByOption.id ? ( - listUiByNetworks - ) : selectedGroupAssetsByItem === AccountsGroupByOption.id ? ( - listUiByAccounts - ) : ( - - {renderedList.map(renderGridViewItem)} - {!assetAutoDiscoveryCompleted && } - - ) - }, [ - selectedGroupAssetsByItem, - listUiByNetworks, - listUiByAccounts, - renderedList, - renderGridViewItem, - assetAutoDiscoveryCompleted - ]) - // effects React.useEffect(() => { - dispatch(WalletActions.refreshNetworksAndTokens({})) - }, [assetAutoDiscoveryCompleted, dispatch]) + braveWalletP3A.recordNFTGalleryView(visibleNfts.length) + }, [braveWalletP3A, visibleNfts.length]) return ( { )} - - {nftList.length === 0 && + + {visibleNfts.length === 0 && userTokensRegistry?.hiddenTokenIds.length === 0 ? ( isNftAutoDiscoveryEnabled ? ( { ) ) : ( - listUi + <> + + {renderedListPage.map((nft) => { + const assetId = getAssetIdKey(nft) + const isSpam = allSpamNftsIds.includes(assetId) + + return ( + + ) + })} + {isLoadingAssets && } + + + + + + + + )} {showAddNftModal && ( diff --git a/components/brave_wallet_ui/components/desktop/views/portfolio/components/nft-grid-view/nft-grid-view-item.tsx b/components/brave_wallet_ui/components/desktop/views/portfolio/components/nft-grid-view/nft-grid-view-item.tsx index bc1b24b5426..27effcad7d8 100644 --- a/components/brave_wallet_ui/components/desktop/views/portfolio/components/nft-grid-view/nft-grid-view-item.tsx +++ b/components/brave_wallet_ui/components/desktop/views/portfolio/components/nft-grid-view/nft-grid-view-item.tsx @@ -56,7 +56,7 @@ interface Props { token: BraveWallet.BlockchainToken isTokenHidden: boolean isTokenSpam: boolean - onSelectAsset: () => void + onSelectAsset: (token: BraveWallet.BlockchainToken) => void } export const NFTGridViewItem = (props: Props) => { @@ -165,7 +165,7 @@ export const NFTGridViewItem = (props: Props) => { }} onClose={() => setShowMore(false)} /> - + onSelectAsset(token)} /> {isTokenSpam && ( {getLocale('braveWalletNftJunk')} diff --git a/components/brave_wallet_ui/components/desktop/views/portfolio/portfolio-overview.tsx b/components/brave_wallet_ui/components/desktop/views/portfolio/portfolio-overview.tsx index a5866d643f8..6374bc901d5 100644 --- a/components/brave_wallet_ui/components/desktop/views/portfolio/portfolio-overview.tsx +++ b/components/brave_wallet_ui/components/desktop/views/portfolio/portfolio-overview.tsx @@ -5,17 +5,12 @@ import * as React from 'react' import { skipToken } from '@reduxjs/toolkit/query/react' -import { useDispatch } from 'react-redux' -import { useHistory } from 'react-router' +import { useHistory, useLocation } from 'react-router' import { Route, Switch, Redirect } from 'react-router-dom' // selectors -import { - useUnsafePageSelector, - useSafeUISelector -} from '../../../../common/hooks/use-safe-selector' +import { useSafeUISelector } from '../../../../common/hooks/use-safe-selector' import { UISelectors } from '../../../../common/selectors' -import { PageSelectors } from '../../../../page/selectors' // hooks import { @@ -38,9 +33,6 @@ import { } from '../../../../constants/types' import { emptyRewardsInfo } from '../../../../common/async/base-query-cache' -// actions -import { WalletPageActions } from '../../../../page/actions' - // Utils import Amount from '../../../../utils/amount' import { @@ -115,12 +107,13 @@ import { querySubscriptionOptions60s // } from '../../../../common/slices/constants' import { - selectAllVisibleUserAssetsFromQueryResult // + selectAllVisibleFungibleUserAssetsFromQueryResult // } from '../../../../common/slices/entities/blockchain-token.entity' export const PortfolioOverview = () => { // routing const history = useHistory() + const location = useLocation() // local-storage const [filteredOutPortfolioNetworkKeys] = useLocalStorage( @@ -153,8 +146,6 @@ export const PortfolioOverview = () => { ) // redux - const dispatch = useDispatch() - const nftMetadata = useUnsafePageSelector(PageSelectors.nftMetadata) const isPanel = useSafeUISelector(UISelectors.isPanel) // queries @@ -164,7 +155,8 @@ export const PortfolioOverview = () => { useGetUserTokensRegistryQuery(undefined, { selectFromResult: (result) => ({ isLoadingUserTokens: result.isLoading, - userVisibleTokensInfo: selectAllVisibleUserAssetsFromQueryResult(result) + userVisibleTokensInfo: + selectAllVisibleFungibleUserAssetsFromQueryResult(result) }) }) const { data: defaultFiat } = useGetDefaultFiatCurrencyQuery() @@ -261,22 +253,11 @@ export const PortfolioOverview = () => { const visibleTokensForFilteredChains = React.useMemo(() => { return userTokensWithRewards.filter((token) => visiblePortfolioNetworkIds.includes( - networkEntityAdapter - .selectId({ - chainId: token.chainId, - coin: token.coin - }) - .toString() + networkEntityAdapter.selectId(token).toString() ) ) }, [userTokensWithRewards, visiblePortfolioNetworkIds]) - const userVisibleNfts = React.useMemo(() => { - return visibleTokensForFilteredChains.filter( - (token) => token.isErc721 || token.isNft - ) - }, [visibleTokensForFilteredChains]) - const { data: tokenBalancesRegistry } = // wait to see if we need rewards before fetching useBalancesFetcher( @@ -339,22 +320,17 @@ export const PortfolioOverview = () => { // wait for balances before computing this list return [] } - return visibleTokensForFilteredChains - .filter( - (asset) => - asset.visible && !asset.isErc721 && !asset.isErc1155 && !asset.isNft - ) - .map((asset) => { - return { - asset, - assetBalance: - getIsRewardsToken(asset) && rewardsBalance - ? new Amount(rewardsBalance) - .multiplyByDecimals(asset.decimals) - .format() - : fullAssetBalance(asset) - } - }) + return visibleTokensForFilteredChains.map((asset) => { + return { + asset, + assetBalance: + getIsRewardsToken(asset) && rewardsBalance + ? new Amount(rewardsBalance) + .multiplyByDecimals(asset.decimals) + .format() + : fullAssetBalance(asset) + } + }) }, [ visibleTokensForFilteredChains, fullAssetBalance, @@ -494,18 +470,9 @@ export const PortfolioOverview = () => { // methods const onSelectAsset = React.useCallback( (asset: BraveWallet.BlockchainToken) => { - if ((asset.isErc721 || asset.isNft) && nftMetadata) { - // reset nft metadata - dispatch(WalletPageActions.updateNFTMetadata(undefined)) - } - history.push( - makePortfolioAssetRoute( - asset.isErc721 || asset.isNft || asset.isErc1155, - getAssetIdKey(asset) - ) - ) + history.push(makePortfolioAssetRoute(false, getAssetIdKey(asset))) }, - [dispatch, history, nftMetadata] + [history] ) const tokenLists = React.useMemo(() => { @@ -675,8 +642,7 @@ export const PortfolioOverview = () => { exact > setShowPortfolioSettings(true)} tokenBalancesRegistry={tokenBalancesRegistry} @@ -692,7 +658,18 @@ export const PortfolioOverview = () => { {showPortfolioSettings && ( setShowPortfolioSettings(false)} + onSave={() => { + // reset to first page after filters change + const newParams = new URLSearchParams(location.search) + newParams.delete('page') + history.push({ + ...location, + search: `?${newParams.toString()}` + }) + }} + onClose={() => { + setShowPortfolioSettings(false) + }} /> )} diff --git a/components/brave_wallet_ui/components/shared/pagination/pagination.stories.tsx b/components/brave_wallet_ui/components/shared/pagination/pagination.stories.tsx new file mode 100644 index 00000000000..08501efd1d1 --- /dev/null +++ b/components/brave_wallet_ui/components/shared/pagination/pagination.stories.tsx @@ -0,0 +1,31 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +import * as React from 'react' + +import { + WalletPanelStory // +} from '../../../stories/wrappers/wallet-panel-story-wrapper' +import { Pagination } from './pagination' + +export const _Pagination = () => { + // state + const [currentPageNumber, onSelectPageNumber] = React.useState(1) + + // render + return ( + + + + ) +} + +_Pagination.storyName = 'Pagination' + +export default _Pagination diff --git a/components/brave_wallet_ui/components/shared/pagination/pagination.styles.ts b/components/brave_wallet_ui/components/shared/pagination/pagination.styles.ts new file mode 100644 index 00000000000..800de97d3e8 --- /dev/null +++ b/components/brave_wallet_ui/components/shared/pagination/pagination.styles.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +import styled from 'styled-components' +import * as leo from '@brave/leo/tokens/css/variables' +import Button from '@brave/leo/react/button' + +import { Row } from '../style' + +export const PaginationRow = styled(Row)` + gap: ${leo.spacing.s}; +` + +export const PaginationButton = styled(Button)` + --leo-button-padding: ${leo.spacing.s}; + min-width: 36px; + flex: 1; +` diff --git a/components/brave_wallet_ui/components/shared/pagination/pagination.tsx b/components/brave_wallet_ui/components/shared/pagination/pagination.tsx new file mode 100644 index 00000000000..82c7dd3c817 --- /dev/null +++ b/components/brave_wallet_ui/components/shared/pagination/pagination.tsx @@ -0,0 +1,128 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +import * as React from 'react' +import Icon from '@brave/leo/react/icon' + +// styles +import { PaginationButton, PaginationRow } from './pagination.styles' + +function createNavPageNumbers(currentPage: number, totalPages: number) { + const pageOffsets = [] + const canGoBackMultiple = currentPage - 2 >= 1 + const canGoBack = currentPage - 1 >= 1 + + const canGoForward = currentPage + 1 <= totalPages + const canGoForwardMultiple = currentPage + 2 <= totalPages + + // back + if (!canGoForwardMultiple && currentPage - 4 >= 1) { + pageOffsets.push(currentPage - 4) + } + if (!canGoForward && currentPage - 3 >= 1) { + pageOffsets.push(currentPage - 3) + } + if (canGoBackMultiple) { + pageOffsets.push(currentPage - 2) + } + if (canGoBack) { + pageOffsets.push(currentPage - 1) + } + + // current + pageOffsets.push(currentPage) + + // forward + if (canGoForward) { + pageOffsets.push(currentPage + 1) + } + if (canGoForwardMultiple) { + pageOffsets.push(currentPage + 2) + } + if (!canGoBackMultiple && currentPage + 3 <= totalPages) { + pageOffsets.push(currentPage + 3) + } + if (!canGoBack && currentPage + 4 <= totalPages) { + pageOffsets.push(currentPage + 4) + } + + return pageOffsets +} + +export function Pagination({ + onSelectPageNumber, + currentPageNumber, + lastPageNumber +}: { + onSelectPageNumber: (pageNumber: number) => void + currentPageNumber: number + lastPageNumber: number +}) { + // computed + const canNavigateBack = currentPageNumber > 1 + const canNavigateForward = currentPageNumber < lastPageNumber + + // render + return ( + + {/* First Page */} + onSelectPageNumber(1)} + > + + + + {/* Back */} + onSelectPageNumber(currentPageNumber - 1)} + > + + + + {/* Numbers for navigating pages (up to 5 numbered buttons) */} + {createNavPageNumbers(currentPageNumber, lastPageNumber).map( + (newPageNumber) => { + const isCurrentPage = newPageNumber === currentPageNumber + return ( + onSelectPageNumber(newPageNumber)} + > + {newPageNumber} + + ) + } + )} + + {/* Forward */} + onSelectPageNumber(currentPageNumber + 1)} + > + + + + {/* Last */} + onSelectPageNumber(lastPageNumber)} + > + + + + ) +} diff --git a/components/brave_wallet_ui/constants/types.ts b/components/brave_wallet_ui/constants/types.ts index 7a37e09b3c5..f9db575744b 100644 --- a/components/brave_wallet_ui/constants/types.ts +++ b/components/brave_wallet_ui/constants/types.ts @@ -17,6 +17,8 @@ import { // path of generated mojom files. export { BraveWallet } export { Url } from 'gen/url/mojom/url.mojom.m.js' +export type NftDropdownOptionId = 'collected' | 'hidden' + export { Origin } from 'gen/url/mojom/origin.mojom.m.js' export { TimeDelta } diff --git a/components/brave_wallet_ui/stories/locale.ts b/components/brave_wallet_ui/stories/locale.ts index faeb2dade47..8d96185bcf8 100644 --- a/components/brave_wallet_ui/stories/locale.ts +++ b/components/brave_wallet_ui/stories/locale.ts @@ -1043,6 +1043,7 @@ provideStrings({ braveWalletShowSpamNftsTitle: 'Spam NFTs', braveWalletShowSpamNftsDescription: 'Show Spam NFTs', braveWalletPortfolioSettings: 'Portfolio Settings', + braveWalletHideNotOwnedNfTs: 'Hide not owned NFTs', // Account Filter braveWalletAccountFilterAllAccounts: 'All accounts', diff --git a/components/brave_wallet_ui/utils/local-storage-utils.ts b/components/brave_wallet_ui/utils/local-storage-utils.ts index 362512edc45..0588abfd447 100644 --- a/components/brave_wallet_ui/utils/local-storage-utils.ts +++ b/components/brave_wallet_ui/utils/local-storage-utils.ts @@ -144,6 +144,23 @@ export const getPersistedPortfolioTokenBalances = (): TokenBalancesRegistry => { } } +export const getPersistedPortfolioSpamTokenBalances = + (): TokenBalancesRegistry => { + try { + const registry: TokenBalancesRegistry = JSON.parse( + window.localStorage.getItem(LOCAL_STORAGE_KEYS.SPAM_TOKEN_BALANCES) || + JSON.stringify(createEmptyTokenBalancesRegistry()) + ) + if (registry.accounts) { + return registry + } + return createEmptyTokenBalancesRegistry() + } catch (error) { + console.error(error) + return createEmptyTokenBalancesRegistry() + } + } + export const setPersistedPortfolioTokenBalances = ( registry: TokenBalancesRegistry ) => { @@ -156,3 +173,16 @@ export const setPersistedPortfolioTokenBalances = ( console.error(error) } } + +export const setPersistedPortfolioSpamTokenBalances = ( + registry: TokenBalancesRegistry +) => { + try { + window.localStorage.setItem( + LOCAL_STORAGE_KEYS.SPAM_TOKEN_BALANCES, + JSON.stringify(registry) + ) + } catch (error) { + console.error(error) + } +} diff --git a/components/brave_wallet_ui/utils/routes-utils.ts b/components/brave_wallet_ui/utils/routes-utils.ts index c8c1061fc0b..1c8239b7aba 100644 --- a/components/brave_wallet_ui/utils/routes-utils.ts +++ b/components/brave_wallet_ui/utils/routes-utils.ts @@ -10,7 +10,8 @@ import { SendPageTabHashes, WalletOrigin, WalletCreationMode, - WalletImportMode + WalletImportMode, + NftDropdownOptionId } from '../constants/types' import { LOCAL_STORAGE_KEYS } from '../common/constants/local-storage-keys' @@ -271,6 +272,17 @@ export const makePortfolioAssetRoute = (isNft: boolean, assetId: string) => { ).replace(':assetId', assetId) } +export const makePortfolioNftsRoute = ( + tab: NftDropdownOptionId, + page?: number +) => { + const params = new URLSearchParams({ + tab: tab, + page: page?.toString() || '0' + }) + return `${WalletRoutes.PortfolioNFTs}?${params.toString()}` +} + // Tabs export function openTab(url: string) { if (chrome.tabs !== undefined) { diff --git a/components/resources/wallet_strings.grdp b/components/resources/wallet_strings.grdp index a6a0c40b7c8..606b38e86db 100644 --- a/components/resources/wallet_strings.grdp +++ b/components/resources/wallet_strings.grdp @@ -1082,4 +1082,5 @@ Account name must be $130 characters or less Enter password (if applicable) Import type + Hide not owned NFTs diff --git a/ui/webui/resources/BUILD.gn b/ui/webui/resources/BUILD.gn index ab13fc32512..78008b4ff31 100644 --- a/ui/webui/resources/BUILD.gn +++ b/ui/webui/resources/BUILD.gn @@ -176,6 +176,8 @@ leo_icons = [ "browser-extensions.svg", "browser-ntp-widget.svg", "carat-down.svg", + "carat-first.svg", + "carat-last.svg", "carat-left.svg", "carat-right.svg", "check-circle-filled.svg",