From 267b1072918920f58d7e16425e58cdf95cb139da Mon Sep 17 00:00:00 2001 From: Nico MASSART Date: Mon, 14 Oct 2024 11:02:37 +0200 Subject: [PATCH 01/21] fix: Android: Splash screen always showing behind other screens (#11760) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ### Cause of the issue The splash screen has been reworked recently for performances and replaced by an Android window background. But this is visible quickly between screen changes as nav stack has been reorganised. ### Fix - remove the transparent background on MainFlow stack navigator to prevent the window background to be visible behind it ## **Related issues** Fixes #11697 ## **Manual testing steps** ```gherkin Feature: Navigate between screens without splash screen visible Scenario: go to token detail screen Given wallet is ready And some tokens are available in the list (load from a test address) When user touches the token title or icon Then detail screen opens And no splash screen fox is visible even very quicly. ``` >[!NOTE] > The issue was on Android but do not hesitate to also test iOS to make sure nothing is broken. ## **Screenshots/Recordings** ### **Before** See recording in #11697 ### **After** [android.webm](https://github.com/user-attachments/assets/98585c9a-942d-406a-ae87-6d440e5f9ec3) ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/components/Nav/Main/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/app/components/Nav/Main/index.js b/app/components/Nav/Main/index.js index 0d0a523f547..32a35e27754 100644 --- a/app/components/Nav/Main/index.js +++ b/app/components/Nav/Main/index.js @@ -492,7 +492,6 @@ const MainFlow = () => ( mode={'modal'} screenOptions={{ headerShown: false, - cardStyle: { backgroundColor: importedColors.transparent }, }} > From 6a2dd749a6ba733dc0fdfeaa4e4df9d5e9db377b Mon Sep 17 00:00:00 2001 From: OGPoyraz Date: Mon, 14 Oct 2024 11:35:10 +0200 Subject: [PATCH 02/21] fix: Add `preferContractSymbol` to Name components (#11771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR adds `preferContractSymbol` property to name components which used in simulations. ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/3494 ## **Manual testing steps** 1. Uniswap in in-app browser 2. Try swap from / to `DAI` 3. In simulations instead of `Dai stablecoin` see `DAI` ## **Screenshots/Recordings** ### **Before** Screenshot 2024-10-14 at 11 16 25 ### **After** Screenshot 2024-10-14 at 11 10 29 ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/components/UI/Name/Name.tsx | 14 ++++++++++++-- app/components/UI/Name/Name.types.ts | 3 +++ .../UI/SimulationDetails/AssetPill/AssetPill.tsx | 3 ++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/app/components/UI/Name/Name.tsx b/app/components/UI/Name/Name.tsx index 1f338a8b0a7..5e44d36fe8f 100644 --- a/app/components/UI/Name/Name.tsx +++ b/app/components/UI/Name/Name.tsx @@ -47,11 +47,21 @@ const UnknownEthereumAddress: React.FC<{ address: string }> = ({ address }) => { ); }; -const Name: React.FC = ({ type, value }) => { +const Name: React.FC = ({ + chainId, + preferContractSymbol, + type, + value, +}) => { if (type !== NameType.EthereumAddress) { throw new Error('Unsupported NameType: ' + type); } - const displayName = useDisplayName(type, value); + const displayName = useDisplayName( + type, + value, + chainId, + preferContractSymbol, + ); const { styles } = useStyles(styleSheet, { displayNameVariant: displayName.variant, }); diff --git a/app/components/UI/Name/Name.types.ts b/app/components/UI/Name/Name.types.ts index 007a5077b06..45f7f241b6f 100644 --- a/app/components/UI/Name/Name.types.ts +++ b/app/components/UI/Name/Name.types.ts @@ -1,4 +1,5 @@ import { ViewProps } from 'react-native'; +import { Hex } from '@metamask/utils'; /** * The name types supported by the NameController. @@ -11,6 +12,8 @@ export enum NameType { } export interface NameProperties extends ViewProps { + chainId?: Hex; + preferContractSymbol?: boolean; type: NameType; value: string; } diff --git a/app/components/UI/SimulationDetails/AssetPill/AssetPill.tsx b/app/components/UI/SimulationDetails/AssetPill/AssetPill.tsx index 96edaf2b2a3..534c0d58c50 100644 --- a/app/components/UI/SimulationDetails/AssetPill/AssetPill.tsx +++ b/app/components/UI/SimulationDetails/AssetPill/AssetPill.tsx @@ -63,9 +63,10 @@ const AssetPill: React.FC = ({ asset }) => { ) : ( )} From 79f0c636397ea892d1b38c08f6ebe1feda306770 Mon Sep 17 00:00:00 2001 From: legobeat <109787230+legobeat@users.noreply.github.com> Date: Mon, 14 Oct 2024 09:38:43 +0000 Subject: [PATCH 03/21] chore(devDeps): remove unused react-native-cli (#11751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This replaces legacy `react-native-cli` with the cli already provided by `react-native`. ## **Related issues** ## **Manual testing steps** - Run `yarn watch:clean` - Run `yarn start:android` and/or `yarn start:ios` ## **Screenshots/Recordings** ### **Before** n/a ### **After** n/a ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- package.json | 1 - yarn.lock | 145 +++++++++------------------------------------------ 2 files changed, 26 insertions(+), 120 deletions(-) diff --git a/package.json b/package.json index 8ad00c34adb..5fc6a3457f5 100644 --- a/package.json +++ b/package.json @@ -476,7 +476,6 @@ "prettier": "^2.2.1", "prettier-plugin-gherkin": "^1.1.1", "react-dom": "18.2.0", - "react-native-cli": "2.0.1", "react-native-flipper": "^0.263.0", "react-native-launch-arguments": "^4.0.1", "react-native-performance": "^5.1.2", diff --git a/yarn.lock b/yarn.lock index ddce029d470..4d029ce93c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12995,11 +12995,6 @@ async-mutex@^0.5.0: dependencies: tslib "^2.4.0" -async@0.2.x, async@~0.2.9: - version "0.2.10" - resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1" - integrity sha1-trvgsGdLnXGXCMo43owjfLUmw9E= - async@^1.4.2: version "1.5.2" resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" @@ -14051,7 +14046,7 @@ cbor-sync@^1.0.4: resolved "https://registry.yarnpkg.com/cbor-sync/-/cbor-sync-1.0.4.tgz#5a11a1ab75c2a14d1af1b237fd84aa8c1593662f" integrity sha512-GWlXN4wiz0vdWWXBU71Dvc1q3aBo0HytqwAZnXF1wOwjqNnDWA1vZ1gDMFLlqohak31VQzmhiYfiCX5QSSfagA== -chalk@^1.1.1, chalk@^1.1.3: +chalk@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg= @@ -14563,11 +14558,6 @@ colorette@^2.0.10, colorette@^2.0.14, colorette@^2.0.20: resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== -colors@0.6.x: - version "0.6.2" - resolved "https://registry.yarnpkg.com/colors/-/colors-0.6.2.tgz#2423fe6678ac0c5dae8852e5d0e5be08c997abcc" - integrity sha1-JCP+ZnisDF2uiFLl0OW+CMmXq8w= - colors@^1.1.2, colors@^1.3.3: version "1.4.0" resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" @@ -15270,7 +15260,7 @@ csv-writer@^1.6.0: resolved "https://registry.yarnpkg.com/csv-writer/-/csv-writer-1.6.0.tgz#d0cea44b6b4d7d3baa2ecc6f3f7209233514bcf9" integrity sha512-NOx7YDFWEsM/fTRAJjRpPp8t+MKRVvniAg9wQlUKx20MFrPs73WLJhFf5iteqrxNYnsy924K3Iroh3yNHeYd2g== -cycle@1.0.x, cycle@^1.0.3: +cycle@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/cycle/-/cycle-1.0.3.tgz#21e80b2be8580f98b468f379430662b046c34ad2" integrity sha1-IegLK+hYD5i0aPN5QwZisEbDStI= @@ -15518,7 +15508,19 @@ dedent@^1.0.0: resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.5.3.tgz#99aee19eb9bae55a67327717b6e848d0bf777e5a" integrity sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ== -deep-equal@*, deep-equal@^2.0.5: +deep-equal@^1.0.0, deep-equal@^1.0.1, deep-equal@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a" + integrity sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g== + dependencies: + is-arguments "^1.0.4" + is-date-object "^1.0.1" + is-regex "^1.0.4" + object-is "^1.0.1" + object-keys "^1.1.1" + regexp.prototype.flags "^1.2.0" + +deep-equal@^2.0.5: version "2.2.2" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.2.2.tgz#9b2635da569a13ba8e1cc159c2f744071b115daa" integrity sha512-xjVyBf0w5vH0I42jdAZzOKVldmPgSulmiyPRywoyq7HXC9qdgo17kxJE+rdnif5Tz6+pIrpJI8dCpMNLIGkUiA== @@ -15542,18 +15544,6 @@ deep-equal@*, deep-equal@^2.0.5: which-collection "^1.0.1" which-typed-array "^1.1.9" -deep-equal@^1.0.0, deep-equal@^1.0.1, deep-equal@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a" - integrity sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g== - dependencies: - is-arguments "^1.0.4" - is-date-object "^1.0.1" - is-regex "^1.0.4" - object-is "^1.0.1" - object-keys "^1.1.1" - regexp.prototype.flags "^1.2.0" - deep-equal@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" @@ -17925,11 +17915,6 @@ extsprintf@^1.2.0: resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= -eyes@0.1.x: - version "0.1.8" - resolved "https://registry.yarnpkg.com/eyes/-/eyes-0.1.8.tgz#62cf120234c683785d902348a800ef3e0cc20bc0" - integrity sha1-Ys8SAjTGg3hdkCNIqADvPgzCC8A= - fake-merkle-patricia-tree@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/fake-merkle-patricia-tree/-/fake-merkle-patricia-tree-1.0.1.tgz#4b8c3acfb520afadf9860b1f14cd8ce3402cddd3" @@ -19685,11 +19670,6 @@ i18n-js@3.0.11: resolved "https://registry.yarnpkg.com/i18n-js/-/i18n-js-3.0.11.tgz#f9e96bdb641c5b9d6be12759d7c422089987ef02" integrity sha512-v7dG3kYJTQTyox3NqDabPDE/ZotWntyMI9kh4cYi+XlCSnsIR+KBTS2opPyObL8WndnklcLzbNU92FP/mLge3Q== -i@0.3.x: - version "0.3.7" - resolved "https://registry.yarnpkg.com/i/-/i-0.3.7.tgz#2a7437a923d59c14b17243dc63a549af24d85799" - integrity sha512-FYz4wlXgkQwIPqhzC5TdNMLSE5+GS1IIDJZY/1ZiEPCT2S3COUVZeT5OW4BmW4r5LHLQuOosSwsvnroG9GR59Q== - iconv-lite@0.4.24, iconv-lite@^0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -20459,7 +20439,7 @@ isomorphic-ws@^4.0.1: resolved "https://registry.yarnpkg.com/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz#55fd4cd6c5e6491e76dc125938dd863f5cd4f2dc" integrity sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w== -isstream@0.1.x, isstream@~0.1.2: +isstream@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= @@ -22993,7 +22973,7 @@ mkdirp@0.5.1: dependencies: minimist "0.0.8" -mkdirp@0.x.x, mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.6, mkdirp@~0.5.1: +mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.6, mkdirp@~0.5.1: version "0.5.6" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== @@ -23226,7 +23206,7 @@ multiple-cucumber-html-reporter@^3.0.1: open "^8.4.2" uuid "^9.0.0" -mute-stream@0.0.8, mute-stream@~0.0.4: +mute-stream@0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== @@ -23289,11 +23269,6 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= -ncp@0.4.x: - version "0.4.2" - resolved "https://registry.yarnpkg.com/ncp/-/ncp-0.4.2.tgz#abcc6cbd3ec2ed2a729ff6e7c1fa8f01784a8574" - integrity sha1-q8xsvT7C7Spyn/bnwfqPAXhKhXQ= - ncp@2.0.0, ncp@^2.0.0, ncp@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3" @@ -24599,16 +24574,6 @@ pkg-types@^1.0.3: mlly "^1.2.0" pathe "^1.1.0" -pkginfo@0.3.x: - version "0.3.1" - resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.3.1.tgz#5b29f6a81f70717142e09e765bbeab97b4f81e21" - integrity sha1-Wyn2qB9wcXFC4J52W76rl7T4HiE= - -pkginfo@0.x.x: - version "0.4.1" - resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.4.1.tgz#b5418ef0439de5425fc4995042dced14fb2a84ff" - integrity sha1-tUGO8EOd5UJfxJlQQtztFPsqhP8= - please-upgrade-node@^3.1.1, please-upgrade-node@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz#aeddd3f994c933e4ad98b99d9a556efa0e2fe942" @@ -24966,17 +24931,6 @@ promise@^8.3.0: dependencies: asap "~2.0.6" -prompt@^0.2.14: - version "0.2.14" - resolved "https://registry.yarnpkg.com/prompt/-/prompt-0.2.14.tgz#57754f64f543fd7b0845707c818ece618f05ffdc" - integrity sha1-V3VPZPVD/XsIRXB8gY7OYY8F/9w= - dependencies: - pkginfo "0.x.x" - read "1.0.x" - revalidator "0.1.x" - utile "0.2.x" - winston "0.8.x" - prompts@^2.0.1, prompts@^2.4.0: version "2.4.2" resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" @@ -25641,16 +25595,6 @@ react-native-camera@^3.36.0: dependencies: prop-types "^15.6.2" -react-native-cli@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/react-native-cli/-/react-native-cli-2.0.1.tgz#f2cd3c7aa1b83828cdfba630e2dfd817df766d54" - integrity sha1-8s08eqG4OCjN+6Yw4t/YF992bVQ= - dependencies: - chalk "^1.1.1" - minimist "^1.2.0" - prompt "^0.2.14" - semver "^5.0.3" - react-native-confetti-cannon@^1.5.0: version "1.5.2" resolved "https://registry.yarnpkg.com/react-native-confetti-cannon/-/react-native-confetti-cannon-1.5.2.tgz#ca1a05edd2a64b080ea8b6238c49277908e1f123" @@ -26428,13 +26372,6 @@ read-tls-client-hello@^1.0.0: dependencies: "@types/node" "*" -read@1.0.x: - version "1.0.7" - resolved "https://registry.yarnpkg.com/read/-/read-1.0.7.tgz#b3da19bd052431a97671d44a42634adf710b40c4" - integrity sha1-s9oZvQUkMal2cdRKQmNK33ELQMQ= - dependencies: - mute-stream "~0.0.4" - "readable-stream@2 || 3", readable-stream@3.6.2, readable-stream@^3.0.2, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.5.0, readable-stream@^3.6.0, readable-stream@^3.6.2: version "3.6.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" @@ -26971,11 +26908,6 @@ reusify@^1.0.4: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== -revalidator@0.1.x: - version "0.1.8" - resolved "https://registry.yarnpkg.com/revalidator/-/revalidator-0.1.8.tgz#fece61bfa0c1b52a206bd6b18198184bdd523a3b" - integrity sha1-/s5hv6DBtSoga9axgZgYS91SOjs= - rfc4648@^1.0.0: version "1.5.3" resolved "https://registry.yarnpkg.com/rfc4648/-/rfc4648-1.5.3.tgz#e62b81736c10361ca614efe618a566e93d0b41c0" @@ -27001,13 +26933,6 @@ rgb2hex@^0.1.0: resolved "https://registry.yarnpkg.com/rgb2hex/-/rgb2hex-0.1.10.tgz#4fdd432665273e2d5900434940ceba0a04c8a8a8" integrity sha512-vKz+kzolWbL3rke/xeTE2+6vHmZnNxGyDnaVW4OckntAIcc7DcZzWkQSfxMDwqHS8vhgySnIFyBUH7lIk6PxvQ== -rimraf@2.x.x, rimraf@^2.2.8, rimraf@^2.5.4, rimraf@^2.6.3: - version "2.7.1" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" - integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== - dependencies: - glob "^7.1.3" - rimraf@3.0.2, rimraf@^3.0.0, rimraf@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" @@ -27015,6 +26940,13 @@ rimraf@3.0.2, rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" +rimraf@^2.2.8, rimraf@^2.5.4, rimraf@^2.6.3: + version "2.7.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" + integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== + dependencies: + glob "^7.1.3" + rimraf@~2.4.0: version "2.4.5" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.4.5.tgz#ee710ce5d93a8fdb856fb5ea8ff0e2d75934b2da" @@ -27300,7 +27232,7 @@ semver-compare@^1.0.0: resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" integrity sha1-De4hahyUGrN+nvsXiPavxf9VN/w= -"semver@2 || 3 || 4 || 5", semver@^5.0.1, semver@^5.0.3, semver@^5.1.0, semver@^5.3.0, semver@^5.5.0, semver@^5.6.0, semver@^5.7.1, semver@^5.7.2, semver@~2.3.1: +"semver@2 || 3 || 4 || 5", semver@^5.0.1, semver@^5.1.0, semver@^5.3.0, semver@^5.5.0, semver@^5.6.0, semver@^5.7.1, semver@^5.7.2, semver@~2.3.1: version "5.7.2" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== @@ -29496,18 +29428,6 @@ utila@~0.4: resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c" integrity sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA== -utile@0.2.x: - version "0.2.1" - resolved "https://registry.yarnpkg.com/utile/-/utile-0.2.1.tgz#930c88e99098d6220834c356cbd9a770522d90d7" - integrity sha1-kwyI6ZCY1iIINMNWy9mncFItkNc= - dependencies: - async "~0.2.9" - deep-equal "*" - i "0.3.x" - mkdirp "0.x.x" - ncp "0.4.x" - rimraf "2.x.x" - utils-merge@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" @@ -30098,19 +30018,6 @@ winston-transport@^4.5.0: readable-stream "^3.6.0" triple-beam "^1.3.0" -winston@0.8.x: - version "0.8.3" - resolved "https://registry.yarnpkg.com/winston/-/winston-0.8.3.tgz#64b6abf4cd01adcaefd5009393b1d8e8bec19db0" - integrity sha1-ZLar9M0Brcrv1QCTk7HY6L7BnbA= - dependencies: - async "0.2.x" - colors "0.6.x" - cycle "1.0.x" - eyes "0.1.x" - isstream "0.1.x" - pkginfo "0.3.x" - stack-trace "0.0.x" - winston@3.x: version "3.10.0" resolved "https://registry.yarnpkg.com/winston/-/winston-3.10.0.tgz#d033cb7bd3ced026fed13bf9d92c55b903116803" From 094c578fd3fee437fcd41038ac7f58bba0dbb638 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Mon, 14 Oct 2024 17:45:03 +0800 Subject: [PATCH 04/21] feat: add utm field to app_open event (#11651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Add utm fields to the APP_OPEN event. ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/core/AppStateEventListener.test.ts | 58 ++++++-------- app/core/AppStateEventListener.ts | 17 ++-- .../ParseManager/extractURLParams.test.ts | 4 + .../ParseManager/extractURLParams.ts | 2 + app/core/processAttribution.test.tsx | 77 ++++++++++++++++--- app/core/processAttribution.tsx | 50 ++++++++++-- 6 files changed, 151 insertions(+), 57 deletions(-) diff --git a/app/core/AppStateEventListener.test.ts b/app/core/AppStateEventListener.test.ts index 3cf93c94aac..34f7c6ad07a 100644 --- a/app/core/AppStateEventListener.test.ts +++ b/app/core/AppStateEventListener.test.ts @@ -3,7 +3,7 @@ import { store } from '../store'; import Logger from '../util/Logger'; import { MetaMetrics, MetaMetricsEvents } from './Analytics'; import { AppStateEventListener } from './AppStateEventListener'; -import extractURLParams from './DeeplinkManager/ParseManager/extractURLParams'; +import { processAttribution } from './processAttribution'; jest.mock('react-native', () => ({ AppState: { @@ -27,12 +27,14 @@ jest.mock('../store', () => ({ }, })); -jest.mock('./DeeplinkManager/ParseManager/extractURLParams', () => jest.fn()); - jest.mock('../util/Logger', () => ({ error: jest.fn(), })); +jest.mock('./processAttribution', () => ({ + processAttribution: jest.fn(), +})); + describe('AppStateEventListener', () => { let appStateManager: AppStateEventListener; let mockAppStateListener: (state: AppStateStatus) => void; @@ -66,11 +68,15 @@ describe('AppStateEventListener', () => { expect(Logger.error).toHaveBeenCalledWith(new Error('store is already initialized')); }); - it('tracks event when app becomes active and conditions are met', () => { - (store.getState as jest.Mock).mockReturnValue({ - security: { dataCollectionForMarketing: true }, - }); - (extractURLParams as jest.Mock).mockReturnValue({ params: { attributionId: 'test123' } }); + it('tracks event when app becomes active and attribution data is available', () => { + const mockAttribution = { + attributionId: 'test123', + utm: 'test_utm', + utm_source: 'source', + utm_medium: 'medium', + utm_campaign: 'campaign', + }; + (processAttribution as jest.Mock).mockReturnValue(mockAttribution); appStateManager.setCurrentDeeplink('metamask://connect?attributionId=test123'); mockAppStateListener('active'); @@ -78,51 +84,31 @@ describe('AppStateEventListener', () => { expect(mockTrackEvent).toHaveBeenCalledWith( MetaMetricsEvents.APP_OPENED, - { attributionId: 'test123' }, + { attributionId: 'test123', utm_source: 'source', utm_medium: 'medium', utm_campaign: 'campaign' }, true ); }); - it('does not track event when data collection is disabled', () => { - (store.getState as jest.Mock).mockReturnValue({ - security: { dataCollectionForMarketing: false }, - }); + it('does not track event when processAttribution returns undefined', () => { + (processAttribution as jest.Mock).mockReturnValue(undefined); mockAppStateListener('active'); jest.advanceTimersByTime(2000); - expect(mockTrackEvent).toHaveBeenCalledWith( - MetaMetricsEvents.APP_OPENED, - {}, - true - ); - }); - - it('does not track event when there is no deeplink', () => { - (store.getState as jest.Mock).mockReturnValue({ - security: { dataCollectionForMarketing: true }, - }); - - mockAppStateListener('active'); - jest.advanceTimersByTime(2000); - - expect(mockTrackEvent).toHaveBeenCalledWith( - MetaMetricsEvents.APP_OPENED, - { attributionId: undefined }, - true - ); + expect(mockTrackEvent).not.toHaveBeenCalled(); }); it('handles errors gracefully', () => { - (store.getState as jest.Mock).mockImplementation(() => { - throw new Error('Test error'); + const testError = new Error('Test error'); + (processAttribution as jest.Mock).mockImplementation(() => { + throw testError; }); mockAppStateListener('active'); jest.advanceTimersByTime(2000); expect(Logger.error).toHaveBeenCalledWith( - expect.any(Error), + testError, 'AppStateManager: Error processing app state change' ); expect(mockTrackEvent).not.toHaveBeenCalled(); diff --git a/app/core/AppStateEventListener.ts b/app/core/AppStateEventListener.ts index 46752626e2a..8a9046462f6 100644 --- a/app/core/AppStateEventListener.ts +++ b/app/core/AppStateEventListener.ts @@ -51,13 +51,16 @@ export class AppStateEventListener { } try { - const attributionId = processAttribution({ currentDeeplink: this.currentDeeplink, store: this.store }); - DevLogger.log(`AppStateManager:: processAppStateChange:: sending event 'APP_OPENED' attributionId=${attributionId}`); - MetaMetrics.getInstance().trackEvent( - MetaMetricsEvents.APP_OPENED, - { attributionId }, - true - ); + const attribution = processAttribution({ currentDeeplink: this.currentDeeplink, store: this.store }); + if(attribution) { + const { attributionId, utm, ...utmParams } = attribution; + DevLogger.log(`AppStateManager:: processAppStateChange:: sending event 'APP_OPENED' attributionId=${attribution.attributionId} utm=${attribution.utm}`, utmParams); + MetaMetrics.getInstance().trackEvent( + MetaMetricsEvents.APP_OPENED, + { attributionId, ...utmParams }, + true + ); + } } catch (error) { Logger.error(error as Error, 'AppStateManager: Error processing app state change'); } diff --git a/app/core/DeeplinkManager/ParseManager/extractURLParams.test.ts b/app/core/DeeplinkManager/ParseManager/extractURLParams.test.ts index 50b942a1366..423f78628a4 100644 --- a/app/core/DeeplinkManager/ParseManager/extractURLParams.test.ts +++ b/app/core/DeeplinkManager/ParseManager/extractURLParams.test.ts @@ -43,6 +43,7 @@ describe('extractURLParams', () => { comm: 'test', v: '2', attributionId: '', + utm: '', }; mockUrlParser.mockImplementation( @@ -83,6 +84,7 @@ describe('extractURLParams', () => { pubkey: '', v: '', attributionId: '', + utm: '', }); }); @@ -116,6 +118,7 @@ describe('extractURLParams', () => { pubkey: '', v: '', attributionId: '', + utm: '', }); expect(alertSpy).toHaveBeenCalledWith( @@ -137,6 +140,7 @@ describe('extractURLParams', () => { sdkVersion: '', pubkey: 'xyz', attributionId: '', + utm: '', }; mockUrlParser.mockImplementation( diff --git a/app/core/DeeplinkManager/ParseManager/extractURLParams.ts b/app/core/DeeplinkManager/ParseManager/extractURLParams.ts index 723ce7148b7..4d992b6c3e4 100644 --- a/app/core/DeeplinkManager/ParseManager/extractURLParams.ts +++ b/app/core/DeeplinkManager/ParseManager/extractURLParams.ts @@ -20,6 +20,7 @@ export interface DeeplinkUrlParams { originatorInfo?: string; request?: string; attributionId?: string; + utm?: string; account?: string; // This is the format => "address@chainId" } @@ -41,6 +42,7 @@ function extractURLParams(url: string) { channelId: '', comm: '', attributionId: '', + utm: '', }; DevLogger.log(`extractParams:: urlObj`, urlObj); diff --git a/app/core/processAttribution.test.tsx b/app/core/processAttribution.test.tsx index fa4a196a2e2..79c29c2b908 100644 --- a/app/core/processAttribution.test.tsx +++ b/app/core/processAttribution.test.tsx @@ -1,6 +1,7 @@ import { store } from '../store'; import extractURLParams from './DeeplinkManager/ParseManager/extractURLParams'; import { processAttribution } from './processAttribution'; +import Logger from '../util/Logger'; jest.mock('../store', () => ({ store: { @@ -9,22 +10,42 @@ jest.mock('../store', () => ({ })); jest.mock('./DeeplinkManager/ParseManager/extractURLParams', () => jest.fn()); +jest.mock('../util/Logger', () => ({ + error: jest.fn(), +})); describe('processAttribution', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('returns attributionId when marketing is enabled and deeplink is provided', () => { + it('returns attribution data when marketing is enabled and deeplink is provided', () => { (store.getState as jest.Mock).mockReturnValue({ security: { dataCollectionForMarketing: true }, }); (extractURLParams as jest.Mock).mockReturnValue({ - params: { attributionId: 'test123' }, + params: { + attributionId: 'test123', + utm: JSON.stringify({ + source: 'twitter', + medium: 'social', + campaign: 'cmp-57731027-afbf09/', + term: null, + content: null + }) + }, }); - const result = processAttribution({ currentDeeplink: 'metamask://connect?attributionId=test123', store }); - expect(result).toBe('test123'); + const result = processAttribution({ currentDeeplink: 'metamask://connect?attributionId=test123&utm=...', store }); + expect(result).toEqual({ + attributionId: 'test123', + utm: expect.any(String), + utm_source: 'twitter', + utm_medium: 'social', + utm_campaign: 'cmp-57731027-afbf09/', + utm_term: null, + utm_content: null + }); }); it('returns undefined when marketing is disabled', () => { @@ -32,7 +53,7 @@ describe('processAttribution', () => { security: { dataCollectionForMarketing: false }, }); - const result = processAttribution({ currentDeeplink: 'metamask://connect?attributionId=test123', store }); + const result = processAttribution({ currentDeeplink: 'metamask://connect?attributionId=test123&utm=...', store }); expect(result).toBeUndefined(); }); @@ -45,15 +66,53 @@ describe('processAttribution', () => { expect(result).toBeUndefined(); }); - it('returns undefined when attributionId is not present in params', () => { + it('returns partial data when some UTM params are missing', () => { (store.getState as jest.Mock).mockReturnValue({ security: { dataCollectionForMarketing: true }, }); (extractURLParams as jest.Mock).mockReturnValue({ - params: {}, + params: { + attributionId: 'test123', + utm: JSON.stringify({ + source: 'twitter', + medium: 'social' + }) + }, }); - const result = processAttribution({ currentDeeplink: 'metamask://connect', store }); - expect(result).toBeUndefined(); + const result = processAttribution({ currentDeeplink: 'metamask://connect?attributionId=test123&utm=...', store }); + expect(result).toEqual({ + attributionId: 'test123', + utm: expect.any(String), + utm_source: 'twitter', + utm_medium: 'social', + utm_campaign: undefined, + utm_term: undefined, + utm_content: undefined + }); + }); + + it('handles JSON parsing errors gracefully', () => { + (store.getState as jest.Mock).mockReturnValue({ + security: { dataCollectionForMarketing: true }, + }); + (extractURLParams as jest.Mock).mockReturnValue({ + params: { + attributionId: 'test123', + utm: 'invalid-json' + }, + }); + + const result = processAttribution({ currentDeeplink: 'metamask://connect?attributionId=test123&utm=invalid-json', store }); + expect(result).toEqual({ + attributionId: 'test123', + utm: 'invalid-json', + utm_source: undefined, + utm_medium: undefined, + utm_campaign: undefined, + utm_term: undefined, + utm_content: undefined + }); + expect(Logger.error).toHaveBeenCalledWith(expect.any(Error), expect.any(Error)); }); }); diff --git a/app/core/processAttribution.tsx b/app/core/processAttribution.tsx index f3a604e3cf5..d49518bdaaa 100644 --- a/app/core/processAttribution.tsx +++ b/app/core/processAttribution.tsx @@ -1,6 +1,8 @@ import extractURLParams from './DeeplinkManager/ParseManager/extractURLParams'; import { RootState } from '../reducers'; import { Store } from 'redux'; +import Logger from '../util/Logger'; +import DevLogger from './SDKConnect/utils/DevLogger'; interface ProcessAttributionParams { currentDeeplink: string | null; @@ -8,13 +10,51 @@ interface ProcessAttributionParams { store: Store; } -export function processAttribution({ currentDeeplink, store }: ProcessAttributionParams): string | undefined { - const state = store.getState(); - const isMarketingEnabled = state.security.dataCollectionForMarketing; +interface AttributionResult { + attributionId?: string; + utm?: string; + utm_source?: string; + utm_medium?: string; + utm_campaign?: string; + utm_term?: string; + utm_content?: string; +} + +export function processAttribution({ currentDeeplink, store }: ProcessAttributionParams): AttributionResult | undefined { + const { security } = store.getState(); + if (!security.dataCollectionForMarketing) { + return undefined; + } - if (isMarketingEnabled && currentDeeplink) { + if (currentDeeplink) { const { params } = extractURLParams(currentDeeplink); - return params.attributionId || undefined; // Force undefined to be returned as extractUrlParams default to empty string on error. + const attributionId = params.attributionId || undefined; + const utm = params.utm || undefined; + let utm_source, utm_medium, utm_campaign, utm_term, utm_content; + + if (utm) { + try { + const utmParams = JSON.parse(utm); + DevLogger.log('processAttribution:: UTM params', utmParams); + utm_source = utmParams.source; + utm_medium = utmParams.medium; + utm_campaign = utmParams.campaign; + utm_term = utmParams.term; + utm_content = utmParams.content; + } catch (error) { + Logger.error(new Error('Error parsing UTM params'), error); + } + } + + return { + attributionId, + utm, + utm_source, + utm_medium, + utm_campaign, + utm_term, + utm_content + }; } return undefined; From 7863f59e44124ecf6b58264386eda548c5071745 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Mon, 14 Oct 2024 15:25:57 +0200 Subject: [PATCH 05/21] chore: upgrade assets controllers v32.0.0 (#11687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** PR to upgrade assets-controllers to v32 ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/0bc5e970-f069-4838-89c5-ca4dfdc71826 https://github.com/user-attachments/assets/7b6d9987-a57b-48d8-9e6c-97cf882120e2 ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/core/Engine.ts | 5 - package.json | 2 +- ...amask+preferences-controller+13.0.3.patch} | 8 +- ...@metamask+assets-controllers+32.0.0.patch} | 511 +++++++----------- yarn.lock | 162 +----- 5 files changed, 227 insertions(+), 461 deletions(-) rename patches/{@metamask+assets-controllers++@metamask+preferences-controller+12.0.0.patch => @metamask+assets-controllers++@metamask+preferences-controller+13.0.3.patch} (54%) rename patches/{@metamask+assets-controllers+31.0.0.patch => @metamask+assets-controllers+32.0.0.patch} (77%) diff --git a/app/core/Engine.ts b/app/core/Engine.ts index 48cc6279e84..0c19aa58502 100644 --- a/app/core/Engine.ts +++ b/app/core/Engine.ts @@ -569,7 +569,6 @@ class Engine { chainId: networkController.getNetworkClientById( networkController?.state.selectedNetworkClientId, ).configuration.chainId, - // @ts-expect-error TODO: Resolve bump the assets controller version. getNetworkClientById: networkController.getNetworkClientById.bind(networkController), }); @@ -654,7 +653,6 @@ class Engine { networkController?.state.selectedNetworkClientId, ).configuration.chainId, selectedAddress: preferencesController.state.selectedAddress, - // @ts-expect-error TODO: Resolve provider type mismatch provider: networkController.getProviderAndBlockTracker().provider, state: initialState.TokensController, // @ts-expect-error TODO: Resolve mismatch between base-controller versions. @@ -953,7 +951,6 @@ class Engine { networkController?.state.selectedNetworkClientId, ).configuration.chainId, ), - // @ts-expect-error TODO: Resolve mismatch between base-controller versions. getNetworkClientById: networkController.getNetworkClientById.bind(networkController), }); @@ -1529,7 +1526,6 @@ class Engine { selectedAddress: preferencesController.state.selectedAddress, tokenPricesService: codefiTokenApiV2, interval: 30 * 60 * 1000, - // @ts-expect-error TODO: Resolve mismatch between base-controller versions. getNetworkClientById: networkController.getNetworkClientById.bind(networkController), }), @@ -1780,7 +1776,6 @@ class Engine { } provider.sendAsync = provider.sendAsync.bind(provider); AccountTrackerController.configure({ provider }); - // @ts-expect-error TODO: Resolve mismatch between base-controller versions. AssetsContractController.configure({ provider }); SwapsController.configure({ diff --git a/package.json b/package.json index 5fc6a3457f5..97c3759e780 100644 --- a/package.json +++ b/package.json @@ -145,7 +145,7 @@ "@metamask/accounts-controller": "^18.2.1", "@metamask/address-book-controller": "^6.0.1", "@metamask/approval-controller": "^7.0.1", - "@metamask/assets-controllers": "^31.0.0", + "@metamask/assets-controllers": "^32.0.0", "@metamask/base-controller": "^7.0.1", "@metamask/composable-controller": "^3.0.0", "@metamask/contract-metadata": "^2.1.0", diff --git a/patches/@metamask+assets-controllers++@metamask+preferences-controller+12.0.0.patch b/patches/@metamask+assets-controllers++@metamask+preferences-controller+13.0.3.patch similarity index 54% rename from patches/@metamask+assets-controllers++@metamask+preferences-controller+12.0.0.patch rename to patches/@metamask+assets-controllers++@metamask+preferences-controller+13.0.3.patch index 00f4da63438..c45e6f5109a 100644 --- a/patches/@metamask+assets-controllers++@metamask+preferences-controller+12.0.0.patch +++ b/patches/@metamask+assets-controllers++@metamask+preferences-controller+13.0.3.patch @@ -1,7 +1,7 @@ -diff --git a/node_modules/@metamask/assets-controllers/node_modules/@metamask/preferences-controller/dist/types/PreferencesController.d.ts b/node_modules/@metamask/assets-controllers/node_modules/@metamask/preferences-controller/dist/types/PreferencesController.d.ts -index ddf6eb4..e8dac6d 100644 ---- a/node_modules/@metamask/assets-controllers/node_modules/@metamask/preferences-controller/dist/types/PreferencesController.d.ts -+++ b/node_modules/@metamask/assets-controllers/node_modules/@metamask/preferences-controller/dist/types/PreferencesController.d.ts +diff --git a/node_modules/@metamask/assets-controllers/node_modules/@metamask/preferences-controller/dist/PreferencesController.d.cts b/node_modules/@metamask/assets-controllers/node_modules/@metamask/preferences-controller/dist/PreferencesController.d.cts +index 04a9d6f..391652d 100644 +--- a/node_modules/@metamask/assets-controllers/node_modules/@metamask/preferences-controller/dist/PreferencesController.d.cts ++++ b/node_modules/@metamask/assets-controllers/node_modules/@metamask/preferences-controller/dist/PreferencesController.d.cts @@ -65,7 +65,7 @@ export type PreferencesState = { /** * Controls whether the OpenSea API is used diff --git a/patches/@metamask+assets-controllers+31.0.0.patch b/patches/@metamask+assets-controllers+32.0.0.patch similarity index 77% rename from patches/@metamask+assets-controllers+31.0.0.patch rename to patches/@metamask+assets-controllers+32.0.0.patch index 7de2ffecb6a..875d616b53e 100644 --- a/patches/@metamask+assets-controllers+31.0.0.patch +++ b/patches/@metamask+assets-controllers+32.0.0.patch @@ -1,10 +1,10 @@ diff --git a/node_modules/@metamask/assets-controllers/dist/chunk-4AC3X2U5.js b/node_modules/@metamask/assets-controllers/dist/chunk-4AC3X2U5.js -index bb55790..b235cbf 100644 +index bb55790..c7e7f99 100644 --- a/node_modules/@metamask/assets-controllers/dist/chunk-4AC3X2U5.js +++ b/node_modules/@metamask/assets-controllers/dist/chunk-4AC3X2U5.js -@@ -292,6 +292,18 @@ var TokensController = class extends _basecontroller.BaseController { - releaseLock(); - } +@@ -187,6 +187,18 @@ var TokensController = class extends _basecontroller.BaseController { + } + ); } + /** + * THIS FUNCTIONS IS CURRENTLY PATCHED AND STILL NEEDS TO BE IMPLEMENTED ON THE CORE REPO @@ -19,9 +19,9 @@ index bb55790..b235cbf 100644 + }); + } /** - * Add a batch of tokens. + * Adds a token to the stored token list. * -@@ -605,9 +617,14 @@ _selectedAddress = new WeakMap(); +@@ -605,9 +617,13 @@ _selectedAddress = new WeakMap(); _provider = new WeakMap(); _abortController = new WeakMap(); _onNetworkDidChange = new WeakSet(); @@ -29,7 +29,6 @@ index bb55790..b235cbf 100644 +onNetworkDidChange_fn = function({ selectedNetworkClientId }) { const { allTokens, allIgnoredTokens, allDetectedTokens } = this.state; - const { chainId } = providerConfig; -+ // This wont be needed in v32 + const selectedNetworkClient = this.messagingSystem.call( + 'NetworkController:getNetworkClientById', + selectedNetworkClientId, @@ -39,22 +38,10 @@ index bb55790..b235cbf 100644 _chunkZ4BLTVTBjs.__privateSet.call(void 0, this, _abortController, new AbortController()); _chunkZ4BLTVTBjs.__privateSet.call(void 0, this, _chainId, chainId); diff --git a/node_modules/@metamask/assets-controllers/dist/chunk-CGLUTXI7.js b/node_modules/@metamask/assets-controllers/dist/chunk-CGLUTXI7.js -index 7cc44fa..a7663b4 100644 +index 7cc44fa..7a1de65 100644 --- a/node_modules/@metamask/assets-controllers/dist/chunk-CGLUTXI7.js +++ b/node_modules/@metamask/assets-controllers/dist/chunk-CGLUTXI7.js -@@ -17,7 +17,10 @@ var _basecontroller = require('@metamask/base-controller'); - - - -- -+/** -+ * Changes regarding displayNftMedia, TokenURI and error nft metadata property are not on the core repo and needed to be refactor to be removed from the patch -+ * updateNftMetadata changes will be introduced on latest versions of changes of assets controllers, v^30 or next -+ */ - - - -@@ -44,7 +47,7 @@ var getDefaultNftControllerState = () => ({ +@@ -44,7 +44,7 @@ var getDefaultNftControllerState = () => ({ allNfts: {}, ignoredNfts: [] }); @@ -63,7 +50,7 @@ index 7cc44fa..a7663b4 100644 var NftController = class extends _basecontroller.BaseController { /** * Creates an NftController instance. -@@ -53,7 +56,7 @@ var NftController = class extends _basecontroller.BaseController { +@@ -53,7 +53,7 @@ var NftController = class extends _basecontroller.BaseController { * @param options.chainId - The chain ID of the current network. * @param options.selectedAddress - The currently selected address. * @param options.ipfsGateway - The configured IPFS gateway. @@ -72,7 +59,7 @@ index 7cc44fa..a7663b4 100644 * @param options.useIpfsSubdomains - Controls whether IPFS subdomains are used. * @param options.isIpfsGatewayEnabled - Controls whether IPFS is enabled or not. * @param options.getERC721AssetName - Gets the name of the asset at the given address. -@@ -71,7 +74,7 @@ var NftController = class extends _basecontroller.BaseController { +@@ -71,7 +71,7 @@ var NftController = class extends _basecontroller.BaseController { chainId: initialChainId, selectedAddress = "", ipfsGateway = _controllerutils.IPFS_DEFAULT_GATEWAY_URL, @@ -81,7 +68,7 @@ index 7cc44fa..a7663b4 100644 useIpfsSubdomains = true, isIpfsGatewayEnabled = true, getERC721AssetName, -@@ -104,7 +107,7 @@ var NftController = class extends _basecontroller.BaseController { +@@ -104,7 +104,7 @@ var NftController = class extends _basecontroller.BaseController { * @param preferencesState - The new state of the preference controller. * @param preferencesState.selectedAddress - The current selected address. * @param preferencesState.ipfsGateway - The configured IPFS gateway. @@ -90,7 +77,7 @@ index 7cc44fa..a7663b4 100644 * @param preferencesState.isIpfsGatewayEnabled - Controls whether IPFS is enabled or not. */ _chunkZ4BLTVTBjs.__privateAdd.call(void 0, this, _onPreferencesControllerStateChange); -@@ -233,7 +236,7 @@ var NftController = class extends _basecontroller.BaseController { +@@ -233,7 +233,7 @@ var NftController = class extends _basecontroller.BaseController { _chunkZ4BLTVTBjs.__privateAdd.call(void 0, this, _selectedAddress, void 0); _chunkZ4BLTVTBjs.__privateAdd.call(void 0, this, _chainId, void 0); _chunkZ4BLTVTBjs.__privateAdd.call(void 0, this, _ipfsGateway, void 0); @@ -99,7 +86,7 @@ index 7cc44fa..a7663b4 100644 _chunkZ4BLTVTBjs.__privateAdd.call(void 0, this, _useIpfsSubdomains, void 0); _chunkZ4BLTVTBjs.__privateAdd.call(void 0, this, _isIpfsGatewayEnabled, void 0); _chunkZ4BLTVTBjs.__privateAdd.call(void 0, this, _getERC721AssetName, void 0); -@@ -246,7 +249,7 @@ var NftController = class extends _basecontroller.BaseController { +@@ -246,7 +246,7 @@ var NftController = class extends _basecontroller.BaseController { _chunkZ4BLTVTBjs.__privateSet.call(void 0, this, _selectedAddress, selectedAddress); _chunkZ4BLTVTBjs.__privateSet.call(void 0, this, _chainId, initialChainId); _chunkZ4BLTVTBjs.__privateSet.call(void 0, this, _ipfsGateway, ipfsGateway); @@ -108,7 +95,7 @@ index 7cc44fa..a7663b4 100644 _chunkZ4BLTVTBjs.__privateSet.call(void 0, this, _useIpfsSubdomains, useIpfsSubdomains); _chunkZ4BLTVTBjs.__privateSet.call(void 0, this, _isIpfsGatewayEnabled, isIpfsGatewayEnabled); _chunkZ4BLTVTBjs.__privateSet.call(void 0, this, _getERC721AssetName, getERC721AssetName); -@@ -268,6 +271,17 @@ var NftController = class extends _basecontroller.BaseController { +@@ -268,6 +268,17 @@ var NftController = class extends _basecontroller.BaseController { getNftApi() { return `${_controllerutils.NFT_API_BASE_URL}/tokens`; } @@ -126,87 +113,7 @@ index 7cc44fa..a7663b4 100644 /** * Adds a new suggestedAsset to state. Parameters will be validated according to * asset type being watched. A `:pending` hub event will be emitted once added. -@@ -430,43 +444,48 @@ var NftController = class extends _basecontroller.BaseController { - userAddress = _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _selectedAddress), - networkClientId - }) { -- const chainId = _chunkZ4BLTVTBjs.__privateMethod.call(void 0, this, _getCorrectChainId, getCorrectChainId_fn).call(this, { networkClientId }); -- const nftsWithChecksumAdr = nfts.map((nft) => { -- return { -- ...nft, -- address: _controllerutils.toChecksumHexAddress.call(void 0, nft.address) -- }; -- }); -- const nftMetadataResults = await Promise.all( -- nftsWithChecksumAdr.map(async (nft) => { -- const resMetadata = await _chunkZ4BLTVTBjs.__privateMethod.call(void 0, this, _getNftInformation, getNftInformation_fn).call(this, nft.address, nft.tokenId, networkClientId); -+ const releaseLock = await _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _mutex).acquire(); -+ try{ -+ const chainId = _chunkZ4BLTVTBjs.__privateMethod.call(void 0, this, _getCorrectChainId, getCorrectChainId_fn).call(this, { networkClientId }); -+ const nftsWithChecksumAdr = nfts.map((nft) => { - return { -- nft, -- newMetadata: resMetadata -+ ...nft, -+ address: _controllerutils.toChecksumHexAddress.call(void 0, nft.address) - }; -- }) -- ); -- const nftsWithDifferentMetadata = []; -- const { allNfts } = this.state; -- const stateNfts = allNfts[userAddress]?.[chainId] || []; -- nftMetadataResults.forEach((singleNft) => { -- const existingEntry = stateNfts.find( -- (nft) => nft.address.toLowerCase() === singleNft.nft.address.toLowerCase() && nft.tokenId === singleNft.nft.tokenId -+ }); -+ const nftMetadataResults = await Promise.all( -+ nftsWithChecksumAdr.map(async (nft) => { -+ const resMetadata = await _chunkZ4BLTVTBjs.__privateMethod.call(void 0, this, _getNftInformation, getNftInformation_fn).call(this, nft.address, nft.tokenId, networkClientId); -+ return { -+ nft, -+ newMetadata: resMetadata -+ }; -+ }) - ); -- if (existingEntry) { -- const differentMetadata = _chunkNEXY7SE2js.compareNftMetadata.call(void 0, -- singleNft.newMetadata, -- existingEntry -+ const nftsWithDifferentMetadata = []; -+ const { allNfts } = this.state; -+ const stateNfts = allNfts[userAddress]?.[chainId] || []; -+ nftMetadataResults.forEach((singleNft) => { -+ const existingEntry = stateNfts.find( -+ (nft) => nft.address.toLowerCase() === singleNft.nft.address.toLowerCase() && nft.tokenId === singleNft.nft.tokenId - ); -- if (differentMetadata) { -- nftsWithDifferentMetadata.push(singleNft); -+ if (existingEntry) { -+ const differentMetadata = _chunkNEXY7SE2js.compareNftMetadata.call(void 0, -+ singleNft.newMetadata, -+ existingEntry -+ ); -+ if (differentMetadata) { -+ nftsWithDifferentMetadata.push(singleNft); -+ } - } -+ }); -+ if (nftsWithDifferentMetadata.length !== 0) { -+ nftsWithDifferentMetadata.forEach( -+ (elm) => this.updateNft(elm.nft, elm.newMetadata, userAddress, chainId) -+ ); - } -- }); -- if (nftsWithDifferentMetadata.length !== 0) { -- nftsWithDifferentMetadata.forEach( -- (elm) => this.updateNft(elm.nft, elm.newMetadata, userAddress, chainId) -- ); -+ } finally { -+ releaseLock(); - } - } - /** -@@ -771,7 +790,7 @@ _mutex = new WeakMap(); +@@ -771,7 +782,7 @@ _mutex = new WeakMap(); _selectedAddress = new WeakMap(); _chainId = new WeakMap(); _ipfsGateway = new WeakMap(); @@ -215,7 +122,7 @@ index 7cc44fa..a7663b4 100644 _useIpfsSubdomains = new WeakMap(); _isIpfsGatewayEnabled = new WeakMap(); _getERC721AssetName = new WeakMap(); -@@ -797,14 +816,14 @@ _onPreferencesControllerStateChange = new WeakSet(); +@@ -797,14 +808,14 @@ _onPreferencesControllerStateChange = new WeakSet(); onPreferencesControllerStateChange_fn = async function({ selectedAddress, ipfsGateway, @@ -233,7 +140,15 @@ index 7cc44fa..a7663b4 100644 if (needsUpdateNftMetadata) { const nfts = this.state.allNfts[selectedAddress]?.[_chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _chainId)] ?? []; const nftsToUpdate = nfts.filter( -@@ -850,12 +869,25 @@ getNftInformationFromApi_fn = async function(contractAddress, tokenId) { +@@ -818,6 +829,7 @@ onPreferencesControllerStateChange_fn = async function({ + } + } + }; ++ + _updateNestedNftState = new WeakSet(); + updateNestedNftState_fn = function(newCollection, baseStateKey, { userAddress, chainId }) { + this.update((state) => { +@@ -850,12 +862,25 @@ getNftInformationFromApi_fn = async function(contractAddress, tokenId) { } } }); @@ -242,9 +157,9 @@ index 7cc44fa..a7663b4 100644 + id: `${nftInformation?.tokens[0]?.token?.collection?.id}` + }).toString(); + const collectionInformation = await _controllerutils.fetchWithErrorHandling.call(void 0, { -+ url: `${_controllerutils.NFT_API_BASE_URL}/collections?${getCollectionParams}`, -+ options: { -+ headers: { ++ url: `${_controllerutils.NFT_API_BASE_URL}/collections?${getCollectionParams}`, ++ options: { ++ headers: { + Version: '1' + } + } @@ -260,52 +175,25 @@ index 7cc44fa..a7663b4 100644 }; } const { -@@ -887,7 +919,16 @@ getNftInformationFromApi_fn = async function(contractAddress, tokenId) { +@@ -887,7 +912,16 @@ getNftInformationFromApi_fn = async function(contractAddress, tokenId) { }, rarityRank && { rarityRank }, rarity && { rarity }, - collection && { collection } + (collection || collectionInformation) && { -+ collection: { -+ ...collection || {}, -+ creator: collection?.creator || collectionInformation?.collections[0].creator, -+ openseaVerificationStatus: collectionInformation?.collections[0].openseaVerificationStatus, -+ contractDeployedAt: collectionInformation?.collections[0].contractDeployedAt, -+ ownerCount: collectionInformation?.collections[0].ownerCount, -+ topBid: collectionInformation?.collections[0].topBid ++ collection: { ++ ...collection || {}, ++ creator: collection?.creator || collectionInformation?.collections[0].creator, ++ openseaVerificationStatus: collectionInformation?.collections[0].openseaVerificationStatus, ++ contractDeployedAt: collectionInformation?.collections[0].contractDeployedAt, ++ ownerCount: collectionInformation?.collections[0].ownerCount, ++ topBid: collectionInformation?.collections[0].topBid ++ } + } -+ } ); return nftMetadata; }; -@@ -896,6 +937,17 @@ getNftInformationFromTokenURI_fn = async function(contractAddress, tokenId, netw - const result = await _chunkZ4BLTVTBjs.__privateMethod.call(void 0, this, _getNftURIAndStandard, getNftURIAndStandard_fn).call(this, contractAddress, tokenId, networkClientId); - let tokenURI = result[0]; - const standard = result[1]; -+ if (!_chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _displayNftMedia) && !_chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _isIpfsGatewayEnabled)) { -+ return { -+ image: null, -+ name: null, -+ description: null, -+ standard: standard || null, -+ favorite: false, -+ tokenURI, -+ }; -+ } -+ - const hasIpfsTokenURI = tokenURI.startsWith("ipfs://"); - if (hasIpfsTokenURI && !_chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _isIpfsGatewayEnabled)) { - return { -@@ -907,7 +959,7 @@ getNftInformationFromTokenURI_fn = async function(contractAddress, tokenId, netw - tokenURI: tokenURI ?? null - }; - } -- const isDisplayNFTMediaToggleEnabled = _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _openSeaEnabled); -+ const isDisplayNFTMediaToggleEnabled = _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _displayNftMedia); - if (!hasIpfsTokenURI && !isDisplayNFTMediaToggleEnabled) { - return { - image: null, -@@ -915,7 +967,8 @@ getNftInformationFromTokenURI_fn = async function(contractAddress, tokenId, netw +@@ -904,10 +938,11 @@ getNftInformationFromTokenURI_fn = async function(contractAddress, tokenId, netw description: null, standard: standard || null, favorite: false, @@ -314,8 +202,12 @@ index 7cc44fa..a7663b4 100644 + error: 'URI import error', }; } - if (hasIpfsTokenURI) { -@@ -925,6 +978,16 @@ getNftInformationFromTokenURI_fn = async function(contractAddress, tokenId, netw +- const isDisplayNFTMediaToggleEnabled = _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _openSeaEnabled); ++ const isDisplayNFTMediaToggleEnabled = _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _displayNftMedia); + if (!hasIpfsTokenURI && !isDisplayNFTMediaToggleEnabled) { + return { + image: null, +@@ -925,6 +960,16 @@ getNftInformationFromTokenURI_fn = async function(contractAddress, tokenId, netw _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _useIpfsSubdomains) ); } @@ -332,7 +224,7 @@ index 7cc44fa..a7663b4 100644 try { const object = await _controllerutils.handleFetch.call(void 0, tokenURI); const image = Object.prototype.hasOwnProperty.call(object, "image") ? "image" : ( -@@ -946,7 +1009,8 @@ getNftInformationFromTokenURI_fn = async function(contractAddress, tokenId, netw +@@ -946,7 +991,8 @@ getNftInformationFromTokenURI_fn = async function(contractAddress, tokenId, netw description: null, standard: standard || null, favorite: false, @@ -342,7 +234,7 @@ index 7cc44fa..a7663b4 100644 }; } }; -@@ -977,15 +1041,26 @@ getNftInformation_fn = async function(contractAddress, tokenId, networkClientId) +@@ -977,15 +1023,27 @@ getNftInformation_fn = async function(contractAddress, tokenId, networkClientId) _controllerutils.safelyExecute.call(void 0, () => _chunkZ4BLTVTBjs.__privateMethod.call(void 0, this, _getNftInformationFromTokenURI, getNftInformationFromTokenURI_fn).call(this, contractAddress, tokenId, networkClientId) ), @@ -351,16 +243,17 @@ index 7cc44fa..a7663b4 100644 () => _chunkZ4BLTVTBjs.__privateMethod.call(void 0, this, _getNftInformationFromApi, getNftInformationFromApi_fn).call(this, contractAddress, tokenId) ) : void 0 ]); ++ + if (blockchainMetadata?.error && nftApiMetadata?.error) { -+ return { -+ image: null, -+ name: null, -+ description: null, -+ standard: blockchainMetadata.standard ?? null, -+ favorite: false, -+ tokenURI: blockchainMetadata.tokenURI ?? null, -+ error: 'Both import failed', -+ }; ++ return { ++ image: null, ++ name: null, ++ description: null, ++ standard: blockchainMetadata.standard ?? null, ++ favorite: false, ++ tokenURI: blockchainMetadata.tokenURI ?? null, ++ error: 'Both import failed', ++ }; + } return { ...nftApiMetadata, @@ -371,7 +264,7 @@ index 7cc44fa..a7663b4 100644 standard: blockchainMetadata?.standard ?? nftApiMetadata?.standard ?? null, tokenURI: blockchainMetadata?.tokenURI ?? null }; -@@ -1048,7 +1123,8 @@ addIndividualNft_fn = async function(tokenAddress, tokenId, nftMetadata, nftCont +@@ -1048,7 +1106,8 @@ addIndividualNft_fn = async function(tokenAddress, tokenId, nftMetadata, nftCont nftMetadata, existingEntry ); @@ -381,7 +274,7 @@ index 7cc44fa..a7663b4 100644 return; } const indexToUpdate = nfts.findIndex( -@@ -1080,7 +1156,8 @@ addIndividualNft_fn = async function(tokenAddress, tokenId, nftMetadata, nftCont +@@ -1080,7 +1139,8 @@ addIndividualNft_fn = async function(tokenAddress, tokenId, nftMetadata, nftCont symbol: nftContract.symbol, tokenId: tokenId.toString(), standard: nftMetadata.standard, @@ -392,7 +285,7 @@ index 7cc44fa..a7663b4 100644 } } finally { diff --git a/node_modules/@metamask/assets-controllers/dist/chunk-ELSMS5S7.js b/node_modules/@metamask/assets-controllers/dist/chunk-ELSMS5S7.js -index 45254ad..cd5f3a1 100644 +index 45254ad..f3c6204 100644 --- a/node_modules/@metamask/assets-controllers/dist/chunk-ELSMS5S7.js +++ b/node_modules/@metamask/assets-controllers/dist/chunk-ELSMS5S7.js @@ -87,6 +87,7 @@ var CurrencyRateController = class extends _pollingcontroller.StaticIntervalPoll @@ -426,7 +319,7 @@ index 45254ad..cd5f3a1 100644 - currentCurrency - }; - }); -+ if (shouldUpdateState) { ++ if(shouldUpdateState) { + this.update(() => { + return { + currencyRates: { @@ -439,13 +332,13 @@ index 45254ad..cd5f3a1 100644 + }, + currentCurrency + }; -+ }) ++ }); + } } finally { releaseLock(); } diff --git a/node_modules/@metamask/assets-controllers/dist/chunk-GU53EI7A.js b/node_modules/@metamask/assets-controllers/dist/chunk-GU53EI7A.js -index 33b048f..8815b95 100644 +index 33b048f..5867375 100644 --- a/node_modules/@metamask/assets-controllers/dist/chunk-GU53EI7A.js +++ b/node_modules/@metamask/assets-controllers/dist/chunk-GU53EI7A.js @@ -61,7 +61,7 @@ var AccountTrackerController = class extends _pollingcontroller.StaticIntervalPo @@ -457,36 +350,34 @@ index 33b048f..8815b95 100644 const accountsForChain = { ...accountsByChainId[chainId] }; for (const address of accountsToUpdate) { const balance = await this.getBalanceFromChain(address, ethQuery); -@@ -80,9 +80,11 @@ var AccountTrackerController = class extends _pollingcontroller.StaticIntervalPo +@@ -80,9 +80,8 @@ var AccountTrackerController = class extends _pollingcontroller.StaticIntervalPo [chainId]: accountsForChain } }); - } catch (err) { + } finally { -+ /** -+ * This change is not present on the core repo -+ */ releaseLock(); - throw err; } }; this.defaultConfig = { diff --git a/node_modules/@metamask/assets-controllers/dist/chunk-HDI4L2DD.js b/node_modules/@metamask/assets-controllers/dist/chunk-HDI4L2DD.js -index 76e3362..f733c85 100644 +index 76e3362..e11991d 100644 --- a/node_modules/@metamask/assets-controllers/dist/chunk-HDI4L2DD.js +++ b/node_modules/@metamask/assets-controllers/dist/chunk-HDI4L2DD.js -@@ -165,7 +165,9 @@ var TokenDetectionController = class extends _pollingcontroller.StaticIntervalPo +@@ -165,7 +165,10 @@ var TokenDetectionController = class extends _pollingcontroller.StaticIntervalPo if (!this.isActive) { return; } - const addressAgainstWhichToDetect = selectedAddress ?? _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _selectedAddress); ++ //const addressAgainstWhichToDetect = selectedAddress ?? _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _selectedAddress); + const currentAddress = _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _selectedAddress); + const currentAddressChecksum = _controllerutils.toChecksumHexAddress.call(void 0, currentAddress) + const addressAgainstWhichToDetect = _controllerutils.toChecksumHexAddress.call(void 0, selectedAddress) ?? currentAddressChecksum; const { chainId, networkClientId: selectedNetworkClientId } = _chunkZ4BLTVTBjs.__privateMethod.call(void 0, this, _getCorrectChainIdAndNetworkClientId, getCorrectChainIdAndNetworkClientId_fn).call(this, networkClientId); const chainIdAgainstWhichToDetect = chainId; const networkClientIdAgainstWhichToDetect = selectedNetworkClientId; -@@ -224,12 +226,10 @@ registerEventListeners_fn = function() { +@@ -224,12 +227,10 @@ registerEventListeners_fn = function() { ); this.messagingSystem.subscribe( "PreferencesController:stateChange", @@ -502,7 +393,7 @@ index 76e3362..f733c85 100644 selectedAddress: _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _selectedAddress) }); diff --git a/node_modules/@metamask/assets-controllers/dist/chunk-IBK6AXPP.js b/node_modules/@metamask/assets-controllers/dist/chunk-IBK6AXPP.js -index f7509a1..52bc67e 100644 +index f7509a1..4573718 100644 --- a/node_modules/@metamask/assets-controllers/dist/chunk-IBK6AXPP.js +++ b/node_modules/@metamask/assets-controllers/dist/chunk-IBK6AXPP.js @@ -19,7 +19,7 @@ function getDefaultTokenBalancesState() { @@ -526,24 +417,23 @@ index f7509a1..52bc67e 100644 this.messagingSystem.subscribe( "TokensController:stateChange", ({ tokens: newTokens, detectedTokens }) => { -@@ -79,6 +81,16 @@ var TokenBalancesController = class extends _basecontroller.BaseController { +@@ -79,6 +81,15 @@ var TokenBalancesController = class extends _basecontroller.BaseController { disable() { _chunkZ4BLTVTBjs.__privateSet.call(void 0, this, _disabled, true); } -+ -+/** ++ /** + * THIS FUNCTIONS IS CURRENTLY PATCHED AND STILL NEEDS TO BE IMPLEMENTED ON THE CORE REPO + * Resets to the default state + */ + reset() { -+ this.update((state) => { ++ this.update((state) => { + state.contractBalances = {}; -+ }); -+ } ++ }); ++ } /** * Starts a new polling interval. * -@@ -100,27 +112,34 @@ var TokenBalancesController = class extends _basecontroller.BaseController { +@@ -100,27 +111,34 @@ var TokenBalancesController = class extends _basecontroller.BaseController { * Updates balances for all tokens. */ async updateBalances() { @@ -567,20 +457,20 @@ index f7509a1..52bc67e 100644 - } - } + const balancePromises = _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _tokens).map((token) => { -+ const { address } = token; ++ const { address } = token; + return _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _getERC20BalanceOf).call(this, address, selectedAddress).then((balance) => { -+ newContractBalances[address] = _controllerutils.toHex.call(void 0, balance); -+ token = { -+ ...token, -+ hasBalanceError: false -+ }; ++ newContractBalances[address] = _controllerutils.toHex.call(void 0, balance); ++ token = { ++ ...token, ++ hasBalanceError: false ++ }; + }).catch((error) => { -+ newContractBalances[address] = _controllerutils.toHex.call(void 0, 0); -+ token = { -+ ...token, -+ hasBalanceError: true -+ }; -+ }); ++ newContractBalances[address] = _controllerutils.toHex.call(void 0, 0); ++ token = { ++ ...token, ++ hasBalanceError: true ++ }; ++ }); + }); + await Promise.all(balancePromises); this.update((state) => { @@ -590,7 +480,7 @@ index f7509a1..52bc67e 100644 } }; _handle = new WeakMap(); -@@ -128,6 +147,7 @@ _getERC20BalanceOf = new WeakMap(); +@@ -128,6 +146,7 @@ _getERC20BalanceOf = new WeakMap(); _interval = new WeakMap(); _tokens = new WeakMap(); _disabled = new WeakMap(); @@ -621,38 +511,36 @@ index 44804c8..911a6e6 100644 this.clearingTokenListData(); } else { diff --git a/node_modules/@metamask/assets-controllers/dist/chunk-QFDTOEYR.js b/node_modules/@metamask/assets-controllers/dist/chunk-QFDTOEYR.js -index 5335fa5..ae37683 100644 +index 5335fa5..0854306 100644 --- a/node_modules/@metamask/assets-controllers/dist/chunk-QFDTOEYR.js +++ b/node_modules/@metamask/assets-controllers/dist/chunk-QFDTOEYR.js -@@ -7,6 +7,8 @@ var _chunkZ4BLTVTBjs = require('./chunk-Z4BLTVTB.js'); - +@@ -8,12 +8,14 @@ var _chunkZ4BLTVTBjs = require('./chunk-Z4BLTVTB.js'); // src/NftDetectionController.ts + +- +var utils_1 = require('@metamask/utils'); +var _chunkR4HATJKUjs = require('./chunk-NEXY7SE2.js'); -@@ -14,6 +16,7 @@ var _chunkZ4BLTVTBjs = require('./chunk-Z4BLTVTB.js'); - var _controllerutils = require('@metamask/controller-utils'); +var supportedNftDetectionNetworks = [_controllerutils.ChainId.mainnet]; var _pollingcontroller = require('@metamask/polling-controller'); var DEFAULT_INTERVAL = 18e4; var controllerName = "NftDetectionController"; -@@ -24,7 +27,9 @@ var BlockaidResultType = /* @__PURE__ */ ((BlockaidResultType2) => { +@@ -24,7 +26,8 @@ var BlockaidResultType = /* @__PURE__ */ ((BlockaidResultType2) => { BlockaidResultType2["Malicious"] = "Malicious"; return BlockaidResultType2; })(BlockaidResultType || {}); -var _intervalId, _interval, _disabled, _addNft, _getNftState, _stopPolling, stopPolling_fn, _startPolling, startPolling_fn, _onPreferencesControllerStateChange, onPreferencesControllerStateChange_fn, _getOwnerNftApi, getOwnerNftApi_fn, _getOwnerNfts, getOwnerNfts_fn; -+// This patch wont be needed in v35 +var MAX_GET_COLLECTION_BATCH_SIZE = 20; +var _intervalId, _interval, _disabled, _addNft, _getNftState, _stopPolling, stopPolling_fn, _startPolling, startPolling_fn, _onPreferencesControllerStateChange, onPreferencesControllerStateChange_fn, _getOwnerNftApi, getOwnerNftApi_fn, _getOwnerNfts, getOwnerNfts_fn, _inProcessNftFetchingUpdates; var NftDetectionController = class extends _pollingcontroller.StaticIntervalPollingController { /** * The controller options -@@ -68,8 +73,10 @@ var NftDetectionController = class extends _pollingcontroller.StaticIntervalPoll +@@ -68,8 +71,10 @@ var NftDetectionController = class extends _pollingcontroller.StaticIntervalPoll _chunkZ4BLTVTBjs.__privateAdd.call(void 0, this, _disabled, void 0); _chunkZ4BLTVTBjs.__privateAdd.call(void 0, this, _addNft, void 0); _chunkZ4BLTVTBjs.__privateAdd.call(void 0, this, _getNftState, void 0); @@ -663,19 +551,16 @@ index 5335fa5..ae37683 100644 _chunkZ4BLTVTBjs.__privateSet.call(void 0, this, _getNftState, getNftState); _chunkZ4BLTVTBjs.__privateSet.call(void 0, this, _addNft, addNft); this.messagingSystem.subscribe( -@@ -126,62 +133,154 @@ var NftDetectionController = class extends _pollingcontroller.StaticIntervalPoll +@@ -126,62 +131,152 @@ var NftDetectionController = class extends _pollingcontroller.StaticIntervalPoll */ async detectNfts(options) { const userAddress = options?.userAddress ?? this.messagingSystem.call("PreferencesController:getState").selectedAddress; - if (!this.isMainnet() || _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _disabled)) { -+ const { selectedNetworkClientId } = this.messagingSystem.call( -+ "NetworkController:getState" -+ ); -+ const { -+ configuration: { chainId } -+ } = this.messagingSystem.call( -+ "NetworkController:getNetworkClientById", -+ selectedNetworkClientId ++ ++ const { selectedNetworkClientId } = this.messagingSystem.call("NetworkController:getState"); ++ const { configuration: { chainId }} = this.messagingSystem.call( ++ "NetworkController:getNetworkClientById", ++ selectedNetworkClientId + ); + if (!supportedNftDetectionNetworks.includes(chainId) || _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _disabled)) { return; @@ -727,19 +612,18 @@ index 5335fa5..ae37683 100644 + const updateKey = `${chainId}:${userAddress}`; + if (updateKey in _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _inProcessNftFetchingUpdates)) { + await _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _inProcessNftFetchingUpdates)[updateKey]; -+ return; ++ return; + } + const { -+ promise: inProgressUpdate, -+ resolve: updateSucceeded, -+ reject: updateFailed -+ } = utils_1.createDeferredPromise.call(void 0, { suppressUnhandledRejection: true }); ++ promise: inProgressUpdate, ++ resolve: updateSucceeded, ++ reject: updateFailed ++ } = utils_1.createDeferredPromise.call(void 0, { suppressUnhandledRejection: true }); + _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _inProcessNftFetchingUpdates)[updateKey] = inProgressUpdate; + let next; + let apiNfts = []; + let resultNftApi; -+ -+ try { ++ try{ + do { + resultNftApi = await _chunkZ4BLTVTBjs.__privateMethod.call(void 0, this, _getOwnerNfts, getOwnerNfts_fn).call(this, userAddress, chainId, next); + apiNfts = resultNftApi.tokens.filter( @@ -752,7 +636,7 @@ index 5335fa5..ae37683 100644 - networkClientId: options?.networkClientId + const collections = apiNfts.reduce((acc, currValue) => { + if (!acc.includes(currValue.token.contract) && currValue.token.contract === currValue?.token?.collection?.id) { -+ acc.push(currValue.token.contract); ++ acc.push(currValue.token.contract); + } + return acc; + }, []); @@ -766,108 +650,110 @@ index 5335fa5..ae37683 100644 + ); + params.append("chainId", "1"); + const collectionResponseForBatch = await _controllerutils.fetchWithErrorHandling.call(void 0, -+ { -+ url: `${_controllerutils.NFT_API_BASE_URL}/collections?${params.toString()}`, -+ options: { ++ { ++ url: `${_controllerutils.NFT_API_BASE_URL}/collections?${params.toString()}`, ++ options: { + headers: { -+ Version: '1' ++ Version: '1' + } -+ }, -+ timeout: 15000 -+ } -+ ); -+ return { ++ }, ++ timeout: 15000 ++ } ++ ); ++ return { + ...allResponses, + ...collectionResponseForBatch -+ }; -+ }, ++ }; ++ }, + initialResult: {} + }); ++ + if (collectionResponse.collections?.length) { + apiNfts.forEach((singleNFT) => { -+ const found = collectionResponse.collections.find( ++ const found = collectionResponse.collections.find( + (elm) => elm.id?.toLowerCase() === singleNFT.token.contract.toLowerCase() -+ ); -+ if (found) { -+ singleNFT.token = { -+ ...singleNFT.token, -+ collection: { -+ ...singleNFT.token.collection ?? {}, -+ creator: found?.creator, -+ openseaVerificationStatus: found?.openseaVerificationStatus, -+ contractDeployedAt: found.contractDeployedAt, -+ ownerCount: found.ownerCount, -+ topBid: found.topBid -+ } -+ }; -+ } ++ ); ++ if (found) { ++ singleNFT.token = { ++ ...singleNFT.token, ++ collection: { ++ ...singleNFT.token.collection ?? {}, ++ creator: found?.creator, ++ openseaVerificationStatus: found?.openseaVerificationStatus, ++ contractDeployedAt: found.contractDeployedAt, ++ ownerCount: found.ownerCount, ++ topBid: found.topBid ++ } ++ }; ++ } + }); + } + } ++ + const addNftPromises = apiNfts.map(async (nft) => { -+ const { ++ const { + tokenId, + contract, + kind, + image: imageUrl, -+ imageSmall: imageThumbnailUrl, -+ metadata: { imageOriginal: imageOriginalUrl } = {}, -+ name, -+ description, -+ attributes, -+ topBid, -+ lastSale, -+ rarityRank, -+ rarityScore, -+ collection -+ } = nft.token; -+ let ignored; -+ const { ignoredNfts } = _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _getNftState).call(this); -+ if (ignoredNfts.length) { ++ imageSmall: imageThumbnailUrl, ++ metadata: { imageOriginal: imageOriginalUrl } = {}, ++ name, ++ description, ++ attributes, ++ topBid, ++ lastSale, ++ rarityRank, ++ rarityScore, ++ collection ++ } = nft.token; ++ let ignored; ++ const { ignoredNfts } = _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _getNftState).call(this); ++ if (ignoredNfts.length) { + ignored = ignoredNfts.find((c) => { + return c.address === _controllerutils.toChecksumHexAddress.call(void 0, contract) && c.tokenId === tokenId; + }); -+ } -+ if (!ignored) { -+ const nftMetadata = Object.assign( -+ {}, -+ { name }, -+ description && { description }, -+ imageUrl && { image: imageUrl }, -+ imageThumbnailUrl && { imageThumbnail: imageThumbnailUrl }, -+ imageOriginalUrl && { imageOriginal: imageOriginalUrl }, -+ kind && { standard: kind.toUpperCase() }, -+ lastSale && { lastSale }, -+ attributes && { attributes }, -+ topBid && { topBid }, -+ rarityRank && { rarityRank }, -+ rarityScore && { rarityScore }, -+ collection && { collection } -+ ); -+ await _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _addNft).call(this, contract, tokenId, { -+ nftMetadata, -+ userAddress, -+ source: "detected" /* Detected */, -+ networkClientId: options?.networkClientId -+ }); -+ } ++ } ++ if (!ignored) { ++ const nftMetadata = Object.assign( ++ {}, ++ { name }, ++ description && { description }, ++ imageUrl && { image: imageUrl }, ++ imageThumbnailUrl && { imageThumbnail: imageThumbnailUrl }, ++ imageOriginalUrl && { imageOriginal: imageOriginalUrl }, ++ kind && { standard: kind.toUpperCase() }, ++ lastSale && { lastSale }, ++ attributes && { attributes }, ++ topBid && { topBid }, ++ rarityRank && { rarityRank }, ++ rarityScore && { rarityScore }, ++ collection && { collection } ++ ); ++ await _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _addNft).call(this, contract, tokenId, { ++ nftMetadata, ++ userAddress, ++ source: "detected" /* Detected */, ++ networkClientId: options?.networkClientId ++ }); ++ } }); - } - }); - await Promise.all(addNftPromises); + await Promise.all(addNftPromises); -+ } while (next = resultNftApi.continuation); ++ } while(next = resultNftApi.continuation) + updateSucceeded(); -+ } catch (error) { ++ }catch(error){ + updateFailed(error); + throw error; -+ } finally { ++ }finally{ + delete _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _inProcessNftFetchingUpdates)[updateKey]; + } } }; _intervalId = new WeakMap(); -@@ -190,6 +289,7 @@ _disabled = new WeakMap(); +@@ -190,6 +285,7 @@ _disabled = new WeakMap(); _addNft = new WeakMap(); _getNftState = new WeakMap(); _stopPolling = new WeakSet(); @@ -875,7 +761,7 @@ index 5335fa5..ae37683 100644 stopPolling_fn = function() { if (_chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _intervalId)) { clearInterval(_chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _intervalId)); -@@ -207,41 +307,26 @@ _onPreferencesControllerStateChange = new WeakSet(); +@@ -207,41 +303,26 @@ _onPreferencesControllerStateChange = new WeakSet(); onPreferencesControllerStateChange_fn = function({ useNftDetection }) { if (!useNftDetection !== _chunkZ4BLTVTBjs.__privateGet.call(void 0, this, _disabled)) { _chunkZ4BLTVTBjs.__privateSet.call(void 0, this, _disabled, !useNftDetection); @@ -909,6 +795,13 @@ index 5335fa5..ae37683 100644 - }); - 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; +getOwnerNfts_fn = async function(address, chainId, cursor) { + const convertedChainId = _controllerutils.convertHexToDecimal.call(void 0, chainId).toString(); + const url = _chunkZ4BLTVTBjs.__privateMethod.call(void 0, this, _getOwnerNftApi, getOwnerNftApi_fn).call(this, { @@ -917,15 +810,9 @@ index 5335fa5..ae37683 100644 + next: cursor + }); + const nftApiResponse = await _controllerutils.handleFetch.call(void 0, url, { -+ headers: { -+ Version: '1' - } -- 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; ++ headers: { ++ Version: '1' ++ } + }); + return nftApiResponse; }; @@ -949,14 +836,11 @@ index 6f461a4..a3573af 100644 } }); diff --git a/node_modules/@metamask/assets-controllers/dist/chunk-UEDNQBJN.js b/node_modules/@metamask/assets-controllers/dist/chunk-UEDNQBJN.js -index 80cecfb..f625ec2 100644 +index 80cecfb..e19a2e9 100644 --- a/node_modules/@metamask/assets-controllers/dist/chunk-UEDNQBJN.js +++ b/node_modules/@metamask/assets-controllers/dist/chunk-UEDNQBJN.js -@@ -378,9 +378,10 @@ fetchAndMapExchangeRatesForSupportedNativeCurrency_fn = async function({ - } - return Object.entries(contractNativeInformations).reduce( +@@ -380,7 +380,7 @@ fetchAndMapExchangeRatesForSupportedNativeCurrency_fn = async function({ (obj, [tokenAddress, token]) => { -+ // This wont be needed in v33 obj = { ...obj, - [tokenAddress.toLowerCase()]: { ...token } @@ -964,7 +848,7 @@ index 80cecfb..f625ec2 100644 }; return obj; }, -@@ -416,7 +417,7 @@ fetchAndMapExchangeRatesForUnsupportedNativeCurrency_fn = async function({ +@@ -416,7 +416,7 @@ fetchAndMapExchangeRatesForUnsupportedNativeCurrency_fn = async function({ ...acc, [tokenAddress]: { ...token, @@ -974,10 +858,10 @@ index 80cecfb..f625ec2 100644 }; return acc; diff --git a/node_modules/@metamask/assets-controllers/dist/chunk-Z6TBQQE5.js b/node_modules/@metamask/assets-controllers/dist/chunk-Z6TBQQE5.js -index 2f1b66f..8436bd9 100644 +index 2f1b66f..f4acd79 100644 --- a/node_modules/@metamask/assets-controllers/dist/chunk-Z6TBQQE5.js +++ b/node_modules/@metamask/assets-controllers/dist/chunk-Z6TBQQE5.js -@@ -295,13 +295,11 @@ var CodefiTokenPricesServiceV2 = class { +@@ -295,13 +295,12 @@ var CodefiTokenPricesServiceV2 = class { (obj, tokenAddress) => { const lowercasedTokenAddress = tokenAddress.toLowerCase(); const marketData = addressCryptoDataMap[lowercasedTokenAddress]; @@ -986,6 +870,7 @@ index 2f1b66f..8436bd9 100644 return obj; } - const { price } = marketData; ++ const token = { tokenAddress, - value: price, @@ -1089,7 +974,7 @@ index 758a85e..a4a4b72 100644 bps?: number; recipient?: string; diff --git a/node_modules/@metamask/assets-controllers/dist/types/TokenBalancesController.d.ts b/node_modules/@metamask/assets-controllers/dist/types/TokenBalancesController.d.ts -index 52bb3ac..b291078 100644 +index 52bb3ac..d4d5c0a 100644 --- a/node_modules/@metamask/assets-controllers/dist/types/TokenBalancesController.d.ts +++ b/node_modules/@metamask/assets-controllers/dist/types/TokenBalancesController.d.ts @@ -79,6 +79,11 @@ export declare class TokenBalancesController extends BaseController Date: Mon, 14 Oct 2024 11:17:52 -0400 Subject: [PATCH 06/21] docs: update e2e best practices link in readme (#11757) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This pull request updates the outdated end-to-end best practices link in the testing section of the README, pointing it to the latest contributor docs for accurate reference. ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- docs/readme/testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/readme/testing.md b/docs/readme/testing.md index 546686c39e0..c79373ddc92 100644 --- a/docs/readme/testing.md +++ b/docs/readme/testing.md @@ -357,4 +357,4 @@ Our CI/CD process is automated through various Bitrise pipelines, each designed ### Best Practices -For more guidelines and best practices, refer to our [Best Practices Document](https://github.com/MetaMask/contributor-docs/blob/main/docs/e2e-testing.md). +For more guidelines and best practices, refer to our [Best Practices Document](https://github.com/MetaMask/contributor-docs/blob/main/docs/testing/e2e-testing.md). From 7bc75e900a5245a0ebd0431c0bf10487886ea179 Mon Sep 17 00:00:00 2001 From: Jonathan Ferreira <44679989+Jonathansoufer@users.noreply.github.com> Date: Mon, 14 Oct 2024 17:46:57 +0100 Subject: [PATCH 07/21] fix: refactor notifications unit tests (#11431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR addresses all comments from [this PR](https://github.com/MetaMask/metamask-mobile/pull/11250) related to unit tests best practices. ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/pull/11250 ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --------- Co-authored-by: Nico MASSART --- .../__snapshots__/index.test.tsx.snap | 2 +- .../NotificationsSettings/index.test.tsx | 150 +------------ .../Settings/NotificationsSettings/index.tsx | 42 +--- .../useToggleNotifications.test.tsx | 125 +++++++++++ .../useToggleNotifications.ts | 70 +++++++ .../notifications/androidChannels.test.ts | 10 +- app/util/notifications/hooks/index.test.ts | 198 +++--------------- app/util/notifications/hooks/index.ts | 4 +- 8 files changed, 244 insertions(+), 357 deletions(-) create mode 100644 app/components/Views/Settings/NotificationsSettings/useToggleNotifications.test.tsx create mode 100644 app/components/Views/Settings/NotificationsSettings/useToggleNotifications.ts diff --git a/app/components/Views/Settings/NotificationsSettings/__snapshots__/index.test.tsx.snap b/app/components/Views/Settings/NotificationsSettings/__snapshots__/index.test.tsx.snap index 1cde78a543d..1859f635317 100644 --- a/app/components/Views/Settings/NotificationsSettings/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/Settings/NotificationsSettings/__snapshots__/index.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`NotificationsSettings should render correctly 1`] = ` +exports[`NotificationsSettings render matches snapshot 1`] = ` { mockGetState = jest.fn(); @@ -57,130 +50,10 @@ jest.mock('../../../../util/notifications/services/NotificationService', () => ( getAllPermissions: jest.fn(), })); -jest.mock('../../../../core/Analytics/MetaMetrics.events', () => ({ - MetaMetricsEvents: { - NOTIFICATIONS_SETTINGS_UPDATED: 'NOTIFICATIONS_SETTINGS_UPDATED', - }, -})); - -const mockDisableNotifications = jest.fn(); -const mockEnableNotifications = jest.fn(); -const mockSetUiNotificationStatus = jest.fn(); -const mockTrackEvent = jest.fn(); - -const mockNavigation = { - navigate: jest.fn(), -} as unknown as NavigationProp; - const setOptions = jest.fn(); -describe('toggleNotificationsEnabled', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - const setup = (basicFunctionalityEnabled: boolean, isMetamaskNotificationsEnabled: boolean, isProfileSyncingEnabled: boolean) => renderHook(() => - useCallback(async () => { - if (!basicFunctionalityEnabled) { - mockNavigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.SHEET.BASIC_FUNCTIONALITY, - params: { - caller: Routes.SETTINGS.NOTIFICATIONS, - }, - }); - } else if (isMetamaskNotificationsEnabled) { - mockDisableNotifications(); - mockSetUiNotificationStatus(false); - } else { - const { permission } = await NotificationsService.getAllPermissions(false); - if (permission !== 'authorized') { - return; - } - - mockEnableNotifications(); - mockSetUiNotificationStatus(true); - } - mockTrackEvent(MetaMetricsEvents.NOTIFICATIONS_SETTINGS_UPDATED, { - settings_type: 'notifications', - old_value: isMetamaskNotificationsEnabled, - new_value: !isMetamaskNotificationsEnabled, - was_profile_syncing_on: isMetamaskNotificationsEnabled ? true : isProfileSyncingEnabled, - }); - }, []) - ); - - it('should navigate to basic functionality screen if basicFunctionalityEnabled is false', async () => { - const { result } = setup(false, false, false); - - await act(async () => { - await result.current(); - }); - - expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.SHEET.BASIC_FUNCTIONALITY, - params: { - caller: Routes.SETTINGS.NOTIFICATIONS, - }, - }); - }); - - it('should disable notifications if isMetamaskNotificationsEnabled is true', async () => { - const { result } = setup(true, true, false); - - await act(async () => { - await result.current(); - }); - - expect(mockDisableNotifications).toHaveBeenCalled(); - expect(mockSetUiNotificationStatus).toHaveBeenCalledWith(false); - }); - - it('should enable notifications if isMetamaskNotificationsEnabled is false and permission is authorized', async () => { - (NotificationsService.getAllPermissions as jest.Mock).mockResolvedValue({ permission: 'authorized' }); - - const { result } = setup(true, false, false); - - await act(async () => { - await result.current(); - }); - - expect(mockEnableNotifications).toHaveBeenCalled(); - expect(mockSetUiNotificationStatus).toHaveBeenCalledWith(true); - }); - - it('should not enable notifications if permission is not authorized', async () => { - (NotificationsService.getAllPermissions as jest.Mock).mockResolvedValue({ permission: 'denied' }); - - const { result } = setup(true, false, false); - - await act(async () => { - await result.current(); - }); - - expect(mockEnableNotifications).not.toHaveBeenCalled(); - expect(mockSetUiNotificationStatus).not.toHaveBeenCalled(); - }); - - it('should track event when notifications settings are updated', async () => { - (NotificationsService.getAllPermissions as jest.Mock).mockResolvedValue({ permission: 'authorized' }); - - const { result } = setup(true, false, true); - - await act(async () => { - await result.current(); - }); - - expect(mockTrackEvent).toHaveBeenCalledWith(MetaMetricsEvents.NOTIFICATIONS_SETTINGS_UPDATED, { - settings_type: 'notifications', - old_value: false, - new_value: true, - was_profile_syncing_on: true, - }); - }); -}); - describe('NotificationsSettings', () => { - it('should render correctly', () => { + it('render matches snapshot', () => { mockGetState.mockImplementation(() => ({ notifications: {}, })); @@ -199,21 +72,4 @@ describe('NotificationsSettings', () => { ); expect(toJSON()).toMatchSnapshot(); }); - - it('should toggle notifications and handle permission correctly', async () => { - const isMetamaskNotificationsEnabled = true; - const basicFunctionalityEnabled = true; - const isProfileSyncingEnabled = true; - - const toggleNotificationsEnabledImpl = jest.fn(() => Promise.resolve({ - isMetamaskNotificationsEnabled, - basicFunctionalityEnabled, - isProfileSyncingEnabled, - })); - - await toggleNotificationsEnabledImpl(); - - expect(NotificationsService.getAllPermissions).toHaveBeenCalledTimes(1); - expect(NotificationsService.getAllPermissions).toHaveBeenCalledWith(false); - }); }); diff --git a/app/components/Views/Settings/NotificationsSettings/index.tsx b/app/components/Views/Settings/NotificationsSettings/index.tsx index 34e9da18047..e046b203c2e 100644 --- a/app/components/Views/Settings/NotificationsSettings/index.tsx +++ b/app/components/Views/Settings/NotificationsSettings/index.tsx @@ -31,7 +31,6 @@ import { selectIsProfileSyncingEnabled, } from '../../../../selectors/notifications'; -import NotificationsService from '../../../../util/notifications/services/NotificationService'; import Routes from '../../../../constants/navigation/Routes'; import ButtonIcon, { @@ -57,6 +56,7 @@ import AppConstants from '../../../../core/AppConstants'; import notificationsRows from './notificationsRows'; import { IconName } from '../../../../component-library/components/Icons/Icon'; import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events'; +import { useToggleNotifications } from './useToggleNotifications'; interface MainNotificationSettingsProps extends Props { toggleNotificationsEnabled: () => void; @@ -109,6 +109,7 @@ const NotificationsSettings = ({ navigation, route }: Props) => { const { accounts } = useAccounts(); const { trackEvent } = useMetrics(); const theme = useTheme(); + const isMetamaskNotificationsEnabled = useSelector( selectIsMetamaskNotificationsEnabled, ); @@ -177,42 +178,15 @@ const NotificationsSettings = ({ navigation, route }: Props) => { * it will request the push notifications permission and enable the notifications * if the permission is granted. */ - const toggleNotificationsEnabled = useCallback(async () => { - if (!basicFunctionalityEnabled) { - navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.SHEET.BASIC_FUNCTIONALITY, - params: { - caller: Routes.SETTINGS.NOTIFICATIONS, - }, - }); - } else if (isMetamaskNotificationsEnabled) { - disableNotifications(); - setUiNotificationStatus(false); - } else { - const { permission } = await NotificationsService.getAllPermissions(false); - if (permission !== 'authorized') { - return; - } - enableNotifications(); - setUiNotificationStatus(true); - } - trackEvent(MetaMetricsEvents.NOTIFICATIONS_SETTINGS_UPDATED, { - settings_type: 'notifications', - old_value: isMetamaskNotificationsEnabled, - new_value: !isMetamaskNotificationsEnabled, - was_profile_syncing_on: isMetamaskNotificationsEnabled - ? true - : isProfileSyncingEnabled, - }); - }, [ + const { toggleNotificationsEnabled } = useToggleNotifications({ + navigation, basicFunctionalityEnabled, - disableNotifications, - enableNotifications, isMetamaskNotificationsEnabled, - navigation, - trackEvent, isProfileSyncingEnabled, - ]); + disableNotifications, + enableNotifications, + setUiNotificationStatus, + }); const toggleCustomNotificationsEnabled = useCallback(async () => { setPlatformAnnouncementsState(!platformAnnouncementsState); diff --git a/app/components/Views/Settings/NotificationsSettings/useToggleNotifications.test.tsx b/app/components/Views/Settings/NotificationsSettings/useToggleNotifications.test.tsx new file mode 100644 index 00000000000..54c7addc69b --- /dev/null +++ b/app/components/Views/Settings/NotificationsSettings/useToggleNotifications.test.tsx @@ -0,0 +1,125 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useToggleNotifications } from './useToggleNotifications'; +import NotificationsService from '../../../../util/notifications/services/NotificationService'; +import Routes from '../../../../constants/navigation/Routes'; +import { NavigationProp, ParamListBase } from '@react-navigation/native'; + +jest.mock( + '../../../../util/notifications/services/NotificationService', + () => ({ + getAllPermissions: jest.fn(), + }), +); + +const mockNavigation = { + navigate: jest.fn(), +} as unknown as NavigationProp; + +const mockDisableNotifications = jest.fn(); +const mockEnableNotifications = jest.fn(); +const mockSetUiNotificationStatus = jest.fn(); + +describe('useToggleNotifications', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('navigates to basic functionality screen if basic functionality is disabled', async () => { + const { result } = renderHook(() => + useToggleNotifications({ + navigation: mockNavigation, + basicFunctionalityEnabled: false, + isMetamaskNotificationsEnabled: false, + isProfileSyncingEnabled: false, + disableNotifications: mockDisableNotifications, + enableNotifications: mockEnableNotifications, + setUiNotificationStatus: mockSetUiNotificationStatus, + }), + ); + + await act(async () => { + await result.current.toggleNotificationsEnabled(); + }); + + expect(mockNavigation.navigate).toHaveBeenCalledWith( + Routes.MODAL.ROOT_MODAL_FLOW, + { + screen: Routes.SHEET.BASIC_FUNCTIONALITY, + params: { + caller: Routes.SETTINGS.NOTIFICATIONS, + }, + }, + ); + }); + + it('switches notifications OFF if notifications previously enabled', async () => { + const { result } = renderHook(() => + useToggleNotifications({ + navigation: mockNavigation, + basicFunctionalityEnabled: true, + isMetamaskNotificationsEnabled: true, + isProfileSyncingEnabled: false, + disableNotifications: mockDisableNotifications, + enableNotifications: mockEnableNotifications, + setUiNotificationStatus: mockSetUiNotificationStatus, + }), + ); + + await act(async () => { + await result.current.toggleNotificationsEnabled(); + }); + + expect(mockDisableNotifications).toHaveBeenCalled(); + expect(mockSetUiNotificationStatus).toHaveBeenCalledWith(false); + }); + + it('switches notifications ON if notifications previously disabled and permission is authorized', async () => { + (NotificationsService.getAllPermissions as jest.Mock).mockResolvedValue({ + permission: 'authorized', + }); + + const { result } = renderHook(() => + useToggleNotifications({ + navigation: mockNavigation, + basicFunctionalityEnabled: true, + isMetamaskNotificationsEnabled: false, + isProfileSyncingEnabled: false, + disableNotifications: mockDisableNotifications, + enableNotifications: mockEnableNotifications, + setUiNotificationStatus: mockSetUiNotificationStatus, + }), + ); + + await act(async () => { + await result.current.toggleNotificationsEnabled(); + }); + + expect(mockEnableNotifications).toHaveBeenCalled(); + expect(mockSetUiNotificationStatus).toHaveBeenCalledWith(true); + }); + + it('switches notifications OFF if device permission is not authorized', async () => { + (NotificationsService.getAllPermissions as jest.Mock).mockResolvedValue({ + permission: 'denied', + }); + + const { result } = renderHook(() => + useToggleNotifications({ + navigation: mockNavigation, + basicFunctionalityEnabled: true, + isMetamaskNotificationsEnabled: false, + isProfileSyncingEnabled: false, + disableNotifications: mockDisableNotifications, + enableNotifications: mockEnableNotifications, + setUiNotificationStatus: mockSetUiNotificationStatus, + }), + ); + + await act(async () => { + await result.current.toggleNotificationsEnabled(); + }); + + expect(mockEnableNotifications).not.toHaveBeenCalled(); + expect(mockSetUiNotificationStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/Views/Settings/NotificationsSettings/useToggleNotifications.ts b/app/components/Views/Settings/NotificationsSettings/useToggleNotifications.ts new file mode 100644 index 00000000000..f5fff6ee5b6 --- /dev/null +++ b/app/components/Views/Settings/NotificationsSettings/useToggleNotifications.ts @@ -0,0 +1,70 @@ +import { useCallback } from 'react'; +import { NavigationProp, ParamListBase } from '@react-navigation/native'; +import Routes from '../../../../constants/navigation/Routes'; +import NotificationsService from '../../../../util/notifications/services/NotificationService'; +import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events'; +import { useMetrics } from '../../../hooks/useMetrics'; + +interface Props { + navigation: NavigationProp; + basicFunctionalityEnabled: boolean; + isMetamaskNotificationsEnabled: boolean; + isProfileSyncingEnabled: boolean | null; + disableNotifications: () => Promise; + enableNotifications: () => Promise; + setUiNotificationStatus: (status: boolean) => void; +} + +export function useToggleNotifications({ + navigation, + basicFunctionalityEnabled, + isMetamaskNotificationsEnabled, + isProfileSyncingEnabled, + disableNotifications, + enableNotifications, + setUiNotificationStatus, +}: Props) { + const { trackEvent } = useMetrics(); + const toggleNotificationsEnabled = useCallback(async () => { + if (!basicFunctionalityEnabled) { + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.BASIC_FUNCTIONALITY, + params: { + caller: Routes.SETTINGS.NOTIFICATIONS, + }, + }); + } else if (isMetamaskNotificationsEnabled) { + disableNotifications(); + setUiNotificationStatus(false); + } else { + const { permission } = await NotificationsService.getAllPermissions( + false, + ); + if (permission !== 'authorized') { + return; + } + + enableNotifications(); + setUiNotificationStatus(true); + } + trackEvent(MetaMetricsEvents.NOTIFICATIONS_SETTINGS_UPDATED, { + settings_type: 'notifications', + old_value: isMetamaskNotificationsEnabled, + new_value: !isMetamaskNotificationsEnabled, + was_profile_syncing_on: isMetamaskNotificationsEnabled + ? true + : isProfileSyncingEnabled, + }); + }, [ + basicFunctionalityEnabled, + isMetamaskNotificationsEnabled, + trackEvent, + isProfileSyncingEnabled, + navigation, + disableNotifications, + setUiNotificationStatus, + enableNotifications, + ]); + + return { toggleNotificationsEnabled }; +} diff --git a/app/util/notifications/androidChannels.test.ts b/app/util/notifications/androidChannels.test.ts index c0cb6a45412..debc8448ab3 100644 --- a/app/util/notifications/androidChannels.test.ts +++ b/app/util/notifications/androidChannels.test.ts @@ -6,11 +6,11 @@ import { } from './androidChannels'; describe('notificationChannels', () => { - it('should have two channels', () => { + it('contains two channels', () => { expect(notificationChannels).toHaveLength(2); }); - it('should have the correct properties for the first channel', () => { + it('first channel has DEFAULT_NOTIFICATION_CHANNEL_ID', () => { const firstChannel: MetaMaskAndroidChannel = notificationChannels[0]; expect(firstChannel).toEqual({ id: ChannelId.DEFAULT_NOTIFICATION_CHANNEL_ID, @@ -23,7 +23,7 @@ describe('notificationChannels', () => { }); }); - it('should have the correct properties for the second channel', () => { + it('second channel should have the correct properties for DEFAULT_NOTIFICATION_CHANNEL_ID', () => { const secondChannel: MetaMaskAndroidChannel = notificationChannels[1]; expect(secondChannel).toEqual({ id: ChannelId.ANNOUNCEMENT_NOTIFICATION_CHANNEL_ID, @@ -36,13 +36,13 @@ describe('notificationChannels', () => { }); }); - it('should have unique titles for each channel', () => { + it('channels have unique titles', () => { const titles = notificationChannels.map((channel) => channel.title); const uniqueTitles = new Set(titles); expect(uniqueTitles.size).toBe(titles.length); }); - it('should have unique subtitles for each channel', () => { + it('channels have unique subtitles ', () => { const subtitles = notificationChannels.map((channel) => channel.subtitle); const uniqueSubtitles = new Set(subtitles); expect(uniqueSubtitles.size).toBe(subtitles.length); diff --git a/app/util/notifications/hooks/index.test.ts b/app/util/notifications/hooks/index.test.ts index bbe94a97dc3..ff4b9fd77aa 100644 --- a/app/util/notifications/hooks/index.test.ts +++ b/app/util/notifications/hooks/index.test.ts @@ -1,19 +1,13 @@ -import { renderHook, act } from '@testing-library/react-hooks'; -import notifee, { - EventType, - Event as NotifeeEvent, -} from '@notifee/react-native'; - +import { act, renderHook } from '@testing-library/react-hooks'; +// eslint-disable-next-line import/no-namespace +import * as constants from '../constants'; import useNotificationHandler from './index'; import { NavigationProp, ParamListBase } from '@react-navigation/native'; import Routes from '../../../constants/navigation/Routes'; import { Notification } from '../../../util/notifications/types'; import { TRIGGER_TYPES } from '../constants'; +import NotificationsService from '../services/NotificationService'; -jest.mock('../../../util/device'); -jest.mock('../../../core/NotificationManager', () => ({ - setTransactionToView: jest.fn(), -})); jest.mock('@notifee/react-native', () => ({ setBadgeCount: jest.fn(), decrementBadgeCount: jest.fn(), @@ -29,12 +23,9 @@ jest.mock('@notifee/react-native', () => ({ }, })); -jest.mock('../../../core/NotificationManager', () => ({ - setTransactionToView: jest.fn(), -})); - -jest.mock('../../../util/device', () => ({ - isAndroid: jest.fn(), +jest.mock('../constants', () => ({ + ...jest.requireActual('../constants'), + isNotificationsFeatureEnabled: jest.fn(), })); const mockNavigate = jest.fn(); @@ -43,10 +34,10 @@ const mockNavigation = { } as unknown as NavigationProp; const notification = { - id: 1, + id: '123', type: TRIGGER_TYPES.ERC1155_RECEIVED, data: { - id: 1, + id: '123', trigger_id: '1', chain_id: 1, block_number: 1, @@ -62,22 +53,22 @@ const notification = { }, } as unknown as Notification; -const mockNotificationEvent = (event: NotifeeEvent) => ({ - type: event.type, - detail: { - notification, - }, -}); - +jest.mock('../services/NotificationService', () => ({ + onForegroundEvent: jest.fn(), + onBackgroundEvent: jest.fn(), + handleNotificationEvent: jest.fn(), +})); describe('useNotificationHandler', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('should navigate to NOTIFICATIONS.DETAILS if notification is pressed', async () => { + it('navigates to NOTIFICATIONS.DETAILS when notification is pressed', async () => { const { result } = renderHook(() => useNotificationHandler(mockNavigation)); - await result.current.handlePressedNotification(notification); + await act(async () => { + result.current.handlePressedNotification(notification); + }); expect(mockNavigation.navigate).toHaveBeenCalledWith( Routes.NOTIFICATIONS.DETAILS, @@ -87,163 +78,32 @@ describe('useNotificationHandler', () => { ); }); - it('should handle notifications correctly', async () => { - const { waitFor } = renderHook(() => - useNotificationHandler(mockNavigation), - ); + it('does not navigates when notification is null', async () => { - await act(async () => { - notifee.onForegroundEvent(() => - mockNotificationEvent({ - type: EventType.PRESS, - detail: { - notification: { - body: 'notificationTest', - data: { - action: 'tx', - id: '123', - }, - }, - }, - }), - ); - await waitFor(() => { - expect(notifee.onForegroundEvent).toHaveBeenCalled(); - }); - }); - }); - it('should do nothing if the EventType is DISMISSED', async () => { - const { waitFor } = renderHook(() => + const { result } = renderHook(() => useNotificationHandler(mockNavigation), ); await act(async () => { - notifee.onForegroundEvent(() => - mockNotificationEvent({ - type: EventType.DISMISSED, - detail: { - notification: { - body: 'notificationTest', - data: { - action: 'tx', - id: '123', - }, - }, - }, - }), - ); - - await waitFor(() => { - expect(notifee.onForegroundEvent).toHaveBeenCalled(); - }); + result.current.handlePressedNotification(); }); - }); - - it('should do nothing if data.action is not tx', async () => { - const { waitFor } = renderHook(() => - useNotificationHandler(mockNavigation), - ); - await act(async () => { - notifee.onForegroundEvent(() => - mockNotificationEvent({ - type: EventType.DELIVERED, - detail: { - notification: { - body: 'notificationTest', - data: { - action: 'no-tx', - id: '123', - }, - }, - }, - }), - ); - - await waitFor(() => { - expect(notifee.onForegroundEvent).toHaveBeenCalled(); - }); - - expect(mockNavigate).not.toHaveBeenCalled(); - }); + expect(mockNavigation.navigate).not.toHaveBeenCalled(); }); - it('handleOpenedNotification should do nothing if notification is null', async () => { - const { waitFor } = renderHook(() => - useNotificationHandler(mockNavigation), - ); - - await act(async () => { - notifee.onForegroundEvent(() => - mockNotificationEvent({ - type: EventType.DELIVERED, - detail: { - notification: undefined, - }, - }), - ); - await waitFor(() => { - expect(notifee.onForegroundEvent).toHaveBeenCalled(); - }); - - expect(mockNavigate).not.toHaveBeenCalled(); - }); - }); + it('does nothing if the isNotificationsFeatureEnabled is false', async () => { + jest.spyOn(constants, 'isNotificationsFeatureEnabled').mockReturnValue(false); - it('should navigate to the transaction view when the notification action is "tx"', async () => { - const { waitFor } = renderHook(() => - useNotificationHandler(mockNavigation), - ); + const { result } = renderHook(() => useNotificationHandler(mockNavigation)); await act(async () => { - notifee.onForegroundEvent(() => - mockNotificationEvent({ - type: EventType.DELIVERED, - detail: { - notification: { - body: 'notificationTest', - data: { - action: 'tx', - id: '123', - }, - }, - }, - }), - ); - await waitFor(() => { - expect(notifee.onForegroundEvent).toHaveBeenCalled(); - }); + result.current.handlePressedNotification(notification); }); - }, 10000); - it('should process notification on Android', async () => { - jest.doMock('react-native/Libraries/Utilities/Platform', () => ({ - OS: 'android', - })); + expect(NotificationsService.onForegroundEvent).not.toHaveBeenCalled(); + expect(NotificationsService.onBackgroundEvent).not.toHaveBeenCalled(); - const { waitFor } = renderHook(() => - useNotificationHandler(mockNavigation), - ); - - await act(async () => { - notifee.onForegroundEvent(() => - mockNotificationEvent({ - type: EventType.PRESS, - detail: { - notification: { - body: 'notificationTest', - data: { - action: 'tx', - id: '123', - }, - }, - }, - }), - ); - await waitFor(() => { - expect(notifee.onForegroundEvent).toHaveBeenCalled(); - }); - }); + jest.restoreAllMocks(); }); }); diff --git a/app/util/notifications/hooks/index.ts b/app/util/notifications/hooks/index.ts index e91fe593eaf..3f6bb98c9a3 100644 --- a/app/util/notifications/hooks/index.ts +++ b/app/util/notifications/hooks/index.ts @@ -4,9 +4,11 @@ import NotificationsService from '../../../util/notifications/services/Notificat import Routes from '../../../constants/navigation/Routes'; import { isNotificationsFeatureEnabled, - TRIGGER_TYPES, } from '../../../util/notifications'; import { Notification } from '../../../util/notifications/types'; +import { + TRIGGER_TYPES, +} from '../../../util/notifications/constants'; import { Linking } from 'react-native'; const useNotificationHandler = (navigation: NavigationProp) => { From 4f8f66c2e2e74b787876e88c51edb351b671a25e Mon Sep 17 00:00:00 2001 From: Pedro Pablo Aste Kompen Date: Mon, 14 Oct 2024 13:30:20 -0600 Subject: [PATCH 08/21] chore(ramp): upgrade sdk to 1.28.5 (#11788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - update on-ramp-sdk to version 1.28.5, which removes a unused dependency ## **Related issues** Fixes: [dependency issue](https://github.com/MetaMask/metamask-mobile/actions/runs/11332800511/job/31515686025?pr=11787) ## **Manual testing steps** - go to buy crypto - request a quote - quote should be provided ## **Screenshots/Recordings** N/A ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- package.json | 2 +- yarn.lock | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 97c3759e780..c65145f8a8a 100644 --- a/package.json +++ b/package.json @@ -136,7 +136,7 @@ "send": "0.19.0" }, "dependencies": { - "@consensys/on-ramp-sdk": "1.28.3", + "@consensys/on-ramp-sdk": "1.28.5", "@ethersproject/abi": "^5.7.0", "@keystonehq/bc-ur-registry-eth": "^0.19.1", "@keystonehq/metamask-airgapped-keyring": "^0.13.1", diff --git a/yarn.lock b/yarn.lock index 9524b5f1a13..057a20745bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1288,16 +1288,15 @@ resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.6.0.tgz#ec6cd237440700bc23ca23087f513c75508958b0" integrity sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA== -"@consensys/on-ramp-sdk@1.28.3": - version "1.28.3" - resolved "https://registry.yarnpkg.com/@consensys/on-ramp-sdk/-/on-ramp-sdk-1.28.3.tgz#5c7c9293f6ee83e1a681a6a76f982e801d3e75ec" - integrity sha512-QwWBFFqP3NMOhcJmcfmFd6fpEU3iXI/tYdWXM8u679/mY1/2rnRKUc3RnuWGFgs3qESZ5sUG2Xc7cp5OrmnDgA== +"@consensys/on-ramp-sdk@1.28.5": + version "1.28.5" + resolved "https://registry.yarnpkg.com/@consensys/on-ramp-sdk/-/on-ramp-sdk-1.28.5.tgz#b9ff6c2b0f46abef30bd32a720f34d963aa76491" + integrity sha512-tBZ2ZsEz+du/vHF4NChTTvcdYddoJCtNZDZj5lry8/TeNxctnYiZGx2tHtw2GJHfYa9Uuux2hwtdhubcR//ZeA== dependencies: async "^3.2.3" axios "^0.28.0" axios-retry "^3.1.2" crypto-js "^4.2.0" - jsonpath-plus "^7.2.0" reflect-metadata "^0.1.13" uuid "^9.0.0" From d5c3dc0560e2a061360e392d61a56936a1170f22 Mon Sep 17 00:00:00 2001 From: Curtis David Date: Mon, 14 Oct 2024 15:44:14 -0400 Subject: [PATCH 09/21] ci: Enable Detox E2E in Release mode (#11710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR enhances our end-to-end testing setup by enabling Detox to run on release builds. This change ensures that our tests are running in a production-like environment, which will help uncover issues that may not be visible in debug builds. There is a noticeable performance difference while building the app in release mode. The apps in release mode take ~12 minutes to build compared to debug mode, where it takes ~17 minutes to build. See release mode build times Screenshot 2024-10-10 at 5 20 27 PM See debug mode build times: Screenshot 2024-10-10 at 5 07 08 PM Key Changes: - Updated build scripts to differentiate between release scripts and debug scripts - Updated the Detox configuration to build the app in release mode while running on CI. - Updated NFT tests to remove redundant steps - The Importing NFTs e2e tests were crashing only in release mode on iOS. The assets team will investigate and address in a follow up PR ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** Smoke runs: - https://app.bitrise.io/app/be69d4368ee7e86d/pipelines/af0893da-2466-4a32-b6f4-4d0056f7be81 - https://app.bitrise.io/app/be69d4368ee7e86d/pipelines/f3ef024d-c0fe-4b58-a601-bd66c0023de0 Regression runs: - https://app.bitrise.io/app/be69d4368ee7e86d/pipelines/c1214e8a-86a2-4e20-972b-6247af5bac8a - https://app.bitrise.io/app/be69d4368ee7e86d/pipelines/53db98a1-bcfe-4dc4-91b3-20491d1171fd ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --------- Co-authored-by: Cal-L --- .depcheckrc.yml | 1 - .detoxrc.js | 28 +++------- android/app/build.gradle | 2 +- bitrise.yml | 53 ++++++++++++++----- e2e/specs/assets/nft-detection-modal.spec.js | 17 +----- ios/Podfile | 2 +- ios/Podfile.lock | 9 +--- package.json | 19 +++---- ...ative-community+datetimepicker+7.7.0.patch | 13 ----- scripts/build.sh | 25 +++++---- yarn.lock | 7 --- 11 files changed, 73 insertions(+), 103 deletions(-) delete mode 100644 patches/@react-native-community+datetimepicker+7.7.0.patch diff --git a/.depcheckrc.yml b/.depcheckrc.yml index c90705901b4..f51f487fd9b 100644 --- a/.depcheckrc.yml +++ b/.depcheckrc.yml @@ -2,7 +2,6 @@ ignores: - '@metamask/oss-attribution-generator' - 'webpack-cli' - - '@react-native-community/datetimepicker' - '@react-native-community/slider' - 'patch-package' - '@lavamoat/allow-scripts' diff --git a/.detoxrc.js b/.detoxrc.js index d637d3c937c..ad953511261 100644 --- a/.detoxrc.js +++ b/.detoxrc.js @@ -26,7 +26,7 @@ module.exports = { configurations: { 'ios.sim.apiSpecs': { device: 'ios.simulator', - app: 'ios.debug', + app: 'ios.qa', testRunner: { args: { "$0": "node e2e/api-specs/run-api-spec-tests.js", @@ -41,10 +41,9 @@ module.exports = { device: 'ios.simulator', app: 'ios.release', }, - // because e2e run on debug mode in bitrise - 'android.emu.bitrise.debug': { - device: 'android.bitrise.emulator', - app: 'android.bitrise.debug', + 'ios.sim.qa': { + device: 'ios.simulator', + app: 'ios.qa', }, 'android.emu.debug': { @@ -86,32 +85,21 @@ module.exports = { binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/MetaMask.app', build: 'yarn start:ios:e2e', }, - 'ios.release': { + 'ios.qa': { type: 'ios.app', binaryPath: - 'ios/build/Build/Products/Release-iphonesimulator/MetaMask.app', - build: "METAMASK_BUILD_TYPE='main' METAMASK_ENVIRONMENT='production' yarn build:ios:release:e2e", - }, - 'android.bitrise.debug': { - type: 'android.apk', - binaryPath: 'android/app/build/outputs/apk/prod/debug/app-prod-debug.apk', - build: 'yarn start:android:e2e', + 'ios/build/Build/Products/Release-iphonesimulator/MetaMask-QA.app', + build: "METAMASK_BUILD_TYPE='main' METAMASK_ENVIRONMENT='qa' yarn build:ios:qa", }, 'android.debug': { type: 'android.apk', binaryPath: 'android/app/build/outputs/apk/prod/debug/app-prod-debug.apk', build: 'yarn start:android:e2e', }, - 'android.release': { - type: 'android.apk', - binaryPath: - 'android/app/build/outputs/apk/prod/release/app-prod-release.apk', - build: "METAMASK_BUILD_TYPE='main' METAMASK_ENVIRONMENT='production' yarn build:android:release:e2e", - }, 'android.qa': { type: 'android.apk', binaryPath: 'android/app/build/outputs/apk/qa/release/app-qa-release.apk', - build: "METAMASK_BUILD_TYPE='main' METAMASK_ENVIRONMENT='qa' yarn build:android:qa:e2e", + build: "METAMASK_BUILD_TYPE='main' METAMASK_ENVIRONMENT='qa' yarn build:android:qa", }, }, }; diff --git a/android/app/build.gradle b/android/app/build.gradle index 7978222f4a7..80ffad8fa28 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -229,7 +229,7 @@ android { release { manifestPlaceholders.isDebug = false minifyEnabled enableProguardInReleaseBuilds - proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro", "${rootProject.projectDir}/../node_modules/detox/android/detox/proguard-rules-app.pro", "${rootProject.projectDir}/../node_modules/detox/android/detox/proguard-rules.pro" } } diff --git a/bitrise.yml b/bitrise.yml index 4df0c76e2c4..40c4e621f3b 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -135,7 +135,7 @@ stages: - run_ios_api_specs: {} - run_tag_smoke_accounts_ios: {} - run_tag_smoke_accounts_android: {} - - run_tag_smoke_assets_ios: {} + # - run_tag_smoke_assets_ios: {} - run_tag_smoke_assets_android: {} - run_tag_smoke_confirmations_ios: {} - run_tag_smoke_confirmations_android: {} @@ -145,8 +145,8 @@ stages: - run_tag_smoke_core_android: {} build_regression_e2e_ios_android_stage: workflows: - - ios_build_regression_tests: {} - - android_build_regression_tests: {} + - ios_e2e_build: {} + - android_e2e_build: {} run_regression_e2e_ios_android_stage: workflows: - ios_run_regression_tests: {} @@ -267,8 +267,29 @@ workflows: source "${HOME}/.nvm/nvm.sh" echo 'source "${HOME}/.nvm/nvm.sh"' | tee -a ${HOME}/.{bashrc,profile} - nvm install ${NODE_VERSION} + # Retry logic for Node installation + MAX_ATTEMPTS=3 + ATTEMPT=1 + until [ $ATTEMPT -gt $MAX_ATTEMPTS ] + do + echo "Attempt $ATTEMPT to install Node.js" + nvm install ${NODE_VERSION} + INSTALL_STATUS=$? # Capture the exit status of the nvm install command + if [ $INSTALL_STATUS -eq 0 ]; then + echo "Node.js installation successful!" + break + else + echo "Node.js installation failed with exit code $INSTALL_STATUS" + ATTEMPT=$((ATTEMPT+1)) + echo "Node.js installation failed, retrying in 5 seconds..." + sleep 5 + fi + done + if [ $ATTEMPT -gt $MAX_ATTEMPTS ]; then + echo "Node.js installation failed after $MAX_ATTEMPTS attempts." + exit 1 + fi envman add --key PATH --value $PATH node --version @@ -586,6 +607,10 @@ workflows: inputs: - ndk_version: $NDK_VERSION - gradlew_path: $PROJECT_LOCATION/gradlew + - file-downloader@1: + inputs: + - source: $BITRISEIO_ANDROID_QA_KEYSTORE_URL + - destination: android/keystores/internalRelease.keystore - script@1: title: Install CCache & symlink inputs: @@ -620,7 +645,7 @@ workflows: node -v export METAMASK_ENVIRONMENT='local' export METAMASK_BUILD_TYPE='main' - IGNORE_BOXLOGS_DEVELOPMENT="true" FORCE_BUNDLING=true yarn test:e2e:android:bitrise:build + IGNORE_BOXLOGS_DEVELOPMENT="true" yarn test:e2e:android:build:qa-release - save-gradle-cache@1: {} - save-cache@1: title: Save CCache @@ -700,7 +725,7 @@ workflows: fi export METAMASK_ENVIRONMENT='local' export METAMASK_BUILD_TYPE='main' - IGNORE_BOXLOGS_DEVELOPMENT="true" FORCE_BUNDLING=true yarn test:e2e:android:bitrise:run "$TEST_SUITE_FOLDER" --testNamePattern="$TEST_SUITE" + IGNORE_BOXLOGS_DEVELOPMENT="true" yarn test:e2e:android:run:qa-release "$TEST_SUITE_FOLDER" --testNamePattern="$TEST_SUITE" - custom-test-results-export@1: title: Export test results is_always_run: true @@ -802,6 +827,8 @@ workflows: - deploy_path: $BITRISE_HTML_REPORT_DIR title: Deploy test report files ios_e2e_build: + envs: + - NO_FLIPPER: '1' before_run: - install_applesimutils - code_setup @@ -833,7 +860,7 @@ workflows: inputs: - content: |- #!/usr/bin/env bash - HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=1 brew install ccache + brew install ccache with HOMEBREW_NO_DEPENDENTS_CHECK=1 ln -s $(which ccache) /usr/local/bin/gcc ln -s $(which ccache) /usr/local/bin/g++ ln -s $(which ccache) /usr/local/bin/cc @@ -867,7 +894,7 @@ workflows: node -v export METAMASK_ENVIRONMENT='local' export METAMASK_BUILD_TYPE='main' - IGNORE_BOXLOGS_DEVELOPMENT="true" FORCE_BUNDLING=true yarn test:e2e:ios:debug:build + IGNORE_BOXLOGS_DEVELOPMENT="true" yarn test:e2e:ios:build:qa-release - save-cocoapods-cache@1: {} - save-cache@1: title: Save CCache @@ -888,6 +915,8 @@ workflows: - key: node_modules-{{ .OS }}-{{ .Arch }}-{{ getenv "BRANCH_COMMIT_HASH" }} - paths: node_modules ios_e2e_test: + envs: + - NO_FLIPPER: '1' before_run: - setup - install_applesimutils @@ -923,7 +952,7 @@ workflows: - set-xcode-build-number@1: inputs: - build_short_version_string: $VERSION_NAME - - plist_path: $PROJECT_LOCATION_IOS/MetaMask/Info.plist + - plist_path: $PROJECT_LOCATION_IOS/MetaMask/MetaMask-QA-Info.plist - script: inputs: - content: |- @@ -954,7 +983,7 @@ workflows: node -v export METAMASK_ENVIRONMENT='local' export METAMASK_BUILD_TYPE='main' - IGNORE_BOXLOGS_DEVELOPMENT="true" FORCE_BUNDLING=true yarn test:e2e:ios:debug:run "$TEST_SUITE_FOLDER" --testNamePattern="$TEST_SUITE" + IGNORE_BOXLOGS_DEVELOPMENT="true" yarn test:e2e:ios:run:qa-release "$TEST_SUITE_FOLDER" --testNamePattern="$TEST_SUITE" - custom-test-results-export@1: is_always_run: true is_skippable: false @@ -1234,8 +1263,6 @@ workflows: inputs: - ipa_path: $BITRISE_APP_STORE_IPA_PATH build_ios_release: - envs: - - NO_FLIPPER: '1' before_run: - code_setup after_run: @@ -1275,8 +1302,6 @@ workflows: - deploy_path: sourcemaps/ios/index.js.map title: Deploy Source Map build_ios_qa: - envs: - - NO_FLIPPER: '1' before_run: - code_setup after_run: diff --git a/e2e/specs/assets/nft-detection-modal.spec.js b/e2e/specs/assets/nft-detection-modal.spec.js index b0b4b4e41ce..798b9267e3a 100644 --- a/e2e/specs/assets/nft-detection-modal.spec.js +++ b/e2e/specs/assets/nft-detection-modal.spec.js @@ -10,12 +10,10 @@ import TestHelpers from '../../helpers'; import Assertions from '../../utils/Assertions'; import NftDetectionModal from '../../pages/modals/NftDetectionModal'; import { SmokeAssets } from '../../tags'; -import NetworkListModal from '../../pages/modals/NetworkListModal'; -import NetworkEducationModal from '../../pages/modals/NetworkEducationModal'; + import { NftDetectionModalSelectorsText } from '../../selectors/Modals/NftDetectionModal.selectors'; describe(SmokeAssets('NFT Detection Modal'), () => { - const ETHEREUM = 'Ethereum Main Network'; beforeAll(async () => { jest.setTimeout(170000); await TestHelpers.reverseServerPort(); @@ -25,7 +23,6 @@ describe(SmokeAssets('NFT Detection Modal'), () => { await withFixtures( { fixture: new FixtureBuilder() - .withGanacheNetwork() .withPreferencesController({ useNftDetection: false, }) @@ -35,12 +32,6 @@ describe(SmokeAssets('NFT Detection Modal'), () => { }, async () => { await loginToApp(); - - // Switch to Mainnet - await WalletView.tapNetworksButtonOnNavBar(); - await NetworkListModal.changeNetworkTo(ETHEREUM); - await NetworkEducationModal.tapGotItButton(); - await Assertions.checkIfVisible(NftDetectionModal.container); // fix flaky test: toast should desapear to get access to cancel button @@ -65,7 +56,6 @@ describe(SmokeAssets('NFT Detection Modal'), () => { await withFixtures( { fixture: new FixtureBuilder() - .withGanacheNetwork() .withPreferencesController({ useNftDetection: false, }) @@ -76,11 +66,6 @@ describe(SmokeAssets('NFT Detection Modal'), () => { async () => { await loginToApp(); - // Switch to Mainnet - await WalletView.tapNetworksButtonOnNavBar(); - await NetworkListModal.changeNetworkTo(ETHEREUM); - await NetworkEducationModal.tapGotItButton(); - await Assertions.checkIfVisible(NftDetectionModal.container); await NftDetectionModal.tapAllowButton(); // Check that we are on the wallet screen diff --git a/ios/Podfile b/ios/Podfile index 759ce5f2e02..56b8c3f9164 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -98,7 +98,7 @@ def common_target_logic pod 'GzipSwift' # Pod for fixing react-native-quick-crypto issue: https://github.com/margelo/react-native-quick-crypto/issues/244 - pod "OpenSSL-Universal", "= 1.1.1100" + pod 'OpenSSL-Universal', :modular_headers => true, :configurations => ['Release'] end target 'MetaMask' do diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b8151245f04..24d83fa9fef 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -702,8 +702,6 @@ PODS: - React-Core - RNCPicker (2.2.1): - React-Core - - RNDateTimePicker (7.7.0): - - React-Core - RNDefaultPreference (1.4.3): - React - RNDeviceInfo (9.0.2): @@ -825,6 +823,7 @@ DEPENDENCIES: - GzipSwift - lottie-ios (from `../node_modules/lottie-ios`) - lottie-react-native (from `../node_modules/lottie-react-native`) + - OpenSSL-Universal - OpenSSL-Universal (= 1.1.1100) - Permission-BluetoothPeripheral (from `../node_modules/react-native-permissions/ios/BluetoothPeripheral`) - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) @@ -897,7 +896,6 @@ DEPENDENCIES: - "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)" - "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)" - "RNCPicker (from `../node_modules/@react-native-picker/picker`)" - - "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)" - RNDefaultPreference (from `../node_modules/react-native-default-preference`) - RNDeviceInfo (from `../node_modules/react-native-device-info`) - "RNFBApp (from `../node_modules/@react-native-firebase/app`)" @@ -1115,8 +1113,6 @@ EXTERNAL SOURCES: :path: "../node_modules/@react-native-masked-view/masked-view" RNCPicker: :path: "../node_modules/@react-native-picker/picker" - RNDateTimePicker: - :path: "../node_modules/@react-native-community/datetimepicker" RNDefaultPreference: :path: "../node_modules/react-native-default-preference" RNDeviceInfo: @@ -1274,7 +1270,6 @@ SPEC CHECKSUMS: RNCClipboard: ddd4d291537f1667209c9c405aaa4307297e252e RNCMaskedView: 090213d32d8b3bb83a4dcb7d12c18f0152591906 RNCPicker: cb57c823d5ce8d2d0b5dfb45ad97b737260dc59e - RNDateTimePicker: 4f3c4dbd4f908be32ec8c93f086e8924bd4a2e07 RNDefaultPreference: 2f8d6d54230edbd78708ada8d63bb275e5a8415b RNDeviceInfo: 1e3f62b9ec32f7754fac60bd06b8f8a27124e7f0 RNFBApp: 5f87753a8d8b37d229adf85cd0ff37709ffdf008 @@ -1302,6 +1297,6 @@ SPEC CHECKSUMS: Yoga: 6f5ab94cd8b1ecd04b6e973d0bc583ede2a598cc YogaKit: f782866e155069a2cca2517aafea43200b01fd5a -PODFILE CHECKSUM: 876298d4a106643492005466f7a314cd08711f4d +PODFILE CHECKSUM: e0bcc4eb12d48746028cd4f4161a292fa9ddc627 COCOAPODS: 1.15.2 diff --git a/package.json b/package.json index c65145f8a8a..1b811292afe 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,6 @@ "start:android:flask": "export METAMASK_BUILD_TYPE='flask' && ./scripts/build.sh android flaskDebug", "build:announce": "node ./scripts/metamask-bot-build-announce-bitrise.js", "build:android:release": "./scripts/build.sh android release", - "build:android:release:e2e": "./scripts/build.sh android releaseE2E", - "build:android:qa:e2e": "./scripts/build.sh android QAE2E", "build:android:checksum": "./scripts/checksum.sh", "build:android:checksum:qa": "./scripts/checksum.sh QA", "build:android:checksum:flask": "export METAMASK_BUILD_TYPE='flask' && ./scripts/checksum.sh flask", @@ -44,28 +42,26 @@ "build:android:pre-release:bundle:flask": "export METAMASK_BUILD_TYPE='flask' && GENERATE_BUNDLE=true ./scripts/build.sh android flask --pre", "build:ios:release": "./scripts/build.sh ios release", "build:ios:pre-flask": "export METAMASK_BUILD_TYPE='flask' && ./scripts/build.sh ios flask --pre", - "build:ios:release:e2e": "./scripts/build.sh ios releaseE2E", "build:ios:pre-release": "./scripts/build.sh ios release --pre", - "build:ios:qa": "./scripts/build.sh ios QA", "build:ios:pre-qa": "./scripts/build.sh ios QA --pre", + "build:android:qa": "NO_FLIPPER='1' ./scripts/build.sh android QA", + "build:ios:qa": "NO_FLIPPER='1' ./scripts/build.sh ios QA", "build:attribution": "./scripts/generate-attributions.sh", "release:android": "./scripts/build.sh android release && open android/app/build/outputs/apk/release/", "release:ios": "./scripts/build.sh ios release", "release:android:qa": "./scripts/build.sh android QA && open android/app/build/outputs/apk/release/", - "test": "yarn test:unit && yarn test:e2e", + "test": "yarn test:unit", "test:unit": "jest ./app/ ./locales/", "test:unit:update": "time jest -u ./app/", "test:api-specs": "detox reset-lock-file && detox test -c ios.sim.apiSpecs", - "test:e2e": "yarn test:e2e:ios && yarn test:e2e:android", - "test:e2e:ios": "detox build -c ios.sim.release && detox test -c ios.sim.release", + "test:e2e:ios:build:qa-release": "IS_TEST='true' detox build -c ios.sim.qa", + "test:e2e:ios:run:qa-release": "IS_TEST='true' detox test -c ios.sim.qa", + "test:e2e:android:build:qa-release": "NO_FLIPPER='1' IS_TEST='true' detox build -c android.emu.release.qa", + "test:e2e:android:run:qa-release": "NO_FLIPPER='1' IS_TEST='true' detox test -c android.emu.release.qa --headless --record-logs all", "test:e2e:ios:debug:build": "IS_TEST='true' detox build -c ios.sim.debug", "test:e2e:ios:debug:run": "IS_TEST='true' detox reset-lock-file && detox test -c ios.sim.debug", "test:e2e:android:debug:build": "IS_TEST='true' detox build -c android.emu.debug", - "test:e2e:android:bitrise:build": "IS_TEST='true' detox build -c android.emu.bitrise.debug", "test:e2e:android:debug:run": "IS_TEST='true' detox test -c android.emu.debug", - "test:e2e:android:bitrise:run": "IS_TEST='true' detox reset-lock-file && detox test -c android.emu.bitrise.debug --headless", - "test:e2e:android": "detox build -c android.emu.release && detox test -c android.emu.release --record-videos failing", - "test:e2e:android:qa": "detox build -c android.emu.release.qa && detox test -c android.emu.release.qa --record-videos failing", "test:wdio:ios": "yarn wdio ./wdio/config/ios.config.debug.js", "test:wdio:ios:browserstack:local": "yarn wdio ./wdio/config/ios.config.browserstack.local.js", "test:wdio:android": "yarn wdio ./wdio/config/android.config.debug.js", @@ -197,7 +193,6 @@ "@react-native-clipboard/clipboard": "1.8.4", "@react-native-community/blur": "^4.4.0", "@react-native-community/checkbox": "^0.5.17", - "@react-native-community/datetimepicker": "^7.5.0", "@react-native-community/netinfo": "^9.5.0", "@react-native-community/slider": "^4.4.3", "@react-native-cookies/cookies": "^6.2.1", diff --git a/patches/@react-native-community+datetimepicker+7.7.0.patch b/patches/@react-native-community+datetimepicker+7.7.0.patch deleted file mode 100644 index 1ebdc150937..00000000000 --- a/patches/@react-native-community+datetimepicker+7.7.0.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/node_modules/@react-native-community/datetimepicker/ios/RNDateTimePickerShadowView.m b/node_modules/@react-native-community/datetimepicker/ios/RNDateTimePickerShadowView.m -index 4ff3362..c139440 100644 ---- a/node_modules/@react-native-community/datetimepicker/ios/RNDateTimePickerShadowView.m -+++ b/node_modules/@react-native-community/datetimepicker/ios/RNDateTimePickerShadowView.m -@@ -41,7 +41,7 @@ - (void)setTimeZoneName:(NSString *)timeZoneName { - YGNodeMarkDirty(self.yogaNode); - } - --static YGSize RNDateTimePickerShadowViewMeasure(YGNodeConstRef node, float width, YGMeasureMode widthMode, float height, YGMeasureMode heightMode) -+static YGSize RNDateTimePickerShadowViewMeasure(YGNodeRef node, float width, YGMeasureMode widthMode, float height, YGMeasureMode heightMode) - { - RNDateTimePickerShadowView *shadowPickerView = (__bridge RNDateTimePickerShadowView *)YGNodeGetContext(node); - diff --git a/scripts/build.sh b/scripts/build.sh index f32e593e54e..1623dc30ca1 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -361,16 +361,16 @@ buildIosReleaseE2E(){ } buildIosQA(){ + echo "Start iOS QA build..." + remapEnvVariableQA prebuild_ios - echo "Start QA build..." - # Replace release.xcconfig with ENV vars if [ "$PRE_RELEASE" = true ] ; then echo "Setting up env vars..."; - echo "$IOS_ENV" + echo "$IOS_ENV" echo "$IOS_ENV" | tr "|" "\n" > $IOS_ENV_FILE echo "Build started..." brew install watchman @@ -380,22 +380,25 @@ buildIosQA(){ if [ ! -f "ios/release.xcconfig" ] ; then echo "$IOS_ENV" | tr "|" "\n" > ios/release.xcconfig fi - ./node_modules/.bin/react-native run-ios --scheme MetaMask-QA--configuration Release --simulator "iPhone 13 Pro" + cd ios && xcodebuild -workspace MetaMask.xcworkspace -scheme MetaMask-QA -configuration Release -sdk iphonesimulator -derivedDataPath build + # ./node_modules/.bin/react-native run-ios --scheme MetaMask-QA- -configuration Release --simulator "iPhone 13 Pro" fi } buildAndroidQA(){ + echo "Start Android QA build..." + remapEnvVariableQA - if [ "$PRE_RELEASE" = false ] ; then - adb uninstall io.metamask.qa - fi + # if [ "$PRE_RELEASE" = false ] ; then + # adb uninstall io.metamask.qa + # fi prebuild_android # Generate APK - cd android && ./gradlew assembleQaRelease --no-daemon --max-workers 2 + cd android && ./gradlew assembleQaRelease app:assembleQaReleaseAndroidTest -PminSdkVersion=26 -DtestBuildType=release # GENERATE BUNDLE if [ "$GENERATE_BUNDLE" = true ] ; then @@ -407,9 +410,9 @@ buildAndroidQA(){ yarn build:android:checksum:qa fi - if [ "$PRE_RELEASE" = false ] ; then - adb install app/build/outputs/apk/qa/release/app-qa-release.apk - fi + # if [ "$PRE_RELEASE" = false ] ; then + # adb install app/build/outputs/apk/qa/release/app-qa-release.apk + # fi } buildAndroidRelease(){ diff --git a/yarn.lock b/yarn.lock index 057a20745bb..4d2b39ab497 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6689,13 +6689,6 @@ prompts "^2.4.0" semver "^7.5.2" -"@react-native-community/datetimepicker@^7.5.0": - version "7.7.0" - resolved "https://registry.yarnpkg.com/@react-native-community/datetimepicker/-/datetimepicker-7.7.0.tgz#0d0162b0434c7b35883f8c5af846f35e23d045ec" - integrity sha512-nYzZy4DQLRFUzKJShWzRleCaebmCJfZ1lIcFmZgMXJoiVuGJNw3OIGHSWmHhPETh3OhP1RO3to882d7WmDIyrA== - dependencies: - invariant "^2.2.4" - "@react-native-community/netinfo@^9.5.0": version "9.5.0" resolved "https://registry.yarnpkg.com/@react-native-community/netinfo/-/netinfo-9.5.0.tgz#93663bbb105feb8f729b8f0271ee06ffc009f024" From 6db794f7dc4799918dc83c97211db25d5a6542e2 Mon Sep 17 00:00:00 2001 From: Nico MASSART Date: Mon, 14 Oct 2024 22:34:03 +0200 Subject: [PATCH 10/21] fix: non deterministic date in test (#11787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixes date that was generating an ever changing duration in the snapshot. - replace it with a relative date to the moment the test is running to make the duration always one day: the notification renders as if it was sent yesterday, always. - fix duration to one day (yesterday) as we have to fix one and it's not a worse value than any other. - rename test case to match guideline while I was on it... ## **Related issues** Fixes #11785 ## **Manual testing steps** N/A ## **Screenshots/Recordings** N/A ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. Co-authored-by: sethkfman <10342624+sethkfman@users.noreply.github.com> --- .../UI/Notification/NotificationMenuItem/Content.test.tsx | 5 +++-- .../NotificationMenuItem/__snapshots__/Content.test.tsx.snap | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/components/UI/Notification/NotificationMenuItem/Content.test.tsx b/app/components/UI/Notification/NotificationMenuItem/Content.test.tsx index 1a65102a1ad..c227b206ec5 100644 --- a/app/components/UI/Notification/NotificationMenuItem/Content.test.tsx +++ b/app/components/UI/Notification/NotificationMenuItem/Content.test.tsx @@ -5,14 +5,15 @@ import NotificationContent from './Content'; describe('NotificationContent', () => { const title = 'Welcome to the new Test!'; - const createdAt = '2024-04-26T16:35:03.147606Z'; + const yesterday = new Date().setDate(new Date().getDate() - 1); + const createdAt = new Date(yesterday).toISOString(); // Relative date: one day before current date const description = { start: 'We are excited to announce the launch of our brand new website and app!', end: 'Ethereum', }; - it('renders correctly', () => { + it('render matches snapshot', () => { const { toJSON } = renderWithProvider( - 6 months ago + Yesterday Date: Tue, 15 Oct 2024 11:41:00 +0100 Subject: [PATCH 11/21] fix: support for batch of signature requests (#11729) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR addresses the issue of multiple signature popups being triggered sequentially on Mobile, which could lead to potential spam or overwhelm the user. Currently, multiple signature requests appear one after the other, even if the user accepts or rejects them. To align with the existing behavior for transactions, this PR removes signatures from the rate-limiting exclusions, ensuring that only one signature request is triggered at a time, preventing spam-like behavior until a proper queuing system is introduced on Mobile. ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/8771 ## **Manual testing steps** 1. go to the test dapp 2. connect mm 3. trigger signature x10 batch 4. See you need to accept/reject 1 time ## **Screenshots/Recordings** [sign one.webm](https://github.com/user-attachments/assets/972f8643-0e29-44a8-9ad9-1587e2bdf5d3) Example of the current behaviour of batch 10 transactions: [transaction one.webm](https://github.com/user-attachments/assets/df6c1f92-3d09-4b5c-848b-07db56b9aede) ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/core/Engine.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/app/core/Engine.ts b/app/core/Engine.ts index 0c19aa58502..95199c1b9eb 100644 --- a/app/core/Engine.ts +++ b/app/core/Engine.ts @@ -232,7 +232,7 @@ import { selectSwapsChainFeatureFlags } from '../reducers/swaps'; import { SmartTransactionStatuses } from '@metamask/smart-transactions-controller/dist/types'; import { submitSmartTransactionHook } from '../util/smart-transactions/smart-publish-hook'; import { zeroAddress } from 'ethereumjs-util'; -import { toChecksumHexAddress } from '@metamask/controller-utils'; +import { ApprovalType, toChecksumHexAddress } from '@metamask/controller-utils'; import { ExtendedControllerMessenger } from './ExtendedControllerMessenger'; import EthQuery from '@metamask/eth-query'; import DomainProxyMap from '../lib/DomainProxyMap/DomainProxyMap'; @@ -514,11 +514,8 @@ class Engine { }), showApprovalRequest: () => undefined, typesExcludedFromRateLimiting: [ - // TODO: Replace with ApprovalType enum from @metamask/controller-utils when breaking change is fixed - 'personal_sign', - 'eth_signTypedData', - 'transaction', - 'wallet_watchAsset', + ApprovalType.Transaction, + ApprovalType.WatchAsset ], }); From fc55bce85c784f8f373a68723c5b9c19a85ddc60 Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Tue, 15 Oct 2024 08:59:45 -0300 Subject: [PATCH 12/21] fix(action): github action to apply release label is broken (#11794) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This fixes Github action that was broken on Sep 27th with this PR: https://github.com/MetaMask/metamask-mobile/pull/10917 A tentative fix was made on Oct 10th with this PR, but it didn't succeed: https://github.com/MetaMask/metamask-mobile/pull/11745 ## **Related issues** Fixes: None, but here's the corresponding [Slack thread](https://consensys.slack.com/archives/C02U025CVU4/p1728917197927679?thread_ts=1728583769.556839&cid=C02U025CVU4) ## **Manual testing steps** Before this PR is merged: 1. Clone the repo 2. Run the following CLI commands ```` cd .github/scripts node -p "require('../../package.json').version" ```` 3. Check that the release version printed corresponds to the one indicated in [main package.json](https://github.com/MetaMask/metamask-mobile/blob/main/package.json#L3) Once this PR will be merged: 1. Go to metamask-mobile repo's Github action 2. Check that [Add release label to PR and linked issues when PR gets merged](https://github.com/MetaMask/metamask-mobile/actions/workflows/add-release-label.yml) executions are back to green ## **Screenshots/Recordings** - None ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .github/scripts/get-next-semver-version.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/get-next-semver-version.sh b/.github/scripts/get-next-semver-version.sh index 8107471978e..552e1fa7061 100755 --- a/.github/scripts/get-next-semver-version.sh +++ b/.github/scripts/get-next-semver-version.sh @@ -16,7 +16,7 @@ VERSION_BRANCHES=$(git branch -r | grep -o 'release/[0-9]*\.[0-9]*\.[0-9]*' | gr VERSION_TAGS=$(git tag | grep -o 'v[0-9]*\.[0-9]*\.[0-9]*' | grep -o '[0-9]*\.[0-9]*\.[0-9]*' | sort --version-sort | tail -n 1) # Get the version from package.json -VERSION_PACKAGE=$(node -p "require('./package.json').version") +VERSION_PACKAGE=$(node -p "require('../../package.json').version") # Compare versions and keep the highest one HIGHEST_VERSION=$(printf "%s\n%s\n%s" "$VERSION_BRANCHES" "$VERSION_TAGS" "$VERSION_PACKAGE" | sort --version-sort | tail -n 1) From 3d31ef9d491ae7ffdfbae565550cc067cb117c8f Mon Sep 17 00:00:00 2001 From: AxelGes <34173844+AxelGes@users.noreply.github.com> Date: Tue, 15 Oct 2024 11:04:10 -0300 Subject: [PATCH 13/21] fix: use object styling in Title component (#11792) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** We need to use object styling instead of an array of styles because the Text component from the design system isn't prepared to receive an array therefore it breaks the views where we apply custom styles to the Title component. This is a patch for `release 7.34.0`. We will revisit this as an improvement in the Component Library. Please check screenshots. ## **Related issues** - ## **Manual testing steps** - ## **Screenshots/Recordings** | **Before** | **After** | |------------|-----------| | ![Before](https://github.com/user-attachments/assets/992279b8-db80-408d-afea-ed27007f0c10) | ![After](https://github.com/user-attachments/assets/a5215bb6-d6e9-487e-bf01-2da33aa35b86) | ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/components/Base/Title/Title.tsx | 12 +- .../__snapshots__/BuildQuote.test.tsx.snap | 160 +++++------------- .../__snapshots__/GetStarted.test.tsx.snap | 20 +-- .../NetworkSwitcher.test.tsx.snap | 60 ++----- .../__snapshots__/OrderDetails.test.tsx.snap | 20 +-- .../PaymentMethods.test.tsx.snap | 80 +++------ .../Quotes/__snapshots__/Quotes.test.tsx.snap | 100 +++-------- .../__snapshots__/Regions.test.tsx.snap | 40 ++--- 8 files changed, 126 insertions(+), 366 deletions(-) diff --git a/app/components/Base/Title/Title.tsx b/app/components/Base/Title/Title.tsx index ea062e2de3b..71f8eb5ccc4 100644 --- a/app/components/Base/Title/Title.tsx +++ b/app/components/Base/Title/Title.tsx @@ -18,12 +18,12 @@ const Title: React.FC = ({ return ( ); diff --git a/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap b/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap index 2c0d679ae3c..d8474b6cb32 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap +++ b/app/components/UI/Ramp/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap @@ -530,24 +530,14 @@ exports[`BuildQuote View Crypto Currency Data renders a special error page if cr accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > @@ -1241,24 +1231,14 @@ exports[`BuildQuote View Crypto Currency Data renders a special error page if cr accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > @@ -1952,24 +1932,14 @@ exports[`BuildQuote View Crypto Currency Data renders an error page when there i accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > @@ -3582,24 +3552,14 @@ exports[`BuildQuote View Fiat Currency Data renders an error page when there is accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > @@ -5212,24 +5172,14 @@ exports[`BuildQuote View Payment Method Data renders an error page when there is accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > @@ -6842,24 +6792,14 @@ exports[`BuildQuote View Regions data renders an error page when there is a regi accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > @@ -14562,24 +14502,14 @@ exports[`BuildQuote View renders correctly when sdkError is present 1`] = ` accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > @@ -15244,24 +15174,14 @@ exports[`BuildQuote View renders correctly when sdkError is present 2`] = ` accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > diff --git a/app/components/UI/Ramp/Views/GetStarted/__snapshots__/GetStarted.test.tsx.snap b/app/components/UI/Ramp/Views/GetStarted/__snapshots__/GetStarted.test.tsx.snap index e5caeaa4a0f..40c849bc25a 100644 --- a/app/components/UI/Ramp/Views/GetStarted/__snapshots__/GetStarted.test.tsx.snap +++ b/app/components/UI/Ramp/Views/GetStarted/__snapshots__/GetStarted.test.tsx.snap @@ -2296,24 +2296,14 @@ exports[`GetStarted renders correctly when sdkError is present 1`] = ` accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > diff --git a/app/components/UI/Ramp/Views/NetworkSwitcher/__snapshots__/NetworkSwitcher.test.tsx.snap b/app/components/UI/Ramp/Views/NetworkSwitcher/__snapshots__/NetworkSwitcher.test.tsx.snap index 92cb998fc5d..da5eb51bb0e 100644 --- a/app/components/UI/Ramp/Views/NetworkSwitcher/__snapshots__/NetworkSwitcher.test.tsx.snap +++ b/app/components/UI/Ramp/Views/NetworkSwitcher/__snapshots__/NetworkSwitcher.test.tsx.snap @@ -8400,24 +8400,14 @@ exports[`NetworkSwitcher View renders correctly with errors 1`] = ` accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > @@ -9059,24 +9049,14 @@ exports[`NetworkSwitcher View renders correctly with errors 2`] = ` accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > @@ -9718,24 +9698,14 @@ exports[`NetworkSwitcher View renders correctly with no data 1`] = ` accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > diff --git a/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap b/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap index b6ab62534d5..06315a15e53 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap +++ b/app/components/UI/Ramp/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap @@ -8415,24 +8415,14 @@ exports[`OrderDetails renders an error screen if a CREATED order cannot be polle accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > diff --git a/app/components/UI/Ramp/Views/PaymentMethods/__snapshots__/PaymentMethods.test.tsx.snap b/app/components/UI/Ramp/Views/PaymentMethods/__snapshots__/PaymentMethods.test.tsx.snap index 5c0641f5780..9f335593db4 100644 --- a/app/components/UI/Ramp/Views/PaymentMethods/__snapshots__/PaymentMethods.test.tsx.snap +++ b/app/components/UI/Ramp/Views/PaymentMethods/__snapshots__/PaymentMethods.test.tsx.snap @@ -4697,24 +4697,14 @@ exports[`PaymentMethods View renders correctly with empty data 1`] = ` accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > @@ -5381,24 +5371,14 @@ exports[`PaymentMethods View renders correctly with empty data for sell 1`] = ` accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > @@ -6065,24 +6045,14 @@ exports[`PaymentMethods View renders correctly with error 1`] = ` accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > @@ -9509,24 +9479,14 @@ exports[`PaymentMethods View renders correctly with sdkError 1`] = ` accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > diff --git a/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap b/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap index b66ad3b7fd6..7a16475b13c 100644 --- a/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Quotes/__snapshots__/Quotes.test.tsx.snap @@ -1095,24 +1095,14 @@ exports[`Quotes renders animation on first fetching 1`] = ` accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > @@ -6515,24 +6505,14 @@ exports[`Quotes renders correctly after animation without quotes 1`] = ` accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > @@ -7283,24 +7263,14 @@ exports[`Quotes renders correctly when fetching quotes errors 1`] = ` accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > @@ -8051,24 +8021,14 @@ exports[`Quotes renders correctly with sdkError 1`] = ` accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > @@ -8819,24 +8779,14 @@ exports[`Quotes renders quotes expired screen 1`] = ` accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > diff --git a/app/components/UI/Ramp/Views/Regions/__snapshots__/Regions.test.tsx.snap b/app/components/UI/Ramp/Views/Regions/__snapshots__/Regions.test.tsx.snap index d513139d165..4d872304a48 100644 --- a/app/components/UI/Ramp/Views/Regions/__snapshots__/Regions.test.tsx.snap +++ b/app/components/UI/Ramp/Views/Regions/__snapshots__/Regions.test.tsx.snap @@ -1934,24 +1934,14 @@ exports[`Regions View renders correctly with error 1`] = ` accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > @@ -3204,24 +3194,14 @@ exports[`Regions View renders correctly with sdkError 1`] = ` accessibilityRole="text" style={ { - "0": { - "color": "#141618", - "fontFamily": "EuclidCircularB-Bold", - "fontSize": 18, - "fontWeight": "600", - "marginVertical": 3, - }, - "1": { - "textAlign": "center", - }, - "2": undefined, - "3": undefined, "color": "#141618", - "fontFamily": "EuclidCircularB-Regular", - "fontSize": 14, - "fontWeight": "400", + "fontFamily": "EuclidCircularB-Bold", + "fontSize": 18, + "fontWeight": "600", "letterSpacing": 0, "lineHeight": 22, + "marginVertical": 3, + "textAlign": "center", } } > From f9f602ac8ffecf139b2ac3f03d7db52e0361236f Mon Sep 17 00:00:00 2001 From: Nick Gambino <35090461+gambinish@users.noreply.github.com> Date: Tue, 15 Oct 2024 07:13:46 -1000 Subject: [PATCH 14/21] chore: Add support for custom network images (#11761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Resurrecting an older PR of mine to provide support for custom network images: https://github.com/MetaMask/metamask-mobile/pull/10448 ## **Related issues** Fixes: Support custom network icons on mobile. ## **Manual testing steps** 1. Add custom network (In this case Flare Mainnet or Songbird Testnet) 2. Icon should appear wherever Network Icon should appear ## **Screenshots/Recordings** https://github.com/user-attachments/assets/6ea5b310-4b6a-4b6c-9656-d892bae83fc3 ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../UI/Tokens/TokenList/TokenListItem/index.tsx | 5 +++++ app/images/flare-mainnet.png | Bin 0 -> 15256 bytes app/images/songbird.png | Bin 0 -> 67703 bytes app/util/networks/customNetworks.tsx | 5 +++++ app/util/networks/index.js | 11 ++++++++++- 5 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 app/images/flare-mainnet.png create mode 100644 app/images/songbird.png diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx index a836606c818..7ab0ad470d5 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx @@ -47,6 +47,7 @@ import { strings } from '../../../../../../locales/i18n'; import { ScamWarningIcon } from '../ScamWarningIcon'; import { ScamWarningModal } from '../ScamWarningModal'; import { StakeButton } from '../StakeButton'; +import { CustomNetworkImgMapping } from '../../../../../util/networks/customNetworks'; interface TokenListItemProps { asset: TokenI; @@ -157,6 +158,10 @@ export const TokenListItem = ({ if (isLineaMainnet) return images['LINEA-MAINNET']; + if (CustomNetworkImgMapping[chainId]) { + return CustomNetworkImgMapping[chainId]; + } + return ticker ? images[ticker] : undefined; }; diff --git a/app/images/flare-mainnet.png b/app/images/flare-mainnet.png new file mode 100644 index 0000000000000000000000000000000000000000..07dac76783f7060f665080012f76c0084db71999 GIT binary patch literal 15256 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

agf83w}EnFV;@^Ur0UrqXr66^lGlixPEawmU;@ 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

u>Gp zec*(~&IIlbYv?Y+HN*0#@fQaAsja2e$H%Mo;&VSQ8I!C`I6m@eC`**BzVo87SkP7N zDR&dYE3@1sW6rGdy3@3(+~{#-={A8&t@YtwDnT|L#arnqwBr5KuEXqS=V!bwW-BYL zZ_Yw_r97Q~PQuk6fA&A?+4*_!hQ(-dk_bnWP?Inwlm#lAnHVYXCQKj+2}?@(3Lzqs z1sc7u%NawZ!^!-myD!9;h-Y_W!9o{zs*LZp)_@sfY3usme%Md5xZL%pb9hjN3i| z*ulRnHy)^q#{sEbvT!v9LwcjE94Qa?{hl3`T`_hceYl&4>5RVZO&d46O5c94w>{lQ z!2>g?`oYV5-IvQg+-;{G;zKhvbh*ZRduDC^u8xP-5tWyH+Kc68l72qN57bS?VS6OI z&MbUFsl?WDJhkMu>jFgt($oOQQYeMshbJ;k%EMxC2>M_kXi2gC%XdIZezV9zK|Te{ z(zU*hsksm%$-A1KT@6^26YqCC?A(w!JzZ#B!4OLTp3J-_tI0-iAJ)7ivG^#E4(7b+ zN(kIuQ+#UDs;a3$K_R10XhPAVwXK>T`(9onmV=c8i&dUL6N*dDk4rAiNXQ&4JsfkU zuXp*NKcT`z#MR1as}F5Rzi6;OyID6_^k`A;fxxL@vLPqaJJTr%g;oyJS|Nt<86-$XGLmH1qB6AVK2)>Dv-rT zTL=(05jSCj^Uk~vWo)aBctMCG-mcAocr!NhLmN?u2}88sQt(Y_p2u`msNMX|}c&5Ta~!QtqV zuA_!gFN1SXFDDbR?{O+&30dH0kzhs+FXLKY())-7Wncm3I(NS?d1Vj_`KarR^*hI1 z^qhQFd>-&Uk3I#9mIx~qUxhQ3BNZf1;eb;d4Pur}1$`r>+{O`?-#@eI=veorKt3Cr zkXQXmZs>v7RqTT;-org{Ukh%AXRXsgr+ZUbBzdbXPPRd{_Nb70>Sa5j!jw&%Fo-Mw zZ;kbpjp#fNKmlXG&;}jQO9MhAHF(Rqa|76|h-V~p|AO6gxZhy0n1bV1JmKt;;##Iy zYLjS7yx4U{W`N1UKn3I|Cg0dpB^hj8G(J{iHyUr=oCQS$fJO623v_`9ig2QT7C1@z zV+J?%d5y6MROiMK5aKMyI`%QTSFNx6lTLr~L~ZV;_j3E}jsIF$sL16YYXUG7`3_aG z=Z%e?)qQU4DIcZVnn$=0!~OgnZEydOKWaLs@2t`7d=uz+s;X|faJ>$<9eaXqG-U0@UFAeHwiDcI=~ z&Q2E-#od7IXhuj2X(fftLpm(nL?|9mV~u|gIHN8$s4xW%J{A^`7S>7xaY_Rraj^7y zWXXFAP*SAL23}?fs}*c$c>dt*Ad|hySWLUaJv~{OFM1byMt@U%)%W`MXKi+&M!;1t zSn@s5K+qYQm}GhNs~{JHbKgeyZTinR<-T)z{c8@-#rHHve5jzoAK~Q{^dH`PIDh8# zZuS;#SsOF~cDMEf#J(I3AsJPYf;|od$u~C_;zTFI3`D&I8kSws*gT)$tKhjgBj3*Zuk!CKNg0u) zQR1IG&IXfsb6REpqXf`{ddA0ED+?88##Vf<$7d?pAM(0Lyb+0as;B}~1bJ~E=lkE2 z8EGId2GM$7VL*l%={No@_1`v=xE!8@o!El+ByQ>B^7ATmo zgoaBa#F9K1(L*Yfs0Owy_tBUSK1kK#e(ADGVEg z?@S6}#H96^Llq*wyjg1VKlroxD7UR|+e!^)HEFofveuX1z^WuDP-Cx z(TXW!!%=p@K%)S$UKQ(y_#0 zVk&cZS1?PLdH4tn1vL-v^H5l)Mi<37fx=$UpowTos6qE+qtoYe+FSfKkGREb1CP%c z?&E;WW+{2%9~=4sH<(dRmjeE5k1n$d|M^zid$W1z?>6I6%K8vs15C5{;2k^D16rr; z$xWY2z!B{BK)C%^U~Ye*{+T)>lRVbPeASeUL~3Fc^3cs9G9ZU+iBgtJ{3?SVPCz?v zPAhX)KB;UlFrE*a07psb?t?=*IG9vvlNtnK#FAxV{Lg}brJ@TA2U1R0 zz#O0jb2xiuiN!mVHaLukESy-Vr-&Fo))s8%?HOo$K77G@n0>MRHaA=#Gw$b{0g9!r z1Cb-@`S;Ib_V~RepEt+99BrupR8}IMtB?L`z0xeLe6%ol#eYHr(1~`7aye@#`l65Yu;R%fU2=O zQlOEEoCp_R1_+@^r5SPXnZj{o8JPh5d7zID0!d^HX2zHK3bf||Ly9$Uw9Ik!?j8cS zFf0~oiyCG2Ol8r5@Kj~$Qe_lhW0G|6e-C21!S|>P%-Nj@c=B=UBi|9wTGzBoAxyo0 zd>Bc<#IjRQq9Xaen0Kw`#!Vl)Gxc`!&)%P}Yqj$$B-ZM*ac2<8z?;x#y(2D9p9lmn+$?pX3^p|c=p@3yqF@Ai zQG}9|nG%UI{v$E~`m)opT*GiT@lvD-Clf;jzyP}{k%I6OYyLYz{kXLzIe>Sr9=5)h z%B>#Tx@vVEH8~V(@i6HZ;;?uEZE1COvi^$09?i%oAIHZ*;DBP{>!xKSQ#;{B9+AkQ zkVw93cmX)7w@bxfM|4|d$9d8dzKizsty{@;GwEDCKMrhft8!~jI(VP$R^_~W79b9Q zN#?hvMnHLBzUYsQF4kFEu*P`+FHIckehkaIyxu(wT8CBN)}9CTmB;_&KG@5Cw* zW|`+8kTPisAGmj%Tw8!94fuo92}i^-#40H+)haWF;4vtipe6Hp(}@;r5^e*w5w{hm zAP@flmKQ+VAp|Fr!kHhZWN?|l9pEfwH6tRzk<;+lEjsk!->TKiT&X`-og3vTZOdQ2 ze2G2Pn@85RcaAN2I()j7C!SuZn3%dCJjD)k zvKh~vDnz?kA`I2t6=Ku2`=Y5N;3x$WfB<~L2;#Idc~DxSMw0wTB42PJpByb2NoBZv zM0>vt5!%~Mi$i%w9;7`%sa>lJv3lpc!@7K8DQ?S_3sWlygCYr1wvW2NW3-w`LRKz5 zW=?GuT7tAP8l$??udh$$di4Y5xiKq2=Z7~3>K%UuT37eh*Vl_LqaWFyuLpove*)j$ zE~$&(9IxJX=UrShQ!uI+}c8`S&Hz;QA;_DDr(ndzN#Fb)pip z3Q!)Z>l>?6o2FxBc$)0|O7-+lb~q9eEfXsfrR**nY0)PK)3GEsvV8ENj9{@u1F2F@ zZTYhWkkqI!KO$#BPQ(=zn%)aUIENdz)($` zT_KW@LuXDtLZv7sMyITxAZ};k1^>zRcmX+&g7~))_o-M9B@$*N#1K;4l>Bsla1Kb9 z6}>eano;$LWnv{D!YTL!@`VClb8Rpe%s;L zbx4zxN5ixjAQa++3}?*yrfnpx&CP#V&ksm$hW!E$R(x$umO*Ws1B+hm!Ri6)bxntK zLq3rfTJcV5Uy9;_-uGMe;X7~?LSM*63>r{Iu+o@JAdlLJ2y7sl`SKAjij?x1DTQ#z zbIMQ;$??Ku*!7rU4(LXO>f(gn6IpQzGWj3%Sx)fPdJFDF)JbxK4sQ!9{s#~s%a&G# z>U=DY-%#C>g{9Puf@k(usK?4WX|e!dHSjMS8Cq{ng3j)*9IXkrpA9tK9YU()`oBZ) z1P(#f!AA1PENE;MqoB{8Su3c%)ZttF=(iig)d}~N2r467{-eASi>P(6yYLjDjCJ3= zeDaj-aCwk!k~|j|AALr*c}5boG9-!cDEsJ^Uk!{(EbF5pkq4RC?P|6o`ak86EQ)6P zX1^BPa6q$N)*I=wm#Z&quXreUE&9?)G*ZjRA{i-_x6@1L928K7JncG7zlwb!tn)f5 zS~}0TX}J)Vs_{~P8=Km8y)fz3K91{7PoF-eFII)HV?MXl1nw(y`rcls#IGh1pUSQVUXkLPjz?XS-Lv-~TC(KbQROU679e5TE8yoi8jUU^VZoKxOtBS7+ zP{^w~*-3w9vNOfw$SX`~q*CmETJ7^Q0V8%hX*63og;@mH%b(AGPPN7_DcN{zSbc2- zAU1GfneHb4c1J?$tkP{k`NNaymi8b}^%<^>|G~WNfkZ8`fp7i=ALqPK_wY8gBidmh z!yX1Ff?1}PX>cSlBX!C&j>grpoLh$LGs#$F0lfn!vJi)&f+`%2HApMudt__87WEh7 zt)+NNkLPzpmJFj79Y5DSETx|9??y!oYtM(&cMx_q|*2O=c-+R^Jt3i&Dq#HxIhmm@!qe}{^snIRVY;0z}IW_jf&Q@mh~*g zREKxbHGP-%>c+9D>dhpov0efJti}%K=_mx-& zrkwl26W~NMgw!7ET)O~6-{G4(;(kqyE%~jQT}|X+(4XD<5n12Z$^(Ax(%+&U{oZTO zQXi{zFK~nz`MqS;{bmD2e%S8gD{S>>ns#cj1`G+#M#y_+$`56P`K>#}`tGvAFa~~#7Uljla*eDc|G6T6m`d*5@*wA@cD$F?rzPZtIZz9t!{4n0#3w!P7YF;Q)wQi z@j2E4dUoGxVkP#FEY|#I^rdiDx59%}H{Y{YP1T1!*BA-)=CFcBO7(gKm!83ZewzMni@}v}Q+t z19-n{=lPti5Rxlo+0P_zXjm*Rm`_z7;!+D@U=Pge9XVv-Hwyu zN$^?N5YaeQ(C63{)=ZbBN?6OGg6v>Inj%PvTK)OP6elAG2GoFoQj>@1juShp#784N z2;Z0YUpUCL6a)x`=OUfvA{~v-n-4rY`#loCv&+s}=PQ5i2llW2{j>Tf-K9V4-{LPV zc)JgZFD&kKMp@twzyrFH`h8;C0k3-Li>8a}cFhKi@1ZXfa0TMF905fSCwr8cr?(4f z=QDE}r9}wBPYoo}qtK?c77r+sGYVkfJ}W3-{{g{^Q;3bQK6yQCuuMaCRr5%(wlsAP zMI%&*aze+q4|e43m1;&-p42{zUmocYv&mLxca+*es305>)Z{7^JP@{896oXhivoEh zq)2D5lB;w9F~6r{~Ju?uC%T)Nh(uM7-&9n$%b zJ$jnYaRu3kMCQf z0yY>;Rw?dcBM@KnnkvdQ*Ym*jz;n2)FG<8Bd(y^I11=3w zQPn57En6E~q5+vsg$1@nS2XkK?JgoQ0>V^o^!o z#s{(#t?>5OxX;AVXS2_Q+m!$KOMN68sD@IKHeC8J?l->6scy#@hvgmwjQ{Q zlPJ`yqv~Hb_@p5F5kcv8kn5B>cuO-%0AZt1%%w5&EYL}Zg; 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 literal 0 HcmV?d00001 diff --git a/app/util/networks/customNetworks.tsx b/app/util/networks/customNetworks.tsx index fc98b3eb65c..8f1518733cd 100644 --- a/app/util/networks/customNetworks.tsx +++ b/app/util/networks/customNetworks.tsx @@ -129,3 +129,8 @@ export const UnpopularNetworkList = [ }, }, ]; + +export const CustomNetworkImgMapping: Record<`0x${string}`, string> = { + '0xe': require('../../images/flare-mainnet.png'), // Flare Mainnet + '0x13': require('../../images/songbird.png'), // Songbird Testnet +}; diff --git a/app/util/networks/index.js b/app/util/networks/index.js index e942a4b7512..6a91fa89a9b 100644 --- a/app/util/networks/index.js +++ b/app/util/networks/index.js @@ -32,7 +32,11 @@ const lineaTestnetLogo = require('../../images/linea-testnet-logo.png'); const lineaMainnetLogo = require('../../images/linea-mainnet-logo.png'); /* eslint-enable */ -import { PopularList, UnpopularNetworkList } from './customNetworks'; +import { + PopularList, + UnpopularNetworkList, + CustomNetworkImgMapping, +} from './customNetworks'; import { strings } from '../../../locales/i18n'; import { getEtherscanAddressUrl, @@ -433,6 +437,8 @@ export const getNetworkImageSource = ({ networkType, chainId }) => { (networkConfig) => networkConfig.chainId === chainId, ); + const customNetworkImg = CustomNetworkImgMapping[chainId]; + const popularNetwork = PopularList.find( (networkConfig) => networkConfig.chainId === chainId, ); @@ -441,6 +447,9 @@ export const getNetworkImageSource = ({ networkType, chainId }) => { if (network) { return network.rpcPrefs.imageSource; } + if (customNetworkImg) { + return customNetworkImg; + } return getTestNetImage(networkType); }; From b416eac8ef22de07a13c2e6b16115fc399b627e3 Mon Sep 17 00:00:00 2001 From: sethkfman <10342624+sethkfman@users.noreply.github.com> Date: Tue, 15 Oct 2024 14:15:02 -0600 Subject: [PATCH 15/21] chore: Update Sentry Performance Sampling utils.js (#11805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR reduces the sampling rate by 50% Sentry Performance. We are currently using too much of our allocation. ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/util/sentry/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/util/sentry/utils.js b/app/util/sentry/utils.js index 26448b31041..024422616a6 100644 --- a/app/util/sentry/utils.js +++ b/app/util/sentry/utils.js @@ -503,7 +503,7 @@ export function setupSentry() { ] : integrations, // Set tracesSampleRate to 1.0, as that ensures that every transaction will be sent to Sentry for development builds. - tracesSampleRate: __DEV__ ? 1.0 : 0.08, + tracesSampleRate: __DEV__ ? 1.0 : 0.04, beforeSend: (report) => rewriteReport(report), beforeBreadcrumb: (breadcrumb) => rewriteBreadcrumb(breadcrumb), beforeSendTransaction: (event) => excludeEvents(event), From 9ac14883f6d5a2085b066400156e42a493a626b7 Mon Sep 17 00:00:00 2001 From: Aslau Mario-Daniel Date: Wed, 16 Oct 2024 01:30:15 +0300 Subject: [PATCH 16/21] feat: 1940 Add custom traces (#11579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Screenshot 2024-10-11 at 20 29 04 Screenshot 2024-10-16 at 00 01 02 Screenshot 2024-10-15 at 17 57 12 This task is for adding custom spans to track activities that happen between app start and wallet UI load. The screenshot below is an example of a trace for Wallet UI load that takes about a minute to load. During that time, we can see a large gap between app start spans and the initial http requests. The goal here is to isolate these areas and track them with custom spans. Once implemented, we can expect to see the custom spans appearing within the gap, which would inform us of the areas to optimize Issue: https://github.com/MetaMask/mobile-planning/issues/1940 Technical Details * Added custom span for when the Login screen is mounted to when the login button is tapped * Added span for when the login button is tapped to when the wallet view is mounted * Added custom span for Engine initialization process * Added custom span for Store creation * Added custom span Storage rehydration * Added custom span fro Create New Wallet to Choose Password * Added custom span for Biometrics authentication ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/components/Nav/App/index.js | 11 ++++- app/components/Views/LockScreen/index.js | 18 ++++++-- app/components/Views/Login/index.js | 36 ++++++++++++--- app/components/Views/Onboarding/index.js | 38 ++++++++++------ app/components/Views/Wallet/index.tsx | 1 + app/store/index.ts | 58 ++++++++++++++++++++++-- app/util/trace.ts | 35 +++++++++++++- 7 files changed, 166 insertions(+), 31 deletions(-) diff --git a/app/components/Nav/App/index.js b/app/components/Nav/App/index.js index a587ef6c3ae..8f424320041 100644 --- a/app/components/Nav/App/index.js +++ b/app/components/Nav/App/index.js @@ -131,6 +131,7 @@ import OptionsSheet from '../../UI/SelectOptionSheet/OptionsSheet'; import FoxLoader from '../../../components/UI/FoxLoader'; import { AppStateEventProcessor } from '../../../core/AppStateEventListener'; import MultiRpcModal from '../../../components/Views/MultiRpcModal/MultiRpcModal'; +import { trace, TraceName, TraceOperation } from '../../../util/trace'; const clearStackNavigatorOptions = { headerShown: false, @@ -354,7 +355,15 @@ const App = (props) => { setOnboarded(!!existingUser); try { if (existingUser) { - await Authentication.appTriggeredAuth(); + await trace( + { + name: TraceName.BiometricAuthentication, + op: TraceOperation.BiometricAuthentication, + }, + async () => { + await Authentication.appTriggeredAuth(); + }, + ); // we need to reset the navigator here so that the user cannot go back to the login screen navigator.reset({ routes: [{ name: Routes.ONBOARDING.HOME_NAV }] }); } else { diff --git a/app/components/Views/LockScreen/index.js b/app/components/Views/LockScreen/index.js index 92f04193389..030bc9ace1d 100644 --- a/app/components/Views/LockScreen/index.js +++ b/app/components/Views/LockScreen/index.js @@ -22,6 +22,7 @@ import { import Routes from '../../../constants/navigation/Routes'; import { CommonActions } from '@react-navigation/native'; import trackErrorAsAnalytics from '../../../util/metrics/TrackError/trackErrorAsAnalytics'; +import { trace, TraceName, TraceOperation } from '../../../util/trace'; const LOGO_SIZE = 175; const createStyles = (colors) => @@ -134,10 +135,19 @@ class LockScreen extends PureComponent { // Retrieve the credentials Logger.log('Lockscreen::unlockKeychain - getting credentials'); - await Authentication.appTriggeredAuth({ - bioStateMachineId, - disableAutoLogout: true, - }); + await trace( + { + name: TraceName.BiometricAuthentication, + op: TraceOperation.BiometricAuthentication, + }, + async () => { + await Authentication.appTriggeredAuth({ + bioStateMachineId, + disableAutoLogout: true, + }); + }, + ); + this.setState({ ready: true }); Logger.log('Lockscreen::unlockKeychain - state: ready'); } catch (error) { diff --git a/app/components/Views/Login/index.js b/app/components/Views/Login/index.js index 488804f7a36..e737df72178 100644 --- a/app/components/Views/Login/index.js +++ b/app/components/Views/Login/index.js @@ -58,6 +58,12 @@ import { LoginViewSelectors } from '../../../../e2e/selectors/LoginView.selector import { withMetricsAwareness } from '../../../components/hooks/useMetrics'; import trackErrorAsAnalytics from '../../../util/metrics/TrackError/trackErrorAsAnalytics'; import { downloadStateLogs } from '../../../util/logs'; +import { + trace, + endTrace, + TraceName, + TraceOperation, +} from '../../../util/trace'; const deviceHeight = Device.getDeviceHeight(); const breakPoint = deviceHeight < 700; @@ -244,6 +250,10 @@ class Login extends PureComponent { fieldRef = React.createRef(); async componentDidMount() { + trace({ + name: TraceName.LoginToPasswordEntry, + op: TraceOperation.LoginToPasswordEntry, + }); this.props.metrics.trackEvent(MetaMetricsEvents.LOGIN_SCREEN_VIEWED); BackHandler.addEventListener('hardwareBackPress', this.handleBackPress); @@ -367,7 +377,15 @@ class Login extends PureComponent { ); try { - await Authentication.userEntryAuth(password, authType); + await trace( + { + name: TraceName.AuthenticateUser, + op: TraceOperation.AuthenticateUser, + }, + async () => { + await Authentication.userEntryAuth(password, authType); + }, + ); Keyboard.dismiss(); @@ -435,7 +453,15 @@ class Login extends PureComponent { const { current: field } = this.fieldRef; field?.blur(); try { - await Authentication.appTriggeredAuth(); + await trace( + { + name: TraceName.BiometricAuthentication, + op: TraceOperation.BiometricAuthentication, + }, + async () => { + await Authentication.appTriggeredAuth(); + }, + ); const onboardingWizard = await StorageWrapper.getItem(ONBOARDING_WIZARD); if (!onboardingWizard) this.props.setOnboardingWizardStep(1); this.props.navigation.replace(Routes.ONBOARDING.HOME_NAV); @@ -454,6 +480,7 @@ class Login extends PureComponent { }; triggerLogIn = () => { + endTrace({ name: TraceName.LoginToPasswordEntry }); this.onLogin(); }; @@ -536,10 +563,7 @@ class Login extends PureComponent { )} - + {strings('login.title')} diff --git a/app/components/Views/Onboarding/index.js b/app/components/Views/Onboarding/index.js index f15aa33c3c5..52ea24f6192 100644 --- a/app/components/Views/Onboarding/index.js +++ b/app/components/Views/Onboarding/index.js @@ -49,6 +49,7 @@ import { OnboardingSelectorIDs } from '../../../../e2e/selectors/Onboarding/Onbo import Routes from '../../../constants/navigation/Routes'; import { selectAccounts } from '../../../selectors/accountTrackerController'; import trackOnboarding from '../../../util/metrics/TrackOnboarding/trackOnboarding'; +import { trace, TraceName, TraceOperation } from '../../../util/trace'; const createStyles = (colors) => StyleSheet.create({ @@ -275,24 +276,33 @@ class Onboarding extends PureComponent { }; onPressCreate = () => { - const action = async () => { - const { metrics } = this.props; - if (metrics.isEnabled()) { - this.props.navigation.navigate('ChoosePassword', { - [PREVIOUS_SCREEN]: ONBOARDING, - }); - this.track(MetaMetricsEvents.WALLET_SETUP_STARTED); - } else { - this.props.navigation.navigate('OptinMetrics', { - onContinue: () => { - this.props.navigation.replace('ChoosePassword', { + const action = () => { + trace( + { + name: TraceName.CreateNewWalletToChoosePassword, + op: TraceOperation.CreateNewWalletToChoosePassword, + }, + () => { + const { metrics } = this.props; + if (metrics.isEnabled()) { + this.props.navigation.navigate('ChoosePassword', { [PREVIOUS_SCREEN]: ONBOARDING, }); this.track(MetaMetricsEvents.WALLET_SETUP_STARTED); - }, - }); - } + } else { + this.props.navigation.navigate('OptinMetrics', { + onContinue: () => { + this.props.navigation.replace('ChoosePassword', { + [PREVIOUS_SCREEN]: ONBOARDING, + }); + this.track(MetaMetricsEvents.WALLET_SETUP_STARTED); + }, + }); + } + }, + ); }; + this.handleExistingUser(action); }; diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index 97984c60b48..e8513940f7e 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -104,6 +104,7 @@ import { import { ButtonVariants } from '../../../component-library/components/Buttons/Button'; import { useListNotifications } from '../../../util/notifications/hooks/useNotifications'; import { isObject } from 'lodash'; + const createStyles = ({ colors, typography }: Theme) => StyleSheet.create({ base: { diff --git a/app/store/index.ts b/app/store/index.ts index aa7a4df512f..84a500f8784 100644 --- a/app/store/index.ts +++ b/app/store/index.ts @@ -9,6 +9,9 @@ import { Authentication } from '../core'; import LockManagerService from '../core/LockManagerService'; import ReadOnlyNetworkStore from '../util/test/network-store'; import { isE2E } from '../util/test/utils'; +import { trace, endTrace, TraceName, TraceOperation } from '../util/trace'; +import StorageWrapper from './storage-wrapper'; + import thunk from 'redux-thunk'; import persistConfig from './persistConfig'; @@ -24,7 +27,7 @@ const pReducer = persistReducer(persistConfig, rootReducer); // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any, import/no-mutable-exports let store: Store, persistor; -const createStoreAndPersistor = async () => { +const createStoreAndPersistor = async (appStartTime: number) => { // Obtain the initial state from ReadOnlyNetworkStore for E2E tests. const initialState = isE2E ? await ReadOnlyNetworkStore.getState() @@ -46,6 +49,24 @@ const createStoreAndPersistor = async () => { middlewares.push(createReduxFlipperDebugger()); } + const jsStartTime = performance.now(); + + trace({ + name: TraceName.LoadScripts, + op: TraceOperation.LoadScripts, + startTime: appStartTime, + }); + + endTrace({ + name: TraceName.LoadScripts, + timestamp: appStartTime + jsStartTime, + }); + + trace({ + name: TraceName.CreateStore, + op: TraceOperation.CreateStore, + }); + store = configureStore({ reducer: pReducer, middleware: middlewares, @@ -54,10 +75,19 @@ const createStoreAndPersistor = async () => { sagaMiddleware.run(rootSaga); + endTrace({ name: TraceName.CreateStore }); + + trace({ + name: TraceName.StorageRehydration, + op: TraceOperation.StorageRehydration, + }); + /** * Initialize services after persist is completed */ - const onPersistComplete = () => { + const onPersistComplete = async () => { + endTrace({ name: TraceName.StorageRehydration }); + /** * EngineService.initalizeEngine(store) with SES/lockdown: * Requires ethjs nested patches (lib->src) @@ -73,6 +103,7 @@ const createStoreAndPersistor = async () => { * - TypeError: undefined is not an object (evaluating 'TokenListController.tokenList') * - V8: SES_UNHANDLED_REJECTION */ + store.dispatch({ type: 'TOGGLE_BASIC_FUNCTIONALITY', basicFunctionalityEnabled: @@ -83,7 +114,17 @@ const createStoreAndPersistor = async () => { store.dispatch({ type: 'FETCH_FEATURE_FLAGS', }); - EngineService.initalizeEngine(store); + + await trace( + { + name: TraceName.EngineInitialization, + op: TraceOperation.EngineInitialization, + }, + () => { + EngineService.initalizeEngine(store); + }, + ); + Authentication.init(store); AppStateEventProcessor.init(store); LockManagerService.init(store); @@ -93,7 +134,16 @@ const createStoreAndPersistor = async () => { }; (async () => { - await createStoreAndPersistor(); + const appStartTime = await StorageWrapper.getItem('appStartTime'); + + await trace( + { + name: TraceName.UIStartup, + op: TraceOperation.UIStartup, + startTime: appStartTime, + }, + async () => await createStoreAndPersistor(appStartTime), + ); })(); export { store, persistor }; diff --git a/app/util/trace.ts b/app/util/trace.ts index 8275c521b84..fd6b4c9dfb3 100644 --- a/app/util/trace.ts +++ b/app/util/trace.ts @@ -19,6 +19,29 @@ export enum TraceName { NotificationDisplay = 'Notification Display', PPOMValidation = 'PPOM Validation', Signature = 'Signature', + LoadScripts = 'Load Scripts', + SetupStore = 'Setup Store', + LoginToPasswordEntry = 'Login to Password Entry', + AuthenticateUser = 'Authenticate User', + BiometricAuthentication = 'Biometrics Authentication', + EngineInitialization = 'Engine Initialization', + CreateStore = 'Create Store', + CreateNewWalletToChoosePassword = 'Create New Wallet to Choose Password', + StorageRehydration = 'Storage Rehydration', + UIStartup = 'Custom UIStartup', +} + +export enum TraceOperation { + LoadScripts = 'custom.load.scripts', + SetupStore = 'custom.setup.store', + LoginToPasswordEntry = 'custom.login.to.password.entry', + BiometricAuthentication = 'biometrics.authentication', + AuthenticateUser = 'custom.authenticate.user', + EngineInitialization = 'custom.engine.initialization', + CreateStore = 'custom.create.store', + CreateNewWalletToChoosePassword = 'custom.create.new.wallet', + StorageRehydration = 'custom.storage.rehydration', + UIStartup = 'custom.ui.startup', } const ID_DEFAULT = 'default'; @@ -45,6 +68,7 @@ export interface TraceRequest { parentContext?: TraceContext; startTime?: number; tags?: Record; + op?: string; } export interface EndTraceRequest { @@ -154,13 +178,20 @@ function startSpan( request: TraceRequest, callback: (spanOptions: StartSpanOptions) => T, ) { - const { data: attributes, name, parentContext, startTime, tags } = request; + const { + data: attributes, + name, + parentContext, + startTime, + tags, + op, + } = request; const parentSpan = (parentContext ?? null) as Span | null; const spanOptions: StartSpanOptions = { attributes, name, - op: OP_DEFAULT, + op: op || OP_DEFAULT, // This needs to be parentSpan once we have the withIsolatedScope implementation in place in the Sentry SDK for React Native // Reference PR that updates @sentry/react-native: https://github.com/getsentry/sentry-react-native/pull/3895 parentSpanId: parentSpan?.spanId, From 9db29fdf09c7df9852518ee516436637ed36079a Mon Sep 17 00:00:00 2001 From: tommasini <46944231+tommasini@users.noreply.github.com> Date: Tue, 15 Oct 2024 23:32:27 +0100 Subject: [PATCH 17/21] fix: persist token and phishing list (#11802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Persisting back again token list and phishing list. From 11KBs to 8MB to an wallet with: * 2 accounts * 7 Networks added plus Ethereum and LInea Mainnet Tokens imported by network * - Ethereum: 16 * - Linea: 6 * - Avalanche: 9 * - Binance: 10 * - Base: 1 * - Optimism: 3 * - Polygon: 3 * - Palm:0 * - ZkSync: 1 * - Arbitrum: 5 App launch times e2e: 1- https://app.bitrise.io/app/be69d4368ee7e86d/pipelines/b1ae0ba0-cee8-472d-978e-17882f132740 2- https://app.bitrise.io/app/be69d4368ee7e86d/pipelines/d8fda877-0c9a-4448-a75a-cc4921551e16 3- https://app.bitrise.io/app/be69d4368ee7e86d/pipelines/38dc5140-e18e-494c-a86a-22492554e837 ------After fixing e2e------ 1- https://app.bitrise.io/app/be69d4368ee7e86d/pipelines/797b8317-6116-4bb5-8a12-e60a8e1b7aeb ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** Exploring app (tokens): https://github.com/user-attachments/assets/638e42de-2219-423f-a9ee-6b18ada57751 Phishing detector in app browser: https://github.com/user-attachments/assets/62f63451-fa7e-445f-b875-21155b13a8bc ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --------- Co-authored-by: Aslau Mario-Daniel --- app/core/EngineService/EngineService.ts | 2 +- app/store/persistConfig.ts | 16 +++------------- wdio/step-definitions/common-steps.js | 1 - 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/app/core/EngineService/EngineService.ts b/app/core/EngineService/EngineService.ts index c46e5118ff5..0c24c4821a1 100644 --- a/app/core/EngineService/EngineService.ts +++ b/app/core/EngineService/EngineService.ts @@ -78,7 +78,7 @@ class EngineService { }, { name: 'PhishingController', - key: `${engine.context.PhishingController.name}:maybeUpdateState`, + key: `${engine.context.PhishingController.name}:stateChange`, }, { name: 'PreferencesController', diff --git a/app/store/persistConfig.ts b/app/store/persistConfig.ts index 2cd7477a432..bab5f5d74fe 100644 --- a/app/store/persistConfig.ts +++ b/app/store/persistConfig.ts @@ -68,14 +68,9 @@ const persistTransform = createTransform( return inboundState; } - const { - TokenListController, - SwapsController, - PhishingController, - ...controllers - } = inboundState.backgroundState || {}; - const { tokenList, tokensChainsCache, ...persistedTokenListController } = - TokenListController; + const { SwapsController, ...controllers } = + inboundState.backgroundState || {}; + const { aggregatorMetadata, aggregatorMetadataLastFetched, @@ -87,16 +82,11 @@ const persistTransform = createTransform( ...persistedSwapsController } = SwapsController; - const { phishingLists, whitelist, ...persistedPhishingController } = - PhishingController; - // Reconstruct data to persist const newState = { backgroundState: { ...controllers, - TokenListController: persistedTokenListController, SwapsController: persistedSwapsController, - PhishingController: persistedPhishingController, }, }; return newState; diff --git a/wdio/step-definitions/common-steps.js b/wdio/step-definitions/common-steps.js index 39f06270b6d..69da7223da4 100644 --- a/wdio/step-definitions/common-steps.js +++ b/wdio/step-definitions/common-steps.js @@ -54,7 +54,6 @@ Given(/^I have imported my wallet$/, async () => { await MetaMetricsScreen.isScreenTitleVisible(); await MetaMetricsScreen.tapIAgreeButton(); await TermOfUseScreen.isDisplayed(); - await TermOfUseScreen.textIsDisplayed(); await TermOfUseScreen.tapAgreeCheckBox(); await TermOfUseScreen.tapScrollEndButton(); if (!(await TermOfUseScreen.isCheckBoxChecked())) { From f996de194b885948f1706a25ace5d293798375fe Mon Sep 17 00:00:00 2001 From: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Date: Tue, 15 Oct 2024 20:05:05 -0400 Subject: [PATCH 18/21] feat: STAKE-824: [FE] build staking input confirmation screen (#11605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR adds the staking confirmation screen with some mock data being used temporarily. ### Change List - Add staking confirmation screen. - Connect existing `` to staking confirmation screen when user enters valid amount to stake. ## **Related issues** Ticket: [FE] Build staking input confirmation screen - ([link](https://consensyssoftware.atlassian.net/browse/STAKE-824)) Figma Designs - [link](https://www.figma.com/design/1c0Y9jDJe6p0j82jydJDcs/Mobile-Staking?node-id=2979-22435&m=dev) ## **Manual testing steps** 1. Add `export MM_POOLED_STAKING_UI_ENABLED=true` to your `.js.env` file. 2. Click on Ethereum In the token list page 3. Scroll down a bit and click "Stake more". This should open the stake input view (not related to this PR) 4. Enter a valid amount to stake and click "Confirm" 5. You should be redirected to a staking confirmation screen. The screen should display the amount to stake in `wETH` and Fiat. ## **Screenshots/Recordings** ### **Before** Nothing would happen after clicking "Confirm" on the stake input view. This screen is new. ### **After** https://github.com/user-attachments/assets/84ea4c52-50c5-48c3-8077-2c2e8a92bf21 ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/components/UI/Navbar/index.js | 63 +- .../StakeConfirmationView.styles.ts | 23 + .../StakeConfirmationView.test.tsx | 74 + .../StakeConfirmationView.tsx | 60 + .../StakeConfirmationView.types.ts | 10 + .../StakeConfirmationView.test.tsx.snap | 1424 +++++++++++++++++ .../Views/StakeInputView/StakeInputView.tsx | 16 +- .../StakeInputView.test.tsx.snap | 100 +- .../UnstakeInputView/UnstakeInputView.tsx | 6 +- .../UnstakeInputView.test.tsx.snap | 100 +- .../StakingBalance/StakingBalance.test.tsx | 40 +- .../StakingBalance/StakingCta/StakingCta.tsx | 6 +- .../__snapshots__/StakingCta.test.tsx.snap | 2 +- .../StakingBalance.test.tsx.snap | 1385 +++++++--------- .../AccountHeaderCard.styles.ts | 40 + .../AccountHeaderCard.test.tsx | 72 + .../AccountHeaderCard/AccountHeaderCard.tsx | 74 + .../AccountHeaderCard.types.ts | 3 + .../AccountHeaderCard.test.tsx.snap | 623 ++++++++ .../AccountTag/AccountTag.test.tsx | 31 + .../AccountTag/AccountTag.tsx | 38 + .../AccountTag/AccountTag.types.ts | 5 + .../__snapshots__/AccountTag.test.tsx.snap | 395 +++++ .../ConfirmationFooter.styles.ts | 114 ++ .../ConfirmationFooter.test.tsx | 19 + .../ConfirmationFooter/ConfirmationFooter.tsx | 19 + .../FooterButtonGroup.styles.ts | 18 + .../FooterButtonGroup.test.tsx | 49 + .../FooterButtonGroup/FooterButtonGroup.tsx | 59 + .../FooterButtonGroup.test.tsx.snap | 189 +++ .../LegalLinks/LegalLinks.styles.ts | 14 + .../LegalLinks/LegalLinks.test.tsx | 61 + .../LegalLinks/LegalLinks.tsx | 52 + .../__snapshots__/LegalLinks.test.tsx.snap | 187 +++ .../ConfirmationFooter.test.tsx.snap | 162 ++ .../ContractTag/ContractTag.test.tsx | 17 + .../ContractTag/ContractTag.tsx | 23 + .../ContractTag/ContractTag.types.ts | 3 + .../__snapshots__/ContractTag.test.tsx.snap | 69 + .../EstimatedGasCard.styles.ts | 39 + .../EstimatedGasCard.test.tsx | 67 + .../EstimatedGasCard/EstimatedGasCard.tsx | 69 + .../EstimatedGasCard.types.ts | 4 + .../EstimatedGasCard.test.tsx.snap | 419 +++++ .../EstimatedGasFeeTooltipContent.styles.ts | 13 + .../EstimatedGasFeeTooltipContent.test.tsx | 53 + .../EstimatedGasFeeTooltipContent.tsx | 43 + ...stimatedGasFeeTooltipContent.test.tsx.snap | 67 + .../RewardsCard/RewardsCard.styles.ts | 16 + .../RewardsCard/RewardsCard.test.tsx | 98 ++ .../RewardsCard/RewardsCard.tsx | 74 + .../RewardsCard/RewardsCard.types.ts | 5 + .../__snapshots__/RewardsCard.test.tsx.snap | 1255 +++++++++++++++ .../TokenValueStack/TokenValueStack.styles.ts | 23 + .../TokenValueStack/TokenValueStack.test.tsx | 33 + .../TokenValueStack/TokenValueStack.tsx | 54 + .../TokenValueStack/TokenValueStack.types.ts | 7 + .../TokenValueStack.test.tsx.snap | 212 +++ app/components/UI/Stake/routes/index.tsx | 5 + .../Views/TooltipModal/ToolTipModal.styles.ts | 1 - app/constants/navigation/Routes.ts | 1 + app/core/AppConstants.ts | 1 + locales/languages/en.json | 19 +- 63 files changed, 7149 insertions(+), 1074 deletions(-) create mode 100644 app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.styles.ts create mode 100644 app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx create mode 100644 app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx create mode 100644 app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.types.ts create mode 100644 app/components/UI/Stake/Views/StakeConfirmationView/__snapshots__/StakeConfirmationView.test.tsx.snap create mode 100644 app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/AccountHeaderCard.styles.ts create mode 100644 app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/AccountHeaderCard.test.tsx create mode 100644 app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/AccountHeaderCard.tsx create mode 100644 app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/AccountHeaderCard.types.ts create mode 100644 app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/__snapshots__/AccountHeaderCard.test.tsx.snap create mode 100644 app/components/UI/Stake/components/StakingConfirmation/AccountTag/AccountTag.test.tsx create mode 100644 app/components/UI/Stake/components/StakingConfirmation/AccountTag/AccountTag.tsx create mode 100644 app/components/UI/Stake/components/StakingConfirmation/AccountTag/AccountTag.types.ts create mode 100644 app/components/UI/Stake/components/StakingConfirmation/AccountTag/__snapshots__/AccountTag.test.tsx.snap create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/ConfirmationFooter.styles.ts create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/ConfirmationFooter.test.tsx create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/ConfirmationFooter.tsx create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.styles.ts create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.test.tsx create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.tsx create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/__snapshots__/FooterButtonGroup.test.tsx.snap create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/LegalLinks/LegalLinks.styles.ts create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/LegalLinks/LegalLinks.test.tsx create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/LegalLinks/LegalLinks.tsx create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/LegalLinks/__snapshots__/LegalLinks.test.tsx.snap create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/__snapshots__/ConfirmationFooter.test.tsx.snap create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ContractTag/ContractTag.test.tsx create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ContractTag/ContractTag.tsx create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ContractTag/ContractTag.types.ts create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ContractTag/__snapshots__/ContractTag.test.tsx.snap create mode 100644 app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/EstimatedGasCard.styles.ts create mode 100644 app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/EstimatedGasCard.test.tsx create mode 100644 app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/EstimatedGasCard.tsx create mode 100644 app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/EstimatedGasCard.types.ts create mode 100644 app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/__snapshots__/EstimatedGasCard.test.tsx.snap create mode 100644 app/components/UI/Stake/components/StakingConfirmation/EstimatedGasFeeTooltipContent/EstimatedGasFeeTooltipContent.styles.ts create mode 100644 app/components/UI/Stake/components/StakingConfirmation/EstimatedGasFeeTooltipContent/EstimatedGasFeeTooltipContent.test.tsx create mode 100644 app/components/UI/Stake/components/StakingConfirmation/EstimatedGasFeeTooltipContent/EstimatedGasFeeTooltipContent.tsx create mode 100644 app/components/UI/Stake/components/StakingConfirmation/EstimatedGasFeeTooltipContent/__snapshots__/EstimatedGasFeeTooltipContent.test.tsx.snap create mode 100644 app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.styles.ts create mode 100644 app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.test.tsx create mode 100644 app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.tsx create mode 100644 app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.types.ts create mode 100644 app/components/UI/Stake/components/StakingConfirmation/RewardsCard/__snapshots__/RewardsCard.test.tsx.snap create mode 100644 app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.styles.ts create mode 100644 app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.test.tsx create mode 100644 app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.tsx create mode 100644 app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.types.ts create mode 100644 app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/__snapshots__/TokenValueStack.test.tsx.snap diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index 8127d67f23c..fd8aa98b1f8 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -1824,34 +1824,61 @@ export const getSettingsNavigationOptions = (title, themeColors) => { }; }; -export function getStakingNavbar(title, navigation, themeColors) { +/** + * + * @param {String} title - Navbar Title. + * @param {NavigationProp} navigation Navigation object returned from useNavigation hook. + * @param {ThemeColors} themeColors theme.colors returned from useStyles hook. + * @param {{ backgroundColor?: string, hasCancelButton?: boolean, hasBackButton?: boolean }} [options] - Optional options for navbar. + * @returns Staking Navbar Component. + */ +export function getStakingNavbar(title, navigation, themeColors, options) { + const { hasBackButton = true, hasCancelButton = true } = options ?? {}; + const innerStyles = StyleSheet.create({ + headerStyle: { + backgroundColor: + options?.backgroundColor ?? themeColors.background.default, + shadowOffset: null, + }, + headerLeft: { + marginHorizontal: 16, + }, headerButtonText: { color: themeColors.primary.default, fontSize: 14, ...fontStyles.normal, }, - headerStyle: { - backgroundColor: themeColors.background.default, - shadowColor: importedColors.transparent, - elevation: 0, - }, }); + + function navigationPop() { + navigation.goBack(); + } + return { headerTitle: () => ( - - ), - headerLeft: () => , - headerRight: () => ( - navigation.dangerouslyGetParent()?.pop()} - style={styles.closeButton} - > - - {strings('navigation.cancel')} - - + {title} ), headerStyle: innerStyles.headerStyle, + headerLeft: () => + hasBackButton ? ( + + ) : null, + headerRight: () => + hasCancelButton ? ( + navigation.dangerouslyGetParent()?.pop()} + style={styles.closeButton} + > + + {strings('navigation.cancel')} + + + ) : null, }; } diff --git a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.styles.ts b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.styles.ts new file mode 100644 index 00000000000..d351fc7302a --- /dev/null +++ b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.styles.ts @@ -0,0 +1,23 @@ +import type { Theme } from '../../../../../util/theme/models'; +import { StyleSheet } from 'react-native'; + +const stylesSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + + return StyleSheet.create({ + mainContainer: { + flex: 1, + paddingTop: 8, + paddingHorizontal: 16, + backgroundColor: colors.background.alternative, + justifyContent: 'space-between', + }, + cardsContainer: { + paddingTop: 16, + gap: 8, + }, + }); +}; + +export default stylesSheet; diff --git a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx new file mode 100644 index 00000000000..109fe3e7fac --- /dev/null +++ b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import StakeConfirmationView from './StakeConfirmationView'; +import { Image } from 'react-native'; +import { createMockAccountsControllerState } from '../../../../../util/test/accountsControllerTestUtils'; +import { backgroundState } from '../../../../../util/test/initial-root-state'; +import configureMockStore from 'redux-mock-store'; +import { Provider } from 'react-redux'; +import { StakeConfirmationViewProps } from './StakeConfirmationView.types'; + +jest.mock('../../../../hooks/useIpfsGateway', () => jest.fn()); + +Image.getSize = jest.fn((_uri, success) => { + success(100, 100); // Mock successful response for ETH native Icon Image +}); + +const MOCK_ADDRESS_1 = '0x0'; +const MOCK_ADDRESS_2 = '0x1'; + +const MOCK_ACCOUNTS_CONTROLLER_STATE = createMockAccountsControllerState([ + MOCK_ADDRESS_1, + MOCK_ADDRESS_2, +]); + +const mockStore = configureMockStore(); + +const mockInitialState = { + settings: {}, + engine: { + backgroundState: { + ...backgroundState, + AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, + }, + }, +}; +const store = mockStore(mockInitialState); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest + .fn() + .mockImplementation((callback) => callback(mockInitialState)), +})); + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useNavigation: () => ({ + navigate: jest.fn(), + setOptions: jest.fn(), + }), + }; +}); + +describe('StakeConfirmationView', () => { + it('render matches snapshot', () => { + const props: StakeConfirmationViewProps = { + route: { + key: '1', + params: { amountWei: '3210000000000000', amountFiat: '7.46' }, + name: 'params', + }, + }; + + const { toJSON } = renderWithProvider( + + + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx new file mode 100644 index 00000000000..2f1a4890286 --- /dev/null +++ b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx @@ -0,0 +1,60 @@ +import React, { useEffect } from 'react'; +import { View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useStyles } from '../../../../hooks/useStyles'; +import { getStakingNavbar } from '../../../Navbar'; +import styleSheet from './StakeConfirmationView.styles'; +import TokenValueStack from '../../components/StakingConfirmation/TokenValueStack/TokenValueStack'; +import AccountHeaderCard from '../../components/StakingConfirmation/AccountHeaderCard/AccountHeaderCard'; +import RewardsCard from '../../components/StakingConfirmation/RewardsCard/RewardsCard'; +import ConfirmationFooter from '../../components/StakingConfirmation/ConfirmationFooter/ConfirmationFooter'; +import { StakeConfirmationViewProps } from './StakeConfirmationView.types'; +import { MOCK_GET_VAULT_RESPONSE } from '../../components/StakingBalance/mockData'; +import { strings } from '../../../../../../locales/i18n'; + +const MOCK_REWARD_DATA = { + REWARDS: { + ETH: '0.13 ETH', + FIAT: '$334.93', + }, +}; + +const MOCK_STAKING_CONTRACT_NAME = 'MM Pooled Staking'; + +const StakeConfirmationView = ({ route }: StakeConfirmationViewProps) => { + const navigation = useNavigation(); + + const { styles, theme } = useStyles(styleSheet, {}); + + useEffect(() => { + navigation.setOptions( + getStakingNavbar(strings('stake.stake'), navigation, theme.colors, { + backgroundColor: theme.colors.background.alternative, + hasCancelButton: false, + }), + ); + }, [navigation, theme.colors]); + + return ( + + + + + + + + + + + ); +}; + +export default StakeConfirmationView; diff --git a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.types.ts b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.types.ts new file mode 100644 index 00000000000..8c723135f4f --- /dev/null +++ b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.types.ts @@ -0,0 +1,10 @@ +import { RouteProp } from '@react-navigation/native'; + +interface StakeConfirmationViewRouteParams { + amountWei: string; + amountFiat: string; +} + +export interface StakeConfirmationViewProps { + route: RouteProp<{ params: StakeConfirmationViewRouteParams }, 'params'>; +} diff --git a/app/components/UI/Stake/Views/StakeConfirmationView/__snapshots__/StakeConfirmationView.test.tsx.snap b/app/components/UI/Stake/Views/StakeConfirmationView/__snapshots__/StakeConfirmationView.test.tsx.snap new file mode 100644 index 00000000000..9d14c100f63 --- /dev/null +++ b/app/components/UI/Stake/Views/StakeConfirmationView/__snapshots__/StakeConfirmationView.test.tsx.snap @@ -0,0 +1,1424 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`StakeConfirmationView render matches snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + 0.00321 + + ETH + + + $7.46 + + + + + + + + + + + + Staking from + + + + + + + + + + + + + + + + + + + + + + + Account 1 + + + + + + + + + + + + + Interacting with + + + + + + + + + + + + + MM Pooled Staking + + + + + + + + + + + + + + + Network + + + + + + + + + + + + + Ethereum Main Network + + + + + + + + + + + + + + + Reward rate + + + + + + + + + + + + 2.8% + + + + + + + + + + + Estimated annual rewards + + + + + + + + + + $334.93 + + + 0.13 ETH + + + + + + + + + + + + Reward frequency + + + + + + + + + + + + 12 hours + + + + + + + + + + + + + Terms of service + + + + + Risk disclosure + + + + + + + Cancel + + + + + Confirm + + + + + +`; diff --git a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx index 1ca6560d7f4..0431e67a77f 100644 --- a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx +++ b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx @@ -50,8 +50,14 @@ const StakeInputView = () => { }; const handleStakePress = useCallback(() => { - // TODO: Display the Review bottom sheet: STAKE-824 - }, []); + navigation.navigate('StakeScreens', { + screen: Routes.STAKING.STAKE_CONFIRMATION, + params: { + amountWei: amountWei.toString(), + amountFiat: fiatAmount, + }, + }); + }, [amountWei, fiatAmount, navigation]); const balanceText = strings('stake.balance'); @@ -66,7 +72,11 @@ const StakeInputView = () => { : `${balanceFiatNumber?.toString()} ${currentCurrency.toUpperCase()}`; useEffect(() => { - navigation.setOptions(getStakingNavbar(title, navigation, theme.colors)); + navigation.setOptions( + getStakingNavbar(title, navigation, theme.colors, { + hasBackButton: false, + }), + ); }, [navigation, theme.colors, title]); useEffect(() => { diff --git a/app/components/UI/Stake/Views/StakeInputView/__snapshots__/StakeInputView.test.tsx.snap b/app/components/UI/Stake/Views/StakeInputView/__snapshots__/StakeInputView.test.tsx.snap index 5085ab0c15e..4a35aa2b83e 100644 --- a/app/components/UI/Stake/Views/StakeInputView/__snapshots__/StakeInputView.test.tsx.snap +++ b/app/components/UI/Stake/Views/StakeInputView/__snapshots__/StakeInputView.test.tsx.snap @@ -56,13 +56,9 @@ exports[`StakeInputView render matches snapshot 1`] = ` { "backgroundColor": "#ffffff", "borderBottomColor": "rgb(216, 216, 216)", - "elevation": 0, "flex": 1, - "shadowColor": "transparent", - "shadowOffset": { - "height": 0.5, - "width": 0, - }, + "shadowColor": "rgb(216, 216, 216)", + "shadowOffset": null, "shadowOpacity": 0.85, "shadowRadius": 0, } @@ -106,96 +102,26 @@ exports[`StakeInputView render matches snapshot 1`] = ` pointerEvents="box-none" style={ { - "alignItems": "flex-start", - "bottom": 0, - "justifyContent": "center", - "left": 0, - "opacity": 1, - "position": "absolute", - "top": 0, - } - } - > - - - - - - Stake ETH - - - - - Ethereum Main Network - - - + Stake ETH + { : strings('stake.review'); useEffect(() => { - navigation.setOptions(getStakingNavbar(title, navigation, theme.colors)); + navigation.setOptions( + getStakingNavbar(title, navigation, theme.colors, { + hasBackButton: false, + }), + ); }, [navigation, theme.colors, title]); const handleUnstakePress = useCallback(() => { diff --git a/app/components/UI/Stake/Views/UnstakeInputView/__snapshots__/UnstakeInputView.test.tsx.snap b/app/components/UI/Stake/Views/UnstakeInputView/__snapshots__/UnstakeInputView.test.tsx.snap index 15e289f23e7..5e7927b0b5c 100644 --- a/app/components/UI/Stake/Views/UnstakeInputView/__snapshots__/UnstakeInputView.test.tsx.snap +++ b/app/components/UI/Stake/Views/UnstakeInputView/__snapshots__/UnstakeInputView.test.tsx.snap @@ -56,13 +56,9 @@ exports[`UnstakeInputView render matches snapshot 1`] = ` { "backgroundColor": "#ffffff", "borderBottomColor": "rgb(216, 216, 216)", - "elevation": 0, "flex": 1, - "shadowColor": "transparent", - "shadowOffset": { - "height": 0.5, - "width": 0, - }, + "shadowColor": "rgb(216, 216, 216)", + "shadowOffset": null, "shadowOpacity": 0.85, "shadowRadius": 0, } @@ -106,96 +102,26 @@ exports[`UnstakeInputView render matches snapshot 1`] = ` pointerEvents="box-none" style={ { - "alignItems": "flex-start", - "bottom": 0, - "justifyContent": "center", - "left": 0, - "opacity": 1, - "position": "absolute", - "top": 0, - } - } - > - - - - - - Unstake ETH - - - - - Ethereum Main Network - - - + Unstake ETH + jest.fn()); + +Image.getSize = jest.fn((_uri, success) => { + success(100, 100); // Mock successful response for ETH native Icon Image +}); const mockNavigate = jest.fn(); @@ -39,15 +29,17 @@ afterEach(() => { }); describe('StakingBalance', () => { + beforeEach(() => jest.resetAllMocks()); + it('render matches snapshot', () => { - render(StakingBalance); - expect(screen.toJSON()).toMatchSnapshot(); + const { toJSON } = renderWithProvider(); + expect(toJSON()).toMatchSnapshot(); }); it('redirects to StakeInputView on stake button click', () => { - render(StakingBalance); + const { getByText } = renderWithProvider(); - fireEvent.press(screen.getByText(strings('stake.stake_more'))); + fireEvent.press(getByText(strings('stake.stake_more'))); expect(mockNavigate).toHaveBeenCalledTimes(1); expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { @@ -56,9 +48,9 @@ describe('StakingBalance', () => { }); it('redirects to UnstakeInputView on unstake button click', () => { - render(StakingBalance); + const { getByText } = renderWithProvider(); - fireEvent.press(screen.getByText(strings('stake.unstake'))); + fireEvent.press(getByText(strings('stake.unstake'))); expect(mockNavigate).toHaveBeenCalledTimes(1); expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', { diff --git a/app/components/UI/Stake/components/StakingBalance/StakingCta/StakingCta.tsx b/app/components/UI/Stake/components/StakingBalance/StakingCta/StakingCta.tsx index 1ab816b649d..13b3d2c8629 100644 --- a/app/components/UI/Stake/components/StakingBalance/StakingCta/StakingCta.tsx +++ b/app/components/UI/Stake/components/StakingBalance/StakingCta/StakingCta.tsx @@ -36,10 +36,10 @@ const StakingCta = ({ estimatedRewardRate, style }: StakingCtaProps) => { {strings('stake.stake_your_eth_cta.base')} - {estimatedRewardRate} - - {strings('stake.stake_your_eth_cta.annually')} + + {estimatedRewardRate} + {strings('stake.stake_your_eth_cta.annually')}