diff --git a/.changeset/cold-rivers-raise.md b/.changeset/cold-rivers-raise.md new file mode 100644 index 000000000000..6e92e736c704 --- /dev/null +++ b/.changeset/cold-rivers-raise.md @@ -0,0 +1,5 @@ +--- +"live-mobile": patch +--- + +Add Spam Filtering for NFTs in TX history diff --git a/apps/ledger-live-mobile/src/actions/settings.ts b/apps/ledger-live-mobile/src/actions/settings.ts index 2e01412b6e31..6f0103c8f47a 100755 --- a/apps/ledger-live-mobile/src/actions/settings.ts +++ b/apps/ledger-live-mobile/src/actions/settings.ts @@ -71,6 +71,8 @@ import { SettingsRemoveStarredMarketcoinsPayload, SettingsSetFromLedgerSyncOnboardingPayload, SettingsSetHasBeenRedirectedToPostOnboardingPayload, + SettingsWhitelistNftCollectionPayload, + SettingsUnwhitelistNftCollectionPayload, } from "./types"; import { ImageType } from "~/components/CustomImage/types"; @@ -147,6 +149,15 @@ export const hideNftCollection = createAction( export const unhideNftCollection = createAction( SettingsActionTypes.UNHIDE_NFT_COLLECTION, ); + +export const whitelistNftCollection = createAction( + SettingsActionTypes.WHITELIST_NFT_COLLECTION, +); + +export const unwhitelistNftCollection = createAction( + SettingsActionTypes.UNWHITELIST_NFT_COLLECTION, +); + export const dismissBanner = createAction( SettingsActionTypes.SETTINGS_DISMISS_BANNER, ); diff --git a/apps/ledger-live-mobile/src/actions/types.ts b/apps/ledger-live-mobile/src/actions/types.ts index 7493911df020..90350625cf43 100644 --- a/apps/ledger-live-mobile/src/actions/types.ts +++ b/apps/ledger-live-mobile/src/actions/types.ts @@ -239,6 +239,8 @@ export enum SettingsActionTypes { BLACKLIST_TOKEN = "BLACKLIST_TOKEN", HIDE_NFT_COLLECTION = "HIDE_NFT_COLLECTION", UNHIDE_NFT_COLLECTION = "UNHIDE_NFT_COLLECTION", + UNWHITELIST_NFT_COLLECTION = "UNWHITELIST_NFT_COLLECTION", + WHITELIST_NFT_COLLECTION = "WHITELIST_NFT_COLLECTION", SETTINGS_DISMISS_BANNER = "SETTINGS_DISMISS_BANNER", SETTINGS_SET_AVAILABLE_UPDATE = "SETTINGS_SET_AVAILABLE_UPDATE", DANGEROUSLY_OVERRIDE_STATE = "DANGEROUSLY_OVERRIDE_STATE", @@ -317,6 +319,8 @@ export type SettingsShowTokenPayload = string; export type SettingsBlacklistTokenPayload = string; export type SettingsHideNftCollectionPayload = string; export type SettingsUnhideNftCollectionPayload = string; +export type SettingsWhitelistNftCollectionPayload = string; +export type SettingsUnwhitelistNftCollectionPayload = string; export type SettingsDismissBannerPayload = string; export type SettingsSetAvailableUpdatePayload = SettingsState["hasAvailableUpdate"]; export type SettingsSetThemePayload = SettingsState["theme"]; @@ -415,6 +419,8 @@ export type SettingsPayload = | SettingsBlacklistTokenPayload | SettingsHideNftCollectionPayload | SettingsUnhideNftCollectionPayload + | SettingsWhitelistNftCollectionPayload + | SettingsUnwhitelistNftCollectionPayload | SettingsDismissBannerPayload | SettingsSetAvailableUpdatePayload | SettingsSetThemePayload diff --git a/apps/ledger-live-mobile/src/components/Nft/HideNftDrawer.tsx b/apps/ledger-live-mobile/src/components/Nft/HideNftDrawer.tsx index 19bd7b88ca8b..8c059fe6d407 100644 --- a/apps/ledger-live-mobile/src/components/Nft/HideNftDrawer.tsx +++ b/apps/ledger-live-mobile/src/components/Nft/HideNftDrawer.tsx @@ -7,10 +7,11 @@ import { useDispatch, useSelector } from "react-redux"; import { Account } from "@ledgerhq/types-live"; import { decodeNftId } from "@ledgerhq/coin-framework/nft/nftId"; import { track, TrackScreen } from "~/analytics"; -import { hideNftCollection } from "~/actions/settings"; +import { hideNftCollection, unwhitelistNftCollection } from "~/actions/settings"; import { accountSelector } from "~/reducers/accounts"; import { State } from "~/reducers/types"; import QueuedDrawer from "../QueuedDrawer"; +import { whitelistedNftCollectionsSelector } from "~/reducers/settings"; type Props = { nftId?: string; @@ -24,6 +25,8 @@ const HideNftDrawer = ({ nftId, nftContract, collection, isOpened, onClose }: Pr const navigation = useNavigation(); const dispatch = useDispatch(); + const whitelistedNftCollections = useSelector(whitelistedNftCollectionsSelector); + const { accountId } = decodeNftId(nftId ?? ""); const account = useSelector(state => accountSelector(state, { accountId }), @@ -35,10 +38,15 @@ const HideNftDrawer = ({ nftId, nftContract, collection, isOpened, onClose }: Pr drawer: "Hide NFT Confirmation", }); - dispatch(hideNftCollection(`${account?.id}|${nftContract}`)); + const collectionId = `${account?.id}|${nftContract}`; + if (whitelistedNftCollections.includes(collectionId)) { + dispatch(unwhitelistNftCollection(collectionId)); + } + + dispatch(hideNftCollection(collectionId)); onClose(); navigation.goBack(); - }, [account?.id, dispatch, navigation, nftContract, onClose]); + }, [account?.id, dispatch, navigation, nftContract, onClose, whitelistedNftCollections]); const onPressClose = useCallback(() => { track("button_clicked", { diff --git a/apps/ledger-live-mobile/src/components/Nft/NftCollectionOptionsMenu.tsx b/apps/ledger-live-mobile/src/components/Nft/NftCollectionOptionsMenu.tsx index 3f532d2e2a6f..53ecf427db87 100644 --- a/apps/ledger-live-mobile/src/components/Nft/NftCollectionOptionsMenu.tsx +++ b/apps/ledger-live-mobile/src/components/Nft/NftCollectionOptionsMenu.tsx @@ -1,11 +1,12 @@ import React, { useCallback } from "react"; -import { useDispatch } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import { Text, IconsLegacy, BoxedIcon, Button, Flex } from "@ledgerhq/native-ui"; import { Account, ProtoNFT } from "@ledgerhq/types-live"; import { useTranslation } from "react-i18next"; -import { hideNftCollection } from "~/actions/settings"; +import { hideNftCollection, unwhitelistNftCollection } from "~/actions/settings"; import QueuedDrawer from "../QueuedDrawer"; +import { whitelistedNftCollectionsSelector } from "~/reducers/settings"; type Props = { isOpen: boolean; @@ -18,10 +19,17 @@ const NftCollectionOptionsMenu = ({ isOpen, onClose, collection, account }: Prop const { t } = useTranslation(); const dispatch = useDispatch(); + const whitelistedNftCollections = useSelector(whitelistedNftCollectionsSelector); + const onConfirm = useCallback(() => { - dispatch(hideNftCollection(`${account.id}|${collection?.[0]?.contract}`)); + const collectionId = `${account.id}|${collection?.[0]?.contract}`; + if (whitelistedNftCollections.includes(collectionId)) { + dispatch(unwhitelistNftCollection(collectionId)); + } + + dispatch(hideNftCollection(collectionId)); onClose(); - }, [dispatch, account.id, collection, onClose]); + }, [account.id, collection, whitelistedNftCollections, dispatch, onClose]); return ( diff --git a/apps/ledger-live-mobile/src/components/Nft/NftGallery/NftList.hook.ts b/apps/ledger-live-mobile/src/components/Nft/NftGallery/NftList.hook.ts index 044735174b98..03b2a5a68400 100644 --- a/apps/ledger-live-mobile/src/components/Nft/NftGallery/NftList.hook.ts +++ b/apps/ledger-live-mobile/src/components/Nft/NftGallery/NftList.hook.ts @@ -7,16 +7,14 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useDispatch, useSelector } from "react-redux"; import { BackHandler } from "react-native"; -import { hideNftCollection } from "../../../actions/settings"; +import { hideNftCollection, unwhitelistNftCollection } from "~/actions/settings"; import { track } from "../../../analytics"; import { NavigatorName, ScreenName } from "~/const"; -import { updateMainNavigatorVisibility } from "../../../actions/appstate"; -import { - galleryFilterDrawerVisibleSelector, - galleryChainFiltersSelector, -} from "../../../reducers/nft"; -import { setGalleryChainFilter, setGalleryFilterDrawerVisible } from "../../../actions/nft"; +import { updateMainNavigatorVisibility } from "~/actions/appstate"; +import { galleryFilterDrawerVisibleSelector, galleryChainFiltersSelector } from "~/reducers/nft"; +import { setGalleryChainFilter, setGalleryFilterDrawerVisible } from "~/actions/nft"; import { NftGalleryChainFiltersState } from "../../../reducers/types"; +import { whitelistedNftCollectionsSelector } from "~/reducers/settings"; const TOAST_ID = "SUCCESS_HIDE"; @@ -30,6 +28,8 @@ export function useNftList({ nftList }: { nftList?: ProtoNFT[] }) { const isFilterDrawerVisible = useSelector(galleryFilterDrawerVisibleSelector); const chainFilters = useSelector(galleryChainFiltersSelector); + const whitelistedNftCollections = useSelector(whitelistedNftCollectionsSelector); + const [nftsToHide, setNftsToHide] = useState([]); // Multi Select ------------------------ @@ -66,7 +66,12 @@ export function useNftList({ nftList }: { nftList?: ProtoNFT[] }) { exitMultiSelectMode(); nftsToHide.forEach(nft => { const { accountId } = decodeNftId(nft.id ?? ""); - dispatch(hideNftCollection(`${accountId}|${nft.contract}`)); + const collectionId = `${accountId}|${nft.contract}`; + if (whitelistedNftCollections.includes(collectionId)) { + dispatch(unwhitelistNftCollection(collectionId)); + } + + dispatch(hideNftCollection(collectionId)); }); pushToast({ @@ -77,7 +82,7 @@ export function useNftList({ nftList }: { nftList?: ProtoNFT[] }) { count: nftsToHide.length, }), }); - }, [exitMultiSelectMode, dispatch, nftsToHide, pushToast, t]); + }, [exitMultiSelectMode, nftsToHide, pushToast, t, whitelistedNftCollections, dispatch]); const triggerMultiSelectMode = useCallback(() => { setNftsToHide([]); diff --git a/apps/ledger-live-mobile/src/components/Nft/NftGallery/NftList.tsx b/apps/ledger-live-mobile/src/components/Nft/NftGallery/NftList.tsx index 706c9f71d6ca..619a02e4f1c8 100644 --- a/apps/ledger-live-mobile/src/components/Nft/NftGallery/NftList.tsx +++ b/apps/ledger-live-mobile/src/components/Nft/NftGallery/NftList.tsx @@ -31,7 +31,6 @@ const RefreshableCollapsibleHeaderFlatList = globalSyncRefreshControl void; - hasNextPage: boolean; isLoading: boolean; error: unknown; refetch: () => void; @@ -51,7 +50,7 @@ const NB_COLUMNS = 2; const keyExtractor = (item: ProtoNFT) => item.id; -const NftList = ({ data, hasNextPage, fetchNextPage, isLoading }: Props) => { +const NftList = ({ data, fetchNextPage, isLoading }: Props) => { const { space, colors } = useTheme(); const dataWithAdd = data.concat(ADD_NEW); @@ -172,14 +171,14 @@ const NftList = ({ data, hasNextPage, fetchNextPage, isLoading }: Props) => { marginBottom: multiSelectModeEnabled ? 0 : space[3], }} ListFooterComponent={ - !isLoading && hasNextPage ? ( + isLoading ? ( ) : null } ListEmptyComponent={ - isLoading ? ( + data.length === 0 && isLoading ? ( diff --git a/apps/ledger-live-mobile/src/hooks/nfts/__tests__/useHideSpamCollection.test.ts b/apps/ledger-live-mobile/src/hooks/nfts/__tests__/useHideSpamCollection.test.ts new file mode 100644 index 000000000000..ef78b77ad2ab --- /dev/null +++ b/apps/ledger-live-mobile/src/hooks/nfts/__tests__/useHideSpamCollection.test.ts @@ -0,0 +1,55 @@ +import { useHideSpamCollection } from "../useHideSpamCollection"; +import { useDispatch } from "react-redux"; +import { renderHook } from "@tests/test-renderer"; +import { hideNftCollection } from "~/actions/settings"; +import { State } from "~/reducers/types"; + +jest.mock("react-redux", () => ({ + ...jest.requireActual("react-redux"), + useDispatch: jest.fn(), +})); + +const mockDispatch = jest.fn(); + +describe("useHideSpamCollection", () => { + beforeEach(() => { + (useDispatch as jest.Mock).mockReturnValue(mockDispatch); + mockDispatch.mockClear(); + }); + + it("should dispatch hideNftCollection action if collection is not whitelisted", () => { + const { result } = renderHook( + () => useHideSpamCollection(), + + { + overrideInitialState: (state: State) => ({ + ...state, + settings: { + ...state.settings, + whitelistedNftCollections: ["collectionA", "collectionB"], + hiddenNftCollections: [], + }, + }), + }, + ); + result.current.hideSpamCollection("collectionC"); + + expect(mockDispatch).toHaveBeenCalledWith(hideNftCollection("collectionC")); + }); + + it("should not dispatch hideNftCollection action if collection is whitelisted", () => { + const { result } = renderHook(() => useHideSpamCollection(), { + overrideInitialState: (state: State) => ({ + ...state, + settings: { + ...state.settings, + hiddenNftCollections: [], + whitelistedNftCollections: ["collectionA", "collectionB"], + }, + }), + }); + result.current.hideSpamCollection("collectionA"); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/ledger-live-mobile/src/hooks/nfts/__tests__/useSyncNFTsWitHAccount.test.ts b/apps/ledger-live-mobile/src/hooks/nfts/__tests__/useSyncNFTsWitHAccount.test.ts new file mode 100644 index 000000000000..0f8b93ca9d03 --- /dev/null +++ b/apps/ledger-live-mobile/src/hooks/nfts/__tests__/useSyncNFTsWitHAccount.test.ts @@ -0,0 +1,90 @@ +import { useSelector } from "react-redux"; +import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; +import { useCheckNftAccount } from "@ledgerhq/live-nft-react"; +import { useHideSpamCollection } from "../useHideSpamCollection"; +import { useSyncNFTsWithAccounts } from "../useSyncNFTsWithAccounts"; + +import { accountsSelector, orderedVisibleNftsSelector } from "~/reducers/accounts"; +import { renderHook } from "@testing-library/react-native"; + +jest.mock("react-redux", () => ({ + useSelector: jest.fn(), +})); + +jest.mock("@ledgerhq/live-common/featureFlags/index", () => ({ + useFeature: jest.fn(), +})); + +jest.mock("../useHideSpamCollection", () => ({ + useHideSpamCollection: jest.fn(), +})); + +jest.mock("@ledgerhq/live-nft-react", () => ({ + useCheckNftAccount: jest.fn(), + isThresholdValid: jest.fn(), + getThreshold: jest.fn(), +})); + +describe("useSyncNFTsWithAccounts", () => { + const mockUseSelector = useSelector as jest.Mock; + const mockUseFeature = useFeature as jest.Mock; + const mockUseHideSpamCollection = useHideSpamCollection as jest.Mock; + const mockUseCheckNftAccount = useCheckNftAccount as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("should refetch periodically based on TIMER", () => { + const mockRefetch = jest.fn(); + const mockAccounts = [{ freshAddress: "0x123" }, { freshAddress: "0x456" }]; + + mockUseFeature.mockReturnValue({ enabled: true }); + mockUseHideSpamCollection.mockReturnValue({ enabled: true, hideSpamCollection: jest.fn() }); + mockUseSelector.mockImplementation(selector => { + if (selector === accountsSelector) return mockAccounts; + if (selector === orderedVisibleNftsSelector) return []; + return []; + }); + mockUseCheckNftAccount.mockReturnValue({ refetch: mockRefetch }); + + renderHook(() => useSyncNFTsWithAccounts()); + + jest.advanceTimersByTime(5 * 60 * 60 * 1000); + + expect(mockRefetch).toHaveBeenCalledTimes(1); + }); + + it("should refetch immediately when a new account is added", () => { + const mockRefetch = jest.fn(); + const initialAccounts = [{ freshAddress: "0x123" }]; + const updatedAccounts = [...initialAccounts, { freshAddress: "0x789" }]; + + mockUseFeature.mockReturnValue({ enabled: true }); + mockUseHideSpamCollection.mockReturnValue({ enabled: true, hideSpamCollection: jest.fn() }); + mockUseSelector + .mockImplementationOnce(selector => { + if (selector === accountsSelector) return initialAccounts; + if (selector === orderedVisibleNftsSelector) return []; + return []; + }) + .mockImplementationOnce(selector => { + if (selector === accountsSelector) return updatedAccounts; + if (selector === orderedVisibleNftsSelector) return []; + return []; + }); + + mockUseCheckNftAccount.mockReturnValue({ refetch: mockRefetch }); + + const { rerender } = renderHook(() => useSyncNFTsWithAccounts()); + + rerender({}); + + expect(mockRefetch).toHaveBeenCalledTimes(2); // 1 for initial render & 1 for adding new account + }); +}); diff --git a/apps/ledger-live-mobile/src/hooks/nfts/useHideSpamCollection.ts b/apps/ledger-live-mobile/src/hooks/nfts/useHideSpamCollection.ts new file mode 100644 index 000000000000..4974265b1240 --- /dev/null +++ b/apps/ledger-live-mobile/src/hooks/nfts/useHideSpamCollection.ts @@ -0,0 +1,25 @@ +import { useCallback } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; +import { hideNftCollection } from "~/actions/settings"; +import { whitelistedNftCollectionsSelector } from "~/reducers/settings"; + +export function useHideSpamCollection() { + const spamFilteringTxFeature = useFeature("spamFilteringTx"); + const whitelistedNftCollections = useSelector(whitelistedNftCollectionsSelector); + + const dispatch = useDispatch(); + const hideSpamCollection = useCallback( + (collection: string) => { + if (!whitelistedNftCollections.includes(collection)) { + dispatch(hideNftCollection(collection)); + } + }, + [dispatch, whitelistedNftCollections], + ); + + return { + hideSpamCollection, + enabled: spamFilteringTxFeature?.enabled, + }; +} diff --git a/apps/ledger-live-mobile/src/hooks/nfts/useNftCollections.ts b/apps/ledger-live-mobile/src/hooks/nfts/useNftCollections.ts new file mode 100644 index 000000000000..b97c94f00f70 --- /dev/null +++ b/apps/ledger-live-mobile/src/hooks/nfts/useNftCollections.ts @@ -0,0 +1,76 @@ +import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; +import { decodeCollectionId, getThreshold, useNftGalleryFilter } from "@ledgerhq/live-nft-react"; +import { nftsByCollections } from "@ledgerhq/live-nft/index"; +import { BlockchainEVM } from "@ledgerhq/live-nft/supported"; +import { Account, ProtoNFT } from "@ledgerhq/types-live"; +import { useMemo } from "react"; +import { useSelector } from "react-redux"; +import { + hiddenNftCollectionsSelector, + whitelistedNftCollectionsSelector, +} from "~/reducers/settings"; + +export function useNftCollections({ + account, + nftsOwned, + addresses, + chains, +}: { + account?: Account; + nftsOwned?: ProtoNFT[]; + addresses?: string; + chains?: string[]; +}) { + const nftsFromSimplehashFeature = useFeature("nftsFromSimplehash"); + const threshold = nftsFromSimplehashFeature?.params?.threshold; + const simplehashEnabled = nftsFromSimplehashFeature?.enabled; + + const whitelistNft = useSelector(whitelistedNftCollectionsSelector); + const hiddenNftCollections = useSelector(hiddenNftCollectionsSelector); + + const nftsOwnedToCheck = useMemo(() => account?.nfts ?? nftsOwned, [account?.nfts, nftsOwned]); + + const whitelistedNfts = useMemo( + () => + nftsOwnedToCheck?.filter(nft => + whitelistNft + .map(collection => decodeCollectionId(collection).contractAddress) + .includes(nft.contract), + ) ?? [], + [nftsOwnedToCheck, whitelistNft], + ); + + const { nfts, fetchNextPage, hasNextPage, ...rest } = useNftGalleryFilter({ + nftsOwned: account?.nfts ?? nftsOwned ?? [], + addresses: account?.freshAddress ?? addresses ?? "", + chains: account + ? [account.currency.id || BlockchainEVM.Ethereum] + : chains ?? [BlockchainEVM.Ethereum], + threshold: getThreshold(threshold), + }); + + const allNfts = useMemo( + () => (simplehashEnabled ? [...nfts, ...whitelistedNfts] : account?.nfts || nftsOwned || []), + [simplehashEnabled, nfts, whitelistedNfts, account, nftsOwned], + ); + + const collections = useMemo( + () => + Object.entries(nftsByCollections(allNfts)).filter( + ([contract]) => !hiddenNftCollections.includes(`${account?.id}|${contract}`), + ), + [account?.id, allNfts, hiddenNftCollections], + ); + + const collectionsLength = Object.keys(collections).length; + + return { + collections, + collectionsLength, + fetchNextPage, + hasNextPage, + nfts, + allNfts, + ...rest, + }; +} diff --git a/apps/ledger-live-mobile/src/hooks/nfts/useSyncNFTsWithAccounts.ts b/apps/ledger-live-mobile/src/hooks/nfts/useSyncNFTsWithAccounts.ts new file mode 100644 index 000000000000..354731e034f2 --- /dev/null +++ b/apps/ledger-live-mobile/src/hooks/nfts/useSyncNFTsWithAccounts.ts @@ -0,0 +1,101 @@ +import { useEffect, useMemo, useState } from "react"; +import { useHideSpamCollection } from "./useHideSpamCollection"; +import { useSelector } from "react-redux"; +import isEqual from "lodash/isEqual"; +import { getEnv } from "@ledgerhq/live-env"; +import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; +import { getThreshold, useCheckNftAccount } from "@ledgerhq/live-nft-react"; +import { accountsSelector, orderedVisibleNftsSelector } from "~/reducers/accounts"; + +/** + * Represents the size of groups for batching address fetching. + * @constant {number} + */ +const GROUP_SIZE = 20; + +/** + * Represents the timer duration for updating address groups. + * 5 hours = 18,000,000 ms. + * @constant {number} + */ +const TIMER = 5 * 60 * 60 * 1000; // 5 hours = 18000000 ms + +/** + * A React hook that synchronizes NFT accounts by fetching their data in groups. + * It utilizes address batching and manages updates based on a timer. + * + * @returns {void} + * + * @example + * import { useSyncNFTsWithAccounts } from './path/to/hook'; + * + * const MyComponent = () => { + * useSyncNFTsWithAccounts(); + * return
Syncing NFT Accounts...
; + * }; + */ + +export function useSyncNFTsWithAccounts() { + const SUPPORTED_NFT_CURRENCIES = getEnv("NFT_CURRENCIES"); + const nftsFromSimplehashFeature = useFeature("nftsFromSimplehash"); + const threshold = getThreshold(nftsFromSimplehashFeature?.params?.threshold); + + const { enabled, hideSpamCollection } = useHideSpamCollection(); + + const accounts = useSelector(accountsSelector); + const nftsOwned = useSelector(orderedVisibleNftsSelector, isEqual); + + const addressGroups = useMemo(() => { + const uniqueAddresses = [ + ...new Set( + accounts.map(account => account.freshAddress).filter(addr => addr.startsWith("0x")), + ), + ]; + + return uniqueAddresses.reduce((acc, _, i, arr) => { + if (i % GROUP_SIZE === 0) { + acc.push(arr.slice(i, i + GROUP_SIZE)); + } + return acc; + }, []); + }, [accounts]); + + const [groupToFetch, setGroupToFetch] = useState( + addressGroups.length > 0 ? addressGroups[0] : [], + ); + const [, setCurrentIndex] = useState(0); + + const { refetch } = useCheckNftAccount({ + addresses: groupToFetch.join(","), + nftsOwned, + chains: SUPPORTED_NFT_CURRENCIES, + threshold, + action: hideSpamCollection, + enabled, + }); + + // Refetch with new last group when addressGroups length changes + useEffect(() => { + if (enabled) { + const newIndex = addressGroups.length - 1; + setCurrentIndex(newIndex); + setGroupToFetch(addressGroups[newIndex] || []); + refetch(); + } + }, [addressGroups.length, addressGroups, refetch, enabled]); + + // Regular interval-based rotation through groups + useEffect(() => { + if (!enabled) return; + + const interval = setInterval(() => { + setCurrentIndex(prevIndex => { + const nextIndex = (prevIndex + 1) % addressGroups.length; + setGroupToFetch(addressGroups[nextIndex]); + return nextIndex; + }); + }, TIMER); + + return () => clearInterval(interval); + }, [addressGroups, enabled]); +} diff --git a/apps/ledger-live-mobile/src/index.tsx b/apps/ledger-live-mobile/src/index.tsx index 031f7e89f66f..2cfc962a7b25 100644 --- a/apps/ledger-live-mobile/src/index.tsx +++ b/apps/ledger-live-mobile/src/index.tsx @@ -91,6 +91,7 @@ import { exportMarketSelector } from "./reducers/market"; import { trustchainStoreSelector } from "@ledgerhq/ledger-key-ring-protocol/store"; import { walletSelector } from "~/reducers/wallet"; import { exportWalletState, walletStateExportShouldDiffer } from "@ledgerhq/live-wallet/store"; +import { useSyncNFTsWithAccounts } from "./hooks/nfts/useSyncNFTsWithAccounts"; if (Config.DISABLE_YELLOW_BOX) { LogBox.ignoreAllLogs(); @@ -144,6 +145,8 @@ function App() { useListenToHidDevices(); useAutoDismissPostOnboardingEntryPoint(); + useSyncNFTsWithAccounts(); + const getSettingsChanged = useCallback((a: State, b: State) => a.settings !== b.settings, []); const getAccountsChanged = useCallback( ( diff --git a/apps/ledger-live-mobile/src/reducers/settings.ts b/apps/ledger-live-mobile/src/reducers/settings.ts index cb7f88187b23..695c66e82900 100644 --- a/apps/ledger-live-mobile/src/reducers/settings.ts +++ b/apps/ledger-live-mobile/src/reducers/settings.ts @@ -81,6 +81,8 @@ import type { SettingsRemoveStarredMarketcoinsPayload, SettingsSetFromLedgerSyncOnboardingPayload, SettingsSetHasBeenRedirectedToPostOnboardingPayload, + SettingsUnwhitelistNftCollectionPayload, + SettingsWhitelistNftCollectionPayload, } from "../actions/types"; import { SettingsActionTypes, @@ -119,6 +121,7 @@ export const INITIAL_STATE: SettingsState = { filterTokenOperationsZeroAmount: true, blacklistedTokenIds: [], hiddenNftCollections: [], + whitelistedNftCollections: [], dismissedBanners: [], hasAvailableUpdate: false, theme: "system", @@ -359,7 +362,9 @@ const handlers: ReducerMap = { const ids = state.hiddenNftCollections; return { ...state, - hiddenNftCollections: [...ids, (action as Action).payload], + hiddenNftCollections: [ + ...new Set(ids.concat((action as Action).payload)), + ], }; }, @@ -373,6 +378,27 @@ const handlers: ReducerMap = { }; }, + [SettingsActionTypes.UNWHITELIST_NFT_COLLECTION]: (state, action) => { + const ids = state.whitelistedNftCollections; + return { + ...state, + whitelistedNftCollections: ids.filter( + id => id !== (action as Action).payload, + ), + }; + }, + [SettingsActionTypes.WHITELIST_NFT_COLLECTION]: (state, action) => { + const collections = state.whitelistedNftCollections; + return { + ...state, + whitelistedNftCollections: [ + ...new Set( + collections.concat((action as Action).payload), + ), + ], + }; + }, + [SettingsActionTypes.SETTINGS_DISMISS_BANNER]: (state, action) => ({ ...state, dismissedBanners: [ @@ -804,6 +830,8 @@ export const countervalueFirstSelector = (state: State) => state.settings.graphC export const readOnlyModeEnabledSelector = (state: State) => state.settings.readOnlyModeEnabled; export const blacklistedTokenIdsSelector = (state: State) => state.settings.blacklistedTokenIds; export const hiddenNftCollectionsSelector = (state: State) => state.settings.hiddenNftCollections; +export const whitelistedNftCollectionsSelector = (state: State) => + state.settings.whitelistedNftCollections; export const exportSettingsSelector = createSelector( counterValueCurrencySelector, () => getEnv("MANAGER_DEV_MODE"), diff --git a/apps/ledger-live-mobile/src/reducers/types.ts b/apps/ledger-live-mobile/src/reducers/types.ts index 24f3584fb53b..736f71eb8e43 100644 --- a/apps/ledger-live-mobile/src/reducers/types.ts +++ b/apps/ledger-live-mobile/src/reducers/types.ts @@ -213,6 +213,7 @@ export type SettingsState = { filterTokenOperationsZeroAmount: boolean; blacklistedTokenIds: string[]; hiddenNftCollections: string[]; + whitelistedNftCollections: string[]; dismissedBanners: string[]; hasAvailableUpdate: boolean; theme: Theme; diff --git a/apps/ledger-live-mobile/src/screens/Account/NftCollectionsList.tsx b/apps/ledger-live-mobile/src/screens/Account/NftCollectionsList.tsx index 435ade26db8a..368d6fd8dfa1 100644 --- a/apps/ledger-live-mobile/src/screens/Account/NftCollectionsList.tsx +++ b/apps/ledger-live-mobile/src/screens/Account/NftCollectionsList.tsx @@ -1,23 +1,19 @@ import React, { useCallback, useMemo, useState } from "react"; -import { useSelector } from "react-redux"; import { Box, InfiniteLoader, Text } from "@ledgerhq/native-ui"; import { Trans, useTranslation } from "react-i18next"; import { StyleSheet, View, FlatList } from "react-native"; import useEnv from "@ledgerhq/live-common/hooks/useEnv"; -import { nftsByCollections } from "@ledgerhq/live-nft"; import { useNavigation, useTheme } from "@react-navigation/native"; import { Account, ProtoNFT } from "@ledgerhq/types-live"; import { ChevronRightMedium, PlusMedium } from "@ledgerhq/native-ui/assets/icons"; import NftCollectionOptionsMenu from "~/components/Nft/NftCollectionOptionsMenu"; -import { hiddenNftCollectionsSelector } from "~/reducers/settings"; import NftCollectionRow from "~/components/Nft/NftCollectionRow"; import { NavigatorName, ScreenName } from "~/const"; import Button from "~/components/wrappedUi/Button"; import Touchable from "~/components/Touchable"; import SectionTitle from "../WalletCentricSections/SectionTitle"; -import useFeature from "@ledgerhq/live-common/featureFlags/useFeature"; -import { useNftGalleryFilter, getThreshold } from "@ledgerhq/live-nft-react"; +import { useNftCollections } from "~/hooks/nfts/useNftCollections"; const MAX_COLLECTIONS_TO_SHOW = 3; @@ -27,29 +23,14 @@ type Props = { export default function NftCollectionsList({ account }: Props) { useEnv("HIDE_EMPTY_TOKEN_ACCOUNTS"); - const nftsFromSimplehashFeature = useFeature("nftsFromSimplehash"); - const threshold = nftsFromSimplehashFeature?.params?.threshold; - const nftsFromSimplehashEnabled = nftsFromSimplehashFeature?.enabled; const { colors } = useTheme(); const { t } = useTranslation(); const navigation = useNavigation(); - const { nfts, isLoading } = useNftGalleryFilter({ - nftsOwned: account.nfts || [], - addresses: account.freshAddress, - chains: [account.currency.id], - threshold: getThreshold(threshold), + const { collections, isLoading } = useNftCollections({ + account, }); - const hiddenNftCollections = useSelector(hiddenNftCollectionsSelector); - const nftCollections = useMemo( - () => - Object.entries(nftsByCollections(nftsFromSimplehashEnabled ? nfts : account.nfts)).filter( - ([contract]) => !hiddenNftCollections.includes(`${account.id}|${contract}`), - ), - [account.id, account.nfts, hiddenNftCollections, nfts, nftsFromSimplehashEnabled], - ) as [string, ProtoNFT[]][]; - const [isCollectionMenuOpen, setIsCollectionMenuOpen] = useState(false); const [selectedCollection, setSelectedCollection] = useState(); @@ -62,8 +43,8 @@ export default function NftCollectionsList({ account }: Props) { ); const data = useMemo( - () => nftCollections.slice(0, MAX_COLLECTIONS_TO_SHOW).map(([, collection]) => collection), - [nftCollections], + () => collections.slice(0, MAX_COLLECTIONS_TO_SHOW).map(([, collection]) => collection), + [collections], ); const navigateToReceive = useCallback( diff --git a/apps/ledger-live-mobile/src/screens/Analytics/Operations.tsx b/apps/ledger-live-mobile/src/screens/Analytics/Operations.tsx index 316a9d0826fc..5130d4c3885c 100644 --- a/apps/ledger-live-mobile/src/screens/Analytics/Operations.tsx +++ b/apps/ledger-live-mobile/src/screens/Analytics/Operations.tsx @@ -32,7 +32,10 @@ import { TrackScreen } from "~/analytics"; import { withDiscreetMode } from "~/context/DiscreetModeContext"; import type { BaseNavigatorStackParamList } from "~/components/RootNavigator/types/BaseNavigator"; import type { StackNavigatorProps } from "~/components/RootNavigator/types/helpers"; -import { filterTokenOperationsZeroAmountEnabledSelector } from "~/reducers/settings"; +import { + filterTokenOperationsZeroAmountEnabledSelector, + hiddenNftCollectionsSelector, +} from "~/reducers/settings"; type Props = StackNavigatorProps; @@ -53,6 +56,7 @@ export function Operations({ navigation, route }: Props) { [accountsFromState, accountsIds], ); const allAccounts: AccountLikeArray = useSelector(flattenAccountsSelector); + const hiddenNftCollections = useSelector(hiddenNftCollectionsSelector); const refreshAccountsOrdering = useRefreshAccountsOrdering(); useFocusEffect(refreshAccountsOrdering); @@ -66,10 +70,15 @@ export function Operations({ navigation, route }: Props) { const removeZeroAmountTokenOp = shouldFilterTokenOpsZeroAmount && isAddressPoisoningOperation(operation, account); - return !removeZeroAmountTokenOp; + // Remove operations coming from an NFT collection considered spam + const opFromBlacklistedNftCollection = operation?.nftOperations?.find(op => + hiddenNftCollections.includes(`${account.id}|${op?.contract}`), + ); + return !opFromBlacklistedNftCollection && !removeZeroAmountTokenOp; }, - [shouldFilterTokenOpsZeroAmount], + [hiddenNftCollections, shouldFilterTokenOpsZeroAmount], ); + const { sections, completed } = groupAccountsOperationsByDay(accountsFiltered, { count: opCount, withSubAccounts: true, diff --git a/apps/ledger-live-mobile/src/screens/CustomImage/NFTGallerySelector.tsx b/apps/ledger-live-mobile/src/screens/CustomImage/NFTGallerySelector.tsx index 11a7ab520db3..cd118e546ffe 100644 --- a/apps/ledger-live-mobile/src/screens/CustomImage/NFTGallerySelector.tsx +++ b/apps/ledger-live-mobile/src/screens/CustomImage/NFTGallerySelector.tsx @@ -13,9 +13,8 @@ import { NavigatorName, ScreenName } from "~/const"; import { BaseComposite, StackNavigatorProps } from "~/components/RootNavigator/types/helpers"; import { CustomImageNavigatorParamList } from "~/components/RootNavigator/types/CustomImageNavigator"; import { TrackScreen } from "~/analytics"; -import useFeature from "@ledgerhq/live-common/featureFlags/useFeature"; -import { getThreshold, useNftGalleryFilter } from "@ledgerhq/live-nft-react"; import { getEnv } from "@ledgerhq/live-env"; +import { useNftCollections } from "~/hooks/nfts/useNftCollections"; const NB_COLUMNS = 2; @@ -33,9 +32,6 @@ const NFTGallerySelector = ({ navigation, route }: NavigationProps) => { const nftsOrdered = useSelector(orderedVisibleNftsSelector, isEqual); - const nftsFromSimplehashFeature = useFeature("nftsFromSimplehash"); - const threshold = nftsFromSimplehashFeature?.params?.threshold; - const nftsFromSimplehashEnabled = nftsFromSimplehashFeature?.enabled; const accounts = useSelector(accountsSelector); const addresses = useMemo( @@ -48,15 +44,13 @@ const NFTGallerySelector = ({ navigation, route }: NavigationProps) => { [accounts], ); - const { nfts: filteredNfts, isLoading } = useNftGalleryFilter({ - nftsOwned: nftsOrdered || [], + const { allNfts, isLoading } = useNftCollections({ + nftsOwned: nftsOrdered, addresses: addresses, chains: SUPPORTED_NFT_CURRENCIES, - threshold: getThreshold(threshold), }); - const nfts = nftsFromSimplehashEnabled ? filteredNfts : nftsOrdered; - const hasNfts = nfts.length > 0; + const hasNfts = allNfts.length > 0; const handlePress = useCallback( (nft: ProtoNFT) => { @@ -74,7 +68,7 @@ const NFTGallerySelector = ({ navigation, route }: NavigationProps) => { const renderItem = useCallback( ({ item, index }: { item: ProtoNFT; index: number }) => { - const incompleteLastRowFirstIndex = nfts.length - (nfts.length % NB_COLUMNS) - 1; + const incompleteLastRowFirstIndex = allNfts.length - (allNfts.length % NB_COLUMNS) - 1; const isOnIncompleteLastRow = index > incompleteLastRowFirstIndex; return ( { ); }, - [handlePress, nfts.length], + [handlePress, allNfts.length], ); return ( @@ -97,7 +91,7 @@ const NFTGallerySelector = ({ navigation, route }: NavigationProps) => { ; const NftGallery = () => { - const nftsFromSimplehashFeature = useFeature("nftsFromSimplehash"); - const threshold = nftsFromSimplehashFeature?.params?.threshold; - const nftsFromSimplehashEnabled = nftsFromSimplehashFeature?.enabled; const navigation = useNavigation(); const { t } = useTranslation(); const { params } = useRoute(); @@ -51,23 +46,12 @@ const NftGallery = () => { setOpen(false); }, []); - const { nfts, fetchNextPage, hasNextPage } = useNftGalleryFilter({ - nftsOwned: account.nfts || [], - addresses: account.freshAddress, - chains: [account.currency.id], - threshold: getThreshold(threshold), + const { collections, fetchNextPage, hasNextPage } = useNftCollections({ + account, }); - const hiddenNftCollections = useSelector(hiddenNftCollectionsSelector); - const collections = useMemo( - () => - Object.entries(nftsByCollections(nftsFromSimplehashEnabled ? nfts : account.nfts)).filter( - ([contract]) => !hiddenNftCollections.includes(`${account.id}|${contract}`), - ), - [account.id, account.nfts, hiddenNftCollections, nfts, nftsFromSimplehashEnabled], - ) as [string, ProtoNFT[]][]; - const [collectionsCount, setCollectionsCount] = useState(MAX_COLLECTIONS_FIRST_RENDER); + const collectionsSlice: Array = useMemo( () => collections.slice(0, collectionsCount).map(([, collection]) => collection), [collections, collectionsCount], diff --git a/apps/ledger-live-mobile/src/screens/Nft/WalletNftGallery/index.tsx b/apps/ledger-live-mobile/src/screens/Nft/WalletNftGallery/index.tsx index 682c80317b8b..82fa971d1bf0 100644 --- a/apps/ledger-live-mobile/src/screens/Nft/WalletNftGallery/index.tsx +++ b/apps/ledger-live-mobile/src/screens/Nft/WalletNftGallery/index.tsx @@ -9,15 +9,14 @@ import { accountsSelector, filteredNftsSelector, hasNftsSelector } from "~/reduc import isEqual from "lodash/isEqual"; import { galleryChainFiltersSelector } from "~/reducers/nft"; -import { getThreshold, useNftGalleryFilter } from "@ledgerhq/live-nft-react"; import useFeature from "@ledgerhq/live-common/featureFlags/useFeature"; +import { useNftCollections } from "~/hooks/nfts/useNftCollections"; const WalletNftGallery = () => { const { space } = useTheme(); const hasNFTs = useSelector(hasNftsSelector); const accounts = useSelector(accountsSelector); const nftsFromSimplehashFeature = useFeature("nftsFromSimplehash"); - const threshold = nftsFromSimplehashFeature?.params?.threshold; const chainFilters = useSelector(galleryChainFiltersSelector); const nftsOwned = useSelector(filteredNftsSelector, isEqual); @@ -40,11 +39,10 @@ const WalletNftGallery = () => { [chainFilters], ); - const { isLoading, hasNextPage, error, fetchNextPage, refetch, nfts } = useNftGalleryFilter({ + const { isLoading, hasNextPage, error, fetchNextPage, refetch, allNfts } = useNftCollections({ addresses, chains, nftsOwned, - threshold: getThreshold(threshold), }); const useSimpleHash = Boolean(nftsFromSimplehashFeature?.enabled); @@ -53,11 +51,10 @@ const WalletNftGallery = () => { {hasNFTs ? ( { if (useSimpleHash && hasNextPage) { fetchNextPage(); diff --git a/apps/ledger-live-mobile/src/screens/SendFunds/01b-SelectCollection.tsx b/apps/ledger-live-mobile/src/screens/SendFunds/01b-SelectCollection.tsx index 3a00e9b53d50..99211d410546 100644 --- a/apps/ledger-live-mobile/src/screens/SendFunds/01b-SelectCollection.tsx +++ b/apps/ledger-live-mobile/src/screens/SendFunds/01b-SelectCollection.tsx @@ -1,18 +1,10 @@ import React, { useCallback, useMemo, useState, memo } from "react"; import { View, StyleSheet, FlatList, TouchableOpacity, Platform } from "react-native"; -import { nftsByCollections } from "@ledgerhq/live-nft"; -import { - getThreshold, - useNftCollectionMetadata, - useNftGalleryFilter, - useNftMetadata, -} from "@ledgerhq/live-nft-react"; +import { useNftCollectionMetadata, useNftMetadata } from "@ledgerhq/live-nft-react"; import type { Account, ProtoNFT } from "@ledgerhq/types-live"; -import { useSelector } from "react-redux"; import { useNavigation, useTheme } from "@react-navigation/native"; import { SafeAreaView } from "react-native-safe-area-context"; -import { hiddenNftCollectionsSelector } from "~/reducers/settings"; import LoadingFooter from "~/components/LoadingFooter"; import NftMedia from "~/components/Nft/NftMedia"; import Skeleton from "~/components/Skeleton"; @@ -24,7 +16,7 @@ import { StackNavigatorProps, } from "~/components/RootNavigator/types/helpers"; import { SendFundsNavigatorStackParamList } from "~/components/RootNavigator/types/SendFundsNavigator"; -import useFeature from "@ledgerhq/live-common/featureFlags/useFeature"; +import { useNftCollections } from "~/hooks/nfts/useNftCollections"; const MAX_COLLECTIONS_FIRST_RENDER = 8; const COLLECTIONS_TO_ADD_ON_LIST_END_REACHED = 8; @@ -90,26 +82,12 @@ const SendFundsSelectCollection = ({ route }: Props) => { const { params } = route; const { account } = params; const { colors } = useTheme(); - const nftsFromSimplehashFeature = useFeature("nftsFromSimplehash"); - const threshold = nftsFromSimplehashFeature?.params?.threshold; - const nftsFromSimplehashEnabled = nftsFromSimplehashFeature?.enabled; - const hiddenNftCollections = useSelector(hiddenNftCollectionsSelector); - - const { nfts, fetchNextPage, hasNextPage } = useNftGalleryFilter({ - nftsOwned: account.nfts || [], - addresses: account.freshAddress, - chains: [account.currency.id], - threshold: getThreshold(threshold), + + const { collections, fetchNextPage, hasNextPage } = useNftCollections({ + account, }); const [collectionsCount, setCollectionsCount] = useState(MAX_COLLECTIONS_FIRST_RENDER); - const collections = useMemo( - () => - Object.entries(nftsByCollections(nftsFromSimplehashEnabled ? nfts : account.nfts)).filter( - ([contract]) => !hiddenNftCollections.includes(`${account.id}|${contract}`), - ), - [account.id, account.nfts, hiddenNftCollections, nfts, nftsFromSimplehashEnabled], - ) as [string, ProtoNFT[]][]; const collectionsSlice: Array = useMemo( () => collections.slice(0, collectionsCount).map(([, collection]) => collection), diff --git a/apps/ledger-live-mobile/src/screens/Settings/Accounts/HiddenNftCollections.tsx b/apps/ledger-live-mobile/src/screens/Settings/Accounts/HiddenNftCollections.tsx index 8880b4f20093..750186db4358 100644 --- a/apps/ledger-live-mobile/src/screens/Settings/Accounts/HiddenNftCollections.tsx +++ b/apps/ledger-live-mobile/src/screens/Settings/Accounts/HiddenNftCollections.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { FlatList } from "react-native"; import { Box, Flex, Text, IconsLegacy } from "@ledgerhq/native-ui"; import { useDispatch, useSelector } from "react-redux"; @@ -11,9 +11,12 @@ import { hiddenNftCollectionsSelector } from "~/reducers/settings"; import { accountSelector } from "~/reducers/accounts"; import NftMedia from "~/components/Nft/NftMedia"; import Skeleton from "~/components/Skeleton"; -import { unhideNftCollection } from "~/actions/settings"; +import { unhideNftCollection, whitelistNftCollection } from "~/actions/settings"; import { State } from "~/reducers/types"; +const MAX_COLLECTIONS_FIRST_RENDER = 10; +const COLLECTIONS_TO_ADD_ON_LIST_END_REACHED = 6; + const CollectionFlatList = styled(FlatList)` min-height: 100%; ` as unknown as typeof FlatList; @@ -88,6 +91,16 @@ const HiddenNftCollections = () => { const hiddenCollections = useSelector(hiddenNftCollectionsSelector); const dispatch = useDispatch(); + const [collectionsCount, setCollectionsCount] = useState(MAX_COLLECTIONS_FIRST_RENDER); + + const onUnhideCollection = useCallback( + (collectionId: string) => { + dispatch(unhideNftCollection(collectionId)); + dispatch(whitelistNftCollection(collectionId)); + }, + [dispatch], + ); + const renderItem = useCallback( ({ item }: { item: string }) => { const [accountId, contractAddress] = item.split("|"); @@ -95,22 +108,32 @@ const HiddenNftCollections = () => { dispatch(unhideNftCollection(item))} + onUnhide={() => onUnhideCollection(item)} /> ); }, - [dispatch], + [onUnhideCollection], ); const keyExtractor = useCallback((item: string) => item, []); + const collectionsSliced: string[] = useMemo( + () => hiddenCollections.slice(0, collectionsCount), + [collectionsCount, hiddenCollections], + ); + + const onEndReached = useCallback(() => { + setCollectionsCount(collectionsCount + COLLECTIONS_TO_ADD_ON_LIST_END_REACHED); + }, [collectionsCount]); + return ( diff --git a/apps/ledger-live-mobile/src/screens/WalletCentricSections/OperationsHistory.tsx b/apps/ledger-live-mobile/src/screens/WalletCentricSections/OperationsHistory.tsx index 6bded55f83b1..cf40447d0523 100644 --- a/apps/ledger-live-mobile/src/screens/WalletCentricSections/OperationsHistory.tsx +++ b/apps/ledger-live-mobile/src/screens/WalletCentricSections/OperationsHistory.tsx @@ -20,7 +20,10 @@ import { ScreenName } from "~/const"; import { parentAccountSelector } from "~/reducers/accounts"; import { track } from "~/analytics"; import { State } from "~/reducers/types"; -import { filterTokenOperationsZeroAmountEnabledSelector } from "~/reducers/settings"; +import { + filterTokenOperationsZeroAmountEnabledSelector, + hiddenNftCollectionsSelector, +} from "~/reducers/settings"; type Props = { accounts: AccountLikeArray; @@ -41,17 +44,22 @@ const OperationsHistory = ({ accounts }: Props) => { const shouldFilterTokenOpsZeroAmount = useSelector( filterTokenOperationsZeroAmountEnabledSelector, ); + const hiddenNftCollections = useSelector(hiddenNftCollectionsSelector); + const filterOperation = useCallback( (operation: Operation, account: AccountLike) => { // Remove operations linked to address poisoning const removeZeroAmountTokenOp = shouldFilterTokenOpsZeroAmount && isAddressPoisoningOperation(operation, account); - return !removeZeroAmountTokenOp; + // Remove operations coming from an NFT collection considered spam + const opFromBlacklistedNftCollection = operation?.nftOperations?.find(op => + hiddenNftCollections.includes(`${account.id}|${op?.contract}`), + ); + return !opFromBlacklistedNftCollection && !removeZeroAmountTokenOp; }, - [shouldFilterTokenOpsZeroAmount], + [hiddenNftCollections, shouldFilterTokenOpsZeroAmount], ); - const { sections, completed } = useMemo( () => groupAccountsOperationsByDay(accounts, {