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
This commit is contained in:
@@ -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},
|
||||
|
||||
@@ -90,6 +90,10 @@ export class BaseQueryCache {
|
||||
private _nftMetadataRegistry: Record<string, NFTMetadataReturnType> = {}
|
||||
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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
GetTokenBalancesRegistryArg //
|
||||
} from '../slices/endpoints/token_balances.endpoints'
|
||||
|
||||
type Arg = Pick<GetTokenBalancesRegistryArg, 'networks'> & {
|
||||
type Arg = Pick<GetTokenBalancesRegistryArg, 'networks' | 'isSpamRegistry'> & {
|
||||
accounts: BraveWallet.AccountInfo[]
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ export const useBalancesFetcher = (arg: Arg | typeof skipToken) => {
|
||||
supportedKeyrings
|
||||
})
|
||||
),
|
||||
useAnkrBalancesFeature
|
||||
useAnkrBalancesFeature,
|
||||
isSpamRegistry: arg.isSpamRegistry
|
||||
}
|
||||
: skipToken,
|
||||
{
|
||||
|
||||
@@ -206,46 +206,25 @@ export const nftsEndpoints = ({
|
||||
}
|
||||
}
|
||||
}),
|
||||
getSimpleHashSpamNfts: query<BraveWallet.BlockchainToken[], void>({
|
||||
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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
+33
-16
@@ -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<boolean>(
|
||||
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 ? (
|
||||
<>
|
||||
<FilterToggleSection
|
||||
title={getLocale('braveWalletShowNetworkLogoOnNftsTitle')}
|
||||
@@ -197,6 +207,14 @@ export const PortfolioFiltersModal = (props: Props) => {
|
||||
setIsSelected={() => setShowNetworkLogo((prev) => !prev)}
|
||||
/>
|
||||
|
||||
<FilterToggleSection
|
||||
title={getLocale('braveWalletHideNotOwnedNfTs')}
|
||||
description={''}
|
||||
icon='web3'
|
||||
isSelected={hideUnownedNftsToggle}
|
||||
setIsSelected={() => setHideUnownedNftsToggle((prev) => !prev)}
|
||||
/>
|
||||
|
||||
{/* Disabled until Spam NFTs feature is implemented in core */}
|
||||
{/* <FilterToggleSection
|
||||
title={getLocale('braveWalletShowSpamNftsTitle')}
|
||||
@@ -208,19 +226,18 @@ export const PortfolioFiltersModal = (props: Props) => {
|
||||
}
|
||||
/> */}
|
||||
</>
|
||||
)}
|
||||
|
||||
<FilterDropdownSection
|
||||
title={getLocale('braveWalletPortfolioGroupByTitle')}
|
||||
description={getLocale('braveWalletPortfolioGroupByDescription')}
|
||||
icon='stack'
|
||||
dropdownOptions={GroupAssetsByOptions}
|
||||
selectedOptionId={selectedGroupAssetsByOption}
|
||||
onSelectOption={setSelectedGroupAssetsByOption}
|
||||
/>
|
||||
|
||||
{!showNftFilters && (
|
||||
) : (
|
||||
<>
|
||||
<FilterDropdownSection
|
||||
title={getLocale('braveWalletPortfolioGroupByTitle')}
|
||||
description={getLocale(
|
||||
'braveWalletPortfolioGroupByDescription'
|
||||
)}
|
||||
icon='stack'
|
||||
dropdownOptions={GroupAssetsByOptions}
|
||||
selectedOptionId={selectedGroupAssetsByOption}
|
||||
onSelectOption={setSelectedGroupAssetsByOption}
|
||||
/>
|
||||
<FilterDropdownSection
|
||||
title={getLocale('braveWalletSortAssets')}
|
||||
description={getLocale('braveWalletSortAssetsDescription')}
|
||||
|
||||
+3
-2
@@ -5,6 +5,9 @@
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
// types
|
||||
import { NftDropdownOptionId } from '../../../../../../constants/types'
|
||||
|
||||
// styles
|
||||
import {
|
||||
DropdownButton,
|
||||
@@ -16,8 +19,6 @@ import {
|
||||
DropdownContainer
|
||||
} from './nft-group-selector.styles'
|
||||
|
||||
export type NftDropdownOptionId = 'collected' | 'hidden'
|
||||
|
||||
export interface NftDropdownOption {
|
||||
id: NftDropdownOptionId
|
||||
label: string
|
||||
|
||||
@@ -5,13 +5,26 @@
|
||||
import * as React from 'react'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { skipToken } from '@reduxjs/toolkit/query'
|
||||
|
||||
// types
|
||||
import { BraveWallet, WalletRoutes } from '../../../../../constants/types'
|
||||
import {
|
||||
BraveWallet,
|
||||
NftDropdownOptionId
|
||||
} from '../../../../../constants/types'
|
||||
import {
|
||||
TokenBalancesRegistry //
|
||||
} from '../../../../../common/slices/entities/token-balance.entity'
|
||||
|
||||
// hooks
|
||||
import { useNftPin } from '../../../../../common/hooks/nft-pin'
|
||||
import { useLocalStorage } from '../../../../../common/hooks/use_local_storage'
|
||||
import { useAccountsQuery } from '../../../../../common/slices/api.slice.extra'
|
||||
import {
|
||||
useBalancesFetcher //
|
||||
} from '../../../../../common/hooks/use-balances-fetcher'
|
||||
import {
|
||||
useSyncedLocalStorage //
|
||||
} from '../../../../../common/hooks/use_local_storage'
|
||||
|
||||
// selectors
|
||||
import {
|
||||
@@ -36,20 +49,18 @@ import {
|
||||
useGetUserTokensRegistryQuery,
|
||||
useSetNftDiscoveryEnabledMutation
|
||||
} from '../../../../../common/slices/api.slice'
|
||||
import { getBalance } from '../../../../../utils/balance-utils'
|
||||
import {
|
||||
AccountsGroupByOption,
|
||||
NetworksGroupByOption,
|
||||
NoneGroupByOption
|
||||
} from '../../../../../options/group-assets-by-options'
|
||||
import { useApiProxy } from '../../../../../common/hooks/use-api-proxy'
|
||||
import { getAssetIdKey } from '../../../../../utils/asset-utils'
|
||||
import { useQuery } from '../../../../../common/hooks/use-query'
|
||||
import { makePortfolioAssetRoute } from '../../../../../utils/routes-utils'
|
||||
import {
|
||||
makePortfolioAssetRoute,
|
||||
makePortfolioNftsRoute
|
||||
} from '../../../../../utils/routes-utils'
|
||||
import {
|
||||
selectAllVisibleUserNFTsFromQueryResult,
|
||||
selectHiddenNftsFromQueryResult //
|
||||
} from '../../../../../common/slices/entities/blockchain-token.entity'
|
||||
import { getBalance } from '../../../../../utils/balance-utils'
|
||||
|
||||
// components
|
||||
import SearchBar from '../../../../shared/search-bar'
|
||||
@@ -57,10 +68,18 @@ import { NFTGridViewItem } from '../../portfolio/components/nft-grid-view/nft-gr
|
||||
import { EnableNftDiscoveryModal } from '../../../popup-modals/enable-nft-discovery-modal/enable-nft-discovery-modal'
|
||||
import { AutoDiscoveryEmptyState } from './auto-discovery-empty-state/auto-discovery-empty-state'
|
||||
import { NftIpfsBanner } from '../../../nft-ipfs-banner/nft-ipfs-banner'
|
||||
import {
|
||||
NftGridViewItemSkeleton //
|
||||
} from '../../portfolio/components/nft-grid-view/nft-grid-view-item-skeleton'
|
||||
import { Pagination } from '../../../../shared/pagination/pagination'
|
||||
import {
|
||||
NftDropdown,
|
||||
NftDropdownOption
|
||||
} from './nft-group-selector/nft-group-selector'
|
||||
|
||||
// styles
|
||||
import { BannerWrapper, NFTListWrapper, NftGrid } from './nfts.styles'
|
||||
import { Row } from '../../../../shared/style'
|
||||
import { Column, Row } from '../../../../shared/style'
|
||||
import { AddOrEditNftModal } from '../../../popup-modals/add-edit-nft-modal/add-edit-nft-modal'
|
||||
import { NftsEmptyState } from './nfts-empty-state/nfts-empty-state'
|
||||
import {
|
||||
@@ -70,24 +89,12 @@ import {
|
||||
ControlBarWrapper,
|
||||
ContentWrapper
|
||||
} from '../../portfolio/style'
|
||||
import { AssetGroupContainer } from '../../../asset-group-container/asset-group-container'
|
||||
import { networkEntityAdapter } from '../../../../../common/slices/entities/network.entity'
|
||||
import {
|
||||
TokenBalancesRegistry //
|
||||
} from '../../../../../common/slices/entities/token-balance.entity'
|
||||
import { NftGridViewItemSkeleton } from '../../portfolio/components/nft-grid-view/nft-grid-view-item-skeleton'
|
||||
import {
|
||||
NftDropdown,
|
||||
NftDropdownOption,
|
||||
NftDropdownOptionId
|
||||
} from './nft-group-selector/nft-group-selector'
|
||||
|
||||
interface Props {
|
||||
networks: BraveWallet.NetworkInfo[]
|
||||
nftList: BraveWallet.BlockchainToken[]
|
||||
accounts: BraveWallet.AccountInfo[]
|
||||
onShowPortfolioSettings?: () => 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<string>(
|
||||
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<HTMLDivElement>(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<boolean>(
|
||||
LOCAL_STORAGE_KEYS.HIDE_UNOWNED_NFTS,
|
||||
false
|
||||
)
|
||||
|
||||
// state
|
||||
const [searchValue, setSearchValue] = React.useState<string>('')
|
||||
const [showAddNftModal, setShowAddNftModal] = React.useState<boolean>(false)
|
||||
@@ -135,19 +174,18 @@ export const Nfts = (props: Props) => {
|
||||
)
|
||||
const [showSearchBar, setShowSearchBar] = React.useState<boolean>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<NFTGridViewItem
|
||||
key={assetId}
|
||||
token={nft}
|
||||
onSelectAsset={() => onSelectAsset(nft)}
|
||||
isTokenHidden={
|
||||
userTokensRegistry?.nonFungibleHiddenTokenIds.includes(assetId) ||
|
||||
isSpam
|
||||
}
|
||||
isTokenSpam={isSpam}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[userTokensRegistry, allSpamNftsIds, onSelectAsset]
|
||||
)
|
||||
|
||||
const listUiByAccounts = React.useMemo(() => {
|
||||
return accounts.map((account) => (
|
||||
<React.Fragment key={account.accountId.uniqueKey}>
|
||||
{getFilteredNftsByAccount(account).length !== 0 && (
|
||||
<AssetGroupContainer
|
||||
balance=''
|
||||
hideBalance={true}
|
||||
account={account}
|
||||
isDisabled={getFilteredNftsByAccount(account).length === 0}
|
||||
>
|
||||
<NftGrid>
|
||||
{getFilteredNftsByAccount(account).map(renderGridViewItem)}
|
||||
{!assetAutoDiscoveryCompleted && <NftGridViewItemSkeleton />}
|
||||
</NftGrid>
|
||||
</AssetGroupContainer>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
}, [
|
||||
accounts,
|
||||
assetAutoDiscoveryCompleted,
|
||||
getFilteredNftsByAccount,
|
||||
renderGridViewItem
|
||||
])
|
||||
|
||||
const listUiByNetworks = React.useMemo(() => {
|
||||
return networks?.map((network) => (
|
||||
<React.Fragment key={networkEntityAdapter.selectId(network).toString()}>
|
||||
{getAssetsByNetwork(network).length !== 0 && (
|
||||
<AssetGroupContainer
|
||||
balance=''
|
||||
hideBalance={true}
|
||||
network={network}
|
||||
isDisabled={getAssetsByNetwork(network).length === 0}
|
||||
>
|
||||
<NftGrid>
|
||||
{getAssetsByNetwork(network).map(renderGridViewItem)}
|
||||
{!assetAutoDiscoveryCompleted && <NftGridViewItemSkeleton />}
|
||||
</NftGrid>
|
||||
</AssetGroupContainer>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
}, [
|
||||
assetAutoDiscoveryCompleted,
|
||||
getAssetsByNetwork,
|
||||
networks,
|
||||
renderGridViewItem
|
||||
])
|
||||
|
||||
const listUi = React.useMemo(() => {
|
||||
return selectedGroupAssetsByItem === NetworksGroupByOption.id ? (
|
||||
listUiByNetworks
|
||||
) : selectedGroupAssetsByItem === AccountsGroupByOption.id ? (
|
||||
listUiByAccounts
|
||||
) : (
|
||||
<NftGrid padding='0px'>
|
||||
{renderedList.map(renderGridViewItem)}
|
||||
{!assetAutoDiscoveryCompleted && <NftGridViewItemSkeleton />}
|
||||
</NftGrid>
|
||||
)
|
||||
}, [
|
||||
selectedGroupAssetsByItem,
|
||||
listUiByNetworks,
|
||||
listUiByAccounts,
|
||||
renderedList,
|
||||
renderGridViewItem,
|
||||
assetAutoDiscoveryCompleted
|
||||
])
|
||||
|
||||
// effects
|
||||
React.useEffect(() => {
|
||||
dispatch(WalletActions.refreshNetworksAndTokens({}))
|
||||
}, [assetAutoDiscoveryCompleted, dispatch])
|
||||
braveWalletP3A.recordNFTGalleryView(visibleNfts.length)
|
||||
}, [braveWalletP3A, visibleNfts.length])
|
||||
|
||||
return (
|
||||
<ContentWrapper
|
||||
@@ -541,8 +553,11 @@ export const Nfts = (props: Props) => {
|
||||
)}
|
||||
</Row>
|
||||
</ControlBarWrapper>
|
||||
<NFTListWrapper>
|
||||
{nftList.length === 0 &&
|
||||
<NFTListWrapper
|
||||
ref={listScrollContainerRef}
|
||||
fullHeight
|
||||
>
|
||||
{visibleNfts.length === 0 &&
|
||||
userTokensRegistry?.hiddenTokenIds.length === 0 ? (
|
||||
isNftAutoDiscoveryEnabled ? (
|
||||
<AutoDiscoveryEmptyState
|
||||
@@ -554,7 +569,45 @@ export const Nfts = (props: Props) => {
|
||||
<NftsEmptyState onImportNft={toggleShowAddNftModal} />
|
||||
)
|
||||
) : (
|
||||
listUi
|
||||
<>
|
||||
<NftGrid padding='0px'>
|
||||
{renderedListPage.map((nft) => {
|
||||
const assetId = getAssetIdKey(nft)
|
||||
const isSpam = allSpamNftsIds.includes(assetId)
|
||||
|
||||
return (
|
||||
<NFTGridViewItem
|
||||
key={assetId}
|
||||
token={nft}
|
||||
onSelectAsset={onSelectAsset}
|
||||
isTokenHidden={
|
||||
isSpam ||
|
||||
Boolean(
|
||||
userTokensRegistry?.nonFungibleHiddenTokenIds.includes(
|
||||
assetId
|
||||
)
|
||||
)
|
||||
}
|
||||
isTokenSpam={isSpam}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{isLoadingAssets && <NftGridViewItemSkeleton />}
|
||||
</NftGrid>
|
||||
|
||||
<Row
|
||||
width='100%'
|
||||
margin={'24px 0px 0px 0px'}
|
||||
>
|
||||
<Column width='50%'>
|
||||
<Pagination
|
||||
onSelectPageNumber={navigateToPage}
|
||||
currentPageNumber={currentPageNumber}
|
||||
lastPageNumber={lastPageNumber}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</NFTListWrapper>
|
||||
{showAddNftModal && (
|
||||
|
||||
+2
-2
@@ -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)}
|
||||
/>
|
||||
<DIVForClickableArea onClick={onSelectAsset} />
|
||||
<DIVForClickableArea onClick={() => onSelectAsset(token)} />
|
||||
{isTokenSpam && (
|
||||
<JunkMarker>
|
||||
{getLocale('braveWalletNftJunk')}
|
||||
|
||||
+33
-56
@@ -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
|
||||
>
|
||||
<Nfts
|
||||
networks={networks}
|
||||
nftList={userVisibleNfts}
|
||||
networks={visiblePortfolioNetworks}
|
||||
accounts={usersFilteredAccounts}
|
||||
onShowPortfolioSettings={() => setShowPortfolioSettings(true)}
|
||||
tokenBalancesRegistry={tokenBalancesRegistry}
|
||||
@@ -692,7 +658,18 @@ export const PortfolioOverview = () => {
|
||||
|
||||
{showPortfolioSettings && (
|
||||
<PortfolioFiltersModal
|
||||
onClose={() => 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)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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 (
|
||||
<WalletPanelStory>
|
||||
<Pagination
|
||||
currentPageNumber={currentPageNumber}
|
||||
onSelectPageNumber={onSelectPageNumber}
|
||||
lastPageNumber={99999}
|
||||
/>
|
||||
</WalletPanelStory>
|
||||
)
|
||||
}
|
||||
|
||||
_Pagination.storyName = 'Pagination'
|
||||
|
||||
export default _Pagination
|
||||
@@ -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;
|
||||
`
|
||||
@@ -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 (
|
||||
<PaginationRow>
|
||||
{/* First Page */}
|
||||
<PaginationButton
|
||||
size='small'
|
||||
isDisabled={!canNavigateBack}
|
||||
kind='plain-faint'
|
||||
onClick={() => onSelectPageNumber(1)}
|
||||
>
|
||||
<Icon name='carat-first' />
|
||||
</PaginationButton>
|
||||
|
||||
{/* Back */}
|
||||
<PaginationButton
|
||||
size='small'
|
||||
isDisabled={!canNavigateBack}
|
||||
kind='plain-faint'
|
||||
onClick={() => onSelectPageNumber(currentPageNumber - 1)}
|
||||
>
|
||||
<Icon name='carat-left' />
|
||||
</PaginationButton>
|
||||
|
||||
{/* Numbers for navigating pages (up to 5 numbered buttons) */}
|
||||
{createNavPageNumbers(currentPageNumber, lastPageNumber).map(
|
||||
(newPageNumber) => {
|
||||
const isCurrentPage = newPageNumber === currentPageNumber
|
||||
return (
|
||||
<PaginationButton
|
||||
size='small'
|
||||
key={newPageNumber}
|
||||
kind={isCurrentPage ? 'outline' : 'plain-faint'}
|
||||
onClick={() => onSelectPageNumber(newPageNumber)}
|
||||
>
|
||||
{newPageNumber}
|
||||
</PaginationButton>
|
||||
)
|
||||
}
|
||||
)}
|
||||
|
||||
{/* Forward */}
|
||||
<PaginationButton
|
||||
size='small'
|
||||
isDisabled={!canNavigateForward}
|
||||
kind='plain-faint'
|
||||
onClick={() => onSelectPageNumber(currentPageNumber + 1)}
|
||||
>
|
||||
<Icon name='carat-right' />
|
||||
</PaginationButton>
|
||||
|
||||
{/* Last */}
|
||||
<PaginationButton
|
||||
size='small'
|
||||
isDisabled={!canNavigateForward}
|
||||
kind='plain-faint'
|
||||
onClick={() => onSelectPageNumber(lastPageNumber)}
|
||||
>
|
||||
<Icon name='carat-last' />
|
||||
</PaginationButton>
|
||||
</PaginationRow>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1082,4 +1082,5 @@
|
||||
<message name="IDS_BRAVE_WALLET_ACCOUNT_NAME_TOO_LONG_ERROR" desc="An error that appears when the user enters an account name that is too long">Account name must be <ph name="VALUE">$1<ex>30</ex></ph> characters or less</message>
|
||||
<message name="IDS_BRAVE_WALLET_ENTER_PASSWORD_IF_APPLICABLE" desc="A label for the input field for entering the password for an imported file">Enter password (if applicable)</message>
|
||||
<message name="IDS_BRAVE_WALLET_PRIVATE_KEY_IMPORT_TYPE" desc="Label for the dropdown to select the format to use for importing a private key">Import type</message>
|
||||
<message name="IDS_BRAVE_WALLET_HIDE_NOT_OWNED_NF_TS" desc="Toggle label to enable or disable the display of NFTs that are not owned by any wallet account">Hide not owned NFTs</message>
|
||||
</grit-part>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user