Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ✨ SpamFiltering for NFTs TX in LLM #8241

Merged
merged 1 commit into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -71,6 +71,8 @@ import {
SettingsRemoveStarredMarketcoinsPayload,
SettingsSetFromLedgerSyncOnboardingPayload,
SettingsSetHasBeenRedirectedToPostOnboardingPayload,
SettingsWhitelistNftCollectionPayload,
SettingsUnwhitelistNftCollectionPayload,
} from "./types";
import { ImageType } from "~/components/CustomImage/types";

Expand Down Expand Up @@ -147,6 +149,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 @@ -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"];
Expand Down Expand Up @@ -415,6 +419,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
Loading