Skip to content

Commit

Permalink
feat: ✨ SpamFiltering for NFTs Tx history
Browse files Browse the repository at this point in the history
- Adding filtering in Operations
- Rework of HiddenCollection display
- Add Whitelist
- Rework hooks
  • Loading branch information
mcayuelas-ledger committed Oct 30, 2024
1 parent 3f90142 commit 9b4a1fc
Show file tree
Hide file tree
Showing 23 changed files with 518 additions and 123 deletions.
5 changes: 5 additions & 0 deletions .changeset/cold-rivers-raise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"live-mobile": patch
---

Add Spam Filtering for NFTs in TX history
11 changes: 11 additions & 0 deletions apps/ledger-live-mobile/src/actions/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ import {
SettingsAddStarredMarketcoinsPayload,
SettingsRemoveStarredMarketcoinsPayload,
SettingsSetFromLedgerSyncOnboardingPayload,
SettingsWhitelistNftCollectionPayload,
SettingsUnwhitelistNftCollectionPayload,
} from "./types";
import { ImageType } from "~/components/CustomImage/types";

Expand Down Expand Up @@ -146,6 +148,15 @@ export const hideNftCollection = createAction<SettingsHideNftCollectionPayload>(
export const unhideNftCollection = createAction<SettingsUnhideNftCollectionPayload>(
SettingsActionTypes.UNHIDE_NFT_COLLECTION,
);

export const whitelistNftCollection = createAction<SettingsWhitelistNftCollectionPayload>(
SettingsActionTypes.WHITELIST_NFT_COLLECTION,
);

export const unwhitelistNftCollection = createAction<SettingsUnwhitelistNftCollectionPayload>(
SettingsActionTypes.UNWHITELIST_NFT_COLLECTION,
);

export const dismissBanner = createAction<SettingsDismissBannerPayload>(
SettingsActionTypes.SETTINGS_DISMISS_BANNER,
);
Expand Down
6 changes: 6 additions & 0 deletions apps/ledger-live-mobile/src/actions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -316,6 +318,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"];
Expand Down Expand Up @@ -412,6 +416,8 @@ export type SettingsPayload =
| SettingsBlacklistTokenPayload
| SettingsHideNftCollectionPayload
| SettingsUnhideNftCollectionPayload
| SettingsWhitelistNftCollectionPayload
| SettingsUnwhitelistNftCollectionPayload
| SettingsDismissBannerPayload
| SettingsSetAvailableUpdatePayload
| SettingsSetThemePayload
Expand Down
14 changes: 11 additions & 3 deletions apps/ledger-live-mobile/src/components/Nft/HideNftDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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, Account | undefined>(state =>
accountSelector(state, { accountId }),
Expand All @@ -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", {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 (
<QueuedDrawer isRequestingToBeOpened={isOpen} onClose={onClose}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<ProtoNFT[]>([]);

// Multi Select ------------------------
Expand Down Expand Up @@ -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({
Expand All @@ -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([]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ const RefreshableCollapsibleHeaderFlatList = globalSyncRefreshControl<FlatListPr
type Props = {
data: ProtoNFT[];
fetchNextPage: () => void;
hasNextPage: boolean;
isLoading: boolean;
error: unknown;
refetch: () => void;
Expand All @@ -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);

Expand Down Expand Up @@ -172,14 +171,14 @@ const NftList = ({ data, hasNextPage, fetchNextPage, isLoading }: Props) => {
marginBottom: multiSelectModeEnabled ? 0 : space[3],
}}
ListFooterComponent={
!isLoading && hasNextPage ? (
isLoading ? (
<Flex paddingBottom={25} paddingTop={25}>
<InfiniteLoader />
</Flex>
) : null
}
ListEmptyComponent={
isLoading ? (
data.length === 0 && isLoading ? (
<Flex flexGrow={1} justifyContent="center" paddingBottom={150}>
<InfiniteLoader />
</Flex>
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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
});
});
25 changes: 25 additions & 0 deletions apps/ledger-live-mobile/src/hooks/nfts/useHideSpamCollection.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading

0 comments on commit 9b4a1fc

Please sign in to comment.