diff --git a/.yarn/patches/@metamask-assets-controllers-patch-a3b39b55a6.patch b/.yarn/patches/@metamask-assets-controllers-patch-a3b39b55a6.patch new file mode 100644 index 000000000000..a0e5a8b3c7e3 --- /dev/null +++ b/.yarn/patches/@metamask-assets-controllers-patch-a3b39b55a6.patch @@ -0,0 +1,251 @@ +diff --git a/dist/chunk-FMZML3V5.js b/dist/chunk-FMZML3V5.js +index ee6155cd938366918de155e8867c7d359b8ea826..b4dfe838c463a561b0e91532bcb674806fdc52bd 100644 +--- a/dist/chunk-FMZML3V5.js ++++ b/dist/chunk-FMZML3V5.js +@@ -5,6 +5,7 @@ + + + var _controllerutils = require('@metamask/controller-utils'); ++var _utils = require('@metamask/utils'); + var _pollingcontroller = require('@metamask/polling-controller'); + var DEFAULT_INTERVAL = 18e4; + var BlockaidResultType = /* @__PURE__ */ ((BlockaidResultType2) => { +@@ -14,6 +15,8 @@ var BlockaidResultType = /* @__PURE__ */ ((BlockaidResultType2) => { + BlockaidResultType2["Malicious"] = "Malicious"; + return BlockaidResultType2; + })(BlockaidResultType || {}); ++const supportedNftDetectionNetworks= [_controllerutils.ChainId.mainnet]; ++var inProcessNftFetchingUpdates; + var NftDetectionController = class extends _pollingcontroller.StaticIntervalPollingControllerV1 { + /** + * Creates an NftDetectionController instance. +@@ -50,6 +53,7 @@ var NftDetectionController = class extends _pollingcontroller.StaticIntervalPoll + * Name of this controller used during composition + */ + this.name = "NftDetectionController"; ++ this.inProcessNftFetchingUpdates= {}; + /** + * Checks whether network is mainnet or not. + * +@@ -72,11 +76,6 @@ var NftDetectionController = class extends _pollingcontroller.StaticIntervalPoll + const { selectedAddress: previouslySelectedAddress, disabled } = this.config; + if (selectedAddress !== previouslySelectedAddress || !useNftDetection !== disabled) { + this.configure({ selectedAddress, disabled: !useNftDetection }); +- if (useNftDetection) { +- this.start(); +- } else { +- this.stop(); +- } + } + }); + onNetworkStateChange(({ selectedNetworkClientId }) => { +@@ -92,34 +91,33 @@ var NftDetectionController = class extends _pollingcontroller.StaticIntervalPoll + this.setIntervalLength(this.config.interval); + } + getOwnerNftApi({ ++ chainId, + address, + next + }) { +- return `${_controllerutils.NFT_API_BASE_URL}/users/${address}/tokens?chainIds=1&limit=50&includeTopBid=true&continuation=${next ?? ""}`; ++ return `${_controllerutils.NFT_API_BASE_URL}/users/${address}/tokens?chainIds=${chainId}&limit=50&includeTopBid=true&continuation=${next ?? ""}`; + } +- async getOwnerNfts(address) { +- let nftApiResponse; +- let nfts = []; +- let next; +- do { +- nftApiResponse = await _controllerutils.fetchWithErrorHandling.call(void 0, { +- url: this.getOwnerNftApi({ address, next }), +- options: { +- headers: { +- Version: "1" +- } ++ async getOwnerNfts( ++ address, ++ chainId, ++ cursor, ++ ) { ++ // Convert hex chainId to number ++ const convertedChainId = (0, _controllerutils.convertHexToDecimal)(chainId).toString(); ++ const url = this.getOwnerNftApi({ ++ chainId: convertedChainId, ++ address, ++ next: cursor, ++ }); ++ ++ const nftApiResponse = await _controllerutils.handleFetch.call(void 0, url, ++ { ++ headers: { ++ Version: "1" + }, +- timeout: 15e3 +- }); +- if (!nftApiResponse) { +- return nfts; + } +- const newNfts = nftApiResponse.tokens.filter( +- (elm) => elm.token.isSpam === false && (elm.blockaidResult?.result_type ? elm.blockaidResult?.result_type === "Benign" /* Benign */ : true) +- ); +- nfts = [...nfts, ...newNfts]; +- } while (next = nftApiResponse.continuation); +- return nfts; ++ ); ++ return nftApiResponse; + } + async _executePoll(networkClientId, options) { + await this.detectNfts({ networkClientId, userAddress: options.address }); +@@ -169,62 +167,103 @@ var NftDetectionController = class extends _pollingcontroller.StaticIntervalPoll + networkClientId, + userAddress + } = { userAddress: this.config.selectedAddress }) { +- if (!this.isMainnet() || this.disabled) { ++ const { chainId } = this.config; ++ if (!supportedNftDetectionNetworks.includes(chainId) || this.disabled) { + return; + } + if (!userAddress) { + return; + } +- const apiNfts = await this.getOwnerNfts(userAddress); +- const addNftPromises = apiNfts.map(async (nft) => { +- const { +- tokenId: token_id, +- contract, +- kind, +- image: image_url, +- imageSmall: image_thumbnail_url, +- metadata: { imageOriginal: image_original_url } = {}, +- name, +- description, +- attributes, +- topBid, +- lastSale, +- rarityRank, +- rarityScore, +- collection +- } = nft.token; +- let ignored; +- const { ignoredNfts } = this.getNftState(); +- if (ignoredNfts.length) { +- ignored = ignoredNfts.find((c) => { +- return c.address === _controllerutils.toChecksumHexAddress.call(void 0, contract) && c.tokenId === token_id; +- }); +- } +- if (!ignored) { +- const nftMetadata = Object.assign( +- {}, +- { name }, +- description && { description }, +- image_url && { image: image_url }, +- image_thumbnail_url && { imageThumbnail: image_thumbnail_url }, +- image_original_url && { imageOriginal: image_original_url }, +- kind && { standard: kind.toUpperCase() }, +- lastSale && { lastSale }, +- attributes && { attributes }, +- topBid && { topBid }, +- rarityRank && { rarityRank }, +- rarityScore && { rarityScore }, +- collection && { collection } +- ); +- await this.addNft(contract, token_id, { +- nftMetadata, +- userAddress, +- source: "detected" /* Detected */, +- networkClientId ++ ++ const updateKey = `${chainId}:${userAddress}`; ++ if (updateKey in this.inProcessNftFetchingUpdates) { ++ // This prevents redundant updates ++ // This promise is resolved after the in-progress update has finished, ++ // and state has been updated. ++ await this.inProcessNftFetchingUpdates[updateKey]; ++ return; ++ } ++ const { ++ promise: inProgressUpdate, ++ resolve: updateSucceeded, ++ reject: updateFailed ++ } = _utils.createDeferredPromise.call(void 0, { suppressUnhandledRejection: true }); ++ this.inProcessNftFetchingUpdates[updateKey] = inProgressUpdate; ++ ++ let next; ++ let apiNfts= []; ++ let resultNftApi; ++ ++ try{ ++ do { ++ resultNftApi = await this.getOwnerNfts(userAddress, chainId, next) ++ apiNfts = resultNftApi.tokens.filter( ++ (elm) => ++ elm.token.isSpam === false && ++ (elm.blockaidResult?.result_type ++ ? elm.blockaidResult?.result_type === BlockaidResultType.Benign ++ : true), ++ ); ++ const addNftPromises = apiNfts.map(async (nft) => { ++ const { ++ tokenId: token_id, ++ contract, ++ kind, ++ image: image_url, ++ imageSmall: image_thumbnail_url, ++ metadata: { imageOriginal: image_original_url } = {}, ++ name, ++ description, ++ attributes, ++ topBid, ++ lastSale, ++ rarityRank, ++ rarityScore, ++ collection, ++ } = nft.token; ++ ++ let ignored; ++ /* istanbul ignore else */ ++ const { ignoredNfts } = this.getNftState(); ++ if (ignoredNfts.length) { ++ ignored = ignoredNfts.find((c) => { ++ return c.address === _controllerutils.toChecksumHexAddress.call(void 0, contract) && c.tokenId === token_id; ++ }); ++ } ++ /* istanbul ignore else */ ++ if (!ignored) { ++ const nftMetadata = Object.assign( ++ {}, ++ { name }, ++ description && { description }, ++ image_url && { image: image_url }, ++ image_thumbnail_url && { imageThumbnail: image_thumbnail_url }, ++ image_original_url && { imageOriginal: image_original_url }, ++ kind && { standard: kind.toUpperCase() }, ++ lastSale && { lastSale }, ++ attributes && { attributes }, ++ topBid && { topBid }, ++ rarityRank && { rarityRank }, ++ rarityScore && { rarityScore }, ++ collection && { collection } ++ ); ++ await this.addNft(contract, token_id, { ++ nftMetadata, ++ userAddress, ++ source: "detected" /* Detected */, ++ networkClientId ++ }); ++ } + }); +- } +- }); +- await Promise.all(addNftPromises); ++ await Promise.all(addNftPromises); ++ } while ((next = resultNftApi.continuation)); ++ updateSucceeded(); ++ } catch (error){ ++ updateFailed(error); ++ throw error; ++ } finally { ++ delete this.inProcessNftFetchingUpdates[updateKey]; ++ } + } + }; + var NftDetectionController_default = NftDetectionController; diff --git a/app/_locales/de/messages.json b/app/_locales/de/messages.json index eaa6a77b362d..32affca2e5d6 100644 --- a/app/_locales/de/messages.json +++ b/app/_locales/de/messages.json @@ -2453,9 +2453,6 @@ "loading": { "message": "Wird geladen ..." }, - "loadingNFTs": { - "message": "NFTs werden geladen ..." - }, "loadingScreenHardwareWalletMessage": { "message": "Bitte schließen Sie die Transaktion im Hardware-Wallet ab." }, diff --git a/app/_locales/el/messages.json b/app/_locales/el/messages.json index 8cfbc2e3af61..57366b6653de 100644 --- a/app/_locales/el/messages.json +++ b/app/_locales/el/messages.json @@ -2453,9 +2453,6 @@ "loading": { "message": "Φόρτωση..." }, - "loadingNFTs": { - "message": "Φόρτωση των NFT..." - }, "loadingScreenHardwareWalletMessage": { "message": "Ολοκληρώστε τη συναλλαγή στο πορτοφόλι υλικού." }, diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 4d251becd20a..a2426dbc5027 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -417,6 +417,9 @@ "allow": { "message": "Allow" }, + "allowMetaMaskToDetectNFTs": { + "message": "Allow MetaMask to detect and display your NFTs with autodetection. You’ll be able to:" + }, "allowMetaMaskToDetectTokens": { "message": "Allow MetaMask to detect and display your tokens with autodetection. You’ll be able to:" }, @@ -1473,6 +1476,9 @@ "displayNftMediaDescription": { "message": "Displaying NFT media and data exposes your IP address to OpenSea or other third parties. This can allow attackers to associate your IP address with your Ethereum address. NFT autodetection relies on this setting, and won't be available when this is turned off." }, + "diveStraightIntoUsingYourNFTs": { + "message": "Dive straight into using your NFTs" + }, "diveStraightIntoUsingYourTokens": { "message": "Dive straight into using your tokens" }, @@ -1616,6 +1622,9 @@ "enableFromSettings": { "message": " Enable it from Settings." }, + "enableNftAutoDetection": { + "message": "Enable NFT autodetection" + }, "enableSnap": { "message": "Enable" }, @@ -2114,6 +2123,9 @@ "imToken": { "message": "imToken" }, + "immediateAccessToYourNFTs": { + "message": "Immediately access your NFTs" + }, "immediateAccessToYourTokens": { "message": "Immediate access to your tokens" }, @@ -2524,9 +2536,6 @@ "loading": { "message": "Loading..." }, - "loadingNFTs": { - "message": "Loading NFTs..." - }, "loadingScreenHardwareWalletMessage": { "message": "Please complete the transaction on the hardware wallet." }, @@ -2965,6 +2974,9 @@ "nftAlreadyAdded": { "message": "NFT has already been added." }, + "nftAutoDetectionEnabled": { + "message": "NFT autodetection enabled" + }, "nftDisclaimer": { "message": "Disclaimer: MetaMask pulls the media file from the source url. This url sometimes gets changed by the marketplace on which the NFT was minted." }, @@ -4471,7 +4483,7 @@ "message": "Select token" }, "selectNFTPrivacyPreference": { - "message": "Turn on NFT detection in Settings" + "message": "Enable NFT Autodetection" }, "selectPathHelp": { "message": "If you don't see the accounts you expect, try switching the HD path or current selected network." diff --git a/app/_locales/es/messages.json b/app/_locales/es/messages.json index db4765dce424..02f7754a725b 100644 --- a/app/_locales/es/messages.json +++ b/app/_locales/es/messages.json @@ -2450,9 +2450,6 @@ "loading": { "message": "Cargando…" }, - "loadingNFTs": { - "message": "Cargando NFT..." - }, "loadingScreenHardwareWalletMessage": { "message": "Por favor, complete la transacción en el monedero físico." }, diff --git a/app/_locales/fr/messages.json b/app/_locales/fr/messages.json index b668b15ccffd..0f6beff29ece 100644 --- a/app/_locales/fr/messages.json +++ b/app/_locales/fr/messages.json @@ -2453,9 +2453,6 @@ "loading": { "message": "Chargement..." }, - "loadingNFTs": { - "message": "Chargement des NFT..." - }, "loadingScreenHardwareWalletMessage": { "message": "Veuillez conclure la transaction sur le portefeuille matériel." }, diff --git a/app/_locales/hi/messages.json b/app/_locales/hi/messages.json index af526ba040ce..fcc67f89f409 100644 --- a/app/_locales/hi/messages.json +++ b/app/_locales/hi/messages.json @@ -2450,9 +2450,6 @@ "loading": { "message": "लोड हो रहा है..." }, - "loadingNFTs": { - "message": "NFTज़ लोड कर रहा है..." - }, "loadingScreenHardwareWalletMessage": { "message": "कृपया hardware wallet पर ट्रांजेक्शन पूरा करें।" }, diff --git a/app/_locales/id/messages.json b/app/_locales/id/messages.json index f5357cfa2106..1bb94a0d3e04 100644 --- a/app/_locales/id/messages.json +++ b/app/_locales/id/messages.json @@ -2453,9 +2453,6 @@ "loading": { "message": "Memuat..." }, - "loadingNFTs": { - "message": "Memuat NFT..." - }, "loadingScreenHardwareWalletMessage": { "message": "Selesaikan transaksi di dompet perangkat keras." }, diff --git a/app/_locales/ja/messages.json b/app/_locales/ja/messages.json index 866f6c544fd9..a5226fd27192 100644 --- a/app/_locales/ja/messages.json +++ b/app/_locales/ja/messages.json @@ -2450,9 +2450,6 @@ "loading": { "message": "ロードしています..." }, - "loadingNFTs": { - "message": "NFTをロードしています..." - }, "loadingScreenHardwareWalletMessage": { "message": "ハードウェアウォレットでトランザクションを完了させてください。" }, diff --git a/app/_locales/ko/messages.json b/app/_locales/ko/messages.json index 4b4a187f2b3b..d9ccf970cbd6 100644 --- a/app/_locales/ko/messages.json +++ b/app/_locales/ko/messages.json @@ -2450,9 +2450,6 @@ "loading": { "message": "로드 중..." }, - "loadingNFTs": { - "message": "NFT 불러오는 중..." - }, "loadingScreenHardwareWalletMessage": { "message": "하드웨어 지갑에서 트랜잭션을 완료하세요." }, diff --git a/app/_locales/pt/messages.json b/app/_locales/pt/messages.json index 06a8a69653d1..971129f0e027 100644 --- a/app/_locales/pt/messages.json +++ b/app/_locales/pt/messages.json @@ -2453,9 +2453,6 @@ "loading": { "message": "Carregando..." }, - "loadingNFTs": { - "message": "Carregando NFTs..." - }, "loadingScreenHardwareWalletMessage": { "message": "Por favor, conclua a transação na carteira de hardware." }, diff --git a/app/_locales/ru/messages.json b/app/_locales/ru/messages.json index 584f6cfb2204..2ebd1f371e74 100644 --- a/app/_locales/ru/messages.json +++ b/app/_locales/ru/messages.json @@ -2453,9 +2453,6 @@ "loading": { "message": "Загрузка..." }, - "loadingNFTs": { - "message": "Загрузка NFT..." - }, "loadingScreenHardwareWalletMessage": { "message": "Завершите транзакцию в аппаратном кошельке." }, diff --git a/app/_locales/tl/messages.json b/app/_locales/tl/messages.json index c9ec6cedfd17..c9f382c3644d 100644 --- a/app/_locales/tl/messages.json +++ b/app/_locales/tl/messages.json @@ -2450,9 +2450,6 @@ "loading": { "message": "Nilo-load..." }, - "loadingNFTs": { - "message": "Nilo-load ang mga NFT..." - }, "loadingScreenHardwareWalletMessage": { "message": "Mangyaring kumpletuhin ang transaksyon sa hardware wallet." }, diff --git a/app/_locales/tr/messages.json b/app/_locales/tr/messages.json index 913ae926635d..1dbad4ab24e5 100644 --- a/app/_locales/tr/messages.json +++ b/app/_locales/tr/messages.json @@ -2453,9 +2453,6 @@ "loading": { "message": "Yükleniyor..." }, - "loadingNFTs": { - "message": "NFT'ler yükleniyor..." - }, "loadingScreenHardwareWalletMessage": { "message": "Lütfen işlemi donanım cüzdanında tamamlayın." }, diff --git a/app/_locales/vi/messages.json b/app/_locales/vi/messages.json index 47ccc078d198..bbbcffe561d6 100644 --- a/app/_locales/vi/messages.json +++ b/app/_locales/vi/messages.json @@ -2450,9 +2450,6 @@ "loading": { "message": "Đang tải..." }, - "loadingNFTs": { - "message": "Đang tải NFT..." - }, "loadingScreenHardwareWalletMessage": { "message": "Vui lòng hoàn tất giao dịch trên ví cứng." }, diff --git a/app/_locales/zh_CN/messages.json b/app/_locales/zh_CN/messages.json index a95feb041140..2b73be877bbe 100644 --- a/app/_locales/zh_CN/messages.json +++ b/app/_locales/zh_CN/messages.json @@ -2450,9 +2450,6 @@ "loading": { "message": "正在加载..." }, - "loadingNFTs": { - "message": "正在加载NFT......" - }, "loadingScreenHardwareWalletMessage": { "message": "请在硬件钱包上完成交易。" }, diff --git a/app/scripts/controllers/preferences.js b/app/scripts/controllers/preferences.js index 2bcf9d4697f2..771f164b7b4c 100644 --- a/app/scripts/controllers/preferences.js +++ b/app/scripts/controllers/preferences.js @@ -56,11 +56,11 @@ export default class PreferencesController { // set to true means the dynamic list from the API is being used // set to false will be using the static list from contract-metadata useTokenDetection: opts?.initState?.useTokenDetection ?? true, - useNftDetection: false, + useNftDetection: opts?.initState?.useTokenDetection ?? true, use4ByteResolution: true, useCurrencyRateCheck: true, useRequestQueue: true, - openSeaEnabled: false, + openSeaEnabled: true, // todo set this to true ///: BEGIN:ONLY_INCLUDE_IF(blockaid) securityAlertsEnabled: true, ///: END:ONLY_INCLUDE_IF @@ -96,6 +96,7 @@ export default class PreferencesController { redesignedConfirmationsEnabled: true, featureNotificationsEnabled: false, showTokenAutodetectModal: null, + showNftAutodetectModal: null, // null because we want to show the modal only the first time }, // ENS decentralized website resolution ipfsGateway: IPFS_DEFAULT_GATEWAY_URL, diff --git a/app/scripts/controllers/preferences.test.js b/app/scripts/controllers/preferences.test.js index c2a382725c0b..fc344ada1264 100644 --- a/app/scripts/controllers/preferences.test.js +++ b/app/scripts/controllers/preferences.test.js @@ -374,10 +374,10 @@ describe('preferences controller', () => { }); describe('setUseNftDetection', () => { - it('should default to false', () => { + it('should default to true', () => { expect( preferencesController.store.getState().useNftDetection, - ).toStrictEqual(false); + ).toStrictEqual(true); }); it('should set the useNftDetection property in state', () => { @@ -405,10 +405,10 @@ describe('preferences controller', () => { }); describe('setOpenSeaEnabled', () => { - it('should default to false', () => { + it('should default to true', () => { expect( preferencesController.store.getState().openSeaEnabled, - ).toStrictEqual(false); + ).toStrictEqual(true); }); it('should set the openSeaEnabled property in state', () => { diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 4d5145e876f6..83a21fcf3281 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -737,7 +737,7 @@ export default class MetamaskController extends EventEmitter { disabled: this.preferencesController.store.getState().useNftDetection === undefined - ? true + ? false // the detection is enabled by default : !this.preferencesController.store.getState().useNftDetection, selectedAddress: this.preferencesController.store.getState().selectedAddress, @@ -2126,6 +2126,7 @@ export default class MetamaskController extends EventEmitter { this.encryptionPublicKeyController.newRequestEncryptionPublicKey.bind( this.encryptionPublicKeyController, ), + processDecryptMessage: this.decryptMessageController.newRequestDecryptMessage.bind( this.decryptMessageController, @@ -2348,12 +2349,7 @@ export default class MetamaskController extends EventEmitter { const preferencesControllerState = this.preferencesController.store.getState(); - const { useCurrencyRateCheck, useNftDetection } = - preferencesControllerState; - - if (useNftDetection) { - this.nftDetectionController.start(); - } + const { useCurrencyRateCheck } = preferencesControllerState; if (useCurrencyRateCheck) { this.tokenRatesController.start(); @@ -2368,7 +2364,6 @@ export default class MetamaskController extends EventEmitter { this.accountTracker.stop(); this.txController.stopIncomingTransactionPolling(); this.tokenDetectionController.disable(); - this.nftDetectionController.stop(); const preferencesControllerState = this.preferencesController.store.getState(); @@ -6223,7 +6218,6 @@ export default class MetamaskController extends EventEmitter { const { data, to: contractAddress, from: userAddress } = txParams; const transactionData = parseStandardTokenTransactionData(data); - // Sometimes the tokenId value is parsed as "_value" param. Not seeing this often any more, but still occasionally: // i.e. call approve() on BAYC contract - https://etherscan.io/token/0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d#writeContract, and tokenId shows up as _value, // not sure why since it doesn't match the ERC721 ABI spec we use to parse these transactions - https://github.com/MetaMask/metamask-eth-abis/blob/d0474308a288f9252597b7c93a3a8deaad19e1b2/src/abis/abiERC721.ts#L62. diff --git a/package.json b/package.json index 889deb2d6bf4..5d28f37fa5b7 100644 --- a/package.json +++ b/package.json @@ -287,7 +287,7 @@ "@metamask/address-book-controller": "^4.0.1", "@metamask/announcement-controller": "^6.1.0", "@metamask/approval-controller": "^7.0.0", - "@metamask/assets-controllers": "patch:@metamask/assets-controllers@patch%3A@metamask/assets-controllers@npm%253A30.0.0%23~/.yarn/patches/@metamask-assets-controllers-npm-30.0.0-8747c20871.patch%3A%3Aversion=30.0.0&hash=9269c8#~/.yarn/patches/@metamask-assets-controllers-patch-26d4328777.patch", + "@metamask/assets-controllers": "patch:@metamask/assets-controllers@patch%3A@metamask/assets-controllers@patch%253A@metamask/assets-controllers@npm%25253A30.0.0%2523~/.yarn/patches/@metamask-assets-controllers-npm-30.0.0-8747c20871.patch%253A%253Aversion=30.0.0&hash=9269c8%23~/.yarn/patches/@metamask-assets-controllers-patch-26d4328777.patch%3A%3Aversion=30.0.0&hash=1ba1a6#~/.yarn/patches/@metamask-assets-controllers-patch-a3b39b55a6.patch", "@metamask/base-controller": "^5.0.1", "@metamask/browser-passworder": "^4.3.0", "@metamask/contract-metadata": "^2.5.0", diff --git a/shared/constants/metametrics.ts b/shared/constants/metametrics.ts index 6db66f796679..a5065ea2dadd 100644 --- a/shared/constants/metametrics.ts +++ b/shared/constants/metametrics.ts @@ -721,6 +721,9 @@ export enum MetaMetricsEventName { // Notifications PushNotificationReceived = 'Push Notification Received', PushNotificationClicked = 'Push Notification Clicked', + + NftAutoDetectionEnableModal = 'Nft Autodetection Enabled from modal', + NftAutoDetectionDisableModal = 'Nft Autodetection Disabled from modal', // Send sendAssetSelected = 'Send Asset Selected', sendFlowExited = 'Send Flow Exited', diff --git a/shared/modules/selectors/index.ts b/shared/modules/selectors/index.ts index 51c2d67f930c..2274b562629b 100644 --- a/shared/modules/selectors/index.ts +++ b/shared/modules/selectors/index.ts @@ -1,3 +1,4 @@ export * from './smart-transactions'; export * from './feature-flags'; export * from './token-auto-detect'; +export * from './nft-auto-detect'; diff --git a/shared/modules/selectors/nft-auto-detect.ts b/shared/modules/selectors/nft-auto-detect.ts new file mode 100644 index 000000000000..889a0348f179 --- /dev/null +++ b/shared/modules/selectors/nft-auto-detect.ts @@ -0,0 +1,29 @@ +import { + getIsMainnet, + getUseNftDetection, +} from '../../../ui/selectors/selectors'; + +type NftAutoDetectionMetaMaskState = { + metamask: { + preferences: { + showNftAutodetectModal: boolean | null; + }; + }; +}; + +export const getShowNftAutodetectModal = ( + state: NftAutoDetectionMetaMaskState, +): boolean | null => { + return state.metamask.preferences?.showNftAutodetectModal; +}; + +export const getIsShowNftAutodetectModal = ( + state: NftAutoDetectionMetaMaskState, +) => { + return ( + !getUseNftDetection(state) && + getIsMainnet(state) && + (getShowNftAutodetectModal(state) === null || + getShowNftAutodetectModal(state) === undefined) + ); +}; diff --git a/ui/components/app/auto-detect-nft/auto-detect-nft-modal.test.stories.js b/ui/components/app/auto-detect-nft/auto-detect-nft-modal.test.stories.js new file mode 100644 index 000000000000..2d915d6a61f5 --- /dev/null +++ b/ui/components/app/auto-detect-nft/auto-detect-nft-modal.test.stories.js @@ -0,0 +1,48 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import testData from '../../../../.storybook/test-data'; +import configureStore from '../../../store/store'; +import AutoDetectNftModal from './auto-detect-nft-modal'; + +const customData = { + ...testData, + metamask: { + ...testData.metamask, + currentCurrency: 'USD', + intlLocale: 'en-US', + }, +}; +const customStore = configureStore(customData); + +export default { + title: 'Components/App/AutoDetectNftModal', + component: AutoDetectNftModal, + decorators: [ + (Story) => ( + + + + ), + ], + argTypes: { + isOpen: { + control: 'boolean', + }, + onClose: { action: 'onClose' }, + }, + args: { + isOpen: true, + }, +}; + +const Template = (args) => ; + +export const ModalOpen = Template.bind({}); +ModalOpen.args = { + isOpen: true, +}; + +export const ModalClosed = Template.bind({}); +ModalClosed.args = { + isOpen: false, +}; diff --git a/ui/components/app/auto-detect-nft/auto-detect-nft-modal.test.tsx b/ui/components/app/auto-detect-nft/auto-detect-nft-modal.test.tsx new file mode 100644 index 000000000000..e3fcc8b6e425 --- /dev/null +++ b/ui/components/app/auto-detect-nft/auto-detect-nft-modal.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { useDispatch } from 'react-redux'; +import configureMockStore from 'redux-mock-store'; +import mockState from '../../../../test/data/mock-state.json'; +import { renderWithProvider } from '../../../../test/lib/render-helpers'; +import AutoDetectNftModal from './auto-detect-nft-modal'; + +// Mock store setup +const mockStore = configureMockStore([])(mockState); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: jest.fn(), +})); + +describe('AutoDetectNftModal', () => { + const useDispatchMock = jest.mocked(useDispatch); + + beforeEach(() => { + jest.resetAllMocks(); + useDispatchMock.mockReturnValue(jest.fn()); + }); + + it('renders the modal when isOpen is true', () => { + renderWithProvider( + ({})} />, + mockStore, + ); + + expect(screen.getByText('Enable NFT autodetection')).toBeInTheDocument(); + expect(screen.getByText('Allow')).toBeInTheDocument(); + expect(screen.getByText('Not right now')).toBeInTheDocument(); + }); + + it('calls onClose with true when Allow button is clicked', () => { + useDispatchMock.mockReturnValue(jest.fn().mockResolvedValue({})); + const handleClose = jest.fn(); + renderWithProvider( + , + mockStore, + ); + + fireEvent.click(screen.getByText('Allow')); + expect(handleClose).toHaveBeenCalledWith(true); + }); + + it('calls onClose with false when Not right now button is clicked', () => { + useDispatchMock.mockReturnValue(jest.fn().mockResolvedValue({})); + const handleClose = jest.fn(); + renderWithProvider( + , + mockStore, + ); + + fireEvent.click(screen.getByText('Not right now')); + expect(handleClose).toHaveBeenCalledWith(false); + }); +}); diff --git a/ui/components/app/auto-detect-nft/auto-detect-nft-modal.tsx b/ui/components/app/auto-detect-nft/auto-detect-nft-modal.tsx new file mode 100644 index 000000000000..5afd1b55fbab --- /dev/null +++ b/ui/components/app/auto-detect-nft/auto-detect-nft-modal.tsx @@ -0,0 +1,131 @@ +import React, { useCallback, useContext } from 'react'; + +import { useDispatch, useSelector } from 'react-redux'; +import { + Modal, + ModalContent, + ModalOverlay, + ModalHeader, + Box, + Text, + ModalBody, + ModalFooter, +} from '../../component-library'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { + AlignItems, + BorderRadius, + Display, + FlexDirection, + JustifyContent, + TextAlign, + TextVariant, +} from '../../../helpers/constants/design-system'; +import { setOpenSeaEnabled, setUseNftDetection } from '../../../store/actions'; +import { MetaMetricsContext } from '../../../contexts/metametrics'; +import { + MetaMetricsEventCategory, + MetaMetricsEventName, +} from '../../../../shared/constants/metametrics'; +import { getProviderConfig } from '../../../ducks/metamask/metamask'; +import { ORIGIN_METAMASK } from '../../../../shared/constants/app'; + +type AutoDetectNftModalProps = { + isOpen: boolean; + onClose: (arg: boolean) => void; +}; +function AutoDetectNftModal({ isOpen, onClose }: AutoDetectNftModalProps) { + const t = useI18nContext(); + const dispatch = useDispatch(); + const trackEvent = useContext(MetaMetricsContext); + const { chainId } = useSelector(getProviderConfig); + + const handleNftAutoDetection = useCallback( + (val) => { + trackEvent({ + event: val + ? MetaMetricsEventName.NftAutoDetectionEnableModal + : MetaMetricsEventName.NftAutoDetectionDisableModal, + category: MetaMetricsEventCategory.Navigation, + properties: { + chain_id: chainId, + referrer: ORIGIN_METAMASK, + }, + }); + if (val) { + dispatch(setOpenSeaEnabled(val)); + dispatch(setUseNftDetection(val)); + } + onClose(val); + }, + [dispatch], + ); + + return ( + onClose(true)} + isClosedOnOutsideClick={false} + isClosedOnEscapeKey={false} + className="mm-modal__custom-scrollbar auto-detect-in-modal" + autoFocus={false} + > + + + + {t('enableNftAutoDetection')} + + + + + + + {t('allowMetaMaskToDetectNFTs')} + + + {t('immediateAccessToYourNFTs')} + + + {t('effortlesslyNavigateYourDigitalAssets')} + + + {t('diveStraightIntoUsingYourNFTs')} + + + + + handleNftAutoDetection(true)} + submitButtonProps={{ + children: t('allow'), + block: true, + }} + onCancel={() => handleNftAutoDetection(false)} + cancelButtonProps={{ + children: t('notRightNow'), + block: true, + }} + /> + + + ); +} + +export default AutoDetectNftModal; diff --git a/ui/components/app/auto-detect-nft/index.scss b/ui/components/app/auto-detect-nft/index.scss new file mode 100644 index 000000000000..5714d957f0f9 --- /dev/null +++ b/ui/components/app/auto-detect-nft/index.scss @@ -0,0 +1,10 @@ +.auto-detect-in-modal { + &__benefit { + flex: 1; + } + + &__dialog { + background-position: -80px 16px; + background-repeat: no-repeat; + } +} diff --git a/ui/components/app/nfts-detection-notice-nfts-tab/nfts-detection-notice-nfts-tab.js b/ui/components/app/nfts-detection-notice-nfts-tab/nfts-detection-notice-nfts-tab.js index c3a19fdf18aa..7ec66e532aec 100644 --- a/ui/components/app/nfts-detection-notice-nfts-tab/nfts-detection-notice-nfts-tab.js +++ b/ui/components/app/nfts-detection-notice-nfts-tab/nfts-detection-notice-nfts-tab.js @@ -1,21 +1,34 @@ import React from 'react'; -import { useHistory } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; import { BannerAlert } from '../../component-library'; import { useI18nContext } from '../../../hooks/useI18nContext'; -import { SECURITY_ROUTE } from '../../../helpers/constants/routes'; +import { + detectNfts, + setOpenSeaEnabled, + setShowNftDetectionEnablementToast, + setUseNftDetection, +} from '../../../store/actions'; +import { getOpenSeaEnabled } from '../../../selectors'; export default function NFTsDetectionNoticeNFTsTab() { const t = useI18nContext(); - const history = useHistory(); + const dispatch = useDispatch(); + const isDisplayNFTMediaToggleEnabled = useSelector(getOpenSeaEnabled); return ( { - e.preventDefault(); - history.push(`${SECURITY_ROUTE}#autodetect-nfts`); + actionButtonOnClick={() => { + if (!isDisplayNFTMediaToggleEnabled) { + dispatch(setOpenSeaEnabled(true)); + } + dispatch(setUseNftDetection(true)); + // Show toast + dispatch(setShowNftDetectionEnablementToast(true)); + // dispatch action to detect nfts + dispatch(detectNfts()); }} > { diff --git a/ui/components/app/nfts-tab/index.scss b/ui/components/app/nfts-tab/index.scss index 2f5e266c136a..44c20656d417 100644 --- a/ui/components/app/nfts-tab/index.scss +++ b/ui/components/app/nfts-tab/index.scss @@ -1,7 +1,15 @@ .nfts-tab { + &__fetching { + display: flex; + height: 100px; + align-items: center; + justify-content: center; + padding: 30px; + } + &__loading { display: flex; - height: 250px; + height: 200px; align-items: center; justify-content: center; padding: 30px; diff --git a/ui/components/app/nfts-tab/nfts-tab.js b/ui/components/app/nfts-tab/nfts-tab.js index f9f87ea0f8a1..99ac8092c83f 100644 --- a/ui/components/app/nfts-tab/nfts-tab.js +++ b/ui/components/app/nfts-tab/nfts-tab.js @@ -23,6 +23,7 @@ import { ///: END:ONLY_INCLUDE_IF getIsMainnet, getUseNftDetection, + getNftIsStillFetchingIndication, } from '../../../selectors'; import { checkAndUpdateAllNftsOwnershipStatus, @@ -49,6 +50,7 @@ import { } from '../../multichain/ramps-card/ramps-card'; import { useAccountTotalFiatBalance } from '../../../hooks/useAccountTotalFiatBalance'; ///: END:ONLY_INCLUDE_IF +import Spinner from '../../ui/spinner'; export default function NftsTab() { const useNftDetection = useSelector(getUseNftDetection); @@ -57,6 +59,9 @@ export default function NftsTab() { const t = useI18nContext(); const dispatch = useDispatch(); const trackEvent = useContext(MetaMetricsContext); + const nftsStillFetchingIndication = useSelector( + getNftIsStillFetchingIndication, + ); ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) const { address: selectedAddress } = useSelector(getSelectedAccount); @@ -114,8 +119,15 @@ export default function NftsTab() { currentLocale, ]); - if (nftsLoading) { - return
{t('loadingNFTs')}
; + if (!hasAnyNfts && nftsStillFetchingIndication) { + return ( + + + + ); } return ( @@ -128,18 +140,29 @@ export default function NftsTab() { ///: END:ONLY_INCLUDE_IF } + {isMainnet && !useNftDetection ? ( + + + + ) : null} {hasAnyNfts > 0 || previouslyOwnedCollection.nfts.length > 0 ? ( - - ) : ( - <> - {isMainnet && !useNftDetection ? ( - - + + + + {nftsStillFetchingIndication ? ( + + ) : null} + + ) : ( + <> { checkAndUpdateAllNftsOwnershipStatus: checkAndUpdateAllNftsOwnershipStatusStub, updateNftDropDownState: updateNftDropDownStateStub, + setUseNftDetection: setUseNftDetectionStub, + setOpenSeaEnabled: setDisplayNftMediaStub, }); const historyPushMock = jest.fn(); @@ -234,31 +238,46 @@ describe('NFT Items', () => { jest.clearAllMocks(); }); + function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + describe('NFTs Detection Notice', () => { - it('should render the NFTs Detection Notice when currently selected network is Mainnet and currently selected account has no nfts', () => { + it('should render the NFTs Detection Notice when currently selected network is Mainnet and nft detection is set to false and user has nfts', () => { + render({ + selectedAddress: ACCOUNT_1, + nfts: NFTS, + }); + expect(screen.queryByText('NFT autodetection')).toBeInTheDocument(); + }); + + it('should render the NFTs Detection Notice when currently selected network is Mainnet and nft detection is set to false and user has no nfts', async () => { render({ selectedAddress: ACCOUNT_2, nfts: NFTS, + useNftDetection: false, }); expect(screen.queryByText('NFT autodetection')).toBeInTheDocument(); }); - it('should not render the NFTs Detection Notice when currently selected network is Mainnet and currently selected account has NFTs', () => { + it('should not render the NFTs Detection Notice when currently selected network is Mainnet and nft detection is ON', () => { render({ selectedAddress: ACCOUNT_1, nfts: NFTS, + useNftDetection: true, }); expect(screen.queryByText('NFT autodetection')).not.toBeInTheDocument(); }); - it('should take user to the experimental settings tab in settings when user clicks "Turn on NFT detection in Settings"', () => { + it('should turn on nft detection without going to settings when user clicks "Enable NFT Autodetection" and nft detection is set to false', async () => { render({ selectedAddress: ACCOUNT_2, nfts: NFTS, + useNftDetection: false, }); - fireEvent.click(screen.queryByText('Turn on NFT detection in Settings')); - expect(historyPushMock).toHaveBeenCalledTimes(1); - expect(historyPushMock).toHaveBeenCalledWith( - `${SECURITY_ROUTE}#autodetect-nfts`, - ); + fireEvent.click(screen.queryByText('Enable NFT Autodetection')); + expect(setUseNftDetectionStub).toHaveBeenCalledTimes(1); + expect(setDisplayNftMediaStub).toHaveBeenCalledTimes(1); + expect(setUseNftDetectionStub.mock.calls[0][0]).toStrictEqual(true); + expect(setDisplayNftMediaStub.mock.calls[0][0]).toStrictEqual(true); }); it('should not render the NFTs Detection Notice when currently selected network is Mainnet and currently selected account has no NFTs but use NFT autodetection preference is set to true', () => { render({ @@ -268,10 +287,20 @@ describe('NFT Items', () => { }); expect(screen.queryByText('NFT autodetection')).not.toBeInTheDocument(); }); - it('should not render the NFTs Detection Notice when currently selected network is Mainnet and currently selected account has no NFTs but user has dismissed the notice before', () => { + it('should render the NFTs Detection Notice when currently selected network is Mainnet and currently selected account has no NFTs but user has dismissed the notice before', () => { + render({ + selectedAddress: ACCOUNT_1, + nfts: NFTS, + }); + expect(screen.queryByText('NFT autodetection')).toBeInTheDocument(); + }); + + it('should not render the NFTs Detection Notice when currently selected network is NOT Mainnet', () => { render({ selectedAddress: ACCOUNT_1, nfts: NFTS, + useNftDetection: false, + chainId: '0x4', }); expect(screen.queryByText('NFT autodetection')).not.toBeInTheDocument(); }); @@ -337,11 +366,13 @@ describe('NFT Items', () => { }); describe('NFT Tab Ramps Card', () => { - it('shows the ramp card when user balance is zero', () => { + it('shows the ramp card when user balance is zero', async () => { const { queryByText } = render({ selectedAddress: ACCOUNT_1, balance: '0x0', }); + // wait for spinner to be removed + await delay(3000); expect(queryByText('Get ETH to buy NFTs')).toBeInTheDocument(); }); diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-nft-tab.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-nft-tab.tsx new file mode 100644 index 000000000000..01f7cbe6e527 --- /dev/null +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal-nft-tab.tsx @@ -0,0 +1,154 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import NftsItems from '../../../app/nfts-items/nfts-items'; +import { + Box, + Text, + ButtonLink, + ButtonLinkSize, +} from '../../../component-library'; +import { + TextColor, + TextVariant, + TextAlign, + Display, + JustifyContent, + AlignItems, + FlexDirection, +} from '../../../../helpers/constants/design-system'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; +import { TokenStandard } from '../../../../../shared/constants/transaction'; +import ZENDESK_URLS from '../../../../helpers/constants/zendesk-url'; +import Spinner from '../../../ui/spinner'; +import { + getIsMainnet, + getNftIsStillFetchingIndication, + getUseNftDetection, +} from '../../../../selectors'; +import NFTsDetectionNoticeNFTsTab from '../../../app/nfts-detection-notice-nfts-tab/nfts-detection-notice-nfts-tab'; + +type NFT = { + address: string; + description: string | null; + favorite: boolean; + image: string | null; + isCurrentlyOwned: boolean; + name: string | null; + standard: TokenStandard; + tokenId: string; + tokenURI?: string; +}; + +type Collection = { + collectionName: string; + collectionImage: string | null; + nfts: NFT[]; +}; + +type PreviouslyOwnedCollections = { + collectionName: string; + nfts: NFT[]; +}; + +type AssetPickerModalNftTabProps = { + collectionDataFiltered: Collection[]; + previouslyOwnedCollection: PreviouslyOwnedCollections; + onClose: () => void; + renderSearch: () => void; +}; + +export function AssetPickerModalNftTab({ + collectionDataFiltered, + previouslyOwnedCollection, + onClose, + renderSearch, +}: AssetPickerModalNftTabProps) { + const t = useI18nContext(); + + const hasAnyNfts = Object.keys(collectionDataFiltered).length > 0; + const useNftDetection = useSelector(getUseNftDetection); + const isMainnet = useSelector(getIsMainnet); + const nftsStillFetchingIndication = useSelector( + getNftIsStillFetchingIndication, + ); + + if (!hasAnyNfts && nftsStillFetchingIndication) { + return ( + + + + ); + } + + if (hasAnyNfts) { + return ( + + {renderSearch()} + onClose()} + showTokenId={true} + displayPreviouslyOwnedCollection={false} + /> + {nftsStillFetchingIndication ? ( + + + + ) : null} + + ); + } + return ( + <> + {isMainnet && !useNftDetection && ( + + + + )} + + + + + + + {t('noNFTs')} + + + {t('learnMoreUpperCase')} + + + + + ); +} diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.test.tsx b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.test.tsx index 36eb5c772aaa..96177e397fd6 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.test.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/asset-picker-modal.test.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { screen, fireEvent } from '@testing-library/react'; import configureStore from 'redux-mock-store'; import { useSelector } from 'react-redux'; +import thunk from 'redux-thunk'; +import sinon from 'sinon'; import { useI18nContext } from '../../../../hooks/useI18nContext'; import { useNftsCollections } from '../../../../hooks/useNftsCollections'; import { useTokenTracker } from '../../../../hooks/useTokenTracker'; @@ -26,6 +28,7 @@ import { } from '../../../../ducks/metamask/metamask'; import { getTopAssets } from '../../../../ducks/swaps/swaps'; import { getRenderableTokenData } from '../../../../hooks/useTokensToSearch'; +import * as actions from '../../../../store/actions'; import { AssetPickerModal } from './asset-picker-modal'; import { Asset } from './types'; import AssetList from './AssetList'; @@ -58,7 +61,7 @@ describe('AssetPickerModal', () => { const useI18nContextMock = useI18nContext as jest.Mock; const useNftsCollectionsMock = useNftsCollections as jest.Mock; const useTokenTrackerMock = useTokenTracker as jest.Mock; - const mockStore = configureStore(); + const mockStore = configureStore([thunk]); const store = mockStore(mockState); const onAssetChangeMock = jest.fn(); @@ -153,6 +156,7 @@ describe('AssetPickerModal', () => { }); it('renders no NFTs message when there are no NFTs', () => { + sinon.stub(actions, 'detectNfts').returns(() => Promise.resolve()); renderWithProvider( 0; - const collectionsKeys = Object.keys(collections); const collectionsData = collectionsKeys.reduce((acc: unknown[], key) => { @@ -166,9 +155,6 @@ export function AssetPickerModal({ getShouldHideZeroBalanceTokens, ); - const useNftDetection = useSelector(getUseNftDetection); - const isMainnet = useSelector(getIsMainnet); - const detectedTokens = useSelector(getAllTokens); const tokens = detectedTokens?.[chainId]?.[selectedAddress] ?? []; @@ -393,65 +379,12 @@ export function AssetPickerModal({ name={t('nfts')} tabKey="nfts" > - {hasAnyNfts ? ( - - - onClose()} - showTokenId={true} - displayPreviouslyOwnedCollection={false} - /> - - ) : ( - <> - {isMainnet && !useNftDetection && ( - - - - )} - - - - - - - {t('noNFTs')} - - - {t('learnMoreUpperCase')} - - - - - )} + Search({ isNFTSearch: true })} + /> } diff --git a/ui/components/multichain/asset-picker-amount/asset-picker-modal/index.scss b/ui/components/multichain/asset-picker-amount/asset-picker-modal/index.scss index 078168b5c5b4..bec402481064 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker-modal/index.scss +++ b/ui/components/multichain/asset-picker-amount/asset-picker-modal/index.scss @@ -93,5 +93,21 @@ max-height: 100%; overflow-y: scroll; } + + &__loading { + display: flex; + height: 200px; + align-items: center; + justify-content: center; + padding: 15px; + } + + &__fetching { + display: flex; + height: 100px; + align-items: center; + justify-content: center; + padding: 15px; + } } } diff --git a/ui/components/multichain/toast/__snapshots__/toast.test.tsx.snap b/ui/components/multichain/toast/__snapshots__/toast.test.tsx.snap index 5fc5d94b974d..1e009735c1e1 100644 --- a/ui/components/multichain/toast/__snapshots__/toast.test.tsx.snap +++ b/ui/components/multichain/toast/__snapshots__/toast.test.tsx.snap @@ -3,7 +3,7 @@ exports[`Toast should render Toast component 1`] = `
void; onClose: () => void; + borderRadius?: BorderRadius; + textVariant?: TextVariant; + autoHideTime?: number; + onAutoHideToast?: () => void; }) => { const { theme } = document.documentElement.dataset; + const [shouldDisplay, setShouldDisplay] = useState(true); + useEffect( + function () { + if (!autoHideTime || autoHideTime === 0) { + return undefined; + } + + const timeout = setTimeout(() => { + setShouldDisplay(false); + onAutoHideToast?.(); + }, autoHideTime); + + return function () { + clearTimeout(timeout); + }; + }, + [autoHideTime], + ); + + if (!shouldDisplay) { + return null; + } return ( {startAdornment} - {text} + + {text} + {actionText && onActionClick ? ( {actionText} ) : null} diff --git a/ui/components/ui/tabs/tabs.component.js b/ui/components/ui/tabs/tabs.component.js index e96576711ceb..9795f697fce3 100644 --- a/ui/components/ui/tabs/tabs.component.js +++ b/ui/components/ui/tabs/tabs.component.js @@ -1,12 +1,14 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; +import { useDispatch } from 'react-redux'; import Box from '../box'; import { BackgroundColor, DISPLAY, JustifyContent, } from '../../../helpers/constants/design-system'; +import { detectNfts } from '../../../store/actions'; const Tabs = ({ defaultActiveTabKey, @@ -20,6 +22,7 @@ const Tabs = ({ const _getValidChildren = () => { return React.Children.toArray(children).filter(Boolean); }; + const dispatch = useDispatch(); /** * Returns the index of the child with the given key @@ -41,6 +44,10 @@ const Tabs = ({ setActiveTabIndex(tabIndex); onTabClick?.(tabKey); } + + if (tabKey === 'nfts') { + dispatch(detectNfts()); + } }; const renderTabs = () => { diff --git a/ui/ducks/app/app.ts b/ui/ducks/app/app.ts index ba43decd429b..5c63ac25dcb7 100644 --- a/ui/ducks/app/app.ts +++ b/ui/ducks/app/app.ts @@ -48,6 +48,8 @@ type AppState = { privateKey?: string; }; isLoading: boolean; + isNftStillFetchingIndication: boolean; + showNftDetectionEnablementToast: boolean; loadingMessage: string | null; scrollToBottom: boolean; warning: string | null | undefined; @@ -132,6 +134,10 @@ const initialState: AppState = { }, // Used to display loading indicator isLoading: false, + // Used to show a spinner at the bottom of the page when we are still fetching nfts + isNftStillFetchingIndication: false, + // Used to display a toast after the user enables the nft auto detection from the notice banner + showNftDetectionEnablementToast: false, loadingMessage: null, // Used to display error text warning: null, @@ -432,6 +438,23 @@ export default function reduceApp( isLoading: false, }; + case actionConstants.SHOW_NFT_STILL_FETCHING_INDICATION: + return { + ...appState, + isNftStillFetchingIndication: true, + }; + case actionConstants.SHOW_NFT_DETECTION_ENABLEMENT_TOAST: + return { + ...appState, + showNftDetectionEnablementToast: action.payload, + }; + + case actionConstants.HIDE_NFT_STILL_FETCHING_INDICATION: + return { + ...appState, + isNftStillFetchingIndication: false, + }; + case actionConstants.DISPLAY_WARNING: return { ...appState, diff --git a/ui/pages/home/home.component.js b/ui/pages/home/home.component.js index e8767bd816da..aba0f4f57d59 100644 --- a/ui/pages/home/home.component.js +++ b/ui/pages/home/home.component.js @@ -15,6 +15,7 @@ import WhatsNewPopup from '../../components/app/whats-new-popup'; import { FirstTimeFlowType } from '../../../shared/constants/onboarding'; import SmartTransactionsOptInModal from '../../components/app/smart-transactions/smart-transactions-opt-in-modal'; import AutoDetectTokenModal from '../../components/app/auto-detect-token/auto-detect-token-modal'; +import AutoDetectNftModal from '../../components/app/auto-detect-nft/auto-detect-nft-modal'; ///: END:ONLY_INCLUDE_IF import HomeNotification from '../../components/app/home-notification'; import MultipleNotifications from '../../components/app/multiple-notifications'; @@ -150,6 +151,7 @@ export default class Home extends PureComponent { onboardedInThisUISession: PropTypes.bool, isSmartTransactionsOptInModalAvailable: PropTypes.bool.isRequired, isShowTokenAutodetectModal: PropTypes.bool.isRequired, + isShowNftAutodetectModal: PropTypes.bool.isRequired, ///: END:ONLY_INCLUDE_IF newNetworkAddedConfigurationId: PropTypes.string, isNotification: PropTypes.bool.isRequired, @@ -197,6 +199,8 @@ export default class Home extends PureComponent { setTokenAutodetectModal: PropTypes.func, // eslint-disable-next-line react/no-unused-prop-types setShowTokenAutodetectModalOnUpgrade: PropTypes.func, + // eslint-disable-next-line react/no-unused-prop-types + setNftAutodetectModal: PropTypes.func, hasAllowedPopupRedirectApprovals: PropTypes.bool.isRequired, useExternalServices: PropTypes.bool, setBasicFunctionalityModalOpen: PropTypes.func, @@ -936,6 +940,8 @@ export default class Home extends PureComponent { isShowTokenAutodetectModal, setTokenAutodetectModal, setShowTokenAutodetectModalOnUpgrade, + isShowNftAutodetectModal, + setNftAutodetectModal, ///: END:ONLY_INCLUDE_IF } = this.props; @@ -967,6 +973,12 @@ export default class Home extends PureComponent { isShowTokenAutodetectModal && !showSmartTransactionsOptInModal && !showWhatsNew; + // TODO show ths after token autodetect modal is merged + const showNftAutoDetectionModal = + canSeeModals && + isShowNftAutodetectModal && + !showSmartTransactionsOptInModal && + !showWhatsNew; const showTermsOfUse = completedOnboarding && !onboardedInThisUISession && showTermsOfUsePopup; @@ -1001,6 +1013,10 @@ export default class Home extends PureComponent { } /> + {showWhatsNew ? : null} {!showWhatsNew && showRecoveryPhraseReminder ? ( { isSmartTransactionsOptInModalAvailable: getIsSmartTransactionsOptInModalAvailable(state), isShowTokenAutodetectModal: getIsShowTokenAutodetectModal(state), + isShowNftAutodetectModal: getIsShowNftAutodetectModal(state), }; }; @@ -272,6 +275,9 @@ const mapDispatchToProps = (dispatch) => { setShowTokenAutodetectModalOnUpgrade: (val) => { dispatch(setShowTokenAutodetectModalOnUpgrade(val)); }, + setNftAutodetectModal: (val) => { + dispatch(setShowNftAutodetectModal(val)); + }, ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) setWaitForConfirmDeepLinkDialog: (wait) => dispatch(mmiActions.setWaitForConfirmDeepLinkDialog(wait)), diff --git a/ui/pages/routes/routes.component.js b/ui/pages/routes/routes.component.js index 640cf27751b5..c13f81d7e8ec 100644 --- a/ui/pages/routes/routes.component.js +++ b/ui/pages/routes/routes.component.js @@ -126,8 +126,13 @@ import KeyringSnapRemovalResult from '../../components/app/modals/keyring-snap-r import { SendPage } from '../../components/multichain/pages/send'; import { DeprecatedNetworkModal } from '../settings/deprecated-network-modal/DeprecatedNetworkModal'; import { getURLHost } from '../../helpers/utils/util'; -import { BorderColor, IconColor } from '../../helpers/constants/design-system'; -import { MILLISECOND } from '../../../shared/constants/time'; +import { + BorderColor, + BorderRadius, + IconColor, + TextVariant, +} from '../../helpers/constants/design-system'; +import { MILLISECOND, SECOND } from '../../../shared/constants/time'; import { MultichainMetaFoxLogo } from '../../components/multichain/app-header/multichain-meta-fox-logo'; import NetworkConfirmationPopover from '../../components/multichain/network-list-menu/network-confirmation-popover/network-confirmation-popover'; @@ -191,6 +196,9 @@ export default class Routes extends Component { hideDeprecatedNetworkModal: PropTypes.func.isRequired, addPermittedAccount: PropTypes.func.isRequired, switchedNetworkDetails: PropTypes.object, + useNftDetection: PropTypes.bool, + showNftEnablementToast: PropTypes.bool, + setHideNftEnablementToast: PropTypes.func.isRequired, clearSwitchedNetworkDetails: PropTypes.func.isRequired, setSwitchedNetworkNeverShowMessage: PropTypes.func.isRequired, networkToAutomaticallySwitchTo: PropTypes.object, @@ -611,12 +619,20 @@ export default class Routes extends Component { setNewPrivacyPolicyToastClickedOrClosed, setSwitchedNetworkNeverShowMessage, switchedNetworkDetails, + useNftDetection, + showNftEnablementToast, + setHideNftEnablementToast, } = this.props; const showAutoNetworkSwitchToast = this.getShowAutoNetworkSwitchTest(); const isPrivacyToastRecent = this.getIsPrivacyToastRecent(); const isPrivacyToastNotShown = !newPrivacyPolicyToastShownDate; + const autoHideToastDelay = 5 * SECOND; + + const onAutoHideToast = () => { + setHideNftEnablementToast(false); + }; if (!this.onHomeScreen()) { return null; } @@ -715,6 +731,19 @@ export default class Routes extends Component { onClose={() => clearSwitchedNetworkDetails()} /> ) : null} + {showNftEnablementToast && useNftDetection ? ( + + } + text={this.context.t('nftAutoDetectionEnabled')} + borderRadius={BorderRadius.LG} + textVariant={TextVariant.bodyMd} + autoHideTime={autoHideToastDelay} + onAutoHideToast={onAutoHideToast} + /> + ) : null} ); } diff --git a/ui/pages/routes/routes.container.js b/ui/pages/routes/routes.container.js index efa7afad4908..d4334e00b4de 100644 --- a/ui/pages/routes/routes.container.js +++ b/ui/pages/routes/routes.container.js @@ -26,6 +26,8 @@ import { getNewPrivacyPolicyToastShownDate, getShowPrivacyPolicyToast, getUseRequestQueue, + getUseNftDetection, + getNftDetectionEnablementToast, } from '../../selectors'; import { getLocalNetworkMenuRedesignFeatureFlag } from '../../helpers/utils/feature-flags'; import { getSmartTransactionsOptInStatus } from '../../../shared/modules/selectors'; @@ -46,6 +48,7 @@ import { automaticallySwitchNetwork, clearSwitchedNetworkDetails, neverShowSwitchedNetworkMessage, + setShowNftDetectionEnablementToast, ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) hideKeyringRemovalResultModal, ///: END:ONLY_INCLUDE_IF @@ -86,6 +89,9 @@ function mapStateToProps(state) { getNetworkToAutomaticallySwitchTo(state); const switchedNetworkDetails = getSwitchedNetworkDetails(state); + const useNftDetection = getUseNftDetection(state); + const showNftEnablementToast = getNftDetectionEnablementToast(state); + return { alertOpen, alertMessage, @@ -124,6 +130,8 @@ function mapStateToProps(state) { isImportNftsModalOpen: state.appState.importNftsModal.open, isIpfsModalOpen: state.appState.showIpfsModalOpen, switchedNetworkDetails, + useNftDetection, + showNftEnablementToast, networkToAutomaticallySwitchTo, unapprovedTransactions: getNumberOfAllUnapprovedTransactionsAndMessages(state), @@ -172,6 +180,8 @@ function mapDispatchToProps(dispatch) { hideShowKeyringSnapRemovalResultModal: () => dispatch(hideKeyringRemovalResultModal()), ///: END:ONLY_INCLUDE_IF + setHideNftEnablementToast: (value) => + dispatch(setShowNftDetectionEnablementToast(value)), }; } diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index e0fd002d17a1..d1d6d55c3a36 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -750,6 +750,14 @@ export function getAppIsLoading(state) { return state.appState.isLoading; } +export function getNftIsStillFetchingIndication(state) { + return state.appState.isNftStillFetchingIndication; +} + +export function getNftDetectionEnablementToast(state) { + return state.appState.showNftDetectionEnablementToast; +} + export function getCurrentCurrency(state) { return state.metamask.currentCurrency; } diff --git a/ui/store/actionConstants.ts b/ui/store/actionConstants.ts index 57ab34e800a0..658e5b30c296 100644 --- a/ui/store/actionConstants.ts +++ b/ui/store/actionConstants.ts @@ -67,6 +67,15 @@ export const SET_HARDWARE_WALLET_DEFAULT_HD_PATH = export const SHOW_LOADING = 'SHOW_LOADING_INDICATION'; export const HIDE_LOADING = 'HIDE_LOADING_INDICATION'; +// Nft still fetching indication spinners +export const SHOW_NFT_STILL_FETCHING_INDICATION = + 'SHOW_NFT_STILL_FETCHING_INDICATION'; +export const HIDE_NFT_STILL_FETCHING_INDICATION = + 'HIDE_NFT_STILL_FETCHING_INDICATION'; + +export const SHOW_NFT_DETECTION_ENABLEMENT_TOAST = + 'SHOW_NFT_DETECTION_ENABLEMENT_TOAST'; + export const TOGGLE_ACCOUNT_MENU = 'TOGGLE_ACCOUNT_MENU'; export const TOGGLE_NETWORK_MENU = 'TOGGLE_NETWORK_MENU'; @@ -148,3 +157,6 @@ export const SHOW_KEYRING_SNAP_REMOVAL_RESULT = export const HIDE_KEYRING_SNAP_REMOVAL_RESULT = 'HIDE_KEYRING_SNAP_REMOVAL_RESULT'; ///: END:ONLY_INCLUDE_IF + +export const SET_SHOW_NFT_AUTO_DETECT_MODAL_UPGRADE = + 'SET_SHOW_NFT_AUTO_DETECT_MODAL_UPGRADE'; diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 2a8d1a983159..c57776fd45ea 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -2779,6 +2779,21 @@ export function showLoadingIndication( }; } +export function showNftStillFetchingIndication(): Action { + return { + type: actionConstants.SHOW_NFT_STILL_FETCHING_INDICATION, + }; +} + +export function setShowNftDetectionEnablementToast( + value: boolean, +): PayloadAction { + return { + type: actionConstants.SHOW_NFT_DETECTION_ENABLEMENT_TOAST, + payload: value, + }; +} + export function setHardwareWalletDefaultHdPath({ device, path, @@ -2798,6 +2813,12 @@ export function hideLoadingIndication(): Action { }; } +export function hideNftStillFetchingIndication(): Action { + return { + type: actionConstants.HIDE_NFT_STILL_FETCHING_INDICATION, + }; +} + /** * An action creator for display a warning to the user in various places in the * UI. It will not be cleared until a new warning replaces it or `hideWarning` @@ -3469,10 +3490,13 @@ export function detectNfts(): ThunkAction< AnyAction > { return async (dispatch: MetaMaskReduxDispatch) => { - dispatch(showLoadingIndication()); + dispatch(showNftStillFetchingIndication()); log.debug(`background.detectNfts`); - await submitRequestToBackground('detectNfts'); - dispatch(hideLoadingIndication()); + try { + await submitRequestToBackground('detectNfts'); + } finally { + dispatch(hideNftStillFetchingIndication()); + } await forceUpdateMetamaskState(dispatch); }; } @@ -5595,6 +5619,10 @@ export function setIsProfileSyncingEnabled( }; } +export function setShowNftAutodetectModal(value: boolean) { + return setPreference('showNftAutodetectModal', value); +} + export async function getNextAvailableAccountName(): Promise { return await submitRequestToBackground( 'getNextAvailableAccountName', diff --git a/yarn.lock b/yarn.lock index 53e38866f75d..175872f95ac4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4780,7 +4780,7 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@patch:@metamask/assets-controllers@patch%3A@metamask/assets-controllers@npm%253A30.0.0%23~/.yarn/patches/@metamask-assets-controllers-npm-30.0.0-8747c20871.patch%3A%3Aversion=30.0.0&hash=9269c8#~/.yarn/patches/@metamask-assets-controllers-patch-26d4328777.patch": +"@metamask/assets-controllers@patch:@metamask/assets-controllers@patch%3A@metamask/assets-controllers@npm%253A30.0.0%23~/.yarn/patches/@metamask-assets-controllers-npm-30.0.0-8747c20871.patch%3A%3Aversion=30.0.0&hash=9269c8#~/.yarn/patches/@metamask-assets-controllers-patch-26d4328777.patch::version=30.0.0&hash=1ba1a6": version: 30.0.0 resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@patch%3A@metamask/assets-controllers@npm%253A30.0.0%23~/.yarn/patches/@metamask-assets-controllers-npm-30.0.0-8747c20871.patch%3A%3Aversion=30.0.0&hash=9269c8#~/.yarn/patches/@metamask-assets-controllers-patch-26d4328777.patch::version=30.0.0&hash=1ba1a6" dependencies: @@ -4822,6 +4822,48 @@ __metadata: languageName: node linkType: hard +"@metamask/assets-controllers@patch:@metamask/assets-controllers@patch%3A@metamask/assets-controllers@patch%253A@metamask/assets-controllers@npm%25253A30.0.0%2523~/.yarn/patches/@metamask-assets-controllers-npm-30.0.0-8747c20871.patch%253A%253Aversion=30.0.0&hash=9269c8%23~/.yarn/patches/@metamask-assets-controllers-patch-26d4328777.patch%3A%3Aversion=30.0.0&hash=1ba1a6#~/.yarn/patches/@metamask-assets-controllers-patch-a3b39b55a6.patch": + version: 30.0.0 + resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@patch%3A@metamask/assets-controllers@patch%253A@metamask/assets-controllers@npm%25253A30.0.0%2523~/.yarn/patches/@metamask-assets-controllers-npm-30.0.0-8747c20871.patch%253A%253Aversion=30.0.0&hash=9269c8%23~/.yarn/patches/@metamask-assets-controllers-patch-26d4328777.patch%3A%3Aversion=30.0.0&hash=1ba1a6#~/.yarn/patches/@metamask-assets-controllers-patch-a3b39b55a6.patch::version=30.0.0&hash=eccb6b" + dependencies: + "@ethereumjs/util": "npm:^8.1.0" + "@ethersproject/address": "npm:^5.7.0" + "@ethersproject/bignumber": "npm:^5.7.0" + "@ethersproject/contracts": "npm:^5.7.0" + "@ethersproject/providers": "npm:^5.7.0" + "@metamask/abi-utils": "npm:^2.0.2" + "@metamask/accounts-controller": "npm:^14.0.0" + "@metamask/approval-controller": "npm:^6.0.2" + "@metamask/base-controller": "npm:^5.0.2" + "@metamask/contract-metadata": "npm:^2.4.0" + "@metamask/controller-utils": "npm:^10.0.0" + "@metamask/eth-query": "npm:^4.0.0" + "@metamask/keyring-controller": "npm:^16.0.0" + "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/network-controller": "npm:^18.1.2" + "@metamask/polling-controller": "npm:^6.0.2" + "@metamask/preferences-controller": "npm:^11.0.0" + "@metamask/rpc-errors": "npm:^6.2.1" + "@metamask/utils": "npm:^8.3.0" + "@types/bn.js": "npm:^5.1.5" + "@types/uuid": "npm:^8.3.0" + async-mutex: "npm:^0.2.6" + bn.js: "npm:^5.2.1" + cockatiel: "npm:^3.1.2" + lodash: "npm:^4.17.21" + multiformats: "npm:^9.5.2" + single-call-balance-checker-abi: "npm:^1.0.0" + uuid: "npm:^8.3.2" + peerDependencies: + "@metamask/accounts-controller": ^14.0.0 + "@metamask/approval-controller": ^6.0.0 + "@metamask/keyring-controller": ^16.0.0 + "@metamask/network-controller": ^18.1.2 + "@metamask/preferences-controller": ^11.0.0 + checksum: 10/f6d5fc9021db8bd95d9b2c19cea5a2b77bf97f2bf0fc01d61d7e372f45c15be7bed434d6519aeaf6ba209e079c2b292121f8aa61ddf5a48edcfe4b711a1e1f3d + languageName: node + linkType: hard + "@metamask/auto-changelog@npm:^2.1.0": version: 2.6.1 resolution: "@metamask/auto-changelog@npm:2.6.1" @@ -24955,7 +24997,7 @@ __metadata: "@metamask/address-book-controller": "npm:^4.0.1" "@metamask/announcement-controller": "npm:^6.1.0" "@metamask/approval-controller": "npm:^7.0.0" - "@metamask/assets-controllers": "patch:@metamask/assets-controllers@patch%3A@metamask/assets-controllers@npm%253A30.0.0%23~/.yarn/patches/@metamask-assets-controllers-npm-30.0.0-8747c20871.patch%3A%3Aversion=30.0.0&hash=9269c8#~/.yarn/patches/@metamask-assets-controllers-patch-26d4328777.patch" + "@metamask/assets-controllers": "patch:@metamask/assets-controllers@patch%3A@metamask/assets-controllers@patch%253A@metamask/assets-controllers@npm%25253A30.0.0%2523~/.yarn/patches/@metamask-assets-controllers-npm-30.0.0-8747c20871.patch%253A%253Aversion=30.0.0&hash=9269c8%23~/.yarn/patches/@metamask-assets-controllers-patch-26d4328777.patch%3A%3Aversion=30.0.0&hash=1ba1a6#~/.yarn/patches/@metamask-assets-controllers-patch-a3b39b55a6.patch" "@metamask/auto-changelog": "npm:^2.1.0" "@metamask/base-controller": "npm:^5.0.1" "@metamask/browser-passworder": "npm:^4.3.0"