From 87218b17b86eaea9daa23c4c9cdf644c7ad2f65a Mon Sep 17 00:00:00 2001 From: Martin CAYUELAS <112866305+mcayuelas-ledger@users.noreply.github.com> Date: Thu, 31 Oct 2024 13:50:58 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20Add=20spamFilteringTx=20in?= =?UTF-8?q?=20LLD=20(#7911)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: ✨ Add spamFilteringTx hook in global feat: ✨ Add spamFilteringTx hook in global review: Enums * feat: 💄UX/UI Improvements on HiddenCollections section (#7912) Fix some UI broken + improve SimpleHash tool * bugfix: empty state account + Check when adding new address test: ✅ Add tests for new Hooks Check when adding new address ✅ Add tests for new Hooks --- .changeset/nervous-pumpkins-remain.md | 8 + .changeset/short-hotels-invite.md | 5 + .changeset/short-spoons-notice.md | 7 + .../Nfts/BreadCrumb/useBreadCrumbModel.tsx | 22 +-- .../Collections/useNftCollectionsModel.tsx | 43 +---- .../screens/Gallery/useNftGalleryModel.tsx | 35 +--- .../src/renderer/Default.tsx | 4 + .../src/renderer/actions/settings.ts | 10 ++ .../components/Breadcrumb/NFTCrumb.tsx | 23 +-- .../components/ContextMenu/NFTContextMenu.tsx | 2 +- .../CustomImage/NFTGallerySelector.tsx | 28 +-- .../components/OperationsList/Operation.tsx | 2 +- .../Web3AppWebview/PlatformAPIWebview.tsx | 2 +- .../Web3AppWebview/WalletAPIWebview.tsx | 6 +- .../NFTViewerDrawer/ExternalViewerButton.tsx | 2 +- .../families/bitcoin/SendRecipientFields.tsx | 2 +- .../__tests__/useHideSpamCollection.test.ts | 48 +++++ .../__tests__/useSyncNFTsWitHAccount.test.ts | 90 ++++++++++ .../hooks/{ => nfts}/useHideSpamCollection.ts | 9 +- .../renderer/hooks/nfts/useNftCollections.ts | 75 ++++++++ .../renderer/hooks/{ => nfts}/useNftLinks.ts | 6 +- .../hooks/nfts/useSyncNFTsWithAccounts.ts | 101 +++++++++++ .../modals/HideNftCollection/Footer.tsx | 13 +- .../renderer/modals/SimpleHashTools/index.tsx | 12 +- .../src/renderer/reducers/settings.ts | 24 ++- .../screens/nft/Collections/Collections.tsx | 46 ++--- .../renderer/screens/nft/Gallery/Gallery.tsx | 26 +-- .../src/renderer/screens/settings/index.tsx | 2 +- .../screens/settings/sections/About/index.tsx | 4 +- .../Accounts/HiddenNFTCollections.tsx | 169 ------------------ .../Accounts/HiddenNFTCollections/helpers.ts | 2 + .../Accounts/HiddenNFTCollections/index.tsx | 114 ++++++++++++ .../Accounts/HiddenNFTCollections/row.tsx | 127 +++++++++++++ .../screens/Account/NftCollectionsList.tsx | 6 +- .../CustomImage/NFTGallerySelector.tsx | 6 +- .../src/screens/Nft/NftGallery/index.tsx | 6 +- .../screens/Nft/WalletNftGallery/index.tsx | 6 +- .../SendFunds/01b-SelectCollection.tsx | 6 +- .../src/market/utils/timers.ts | 1 + .../live-nft-react/src/hooks/helpers/const.ts | 3 + .../live-nft-react/src/hooks/helpers/index.ts | 11 ++ libs/live-nft-react/src/hooks/types.ts | 1 + .../src/hooks/useCheckNftAccount.ts | 29 ++- libs/live-nft-react/src/index.ts | 1 + libs/live-nft/src/__tests__/index.test.ts | 34 +++- libs/live-nft/src/api/simplehash.ts | 7 +- libs/live-nft/src/index.ts | 10 +- 47 files changed, 796 insertions(+), 400 deletions(-) create mode 100644 .changeset/nervous-pumpkins-remain.md create mode 100644 .changeset/short-hotels-invite.md create mode 100644 .changeset/short-spoons-notice.md create mode 100644 apps/ledger-live-desktop/src/renderer/hooks/nfts/__tests__/useHideSpamCollection.test.ts create mode 100644 apps/ledger-live-desktop/src/renderer/hooks/nfts/__tests__/useSyncNFTsWitHAccount.test.ts rename apps/ledger-live-desktop/src/renderer/hooks/{ => nfts}/useHideSpamCollection.ts (68%) create mode 100644 apps/ledger-live-desktop/src/renderer/hooks/nfts/useNftCollections.ts rename apps/ledger-live-desktop/src/renderer/hooks/{ => nfts}/useNftLinks.ts (96%) create mode 100644 apps/ledger-live-desktop/src/renderer/hooks/nfts/useSyncNFTsWithAccounts.ts delete mode 100644 apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/HiddenNFTCollections.tsx create mode 100644 apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/HiddenNFTCollections/helpers.ts create mode 100644 apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/HiddenNFTCollections/index.tsx create mode 100644 apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/HiddenNFTCollections/row.tsx create mode 100644 libs/live-nft-react/src/hooks/helpers/const.ts diff --git a/.changeset/nervous-pumpkins-remain.md b/.changeset/nervous-pumpkins-remain.md new file mode 100644 index 000000000000..91ee80c43882 --- /dev/null +++ b/.changeset/nervous-pumpkins-remain.md @@ -0,0 +1,8 @@ +--- +"@ledgerhq/types-live": patch +"ledger-live-desktop": patch +"@ledgerhq/live-common": patch +"@ledgerhq/live-nft-react": patch +--- + +Add useCheckNftAccount Hook diff --git a/.changeset/short-hotels-invite.md b/.changeset/short-hotels-invite.md new file mode 100644 index 000000000000..81c53a5bd550 --- /dev/null +++ b/.changeset/short-hotels-invite.md @@ -0,0 +1,5 @@ +--- +"ledger-live-desktop": patch +--- + +UX/UI Improvements on HiddenCollections section diff --git a/.changeset/short-spoons-notice.md b/.changeset/short-spoons-notice.md new file mode 100644 index 000000000000..907408392f60 --- /dev/null +++ b/.changeset/short-spoons-notice.md @@ -0,0 +1,7 @@ +--- +"ledger-live-desktop": patch +"@ledgerhq/live-common": patch +"@ledgerhq/live-nft-react": patch +--- + +use Hook CheckNft in Default and handle global sync of NFTs every 12 hours diff --git a/apps/ledger-live-desktop/src/newArch/features/Collectibles/Nfts/BreadCrumb/useBreadCrumbModel.tsx b/apps/ledger-live-desktop/src/newArch/features/Collectibles/Nfts/BreadCrumb/useBreadCrumbModel.tsx index 00be2b1f1c1c..ea7470522ba3 100644 --- a/apps/ledger-live-desktop/src/newArch/features/Collectibles/Nfts/BreadCrumb/useBreadCrumbModel.tsx +++ b/apps/ledger-live-desktop/src/newArch/features/Collectibles/Nfts/BreadCrumb/useBreadCrumbModel.tsx @@ -2,39 +2,27 @@ import { useSelector } from "react-redux"; import { useHistory, useParams } from "react-router-dom"; import { State } from "~/renderer/reducers"; import { accountSelector } from "~/renderer/reducers/accounts"; -import { nftsByCollections } from "@ledgerhq/live-nft"; import { useCallback, useMemo } from "react"; import { ProtoNFT } from "@ledgerhq/types-live"; import { DropDownItemType } from "~/renderer/components/DropDownSelector"; import { setTrackingSource } from "~/renderer/analytics/TrackPage"; -import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; -import { isThresholdValid, useNftGalleryFilter } from "@ledgerhq/live-nft-react"; +import { useNftCollections } from "~/renderer/hooks/nfts/useNftCollections"; const useBreadCrumbModel = () => { const history = useHistory(); const { id, collectionAddress } = useParams<{ id?: string; collectionAddress?: string }>(); - const nftsFromSimplehashFeature = useFeature("nftsFromSimplehash"); - const thresold = nftsFromSimplehashFeature?.params?.threshold; const account = useSelector((state: State) => - id ? accountSelector(state, { accountId: id }) : null, + id ? accountSelector(state, { accountId: id }) : undefined, ); - const { nfts } = useNftGalleryFilter({ - nftsOwned: account?.nfts || [], - addresses: String(account?.freshAddress), - chains: [String(account?.currency.id)], - threshold: isThresholdValid(thresold) ? Number(thresold) : 75, + const { collections } = useNftCollections({ + account, }); - const collections = useMemo( - () => nftsByCollections(nftsFromSimplehashFeature?.enabled ? nfts : account?.nfts), - [account?.nfts, nfts, nftsFromSimplehashFeature], - ); - const items: DropDownItemType[] = useMemo( () => - Object.entries(collections).map(([contract, nfts]: [string, ProtoNFT[]]) => ({ + collections.map(([contract, nfts]: [string, ProtoNFT[]]) => ({ key: contract, label: contract, content: nfts[0], diff --git a/apps/ledger-live-desktop/src/newArch/features/Collectibles/Nfts/Collections/useNftCollectionsModel.tsx b/apps/ledger-live-desktop/src/newArch/features/Collectibles/Nfts/Collections/useNftCollectionsModel.tsx index d14d62a8e132..ae215c1c4490 100644 --- a/apps/ledger-live-desktop/src/newArch/features/Collectibles/Nfts/Collections/useNftCollectionsModel.tsx +++ b/apps/ledger-live-desktop/src/newArch/features/Collectibles/Nfts/Collections/useNftCollectionsModel.tsx @@ -1,16 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Account, ProtoNFT } from "@ledgerhq/types-live"; -import { useDispatch, useSelector } from "react-redux"; +import { useDispatch } from "react-redux"; import { useHistory } from "react-router-dom"; import { openModal } from "~/renderer/actions/modals"; -import { hiddenNftCollectionsSelector } from "~/renderer/reducers/settings"; -import { nftsByCollections } from "@ledgerhq/live-nft/index"; -import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; -import { isThresholdValid, useNftGalleryFilter } from "@ledgerhq/live-nft-react"; -import { - filterHiddenCollections, - mapCollectionsToStructure, -} from "LLD/features/Collectibles/utils/collectionUtils"; +import { mapCollectionsToStructure } from "LLD/features/Collectibles/utils/collectionUtils"; +import { useNftCollections } from "~/renderer/hooks/nfts/useNftCollections"; type NftsInTheCollections = { contract: string; @@ -27,9 +21,6 @@ const INCREMENT = 5; export const useNftCollectionsModel = ({ account }: Props) => { const history = useHistory(); const dispatch = useDispatch(); - const nftsFromSimplehashFeature = useFeature("nftsFromSimplehash"); - const thresold = nftsFromSimplehashFeature?.params?.threshold; - const hiddenNftCollections = useSelector(hiddenNftCollectionsSelector); const [numberOfVisibleCollections, setNumberOfVisibleCollections] = useState(INCREMENT); const [displayShowMore, setDisplayShowMore] = useState(false); @@ -53,20 +44,10 @@ export const useNftCollectionsModel = ({ account }: Props) => { history.push(`/account/${account.id}/nft-collection`); }, [account.id, history]); - const { nfts, fetchNextPage, hasNextPage } = useNftGalleryFilter({ - nftsOwned: account.nfts || [], - addresses: account.freshAddress, - chains: [account.currency.id], - threshold: isThresholdValid(thresold) ? Number(thresold) : 75, + const { fetchNextPage, hasNextPage, collections, collectionsLength } = useNftCollections({ + account, }); - const collections = useMemo( - () => nftsByCollections(nftsFromSimplehashFeature?.enabled ? nfts : account.nfts), - [account.nfts, nfts, nftsFromSimplehashFeature], - ); - - const collectionsLength = Object.keys(collections).length; - const onShowMore = useCallback(() => { setNumberOfVisibleCollections(numberOfVisibleCollections => Math.min(numberOfVisibleCollections + INCREMENT, collectionsLength), @@ -74,21 +55,15 @@ export const useNftCollectionsModel = ({ account }: Props) => { if (hasNextPage) fetchNextPage(); }, [collectionsLength, fetchNextPage, hasNextPage]); - const filteredCollections = useMemo( - () => filterHiddenCollections(collections, hiddenNftCollections, account.id), - [account.id, collections, hiddenNftCollections], - ); - const nftsInTheCollection: NftsInTheCollections[] = useMemo( - () => - mapCollectionsToStructure(filteredCollections, numberOfVisibleCollections, onOpenCollection), - [filteredCollections, numberOfVisibleCollections, onOpenCollection], + () => mapCollectionsToStructure(collections, numberOfVisibleCollections, onOpenCollection), + [collections, numberOfVisibleCollections, onOpenCollection], ); useEffect(() => { - const moreToShow = numberOfVisibleCollections < filteredCollections.length; + const moreToShow = numberOfVisibleCollections < collections.length; setDisplayShowMore(moreToShow); - }, [numberOfVisibleCollections, filteredCollections.length]); + }, [numberOfVisibleCollections, collections.length]); return { nftsInTheCollection, diff --git a/apps/ledger-live-desktop/src/newArch/features/Collectibles/Nfts/screens/Gallery/useNftGalleryModel.tsx b/apps/ledger-live-desktop/src/newArch/features/Collectibles/Nfts/screens/Gallery/useNftGalleryModel.tsx index e22136ad0620..d58c3b864797 100644 --- a/apps/ledger-live-desktop/src/newArch/features/Collectibles/Nfts/screens/Gallery/useNftGalleryModel.tsx +++ b/apps/ledger-live-desktop/src/newArch/features/Collectibles/Nfts/screens/Gallery/useNftGalleryModel.tsx @@ -2,14 +2,10 @@ import { useHistory, useParams } from "react-router-dom"; import { useDispatch, useSelector } from "react-redux"; import { State } from "~/renderer/reducers"; import { accountSelector } from "~/renderer/reducers/accounts"; -import { hiddenNftCollectionsSelector } from "~/renderer/reducers/settings"; -import { useNftGalleryFilter, isThresholdValid } from "@ledgerhq/live-nft-react"; -import { nftsByCollections } from "@ledgerhq/live-nft"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { openModal } from "~/renderer/actions/modals"; import { useOnScreen } from "LLD/hooks/useOnScreen"; -import useFeature from "@ledgerhq/live-common/featureFlags/useFeature"; -import { ChainsEnum } from "LLD/features/Collectibles/types/enum/Chains"; +import { useNftCollections } from "~/renderer/hooks/nfts/useNftCollections"; const defaultNumberOfVisibleNfts = 10; @@ -18,31 +14,17 @@ const useNftGalleryModel = () => { const history = useHistory(); const { id } = useParams<{ id: string }>(); - const nftsFromSimplehashFeature = useFeature("nftsFromSimplehash"); - const threshold = nftsFromSimplehashFeature?.params?.threshold; - const listFooterRef = useRef(null); const [maxVisibleNFTs, setMaxVisibleNFTs] = useState(defaultNumberOfVisibleNfts); - const { account, hiddenNftCollections } = useSelector((state: State) => ({ + const { account } = useSelector((state: State) => ({ account: accountSelector(state, { accountId: id }), - hiddenNftCollections: hiddenNftCollectionsSelector(state), })); - const { nfts, fetchNextPage, hasNextPage } = useNftGalleryFilter({ - nftsOwned: account?.nfts || [], - addresses: account?.freshAddress || "", - chains: [account?.currency.id ?? ChainsEnum.ETHEREUM], - threshold: isThresholdValid(threshold) ? Number(threshold) : 75, + const { fetchNextPage, hasNextPage, collections, allNfts } = useNftCollections({ + account, }); - const collections = useMemo(() => { - const allNfts = nftsFromSimplehashFeature?.enabled ? nfts : account?.nfts; - return Object.entries(nftsByCollections(allNfts)).filter( - ([contract]) => !hiddenNftCollections.includes(`${account?.id}|${contract}`), - ); - }, [account?.id, account?.nfts, hiddenNftCollections, nfts, nftsFromSimplehashFeature?.enabled]); - useEffect(() => { if (collections.length < 1) { history.push(`/account/${account?.id}/`); @@ -70,13 +52,13 @@ const useNftGalleryModel = () => { }, [hasNextPage, fetchNextPage]); useOnScreen({ - enabled: maxVisibleNFTs < nfts?.length, + enabled: maxVisibleNFTs < allNfts?.length, onIntersect: updateMaxVisibleNtfs, target: listFooterRef, threshold: 0.5, }); - const nftsByCollection = nfts.reduce( + const nftsByCollection = allNfts.reduce( (acc, nft) => { const collectionKey = nft.contract || "-"; if (!acc[collectionKey]) { @@ -85,12 +67,11 @@ const useNftGalleryModel = () => { acc[collectionKey].push(nft); return acc; }, - {} as Record, + {} as Record, ); return { account, - hiddenNftCollections, nftsByCollection, listFooterRef, collections, diff --git a/apps/ledger-live-desktop/src/renderer/Default.tsx b/apps/ledger-live-desktop/src/renderer/Default.tsx index 0bed65b6e48a..421e5089469b 100644 --- a/apps/ledger-live-desktop/src/renderer/Default.tsx +++ b/apps/ledger-live-desktop/src/renderer/Default.tsx @@ -56,6 +56,7 @@ import { isLocked as isLockedSelector } from "~/renderer/reducers/application"; import { useAutoDismissPostOnboardingEntryPoint } from "@ledgerhq/live-common/postOnboarding/hooks/index"; import { setShareAnalytics, setSharePersonalizedRecommendations } from "./actions/settings"; import useEnv from "@ledgerhq/live-common/hooks/useEnv"; +import { useSyncNFTsWithAccounts } from "./hooks/nfts/useSyncNFTsWithAccounts"; const PlatformCatalog = lazy(() => import("~/renderer/screens/platform")); const Dashboard = lazy(() => import("~/renderer/screens/dashboard")); @@ -202,12 +203,15 @@ export default function Default() { useRecoverRestoreOnboarding(); useAutoDismissPostOnboardingEntryPoint(); + useSyncNFTsWithAccounts(); + const analyticsFF = useFeature("lldAnalyticsOptInPrompt"); const hasSeenAnalyticsOptInPrompt = useSelector(hasSeenAnalyticsOptInPromptSelector); const nftReworked = useFeature("lldNftsGalleryNewArch"); const isLocked = useSelector(isLockedSelector); const dispatch = useDispatch(); const isNftReworkedEnabled = nftReworked?.enabled; + useEffect(() => { if ( !isLocked && diff --git a/apps/ledger-live-desktop/src/renderer/actions/settings.ts b/apps/ledger-live-desktop/src/renderer/actions/settings.ts index e90520966f32..bc199fe40776 100644 --- a/apps/ledger-live-desktop/src/renderer/actions/settings.ts +++ b/apps/ledger-live-desktop/src/renderer/actions/settings.ts @@ -218,6 +218,12 @@ export const hideNftCollection = (collectionId: string) => ({ type: "HIDE_NFT_COLLECTION", payload: collectionId, }); + +export const whitelistNftCollection = (collectionId: string) => ({ + type: "WHITELIST_NFT_COLLECTION", + payload: collectionId, +}); + export const hideOrdinalsAsset = (inscriptionId: string) => ({ type: "HIDE_ORDINALS_ASSET", payload: inscriptionId, @@ -252,6 +258,10 @@ export const unhideNftCollection = (collectionId: string) => ({ type: "UNHIDE_NFT_COLLECTION", payload: collectionId, }); +export const unwhitelistNftCollection = (collectionId: string) => ({ + type: "UNWHITELIST_NFT_COLLECTION", + payload: collectionId, +}); export const unhideOrdinalsAsset = (inscriptionId: string) => ({ type: "UNHIDE_ORDINALS_ASSET", payload: inscriptionId, diff --git a/apps/ledger-live-desktop/src/renderer/components/Breadcrumb/NFTCrumb.tsx b/apps/ledger-live-desktop/src/renderer/components/Breadcrumb/NFTCrumb.tsx index 58d168582774..f26dd379142f 100644 --- a/apps/ledger-live-desktop/src/renderer/components/Breadcrumb/NFTCrumb.tsx +++ b/apps/ledger-live-desktop/src/renderer/components/Breadcrumb/NFTCrumb.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useMemo, memo } from "react"; import { useHistory, useParams } from "react-router-dom"; import { useSelector } from "react-redux"; -import { nftsByCollections } from "@ledgerhq/live-nft"; import { accountSelector } from "~/renderer/reducers/accounts"; import DropDownSelector, { DropDownItemType } from "~/renderer/components/DropDownSelector"; import Button from "~/renderer/components/Button"; @@ -14,8 +13,7 @@ import { setTrackingSource } from "~/renderer/analytics/TrackPage"; import CollectionName from "~/renderer/components/Nft/CollectionName"; import { ProtoNFT } from "@ledgerhq/types-live"; import { State } from "~/renderer/reducers"; -import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; -import { isThresholdValid, useNftGalleryFilter } from "@ledgerhq/live-nft-react"; +import { useNftCollections } from "~/renderer/hooks/nfts/useNftCollections"; const LabelWithMeta = ({ item, @@ -38,38 +36,29 @@ const LabelWithMeta = ({ const NFTCrumb = () => { const history = useHistory(); - const nftsFromSimplehashFeature = useFeature("nftsFromSimplehash"); - const thresold = nftsFromSimplehashFeature?.params?.threshold; const { id, collectionAddress } = useParams<{ id?: string; collectionAddress?: string }>(); const account = useSelector((state: State) => id ? accountSelector(state, { accountId: id, }) - : null, + : undefined, ); - const { nfts } = useNftGalleryFilter({ - nftsOwned: account?.nfts || [], - addresses: String(account?.freshAddress), - chains: [String(account?.currency.id)], - threshold: isThresholdValid(thresold) ? Number(thresold) : 75, + const { collections } = useNftCollections({ + account, }); - const collections = useMemo( - () => nftsByCollections(nftsFromSimplehashFeature?.enabled ? nfts : account?.nfts), - [account?.nfts, nfts, nftsFromSimplehashFeature], - ); - const items: DropDownItemType[] = useMemo( () => - Object.entries(collections).map(([contract, nfts]: [string, ProtoNFT[]]) => ({ + collections.map(([contract, nfts]: [string, ProtoNFT[]]) => ({ key: contract, label: contract, content: nfts[0], })), [collections], ); + const activeItem: DropDownItemType | undefined | null = useMemo( () => items.find(item => item.key === collectionAddress) || items[0], [collectionAddress, items], diff --git a/apps/ledger-live-desktop/src/renderer/components/ContextMenu/NFTContextMenu.tsx b/apps/ledger-live-desktop/src/renderer/components/ContextMenu/NFTContextMenu.tsx index bbd3e824f31a..70f1af44ee04 100644 --- a/apps/ledger-live-desktop/src/renderer/components/ContextMenu/NFTContextMenu.tsx +++ b/apps/ledger-live-desktop/src/renderer/components/ContextMenu/NFTContextMenu.tsx @@ -1,7 +1,7 @@ import React, { memo } from "react"; import ContextMenuItem from "./ContextMenuItem"; import { Account, ProtoNFT, NFTMetadata } from "@ledgerhq/types-live"; -import useNftLinks from "~/renderer/hooks/useNftLinks"; +import useNftLinks from "~/renderer/hooks/nfts/useNftLinks"; type Props = { account: Account; diff --git a/apps/ledger-live-desktop/src/renderer/components/CustomImage/NFTGallerySelector.tsx b/apps/ledger-live-desktop/src/renderer/components/CustomImage/NFTGallerySelector.tsx index 74f75add2433..39b28538b99b 100644 --- a/apps/ledger-live-desktop/src/renderer/components/CustomImage/NFTGallerySelector.tsx +++ b/apps/ledger-live-desktop/src/renderer/components/CustomImage/NFTGallerySelector.tsx @@ -2,16 +2,15 @@ import React, { useMemo, useRef, useState } from "react"; import { useSelector } from "react-redux"; import { Flex, Grid, InfiniteLoader, Text } from "@ledgerhq/react-ui"; import { NFTMetadata } from "@ledgerhq/types-live"; -import { accountsSelector, orderedVisibleNftsSelector } from "../../reducers/accounts"; +import { accountsSelector, orderedVisibleNftsSelector } from "~/renderer/reducers/accounts"; import NftGalleryEmptyState from "./NftGalleryEmptyState"; import isEqual from "lodash/isEqual"; import NFTItem from "./NFTItem"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; import { useOnScreen } from "~/renderer/screens/nft/useOnScreen"; -import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; -import { isThresholdValid, useNftGalleryFilter } from "@ledgerhq/live-nft-react"; import { getEnv } from "@ledgerhq/live-env"; +import { useNftCollections } from "~/renderer/hooks/nfts/useNftCollections"; const ScrollContainer = styled(Flex).attrs({ flexDirection: "column", @@ -32,8 +31,6 @@ type Props = { const NFTGallerySelector = ({ handlePickNft, selectedNftId }: Props) => { const SUPPORTED_NFT_CURRENCIES = getEnv("NFT_CURRENCIES"); - const nftsFromSimplehashFeature = useFeature("nftsFromSimplehash"); - const threshold = nftsFromSimplehashFeature?.params?.threshold; const accounts = useSelector(accountsSelector); const nftsOrdered = useSelector(orderedVisibleNftsSelector, isEqual); @@ -47,26 +44,19 @@ const NFTGallerySelector = ({ handlePickNft, selectedNftId }: Props) => { [accounts], ); - const { - nfts: nftsFiltered, - fetchNextPage, - hasNextPage, - } = useNftGalleryFilter({ - nftsOwned: nftsOrdered || [], + const { fetchNextPage, hasNextPage, allNfts } = useNftCollections({ + nftsOwned: nftsOrdered, addresses: addresses, chains: SUPPORTED_NFT_CURRENCIES, - threshold: isThresholdValid(threshold) ? Number(threshold) : 75, }); - const nfts = nftsFromSimplehashFeature?.enabled ? nftsFiltered : nftsOrdered; - const { t } = useTranslation(); const [displayedCount, setDisplayedCount] = useState(10); const content = useMemo( () => - nfts.slice(0, displayedCount).map((nft, index) => { + allNfts.slice(0, displayedCount).map((nft, index) => { const { id } = nft; return ( { /> ); }), - [nfts, displayedCount, selectedNftId, handlePickNft], + [allNfts, displayedCount, selectedNftId, handlePickNft], ); const loaderContainerRef = useRef(null); @@ -91,13 +81,13 @@ const NFTGallerySelector = ({ handlePickNft, selectedNftId }: Props) => { }; useOnScreen({ - enabled: displayedCount < nfts.length, + enabled: displayedCount < allNfts.length, onIntersect: updateDisplayable, target: loaderContainerRef, threshold: 0.5, }); - if (nfts.length <= 0) return ; + if (allNfts.length <= 0) return ; return ( @@ -108,7 +98,7 @@ const NFTGallerySelector = ({ handlePickNft, selectedNftId }: Props) => { {content} - {displayedCount < nfts.length ? ( + {displayedCount < allNfts.length ? ( diff --git a/apps/ledger-live-desktop/src/renderer/components/OperationsList/Operation.tsx b/apps/ledger-live-desktop/src/renderer/components/OperationsList/Operation.tsx index 8b962a07f3df..79cf5176108d 100644 --- a/apps/ledger-live-desktop/src/renderer/components/OperationsList/Operation.tsx +++ b/apps/ledger-live-desktop/src/renderer/components/OperationsList/Operation.tsx @@ -15,7 +15,7 @@ import { confirmationsNbForCurrencySelector } from "~/renderer/reducers/settings import { isConfirmedOperation } from "@ledgerhq/live-common/operation"; import { useAccountUnit } from "~/renderer/hooks/useAccountUnit"; import { State } from "~/renderer/reducers"; -import { useAccountName } from "../../reducers/wallet"; +import { useAccountName } from "~/renderer/reducers/wallet"; const OperationRow = styled(Box).attrs(() => ({ horizontal: true, diff --git a/apps/ledger-live-desktop/src/renderer/components/Web3AppWebview/PlatformAPIWebview.tsx b/apps/ledger-live-desktop/src/renderer/components/Web3AppWebview/PlatformAPIWebview.tsx index 42edf84b62ae..d6139d5825f1 100644 --- a/apps/ledger-live-desktop/src/renderer/components/Web3AppWebview/PlatformAPIWebview.tsx +++ b/apps/ledger-live-desktop/src/renderer/components/Web3AppWebview/PlatformAPIWebview.tsx @@ -25,7 +25,7 @@ import { } from "@ledgerhq/live-common/platform/react"; import trackingWrapper from "@ledgerhq/live-common/platform/tracking"; import { openModal } from "../../actions/modals"; -import { flattenAccountsSelector } from "../../reducers/accounts"; +import { flattenAccountsSelector } from "~/renderer/reducers/accounts"; import BigSpinner from "../BigSpinner"; import { track } from "~/renderer/analytics/segment"; import { diff --git a/apps/ledger-live-desktop/src/renderer/components/Web3AppWebview/WalletAPIWebview.tsx b/apps/ledger-live-desktop/src/renderer/components/Web3AppWebview/WalletAPIWebview.tsx index c1237d6124bb..a2a5016f063d 100644 --- a/apps/ledger-live-desktop/src/renderer/components/Web3AppWebview/WalletAPIWebview.tsx +++ b/apps/ledger-live-desktop/src/renderer/components/Web3AppWebview/WalletAPIWebview.tsx @@ -35,9 +35,9 @@ import { setDrawer } from "~/renderer/drawers/Provider"; import { shareAnalyticsSelector } from "~/renderer/reducers/settings"; import { walletSelector } from "~/renderer/reducers/wallet"; import { getStoreValue, setStoreValue } from "~/renderer/store"; -import { updateAccountWithUpdater } from "../../actions/accounts"; -import { openModal } from "../../actions/modals"; -import { flattenAccountsSelector } from "../../reducers/accounts"; +import { updateAccountWithUpdater } from "~/renderer/actions/accounts"; +import { openModal } from "~/renderer/actions/modals"; +import { flattenAccountsSelector } from "~/renderer/reducers/accounts"; import BigSpinner from "../BigSpinner"; import { NetworkErrorScreen } from "./NetworkError"; import { NoAccountOverlay } from "./NoAccountOverlay"; diff --git a/apps/ledger-live-desktop/src/renderer/drawers/NFTViewerDrawer/ExternalViewerButton.tsx b/apps/ledger-live-desktop/src/renderer/drawers/NFTViewerDrawer/ExternalViewerButton.tsx index 365f1b619c92..d2ba072556b2 100644 --- a/apps/ledger-live-desktop/src/renderer/drawers/NFTViewerDrawer/ExternalViewerButton.tsx +++ b/apps/ledger-live-desktop/src/renderer/drawers/NFTViewerDrawer/ExternalViewerButton.tsx @@ -5,7 +5,7 @@ import Box from "~/renderer/components/Box"; import Button from "~/renderer/components/Button"; import DropDownSelector, { DropDownItem } from "~/renderer/components/DropDownSelector"; import IconExternal from "~/renderer/icons/ExternalLink"; -import useNftLinks from "~/renderer/hooks/useNftLinks"; +import useNftLinks from "~/renderer/hooks/nfts/useNftLinks"; import { setDrawer } from "~/renderer/drawers/Provider"; import { Account, ProtoNFT, NFTMetadata } from "@ledgerhq/types-live"; import { Icons } from "@ledgerhq/react-ui"; diff --git a/apps/ledger-live-desktop/src/renderer/families/bitcoin/SendRecipientFields.tsx b/apps/ledger-live-desktop/src/renderer/families/bitcoin/SendRecipientFields.tsx index d856fdfe9827..2f41c9d6ffaf 100644 --- a/apps/ledger-live-desktop/src/renderer/families/bitcoin/SendRecipientFields.tsx +++ b/apps/ledger-live-desktop/src/renderer/families/bitcoin/SendRecipientFields.tsx @@ -7,7 +7,7 @@ import { connect } from "react-redux"; import Alert from "~/renderer/components/Alert"; import TranslatedError from "~/renderer/components/TranslatedError"; import { State } from "~/renderer/reducers"; -import { confirmationsNbForCurrencySelector } from "../../reducers/settings"; +import { confirmationsNbForCurrencySelector } from "~/renderer/reducers/settings"; // FIXME: ConfirmationNB seems to be specific. // So we can't do diff --git a/apps/ledger-live-desktop/src/renderer/hooks/nfts/__tests__/useHideSpamCollection.test.ts b/apps/ledger-live-desktop/src/renderer/hooks/nfts/__tests__/useHideSpamCollection.test.ts new file mode 100644 index 000000000000..f6406bd162c4 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/hooks/nfts/__tests__/useHideSpamCollection.test.ts @@ -0,0 +1,48 @@ +import { hideNftCollection } from "~/renderer/actions/settings"; +import { useHideSpamCollection } from "../useHideSpamCollection"; +import { renderHook } from "tests/testUtils"; +import { INITIAL_STATE } from "~/renderer/reducers/settings"; +import { useDispatch } from "react-redux"; + +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(), { + initialState: { + settings: { + ...INITIAL_STATE, + 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(), { + initialState: { + settings: { + hiddenNftCollections: [], + whitelistedNftCollections: ["collectionA", "collectionB"], + }, + }, + }); + result.current.hideSpamCollection("collectionA"); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/ledger-live-desktop/src/renderer/hooks/nfts/__tests__/useSyncNFTsWitHAccount.test.ts b/apps/ledger-live-desktop/src/renderer/hooks/nfts/__tests__/useSyncNFTsWitHAccount.test.ts new file mode 100644 index 000000000000..d43721130372 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/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 "~/renderer/reducers/accounts"; +import { renderHook } from "@testing-library/react"; + +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-desktop/src/renderer/hooks/useHideSpamCollection.ts b/apps/ledger-live-desktop/src/renderer/hooks/nfts/useHideSpamCollection.ts similarity index 68% rename from apps/ledger-live-desktop/src/renderer/hooks/useHideSpamCollection.ts rename to apps/ledger-live-desktop/src/renderer/hooks/nfts/useHideSpamCollection.ts index e4ac73c9cc1a..7772d51dddee 100644 --- a/apps/ledger-live-desktop/src/renderer/hooks/useHideSpamCollection.ts +++ b/apps/ledger-live-desktop/src/renderer/hooks/nfts/useHideSpamCollection.ts @@ -1,20 +1,21 @@ import { useCallback } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; +import { whitelistedNftCollectionsSelector } from "~/renderer/reducers/settings"; import { hideNftCollection } from "~/renderer/actions/settings"; -import { hiddenNftCollectionsSelector } from "../reducers/settings"; export function useHideSpamCollection() { const spamFilteringTxFeature = useFeature("spamFilteringTx"); - const hiddenNftCollections = useSelector(hiddenNftCollectionsSelector); + const whitelistedNftCollections = useSelector(whitelistedNftCollectionsSelector); + const dispatch = useDispatch(); const hideSpamCollection = useCallback( (collection: string) => { - if (!hiddenNftCollections.includes(collection)) { + if (!whitelistedNftCollections.includes(collection)) { dispatch(hideNftCollection(collection)); } }, - [dispatch, hiddenNftCollections], + [dispatch, whitelistedNftCollections], ); return { diff --git a/apps/ledger-live-desktop/src/renderer/hooks/nfts/useNftCollections.ts b/apps/ledger-live-desktop/src/renderer/hooks/nfts/useNftCollections.ts new file mode 100644 index 000000000000..16832100f6e5 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/hooks/nfts/useNftCollections.ts @@ -0,0 +1,75 @@ +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 "~/renderer/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 } = 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, + }; +} diff --git a/apps/ledger-live-desktop/src/renderer/hooks/useNftLinks.ts b/apps/ledger-live-desktop/src/renderer/hooks/nfts/useNftLinks.ts similarity index 96% rename from apps/ledger-live-desktop/src/renderer/hooks/useNftLinks.ts rename to apps/ledger-live-desktop/src/renderer/hooks/nfts/useNftLinks.ts index f6dc75342e95..f3ef4a4977e0 100644 --- a/apps/ledger-live-desktop/src/renderer/hooks/useNftLinks.ts +++ b/apps/ledger-live-desktop/src/renderer/hooks/nfts/useNftLinks.ts @@ -10,11 +10,11 @@ import IconOpensea from "~/renderer/icons/Opensea"; import IconRarible from "~/renderer/icons/Rarible"; import { openURL } from "~/renderer/linking"; import { getMetadataMediaTypes } from "~/helpers/nft"; -import { setDrawer } from "../drawers/Provider"; +import { setDrawer } from "../../drawers/Provider"; import CustomImage from "~/renderer/screens/customImage"; import NFTViewerDrawer from "~/renderer/drawers/NFTViewerDrawer"; -import { ContextMenuItemType } from "../components/ContextMenu/ContextMenuWrapper"; -import { devicesModelListSelector } from "../reducers/settings"; +import { ContextMenuItemType } from "../../components/ContextMenu/ContextMenuWrapper"; +import { devicesModelListSelector } from "~/renderer/reducers/settings"; function safeList(items: (ContextMenuItemType | "" | undefined)[]): ContextMenuItemType[] { return items.filter(Boolean) as ContextMenuItemType[]; diff --git a/apps/ledger-live-desktop/src/renderer/hooks/nfts/useSyncNFTsWithAccounts.ts b/apps/ledger-live-desktop/src/renderer/hooks/nfts/useSyncNFTsWithAccounts.ts new file mode 100644 index 000000000000..b795bb62fc0c --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/hooks/nfts/useSyncNFTsWithAccounts.ts @@ -0,0 +1,101 @@ +import { useEffect, useMemo, useState } from "react"; +import { useHideSpamCollection } from "./useHideSpamCollection"; +import { getThreshold, useCheckNftAccount } from "@ledgerhq/live-nft-react"; +import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; +import { useSelector } from "react-redux"; +import { accountsSelector, orderedVisibleNftsSelector } from "~/renderer/reducers/accounts"; +import isEqual from "lodash/isEqual"; +import { getEnv } from "@ledgerhq/live-env"; + +/** + * 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-desktop/src/renderer/modals/HideNftCollection/Footer.tsx b/apps/ledger-live-desktop/src/renderer/modals/HideNftCollection/Footer.tsx index 5d07f94bdf86..da297a4fed5f 100644 --- a/apps/ledger-live-desktop/src/renderer/modals/HideNftCollection/Footer.tsx +++ b/apps/ledger-live-desktop/src/renderer/modals/HideNftCollection/Footer.tsx @@ -2,15 +2,22 @@ import React, { useCallback } from "react"; import Button from "~/renderer/components/Button"; import { Trans } from "react-i18next"; import Box from "~/renderer/components/Box"; -import { useDispatch } from "react-redux"; -import { hideNftCollection } from "~/renderer/actions/settings"; +import { useDispatch, useSelector } from "react-redux"; +import { hideNftCollection, unwhitelistNftCollection } from "~/renderer/actions/settings"; +import { whitelistedNftCollectionsSelector } from "~/renderer/reducers/settings"; const Footer = ({ onClose, collectionId }: { onClose: () => void; collectionId: string }) => { const dispatch = useDispatch(); + const whitelistedNftCollections = useSelector(whitelistedNftCollectionsSelector); + const confirmHideNftCollection = useCallback( (collectionId: string) => { + if (whitelistedNftCollections.includes(collectionId)) { + dispatch(unwhitelistNftCollection(collectionId)); + } + dispatch(hideNftCollection(collectionId)); }, - [dispatch], + [dispatch, whitelistedNftCollections], ); return ( diff --git a/apps/ledger-live-desktop/src/renderer/modals/SimpleHashTools/index.tsx b/apps/ledger-live-desktop/src/renderer/modals/SimpleHashTools/index.tsx index ec88031582f3..0ab69a72fea4 100644 --- a/apps/ledger-live-desktop/src/renderer/modals/SimpleHashTools/index.tsx +++ b/apps/ledger-live-desktop/src/renderer/modals/SimpleHashTools/index.tsx @@ -11,7 +11,7 @@ import RefreshMetadata, { HookResult as RefreshHookResult, useHook as useHookRefresh, } from "~/renderer/screens/settings/sections/Developer/SimpleHashTools/RefreshMetadata"; -import { Flex } from "@ledgerhq/react-ui"; +import { Flex, InfiniteLoader } from "@ledgerhq/react-ui"; import Button from "~/renderer/components/ButtonV3"; import SpamScore, { HookResult as SpamScoreHookResult, @@ -35,6 +35,7 @@ const getItems = ( cta: t("settings.developer.debugSimpleHash.debugSpamNft.report"), closeInfo: hooks.spam.closeInfo, displayInfo: hooks.spam.displayInfo, + isLoading: hooks.spam.spamReportMutation.isPending, }, { key: "refresh", @@ -44,6 +45,7 @@ const getItems = ( cta: t("settings.developer.debugSimpleHash.debugRefreshMetadata.refresh"), closeInfo: hooks.refresh.closeInfo, displayInfo: hooks.refresh.displayInfo, + isLoading: hooks.refresh.refreshMutation.isPending, }, { key: "check", @@ -53,6 +55,7 @@ const getItems = ( cta: t("settings.developer.debugSimpleHash.debugCheckSpamScore.check"), closeInfo: hooks.check.closeInfo, displayInfo: hooks.check.displayInfo, + isLoading: hooks.check.checkSpamScore.isLoading, }, ]; @@ -80,6 +83,7 @@ const SimpleHashToolsDebugger = () => { ( { height={15} /> - + {activeItem.value} @@ -109,7 +113,9 @@ const SimpleHashToolsDebugger = () => { )} renderFooter={() => ( <> - {displayInfo ? ( + {activeItem.isLoading ? ( + + ) : displayInfo ? ( diff --git a/apps/ledger-live-desktop/src/renderer/reducers/settings.ts b/apps/ledger-live-desktop/src/renderer/reducers/settings.ts index 38cabc8fb56e..deb7e81dfdbe 100644 --- a/apps/ledger-live-desktop/src/renderer/reducers/settings.ts +++ b/apps/ledger-live-desktop/src/renderer/reducers/settings.ts @@ -82,6 +82,7 @@ export type SettingsState = { starredAccountIds?: string[]; blacklistedTokenIds: string[]; hiddenNftCollections: string[]; + whitelistedNftCollections: string[]; hiddenOrdinalsAsset: string[]; deepLinkUrl: string | undefined | null; lastSeenCustomImage: { @@ -182,6 +183,7 @@ export const INITIAL_STATE: SettingsState = { latestFirmware: null, blacklistedTokenIds: [], hiddenNftCollections: [], + whitelistedNftCollections: [], hiddenOrdinalsAsset: [], deepLinkUrl: null, firstTimeLend: false, @@ -228,6 +230,8 @@ type HandlersPayloads = { BLACKLIST_TOKEN: string; UNHIDE_NFT_COLLECTION: string; HIDE_NFT_COLLECTION: string; + WHITELIST_NFT_COLLECTION: string; + UNWHITELIST_NFT_COLLECTION: string; UNHIDE_ORDINALS_ASSET: string; HIDE_ORDINALS_ASSET: string; LAST_SEEN_DEVICE_INFO: { @@ -334,9 +338,25 @@ const handlers: SettingsHandlers = { const collections = state.hiddenNftCollections; return { ...state, - hiddenNftCollections: [...collections, collectionId], + hiddenNftCollections: [...new Set(collections.concat(collectionId))], }; }, + + UNWHITELIST_NFT_COLLECTION: (state, { payload: collectionId }) => { + const ids = state.whitelistedNftCollections; + return { + ...state, + whitelistedNftCollections: ids.filter(id => id !== collectionId), + }; + }, + WHITELIST_NFT_COLLECTION: (state, { payload: collectionId }) => { + const collections = state.whitelistedNftCollections; + return { + ...state, + whitelistedNftCollections: [...new Set(collections.concat(collectionId))], + }; + }, + UNHIDE_ORDINALS_ASSET: (state, { payload: inscriptionId }) => { const ids = state.hiddenOrdinalsAsset; return { @@ -761,6 +781,8 @@ export const enableLearnPageStagingUrlSelector = (state: State) => state.settings.enableLearnPageStagingUrl; 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 hiddenOrdinalsAssetSelector = (state: State) => state.settings.hiddenOrdinalsAsset; export const hasCompletedOnboardingSelector = (state: State) => state.settings.hasCompletedOnboarding || getEnv("SKIP_ONBOARDING"); diff --git a/apps/ledger-live-desktop/src/renderer/screens/nft/Collections/Collections.tsx b/apps/ledger-live-desktop/src/renderer/screens/nft/Collections/Collections.tsx index 345a47071e1d..f28a12c3c182 100644 --- a/apps/ledger-live-desktop/src/renderer/screens/nft/Collections/Collections.tsx +++ b/apps/ledger-live-desktop/src/renderer/screens/nft/Collections/Collections.tsx @@ -1,14 +1,12 @@ import React, { useState, useCallback, useEffect, useMemo, memo } from "react"; -import { nftsByCollections } from "@ledgerhq/live-nft"; import { Account, NFT, ProtoNFT } from "@ledgerhq/types-live"; -import { useDispatch, useSelector } from "react-redux"; +import { useDispatch } from "react-redux"; import { useTranslation } from "react-i18next"; import { useHistory } from "react-router-dom"; import styled from "styled-components"; import { TokenShowMoreIndicator, IconAngleDown } from "~/renderer/screens/account/TokensList"; import TableContainer, { TableHeader } from "~/renderer/components/TableContainer"; import LabelWithExternalIcon from "~/renderer/components/LabelWithExternalIcon"; -import { hiddenNftCollectionsSelector } from "~/renderer/reducers/settings"; import { supportLinkByTokenType } from "~/config/urls"; import { openModal } from "~/renderer/actions/modals"; import { track } from "~/renderer/analytics/segment"; @@ -19,9 +17,7 @@ import Text from "~/renderer/components/Text"; import { openURL } from "~/renderer/linking"; import Box from "~/renderer/components/Box"; import Row from "./Row"; -import { isThresholdValid, useCheckNftAccount } from "@ledgerhq/live-nft-react"; -import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; -import { useHideSpamCollection } from "~/renderer/hooks/useHideSpamCollection"; +import { useNftCollections } from "~/renderer/hooks/nfts/useNftCollections"; const INCREMENT = 5; const EmptyState = styled.div` @@ -46,13 +42,15 @@ type Props = { account: Account; }; const Collections = ({ account }: Props) => { - const nftsFromSimplehashFeature = useFeature("nftsFromSimplehash"); - const thresold = nftsFromSimplehashFeature?.params?.threshold; - const dispatch = useDispatch(); const { t } = useTranslation(); const history = useHistory(); const [numberOfVisibleCollections, setNumberOfVisibleCollections] = useState(INCREMENT); + + const { fetchNextPage, hasNextPage, collectionsLength, collections } = useNftCollections({ + account, + }); + const onOpenGallery = useCallback(() => { history.push(`/account/${account.id}/nft-collection`); }, [account.id, history]); @@ -72,21 +70,6 @@ const Collections = ({ account }: Props) => { [account.id, history], ); - const { enabled, hideSpamCollection } = useHideSpamCollection(); - - const { nfts, fetchNextPage, hasNextPage } = useCheckNftAccount({ - nftsOwned: account.nfts || [], - addresses: account.freshAddress, - chains: [account.currency.id], - threshold: isThresholdValid(thresold) ? Number(thresold) : 75, - ...(enabled && { action: hideSpamCollection }), - }); - - const collections = useMemo( - () => nftsByCollections(nftsFromSimplehashFeature?.enabled ? nfts : account.nfts), - [account.nfts, nfts, nftsFromSimplehashFeature], - ); - const collectionsLength = Object.keys(collections).length; const onShowMore = useCallback(() => { setNumberOfVisibleCollections(numberOfVisibleCollections => Math.min(numberOfVisibleCollections + INCREMENT, collectionsLength), @@ -95,17 +78,10 @@ const Collections = ({ account }: Props) => { fetchNextPage(); } }, [collectionsLength, fetchNextPage, hasNextPage]); - const hiddenNftCollections = useSelector(hiddenNftCollectionsSelector); - const filteredCollections = useMemo( - () => - Object.entries(collections).filter( - ([contract]) => !hiddenNftCollections.includes(`${account.id}|${contract}`), - ), - [account.id, collections, hiddenNftCollections], - ); + const visibleCollections = useMemo( () => - filteredCollections + collections .slice(0, numberOfVisibleCollections) .map(([contract, nfts]: [string, (ProtoNFT | NFT)[]]) => ( { nfts={nfts} /> )), - [account, filteredCollections, numberOfVisibleCollections, onOpenCollection], + [account, collections, numberOfVisibleCollections, onOpenCollection], ); useEffect(() => { @@ -169,7 +145,7 @@ const Collections = ({ account }: Props) => { )} - {filteredCollections.length > numberOfVisibleCollections ? ( + {collections.length > numberOfVisibleCollections ? ( diff --git a/apps/ledger-live-desktop/src/renderer/screens/nft/Gallery/Gallery.tsx b/apps/ledger-live-desktop/src/renderer/screens/nft/Gallery/Gallery.tsx index 2659c6c90afc..3a2d838c84ed 100644 --- a/apps/ledger-live-desktop/src/renderer/screens/nft/Gallery/Gallery.tsx +++ b/apps/ledger-live-desktop/src/renderer/screens/nft/Gallery/Gallery.tsx @@ -4,8 +4,6 @@ import { useSelector, useDispatch } from "react-redux"; import { useTranslation } from "react-i18next"; import { accountSelector } from "~/renderer/reducers/accounts"; import { openModal } from "~/renderer/actions/modals"; -import { nftsByCollections } from "@ledgerhq/live-nft"; -import { hiddenNftCollectionsSelector } from "~/renderer/reducers/settings"; import styled from "styled-components"; import IconSend from "~/renderer/icons/Send"; import CollectionName from "~/renderer/components/Nft/CollectionName"; @@ -20,8 +18,7 @@ import { State } from "~/renderer/reducers"; import { ProtoNFT } from "@ledgerhq/types-live"; import theme from "@ledgerhq/react-ui/styles/theme"; import { useOnScreen } from "../useOnScreen"; -import { isThresholdValid, useNftGalleryFilter } from "@ledgerhq/live-nft-react"; -import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; +import { useNftCollections } from "~/renderer/hooks/nfts/useNftCollections"; const SpinnerContainer = styled.div` display: flex; @@ -52,9 +49,7 @@ const Footer = styled.footer` `; const Gallery = () => { - const nftsFromSimplehashFeature = useFeature("nftsFromSimplehash"); - const thresold = nftsFromSimplehashFeature?.params?.threshold; - + const history = useHistory(); const { t } = useTranslation(); const dispatch = useDispatch(); const { id } = useParams<{ id: string }>(); @@ -63,24 +58,11 @@ const Gallery = () => { accountId: id, }), ); - const history = useHistory(); - const hiddenNftCollections = useSelector(hiddenNftCollectionsSelector); - const { nfts, fetchNextPage, hasNextPage } = useNftGalleryFilter({ - nftsOwned: account?.nfts || [], - addresses: account?.freshAddress || "", - chains: [account?.currency.id ?? "ethereum"], - threshold: isThresholdValid(thresold) ? Number(thresold) : 75, + const { fetchNextPage, hasNextPage, collections } = useNftCollections({ + account, }); - const collections = useMemo( - () => - Object.entries( - nftsByCollections(nftsFromSimplehashFeature?.enabled ? nfts : account?.nfts), - ).filter(([contract]) => !hiddenNftCollections.includes(`${account?.id}|${contract}`)), - [account?.id, account?.nfts, hiddenNftCollections, nfts, nftsFromSimplehashFeature?.enabled], - ); - // Should redirect to the account page if there is not NFT anymore in the page. useEffect(() => { if (collections.length <= 0) { diff --git a/apps/ledger-live-desktop/src/renderer/screens/settings/index.tsx b/apps/ledger-live-desktop/src/renderer/screens/settings/index.tsx index 4c91b144986b..7ca6a725fb62 100644 --- a/apps/ledger-live-desktop/src/renderer/screens/settings/index.tsx +++ b/apps/ledger-live-desktop/src/renderer/screens/settings/index.tsx @@ -13,7 +13,7 @@ import SectionAccounts from "./sections/Accounts"; import SectionAbout from "./sections/About"; import SectionHelp from "./sections/Help"; import { setTrackingSource } from "~/renderer/analytics/TrackPage"; -import { developerModeSelector } from "../../reducers/settings"; +import { developerModeSelector } from "~/renderer/reducers/settings"; const getItems = (t: (a: string) => string, devMode?: boolean) => { const items = [ diff --git a/apps/ledger-live-desktop/src/renderer/screens/settings/sections/About/index.tsx b/apps/ledger-live-desktop/src/renderer/screens/settings/sections/About/index.tsx index 914561ff0f32..590424444728 100644 --- a/apps/ledger-live-desktop/src/renderer/screens/settings/sections/About/index.tsx +++ b/apps/ledger-live-desktop/src/renderer/screens/settings/sections/About/index.tsx @@ -5,11 +5,11 @@ import { getEnv } from "@ledgerhq/live-env"; import { SettingsSectionBody as Body, SettingsSectionRow as Row } from "../../SettingsSection"; import RowItem from "../../RowItem"; import ReleaseNotesButton from "./ReleaseNotesButton"; -import { setDeveloperMode } from "../../../../actions/settings"; +import { setDeveloperMode } from "~/renderer/actions/settings"; import { useDispatch, useSelector } from "react-redux"; import { useToasts } from "@ledgerhq/live-common/notifications/ToastProvider/index"; import { v4 as uuidv4 } from "uuid"; -import { developerModeSelector } from "../../../../reducers/settings"; +import { developerModeSelector } from "~/renderer/reducers/settings"; import { useLocalizedUrl } from "~/renderer/hooks/useLocalizedUrls"; import { urls } from "~/config/urls"; diff --git a/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/HiddenNFTCollections.tsx b/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/HiddenNFTCollections.tsx deleted file mode 100644 index 31c56de3dc5c..000000000000 --- a/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/HiddenNFTCollections.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import React, { useCallback, useState, useMemo } from "react"; -import styled from "styled-components"; -import { useTranslation } from "react-i18next"; -import { useDispatch, useSelector } from "react-redux"; -import { useNftMetadata, useNftCollectionMetadata } from "@ledgerhq/live-nft-react"; -import { SettingsSection as Section, SettingsSectionRow as Row } from "../../SettingsSection"; -import Text from "~/renderer/components/Text"; -import Box from "~/renderer/components/Box"; -import Media from "~/renderer/components/Nft/Media"; -import Skeleton from "~/renderer/components/Nft/Skeleton"; -import { unhideNftCollection } from "~/renderer/actions/settings"; -import { hiddenNftCollectionsSelector } from "~/renderer/reducers/settings"; -import { accountSelector } from "~/renderer/reducers/accounts"; -import Track from "~/renderer/analytics/Track"; -import { State } from "~/renderer/reducers"; -import { NFTMetadata } from "@ledgerhq/types-live"; -import { Icons } from "@ledgerhq/react-ui"; - -const HiddenNftCollectionRow = ({ - contractAddress, - accountId, - onUnhide, -}: { - contractAddress: string; - accountId: string; - onUnhide: () => void; -}) => { - const account = useSelector((state: State) => - accountSelector(state, { - accountId, - }), - ); - const firstNft = account?.nfts?.find(nft => nft.contract === contractAddress); - const { metadata: nftMetadata, status: nftStatus } = useNftMetadata( - contractAddress, - firstNft?.tokenId, - firstNft?.currencyId, - ); - const { metadata: collectionMetadata, status: collectionStatus } = useNftCollectionMetadata( - contractAddress, - firstNft?.currencyId, - ); - const loading = useMemo( - () => nftStatus === "loading" || collectionStatus === "loading", - [collectionStatus, nftStatus], - ); - - return ( - - - {nftMetadata && firstNft && ( - - )} - - - {collectionMetadata?.tokenName || contractAddress} - - - - - - ); -}; -export default function HiddenNftCollections() { - const { t } = useTranslation(); - const dispatch = useDispatch(); - const [sectionVisible, setSectionVisible] = useState(false); - const onUnhideCollection = useCallback( - (collectionId: string) => { - dispatch(unhideNftCollection(collectionId)); - }, - [dispatch], - ); - const hiddenNftCollections = useSelector(hiddenNftCollectionsSelector); - const toggleCurrencySection = useCallback(() => { - setSectionVisible(prevState => !prevState); - }, [setSectionVisible]); - return ( -
- - - {hiddenNftCollections.length ? ( - - - {t("settings.accounts.hiddenNftCollections.count", { - count: hiddenNftCollections.length, - })} - - - - - - ) : null} - - - {sectionVisible && ( - - {hiddenNftCollections.map(collectionId => { - const [accountId, contractAddress] = collectionId.split("|"); - return ( - onUnhideCollection(collectionId)} - /> - ); - })} - - )} -
- ); -} -const IconContainer = styled.div` - color: ${p => p.theme.colors.palette.text.shade60}; - text-align: center; - &:hover { - cursor: pointer; - color: ${p => p.theme.colors.palette.text.shade40}; - } -`; -const HiddenNftCollectionRowContainer = styled(Box).attrs({ - alignItems: "center", - horizontal: true, - flow: 1, - py: 1, -})` - margin: 0px; - &:not(:last-child) { - border-bottom: 1px solid ${p => p.theme.colors.palette.text.shade10}; - } - padding: 14px 6px; -`; -const Body = styled(Box)` - &:not(:empty) { - padding: 0 20px; - } -`; - -const Show = styled(Box).attrs<{ visible?: boolean }>({})<{ visible?: boolean }>` - transform: rotate(${p => (p.visible ? 0 : 270)}deg); -`; diff --git a/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/HiddenNFTCollections/helpers.ts b/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/HiddenNFTCollections/helpers.ts new file mode 100644 index 000000000000..161739c68aaa --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/HiddenNFTCollections/helpers.ts @@ -0,0 +1,2 @@ +export const splitAddress = (str: string, length: number) => + `${str.slice(0, length)}...${str.slice(-length)}`; diff --git a/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/HiddenNFTCollections/index.tsx b/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/HiddenNFTCollections/index.tsx new file mode 100644 index 000000000000..3c8bbd83d1fa --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/HiddenNFTCollections/index.tsx @@ -0,0 +1,114 @@ +import React, { useState, useCallback, useMemo } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; +import { unhideNftCollection, whitelistNftCollection } from "~/renderer/actions/settings"; +import { hiddenNftCollectionsSelector } from "~/renderer/reducers/settings"; + +import { SettingsSection as Section, SettingsSectionRow as Row } from "../../../SettingsSection"; +import Box from "~/renderer/components/Box"; +import Track from "~/renderer/analytics/Track"; +import ShowMore from "LLD/features/Collectibles/components/Collection/ShowMore"; + +import IconAngleDown from "~/renderer/icons/AngleDown"; +import { HiddenNftCollectionRow } from "./row"; +import { decodeCollectionId } from "@ledgerhq/live-nft-react"; + +// Styled components and layout + +const Body = styled(Box)` + &:not(:empty) { + padding: 0 20px; + } +`; + +const Show = styled(Box).attrs<{ visible?: boolean }>({})<{ visible?: boolean }>` + transform: rotate(${p => (p.visible ? 0 : 270)}deg); +`; + +const Collections = styled(Box)` + &:hover { + cursor: pointer; + } +`; + +const INCREMENT = 10; + +export default function HiddenNftCollections() { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const [sectionVisible, setSectionVisible] = useState(false); + + const hiddenNftCollections = useSelector(hiddenNftCollectionsSelector); + + const [numberOfVisibleCollections, setNumberOfVisibleCollections] = useState(INCREMENT); + + const onUnhideCollection = useCallback( + (collectionId: string) => { + dispatch(unhideNftCollection(collectionId)); + dispatch(whitelistNftCollection(collectionId)); + }, + [dispatch], + ); + + const toggleHiddenCollectionsSection = useCallback(() => { + setSectionVisible(prevState => !prevState); + }, []); + + const onShowMore = useCallback(() => { + setNumberOfVisibleCollections(numberOfVisibleCollections => + Math.min(numberOfVisibleCollections + INCREMENT, hiddenNftCollections.length), + ); + }, [hiddenNftCollections.length]); + + const visibleCollections = useMemo( + () => hiddenNftCollections.slice(0, numberOfVisibleCollections), + + [hiddenNftCollections, numberOfVisibleCollections], + ); + + const displayShowMore = numberOfVisibleCollections < hiddenNftCollections.length; + + return ( +
+ + + {hiddenNftCollections.length ? ( + + + {t("settings.accounts.hiddenNftCollections.count", { + count: hiddenNftCollections.length, + })} + + + + + + ) : null} + + + {sectionVisible && ( + + {visibleCollections.map(collectionId => { + const { accountId, contractAddress } = decodeCollectionId(collectionId); + return ( + onUnhideCollection(collectionId)} + /> + ); + })} + + {displayShowMore && } + + )} +
+ ); +} diff --git a/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/HiddenNFTCollections/row.tsx b/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/HiddenNFTCollections/row.tsx new file mode 100644 index 000000000000..477f1958481f --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/HiddenNFTCollections/row.tsx @@ -0,0 +1,127 @@ +import React, { useState, useMemo, useCallback } from "react"; +import { useSelector } from "react-redux"; +import { useTranslation } from "react-i18next"; +import { useNftMetadata, useNftCollectionMetadata } from "@ledgerhq/live-nft-react"; +import { accountSelector } from "~/renderer/reducers/accounts"; +import { State } from "~/renderer/reducers"; +import { NFTMetadata } from "@ledgerhq/types-live"; +import { clipboard } from "electron"; + +import Text from "~/renderer/components/Text"; +import Media from "~/renderer/components/Nft/Media"; +import Skeleton from "~/renderer/components/Nft/Skeleton"; +import { Flex, Icons } from "@ledgerhq/react-ui"; + +import IconCross from "~/renderer/icons/Cross"; +import styled from "styled-components"; +import { splitAddress } from "./helpers"; + +export const HiddenNftCollectionRow = ({ + contractAddress, + accountId, + onUnhide, +}: { + contractAddress: string; + accountId: string; + onUnhide: () => void; +}) => { + const { t } = useTranslation(); + const account = useSelector((state: State) => accountSelector(state, { accountId })); + const firstNft = useMemo( + () => account?.nfts?.find(nft => nft.contract === contractAddress), + [account, contractAddress], + ); + + const { metadata: nftMetadata, status: nftStatus } = useNftMetadata( + contractAddress, + firstNft?.tokenId, + firstNft?.currencyId, + ); + const { metadata: collectionMetadata, status: collectionStatus } = useNftCollectionMetadata( + contractAddress, + firstNft?.currencyId, + ); + + const loading = useMemo( + () => nftStatus === "loading" || collectionStatus === "loading", + [nftStatus, collectionStatus], + ); + + const [copyFeedback, setCopyFeedback] = useState(false); + const onCopy = useCallback(() => { + clipboard.writeText(contractAddress); + setCopyFeedback(true); + + setTimeout(() => setCopyFeedback(false), 1e3); + }, [contractAddress]); + + return ( + + + + {nftMetadata && firstNft && ( + + )} + + + + + {collectionMetadata?.tokenName || contractAddress} + + + + + {splitAddress(contractAddress, 8)} + + + {!copyFeedback ? ( + + ) : ( + <> + + + {t("common.addressCopied")} + + + )} + + + + + + + + + ); +}; + +const IconContainer = styled(Flex)` + color: ${p => p.theme.colors.palette.text.shade60}; + &:hover { + cursor: pointer; + color: ${p => p.theme.colors.palette.text.shade40}; + } +`; + +const HiddenNftCollectionRowContainer = styled(Flex).attrs({ + alignItems: "center", + justifyContent: "space-between", + py: 1, +})` + margin: 0px; + &:not(:last-child) { + border-bottom: 1px solid ${p => p.theme.colors.palette.text.shade10}; + } + padding: 14px 6px; +`; + +const StyledFlex = styled(Flex)` + &:hover { + cursor: pointer; + color: ${p => p.theme.colors.primary.c80}; + } +`; diff --git a/apps/ledger-live-mobile/src/screens/Account/NftCollectionsList.tsx b/apps/ledger-live-mobile/src/screens/Account/NftCollectionsList.tsx index 6b2e1ee0d59b..435ade26db8a 100644 --- a/apps/ledger-live-mobile/src/screens/Account/NftCollectionsList.tsx +++ b/apps/ledger-live-mobile/src/screens/Account/NftCollectionsList.tsx @@ -17,7 +17,7 @@ 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, isThresholdValid } from "@ledgerhq/live-nft-react"; +import { useNftGalleryFilter, getThreshold } from "@ledgerhq/live-nft-react"; const MAX_COLLECTIONS_TO_SHOW = 3; @@ -28,7 +28,7 @@ type Props = { export default function NftCollectionsList({ account }: Props) { useEnv("HIDE_EMPTY_TOKEN_ACCOUNTS"); const nftsFromSimplehashFeature = useFeature("nftsFromSimplehash"); - const thresold = nftsFromSimplehashFeature?.params?.threshold; + const threshold = nftsFromSimplehashFeature?.params?.threshold; const nftsFromSimplehashEnabled = nftsFromSimplehashFeature?.enabled; const { colors } = useTheme(); const { t } = useTranslation(); @@ -38,7 +38,7 @@ export default function NftCollectionsList({ account }: Props) { nftsOwned: account.nfts || [], addresses: account.freshAddress, chains: [account.currency.id], - threshold: isThresholdValid(thresold) ? Number(thresold) : 75, + threshold: getThreshold(threshold), }); const hiddenNftCollections = useSelector(hiddenNftCollectionsSelector); diff --git a/apps/ledger-live-mobile/src/screens/CustomImage/NFTGallerySelector.tsx b/apps/ledger-live-mobile/src/screens/CustomImage/NFTGallerySelector.tsx index dc28ce0c4137..11a7ab520db3 100644 --- a/apps/ledger-live-mobile/src/screens/CustomImage/NFTGallerySelector.tsx +++ b/apps/ledger-live-mobile/src/screens/CustomImage/NFTGallerySelector.tsx @@ -14,7 +14,7 @@ import { BaseComposite, StackNavigatorProps } from "~/components/RootNavigator/t import { CustomImageNavigatorParamList } from "~/components/RootNavigator/types/CustomImageNavigator"; import { TrackScreen } from "~/analytics"; import useFeature from "@ledgerhq/live-common/featureFlags/useFeature"; -import { isThresholdValid, useNftGalleryFilter } from "@ledgerhq/live-nft-react"; +import { getThreshold, useNftGalleryFilter } from "@ledgerhq/live-nft-react"; import { getEnv } from "@ledgerhq/live-env"; const NB_COLUMNS = 2; @@ -34,7 +34,7 @@ const NFTGallerySelector = ({ navigation, route }: NavigationProps) => { const nftsOrdered = useSelector(orderedVisibleNftsSelector, isEqual); const nftsFromSimplehashFeature = useFeature("nftsFromSimplehash"); - const thresold = nftsFromSimplehashFeature?.params?.threshold; + const threshold = nftsFromSimplehashFeature?.params?.threshold; const nftsFromSimplehashEnabled = nftsFromSimplehashFeature?.enabled; const accounts = useSelector(accountsSelector); @@ -52,7 +52,7 @@ const NFTGallerySelector = ({ navigation, route }: NavigationProps) => { nftsOwned: nftsOrdered || [], addresses: addresses, chains: SUPPORTED_NFT_CURRENCIES, - threshold: isThresholdValid(thresold) ? Number(thresold) : 75, + threshold: getThreshold(threshold), }); const nfts = nftsFromSimplehashEnabled ? filteredNfts : nftsOrdered; diff --git a/apps/ledger-live-mobile/src/screens/Nft/NftGallery/index.tsx b/apps/ledger-live-mobile/src/screens/Nft/NftGallery/index.tsx index 6a9037476d34..82478272b6e3 100644 --- a/apps/ledger-live-mobile/src/screens/Nft/NftGallery/index.tsx +++ b/apps/ledger-live-mobile/src/screens/Nft/NftGallery/index.tsx @@ -21,7 +21,7 @@ import { AccountsNavigatorParamList } from "~/components/RootNavigator/types/Acc import InfoModal from "~/modals/Info"; import { notAvailableModalInfo } from "../NftInfoNotAvailable"; import invariant from "invariant"; -import { useNftGalleryFilter, isThresholdValid } from "@ledgerhq/live-nft-react"; +import { useNftGalleryFilter, getThreshold } from "@ledgerhq/live-nft-react"; const MAX_COLLECTIONS_FIRST_RENDER = 12; const COLLECTIONS_TO_ADD_ON_LIST_END_REACHED = 6; @@ -32,7 +32,7 @@ type NavigationProps = BaseComposite< const NftGallery = () => { const nftsFromSimplehashFeature = useFeature("nftsFromSimplehash"); - const thresold = nftsFromSimplehashFeature?.params?.threshold; + const threshold = nftsFromSimplehashFeature?.params?.threshold; const nftsFromSimplehashEnabled = nftsFromSimplehashFeature?.enabled; const navigation = useNavigation(); const { t } = useTranslation(); @@ -55,7 +55,7 @@ const NftGallery = () => { nftsOwned: account.nfts || [], addresses: account.freshAddress, chains: [account.currency.id], - threshold: isThresholdValid(thresold) ? Number(thresold) : 75, + threshold: getThreshold(threshold), }); const hiddenNftCollections = useSelector(hiddenNftCollectionsSelector); 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 0cbcae827846..682c80317b8b 100644 --- a/apps/ledger-live-mobile/src/screens/Nft/WalletNftGallery/index.tsx +++ b/apps/ledger-live-mobile/src/screens/Nft/WalletNftGallery/index.tsx @@ -9,7 +9,7 @@ import { accountsSelector, filteredNftsSelector, hasNftsSelector } from "~/reduc import isEqual from "lodash/isEqual"; import { galleryChainFiltersSelector } from "~/reducers/nft"; -import { isThresholdValid, useNftGalleryFilter } from "@ledgerhq/live-nft-react"; +import { getThreshold, useNftGalleryFilter } from "@ledgerhq/live-nft-react"; import useFeature from "@ledgerhq/live-common/featureFlags/useFeature"; const WalletNftGallery = () => { @@ -17,7 +17,7 @@ const WalletNftGallery = () => { const hasNFTs = useSelector(hasNftsSelector); const accounts = useSelector(accountsSelector); const nftsFromSimplehashFeature = useFeature("nftsFromSimplehash"); - const thresold = nftsFromSimplehashFeature?.params?.threshold; + const threshold = nftsFromSimplehashFeature?.params?.threshold; const chainFilters = useSelector(galleryChainFiltersSelector); const nftsOwned = useSelector(filteredNftsSelector, isEqual); @@ -44,7 +44,7 @@ const WalletNftGallery = () => { addresses, chains, nftsOwned, - threshold: isThresholdValid(thresold) ? Number(thresold) : 75, + threshold: getThreshold(threshold), }); const useSimpleHash = Boolean(nftsFromSimplehashFeature?.enabled); 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 7a5c57246770..3a00e9b53d50 100644 --- a/apps/ledger-live-mobile/src/screens/SendFunds/01b-SelectCollection.tsx +++ b/apps/ledger-live-mobile/src/screens/SendFunds/01b-SelectCollection.tsx @@ -3,7 +3,7 @@ import React, { useCallback, useMemo, useState, memo } from "react"; import { View, StyleSheet, FlatList, TouchableOpacity, Platform } from "react-native"; import { nftsByCollections } from "@ledgerhq/live-nft"; import { - isThresholdValid, + getThreshold, useNftCollectionMetadata, useNftGalleryFilter, useNftMetadata, @@ -91,7 +91,7 @@ const SendFundsSelectCollection = ({ route }: Props) => { const { account } = params; const { colors } = useTheme(); const nftsFromSimplehashFeature = useFeature("nftsFromSimplehash"); - const thresold = nftsFromSimplehashFeature?.params?.threshold; + const threshold = nftsFromSimplehashFeature?.params?.threshold; const nftsFromSimplehashEnabled = nftsFromSimplehashFeature?.enabled; const hiddenNftCollections = useSelector(hiddenNftCollectionsSelector); @@ -99,7 +99,7 @@ const SendFundsSelectCollection = ({ route }: Props) => { nftsOwned: account.nfts || [], addresses: account.freshAddress, chains: [account.currency.id], - threshold: isThresholdValid(thresold) ? Number(thresold) : 75, + threshold: getThreshold(threshold), }); const [collectionsCount, setCollectionsCount] = useState(MAX_COLLECTIONS_FIRST_RENDER); diff --git a/libs/ledger-live-common/src/market/utils/timers.ts b/libs/ledger-live-common/src/market/utils/timers.ts index 2e9bac6e03b4..1a01e24cbb1f 100644 --- a/libs/ledger-live-common/src/market/utils/timers.ts +++ b/libs/ledger-live-common/src/market/utils/timers.ts @@ -3,3 +3,4 @@ export const REFETCH_TIME_ONE_MINUTE = 60 * 1000; export const BASIC_REFETCH = 3; // nb minutes export const ONE_DAY = 24 * 60 * 60 * 1000; +export const HALF_DAY = ONE_DAY / 2; diff --git a/libs/live-nft-react/src/hooks/helpers/const.ts b/libs/live-nft-react/src/hooks/helpers/const.ts new file mode 100644 index 000000000000..d74658dfd200 --- /dev/null +++ b/libs/live-nft-react/src/hooks/helpers/const.ts @@ -0,0 +1,3 @@ +const DEFAULT_THRESHOLD = 75; + +export { DEFAULT_THRESHOLD }; diff --git a/libs/live-nft-react/src/hooks/helpers/index.ts b/libs/live-nft-react/src/hooks/helpers/index.ts index 55657dcfe669..717b05437ac3 100644 --- a/libs/live-nft-react/src/hooks/helpers/index.ts +++ b/libs/live-nft-react/src/hooks/helpers/index.ts @@ -1,3 +1,5 @@ +import { DEFAULT_THRESHOLD } from "./const"; + export function hashProtoNFT(contract: string, tokenId: string, currencyId: string): string { return `${contract}|${tokenId}|${currencyId}`; } @@ -5,3 +7,12 @@ export function hashProtoNFT(contract: string, tokenId: string, currencyId: stri export function isThresholdValid(threshold?: string | number): boolean { return Number(threshold) >= 0 && Number(threshold) <= 100; } + +export function decodeCollectionId(collectionId: string) { + const [accountId, contractAddress] = collectionId.split("|"); + return { accountId, contractAddress }; +} + +export function getThreshold(threshold?: string | number): number { + return isThresholdValid(threshold) ? Number(threshold) : DEFAULT_THRESHOLD; +} diff --git a/libs/live-nft-react/src/hooks/types.ts b/libs/live-nft-react/src/hooks/types.ts index 8a33820aa55c..051f88385404 100644 --- a/libs/live-nft-react/src/hooks/types.ts +++ b/libs/live-nft-react/src/hooks/types.ts @@ -19,6 +19,7 @@ export type HookProps = { chains: string[]; threshold: number; action?: (collection: string) => void; + enabled?: boolean; }; export type PartialProtoNFT = Partial; diff --git a/libs/live-nft-react/src/hooks/useCheckNftAccount.ts b/libs/live-nft-react/src/hooks/useCheckNftAccount.ts index 4eaad5874cbd..6d67b3f56ef4 100644 --- a/libs/live-nft-react/src/hooks/useCheckNftAccount.ts +++ b/libs/live-nft-react/src/hooks/useCheckNftAccount.ts @@ -9,19 +9,32 @@ import { nftsByCollections } from "@ledgerhq/live-nft/index"; import { hashProtoNFT } from "./helpers"; /** - * useCheckNftAccount() will apply a spam filtering on top of existing NFT data. - * - addresses: a list of wallet addresses separated by a "," - * - nftOwned: the array of all nfts as found by all user's account on Ledger Live - * - chains: a list of selected network to search for NFTs - * - action: custom action to handle collections - * NB: for performance, make sure that addresses, nftOwned and chains are memoized + * A React hook that checks NFT accounts against specified criteria and provides filtering functionality for managing NFT collections. + * + * @param {Object} params - The parameters for the hook. + * @param {string} params.addresses - A comma-separated string of NFT addresses to check. + * @param {Array} params.nftsOwned - An array of owned NFTs. + * @param {Array} params.chains - An array representing the blockchain chains. + * @param {number} params.threshold - A numeric threshold for filtering NFTs. + * @param {Function} params.action - A callback function to execute when spam is detected. + * @param {boolean} [params.enabled=false] - A flag to enable or disable the hook's functionality. + * + * @returns {Object} The result of the hook. + * @returns {Array} returns.nfts - An array of filtered NFTs. + * @returns {Object} returns.queryResult - The result of the infinite query, containing pagination and loading states. + * */ + +export const ONE_DAY = 24 * 60 * 60 * 1000; +export const HALF_DAY = ONE_DAY / 2; + export function useCheckNftAccount({ addresses, nftsOwned, chains, threshold, action, + enabled, }: HookProps): NftsFilterResult { // for performance, we hashmap the list of nfts by hash. const nftsWithProperties = useMemo( @@ -36,7 +49,9 @@ export function useCheckNftAccount({ fetchNftsFromSimpleHash({ addresses, chains, cursor: pageParam, threshold }), initialPageParam: undefined, getNextPageParam: lastPage => lastPage.next_cursor, - enabled: addresses.length > 0, + enabled: enabled && addresses.length > 0, + refetchInterval: HALF_DAY, + staleTime: HALF_DAY, }); useEffect(() => { diff --git a/libs/live-nft-react/src/index.ts b/libs/live-nft-react/src/index.ts index bbe9948fc0b9..21922170f011 100644 --- a/libs/live-nft-react/src/index.ts +++ b/libs/live-nft-react/src/index.ts @@ -9,4 +9,5 @@ export * from "./hooks/useFetchOrdinalByTokenId"; export * from "./hooks/helpers/ordinals"; export * from "./hooks/useCheckNftAccount"; export * from "./hooks/helpers/index"; +export * from "./hooks/helpers/const"; export * from "./hooks/types"; diff --git a/libs/live-nft/src/__tests__/index.test.ts b/libs/live-nft/src/__tests__/index.test.ts index 16dc7888f79b..d355c0a1eac0 100644 --- a/libs/live-nft/src/__tests__/index.test.ts +++ b/libs/live-nft/src/__tests__/index.test.ts @@ -3,7 +3,16 @@ import { getCryptoCurrencyById } from "@ledgerhq/cryptoassets"; import { Account, NFTStandard, ProtoNFT } from "@ledgerhq/types-live"; import { encodeNftId } from "@ledgerhq/coin-framework/nft/nftId"; import { genAccount } from "@ledgerhq/coin-framework/mocks/account"; -import { getNFT, getNftCollectionKey, getNftKey, groupByCurrency, orderByLastReceived } from ".."; +import { + getNFT, + getNftCollectionKey, + getNftKey, + groupByCurrency, + mapChain, + mapChains, + orderByLastReceived, +} from ".."; +import { BlockchainEVM } from "../supported"; const NFT_1 = { id: encodeNftId("js:2:0ddkdlsPmds", "contract", "nft.tokenId", "ethereum"), @@ -46,24 +55,35 @@ const accounts: Account[] = [ genAccount("mocked-account-2", { currency: ETH, withNft: true }), ]; -describe("helpers", () => { - it("getNftKey", () => { +describe("Helpers functions", () => { + it("should return Nft key", () => { expect(getNftKey("contract", "tokenId", "currencyId")).toEqual("currencyId-contract-tokenId"); }); - it("getNftCollectionKey", () => { + it("should get NftCollection Key", () => { expect(getNftCollectionKey("contract", "currencyId")).toEqual("currencyId-contract"); }); - it("getNFT", () => { + it("should ge NFT", () => { expect(getNFT("contract", "nft.tokenId", NFTs)).toEqual(NFT_1); }); - it("groupByCurrency", () => { + it("should group by Currency", () => { expect(groupByCurrency(NFTs)).toEqual([NFT_1, NFT_4, NFT_2, NFT_3]); }); - it("orderByLastReceived", () => { + it("should order By last received", () => { expect(orderByLastReceived(accounts, NFTs)).toHaveLength(0); const NFTs_TEST = accounts.map(a => a.nfts).flat() as ProtoNFT[]; expect(orderByLastReceived(accounts, NFTs.concat(NFTs_TEST)).length).toBeGreaterThanOrEqual(1); }); + + it("should map a single chain", () => { + expect(mapChain(BlockchainEVM.Avalanche)).toEqual("avalanche"); + }); + + it("should map all chains", () => { + expect(mapChains([BlockchainEVM.Avalanche, BlockchainEVM.Ethereum])).toEqual([ + "avalanche", + "ethereum", + ]); + }); }); diff --git a/libs/live-nft/src/api/simplehash.ts b/libs/live-nft/src/api/simplehash.ts index 9ba69abfc812..c4df41b4918e 100644 --- a/libs/live-nft/src/api/simplehash.ts +++ b/libs/live-nft/src/api/simplehash.ts @@ -5,8 +5,7 @@ import { SimpleHashSpamReportResponse, } from "./types"; import { getEnv } from "@ledgerhq/live-env"; -import { replacements } from "../supported"; -import { mapChains } from ".."; +import { mapChain, mapChains } from ".."; /** * @@ -79,7 +78,7 @@ const defaultOpts = { export async function fetchNftsFromSimpleHash(opts: NftFetchOpts): Promise { const { chains, addresses, limit, filters, cursor, threshold } = { ...defaultOpts, ...opts }; - const chainsMapped = mapChains(chains, replacements); + const chainsMapped = mapChains(chains); const enrichedFilters = buildFilters(filters, { threshold: String(threshold) }); const { data } = await network({ @@ -139,7 +138,7 @@ export async function reportSpamNtf( }, data: JSON.stringify({ contract_address: opts.contractAddress, - chain_id: opts.chainId, + chain_id: mapChain(opts.chainId), token_id: opts.tokenId, collection_id: opts.collectionId, event_type: opts.eventType, diff --git a/libs/live-nft/src/index.ts b/libs/live-nft/src/index.ts index 501b87d06a04..6165681be904 100644 --- a/libs/live-nft/src/index.ts +++ b/libs/live-nft/src/index.ts @@ -2,6 +2,7 @@ import { getEnv } from "@ledgerhq/live-env"; import { groupAccountsOperationsByDay } from "@ledgerhq/coin-framework/account/index"; import type { Operation, ProtoNFT, NFT, Account } from "@ledgerhq/types-live"; import { NFTResource } from "./types"; +import { replacements } from "./supported"; export const GENESIS_PASS_COLLECTION_CONTRACT = "0x33c6Eec1723B12c46732f7AB41398DE45641Fa42"; export const INFINITY_PASS_COLLECTION_CONTRACT = "0xfe399E9a4B0bE4087a701fF0B1c89dABe7ce5425"; @@ -130,6 +131,11 @@ export const isNftTransaction = (transaction: T | undefined | null): boolean return false; }; -export const mapChains = (chains: string[], replacements: { [key: string]: string }) => { - return chains.map(chain => replacements[chain] || chain); +export const mapChains = (chains: string[]) => { + return chains.map(mapChain); +}; + +export const mapChain = (chain?: string) => { + if (!chain) return; + return replacements[chain] || chain; };