From e23e3e10fa243e6ac00b077f382cd737aadff8cb Mon Sep 17 00:00:00 2001 From: Wei Sun Date: Tue, 6 Jan 2026 09:38:52 -0800 Subject: [PATCH 1/7] chore: add EAS production cert (#24220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Add RC and Production EAS certificates ## **Changelog** CHANGELOG entry: Added RC and Production EAS certificates ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/23448 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **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. --- > [!NOTE] > Introduces environment-driven OTA code signing and production channel support. > > - Adds `certs/production.certificate.pem` and `certs/rc.certificate.pem`; keeps `certs/exp.certificate.pem` > - `app.config.js`: selects `updates.codeSigningCertificate` and `codeSigningMetadata.keyid` via `METAMASK_ENVIRONMENT` (fallback to `exp`); removes hardcoded request headers > - `scripts/update-expo-channel.js`: adds `production` to `CONFIG_MAP`; derives OTA env (`production`/`rc`/`exp`) and loads matching cert/keyid; writes code-signing certificate and metadata to Android (`AndroidManifest.xml`) and iOS (`Expo.plist`); continues setting channel, runtimeVersion, update URL, check policy, and launch wait > - General refactors: path constants formatting and safer missing-cert handling > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 4edfcdee34f42a231ecb870a72e854d07d4995ee. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: metamaskbot --- app.config.js | 30 ++++-- .../{certificate.pem => exp.certificate.pem} | 0 certs/production.certificate.pem | 18 ++++ certs/rc.certificate.pem | 18 ++++ scripts/update-expo-channel.js | 96 +++++++++++++++---- 5 files changed, 140 insertions(+), 22 deletions(-) rename certs/{certificate.pem => exp.certificate.pem} (100%) create mode 100644 certs/production.certificate.pem create mode 100644 certs/rc.certificate.pem diff --git a/app.config.js b/app.config.js index 1de67933ccd..de9362b11e2 100644 --- a/app.config.js +++ b/app.config.js @@ -1,5 +1,27 @@ const { RUNTIME_VERSION, PROJECT_ID, UPDATE_URL } = require('./ota.config.js'); +// Use METAMASK_ENVIRONMENT to select OTA certs: +// - "production" and "rc" use their own certificates +// - all other environments (exp, dev, test, e2e, beta, etc.) fall back to "exp" +const OTA_ENV_MAP = { + production: 'production', + rc: 'rc', +}; + +const OTA_ENV = OTA_ENV_MAP[process.env.METAMASK_ENVIRONMENT] ?? 'exp'; + +const CODE_SIGNING_CERTS = { + production: './certs/production.certificate.pem', + exp: './certs/exp.certificate.pem', + rc: './certs/rc.certificate.pem', +}; + +const CODE_SIGNING_KEYIDS = { + production: 'production', + exp: 'exp', + rc: 'rc', +}; + module.exports = { name: 'MetaMask', displayName: 'MetaMask', @@ -75,16 +97,12 @@ module.exports = { owner: 'metamask', runtimeVersion: RUNTIME_VERSION, updates: { - codeSigningCertificate: './certs/certificate.pem', + codeSigningCertificate: CODE_SIGNING_CERTS[OTA_ENV], codeSigningMetadata: { - keyid: 'main', + keyid: CODE_SIGNING_KEYIDS[OTA_ENV], alg: 'rsa-v1_5-sha256', }, url: UPDATE_URL, - // Channel is set by requestHeaders, will be overridden with build script - requestHeaders: { - 'expo-channel-name': 'preview', - }, }, extra: { eas: { diff --git a/certs/certificate.pem b/certs/exp.certificate.pem similarity index 100% rename from certs/certificate.pem rename to certs/exp.certificate.pem diff --git a/certs/production.certificate.pem b/certs/production.certificate.pem new file mode 100644 index 00000000000..7b9de71a0c6 --- /dev/null +++ b/certs/production.certificate.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC0zCCAbugAwIBAgIJaqBgJ/nwfDyGMA0GCSqGSIb3DQEBCwUAMBMxETAPBgNV +BAMTCE1ldGFNYXNrMB4XDTI1MTIxOTIzMjIyMloXDTM1MTIxOTIzMjIyMlowEzER +MA8GA1UEAxMITWV0YU1hc2swggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB +AQCYLssVwzNInWR+fQ7bUGsDVk+1+lF3TZhR3MkZjlY/Cm/5jG2H5oW8gpL9+3Ip +2VjGqp4bIwEQKSrkIKeg4188pI3Qqpn2fR43GHMbSSmtQ0EF9N1OTH8vh3pLR717 +T6P9hXBMJtNS1sOlwRpRjZZy0DC/8SpdjA9tE++PgiU0AY2y+sNMXKk/eMITjr6P +gAbUGgph4iRwMrsW/PC2wD3QE6D5nCOviTp17tC4+TkaEyw+cO2D4BCG8Z/oozLK +JQSskcDCYfaFMcf0kYQrliiGvwmFvp/tdpT6TSYut1hfEV2H1hfvdtP5J/9L/k4J +d9sV37QI3jCzOfencXaKNTX7AgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNV +HSUBAf8EDDAKBggrBgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAkKTLPjiqrLkH +QKA8X223RikeCYOwFG4bqhpvQ867TPn6nARAwwtOssVu6duaKNwQFXhN424wWpcT +JeCvxJdnQS9qKNCeNEbjtTxmqDQmqVNnUy6kPXBYbGiSVKX49GjRfdjVZSmeHQCh +dXQ5jsRqcpNjn+VkzHzZ7iWx7RZBQ6ueSl/udFuTHlswUBRdgZxCcpANFplVVF5E +2xegZVSolQHKQaS7pVD6EnnK3B8VlGV4o0yUe4Jz8bdRC0KCMN3SxLxBSj2f7/cQ +OF6hIMvGG6Qw0IHfQBRIcmnJm/ciny+4PTmSVlm2zMbeZ6hFVGzKo8SgUcxwTf/N +Uuat8HYBag== +-----END CERTIFICATE----- diff --git a/certs/rc.certificate.pem b/certs/rc.certificate.pem new file mode 100644 index 00000000000..f7842601956 --- /dev/null +++ b/certs/rc.certificate.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC0zCCAbugAwIBAgIJGH+Ulb6OVEeJMA0GCSqGSIb3DQEBCwUAMBMxETAPBgNV +BAMTCE1ldGFNYXNrMB4XDTI1MTIxOTIzMTY1NFoXDTM1MTIxOTIzMTY1NFowEzER +MA8GA1UEAxMITWV0YU1hc2swggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB +AQDWvpseYyys+mFZrxGXMDD4VRGIs0u5mXgjwDf7fEOJWI7uldvVcVDPCO1v+/Ig +0nlz/NgDdVvYNgKFJJq4JMLDJNzxNIcaqSCKlO9IU5qWvnnfEeyWx6Pv0NQssHOD +Sc+WnvfCXub3akM9CE6Noy/KbIHpyUNwypux1eU5KGnZ704kNVsWmU7PeRn5Olnl +6t5Q93sIe2fbESDvHYh0TKC9eQvgkvrCCKlMgyqnZb4fdylXGbWRjivp+AKh/DHz +37bz9KtQO7YF8oZ1QvTiSVOb0hS1rzAE/YMatTbC214FQu5/w3vHCe7ejKcNCjzM +xFm6nSd41ho/SpW4r92dt0xvAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNV +HSUBAf8EDDAKBggrBgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAn8OKneCOuUcI +wUGRVqWDcej4yYGugWWiIdEgy4as+vXUhMCk47uPwWuKNSILDuPeSxt2lo+AND8U +4N3I87+oYbLktOyph5FtpwUEMC8R/YE8Q5bNmi0LHzzGteenfUhSc+MVhpVVwZ1I +SbHGrj6/oet39FFFqJhWAU+RbMwSnYZKrZTfYELuEppP3WO03P9I8T3XR0d+CmMb +YdeLUvYilZI+3VxKL6tg/UWlZOX8MH6JbtNTm+2YMi1fp/hE8mrd+rR2Re5d87ub +srOY8HZvM0JX4RPddITpfSEwLwrPhdSFRwxyAVmBALYWVHeuiFn2pI3jfNeHF2Lt +SS2hIbf10Q== +-----END CERTIFICATE----- diff --git a/scripts/update-expo-channel.js b/scripts/update-expo-channel.js index efdc2462272..fb5113f16c5 100755 --- a/scripts/update-expo-channel.js +++ b/scripts/update-expo-channel.js @@ -12,16 +12,55 @@ const fs = require('fs'); const path = require('path'); const { RUNTIME_VERSION, UPDATE_URL } = require('../ota.config.js'); -const VALID_ENVIRONMENTS = ['beta', 'rc', 'exp', 'test', 'e2e', 'dev', 'production']; - -const ANDROID_MANIFEST_PATH = path.join(__dirname, '..', 'android', 'app', 'src', 'main', 'AndroidManifest.xml'); +const VALID_ENVIRONMENTS = [ + 'beta', + 'rc', + 'exp', + 'test', + 'e2e', + 'dev', + 'production', +]; + +const ANDROID_MANIFEST_PATH = path.join( + __dirname, + '..', + 'android', + 'app', + 'src', + 'main', + 'AndroidManifest.xml', +); const IOS_EXPO_PLIST_PATH = path.join(__dirname, '..', 'ios', 'Expo.plist'); -const CERTIFICATE_PATH = path.join(__dirname, '..', 'certs', 'certificate.pem'); -const CODE_SIGNING_KEY_ID = 'main'; const CODE_SIGNING_ALGORITHM = 'rsa-v1_5-sha256'; -//TODO: add production channel when it's ready +// Match the OTA environment selection logic from app.config.js: +// - "production" and "rc" use their own certificates +// - all other environments (exp, dev, test, e2e, beta, etc.) fall back to "exp" +function getOtaEnvironment(environment) { + return environment === 'production' || environment === 'rc' + ? environment + : 'exp'; +} + +const CODE_SIGNING_CERTS = { + production: path.join( + __dirname, + '..', + 'certs', + 'production.certificate.pem', + ), + exp: path.join(__dirname, '..', 'certs', 'exp.certificate.pem'), + rc: path.join(__dirname, '..', 'certs', 'rc.certificate.pem'), +}; + +const CODE_SIGNING_KEYIDS = { + production: 'production', + exp: 'exp', + rc: 'rc', +}; + const CONFIG_MAP = { exp: { channel: 'exp', @@ -39,6 +78,14 @@ const CONFIG_MAP = { checkAutomatically: 'NEVER', fallbackToCacheTimeout: 0, }, + production: { + channel: 'production', + runtimeVersion: RUNTIME_VERSION, + updatesEnabled: true, + updateUrl: UPDATE_URL, + checkAutomatically: 'NEVER', + fallbackToCacheTimeout: 0, + }, }; // Official Expo Updates configuration keys @@ -82,6 +129,7 @@ const EXPO_CONFIG_MAP = { * Loads the code signing certificate and generates Expo-compatible payloads * for Android and iOS configuration files. * + * @param {string} environment * @returns {null | { * certificatePath: string, * androidCertificateValue: string, @@ -90,16 +138,28 @@ const EXPO_CONFIG_MAP = { * androidMetadataValue: string, * }} Configuration object or null when certificate is missing */ -function loadCodeSigningConfiguration() { - if (!fs.existsSync(CERTIFICATE_PATH)) { +function loadCodeSigningConfiguration(environment) { + const otaEnv = getOtaEnvironment(environment); + const certificatePath = CODE_SIGNING_CERTS[otaEnv]; + const keyId = CODE_SIGNING_KEYIDS[otaEnv]; + + if (!certificatePath || !keyId) { console.warn( - `⚠️ Code signing certificate not found at ${CERTIFICATE_PATH}. ` + + `⚠️ No code signing configuration found for environment "${environment}" (OTA env "${otaEnv}"). ` + 'Skipping code signing configuration updates.', ); return null; } - const certificateContent = fs.readFileSync(CERTIFICATE_PATH, 'utf8'); + if (!fs.existsSync(certificatePath)) { + console.warn( + `⚠️ Code signing certificate not found at ${certificatePath}. ` + + 'Skipping code signing configuration updates.', + ); + return null; + } + + const certificateContent = fs.readFileSync(certificatePath, 'utf8'); const androidCertificateValue = certificateContent .replace(/\r/g, ' ') @@ -108,7 +168,7 @@ function loadCodeSigningConfiguration() { const iosCertificateValue = certificateContent.replace(/\r/g, ' '); const metadata = { - keyid: CODE_SIGNING_KEY_ID, + keyid: keyId, alg: CODE_SIGNING_ALGORITHM, }; @@ -118,7 +178,7 @@ function loadCodeSigningConfiguration() { ); return { - certificatePath: CERTIFICATE_PATH, + certificatePath, androidCertificateValue, iosCertificateValue, metadata, @@ -445,7 +505,6 @@ function updatePlistFile( */ function main() { const environment = process.env.METAMASK_ENVIRONMENT; - const codeSigningConfig = loadCodeSigningConfiguration(); console.log('======================================'); console.log(' Updating Expo Updates Configuration'); @@ -467,12 +526,17 @@ function main() { console.log(`Environment: ${environment}`); - // only enable expo updates for exp and rc builds - if (!(environment === 'exp' || environment === 'rc')) { + // Only apply configuration for environments that have a CONFIG_MAP entry + // (currently: exp, rc, production) + const config = getConfigForEnvironment(environment); + + if (!config) { console.log('✓ No configuration changes made'); return; } + const codeSigningConfig = loadCodeSigningConfiguration(environment); + // Get configuration for this environment const { channel, @@ -481,7 +545,7 @@ function main() { updateUrl, checkAutomatically, fallbackToCacheTimeout, - } = getConfigForEnvironment(environment); + } = config; // Check if files exist if (!fs.existsSync(ANDROID_MANIFEST_PATH)) { From 58160263be1cee174ad4b544f7cab9f7bfc544f4 Mon Sep 17 00:00:00 2001 From: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Date: Tue, 6 Jan 2026 12:50:13 -0500 Subject: [PATCH 2/7] feat: MUSD-166 right align convert to musd cta (#24249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Moved the "Convert to mUSD" CTA to the AssetElement's secondary balance position for supported stablecoins. This is where the price change percentage is normally displayed. ## **Changelog** CHANGELOG entry: updated token list item musd conversion cta position to secondary balance position. This replaces the price change percentage for specified stablecoins ## **Related issues** Fixes: [MUSD-166: Right align convert to mUSD CTA](https://consensyssoftware.atlassian.net/browse/MUSD-166) ## **Manual testing steps** ```gherkin Feature: Stablecoin conversion to mUSD from token list Scenario: user initiates mUSD conversion from token list secondary balance Given user is viewing the Wallet token list And mUSD conversion is enabled And user has a supported stablecoin balance greater than 0 When user taps "Convert to mUSD" on the token’s secondary balance Then the mUSD conversion flow is started with the selected token pre-selected Scenario: user views non-convertible token in token list Given user is viewing the Wallet token list And the token is not eligible for mUSD conversion When user views the token row Then "Convert to mUSD" is not displayed in the secondary balance position And the secondary balance shows the token’s percentage change (when available) Scenario: user has a convertible stablecoin with zero balance Given user is viewing the Wallet token list And mUSD conversion is enabled And the token is eligible for mUSD conversion And the token balance is 0 When user views the token row Then "Convert to mUSD" is not displayed in the secondary balance position ``` ## **Screenshots/Recordings** ### **Before** image ### **After** https://github.com/user-attachments/assets/687a1a4f-5441-4693-9895-81de0351a1d1 ## **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. --- > [!NOTE] > Shifts the mUSD conversion entry point to the token row’s secondary balance and wires it to initiate conversion. > > - AssetElement: wraps `secondaryBalance` in a pressable with `SECONDARY_BALANCE_BUTTON_TEST_ID`; adds `onSecondaryBalancePress(asset)` prop and disables when no handler or `disabled` is true > - TokenListItem: replaces percentage with `"Convert to mUSD"` when conversion is enabled, token is eligible, and balance > 0; pressing calls `initiateConversion` with `outputChainId`, `preferredPaymentToken`, and `navigationStack`; otherwise keeps percentage logic > - StakeButton: removes mUSD conversion logic/flags and related tests; retains pooled staking/lending flows > - Updates tests and snapshots for `AssetElement`, `TokenListItem`, `CardAssetItem`, and `EarnLendingBalance`; adds new test IDs and coverage for press behavior > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d2231081397bb6a009328af56b1e4719d35a1cf2. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../__snapshots__/index.test.tsx.snap | 2 +- .../UI/AssetElement/index.constants.ts | 2 + app/components/UI/AssetElement/index.test.tsx | 61 ++++- app/components/UI/AssetElement/index.tsx | 58 +++-- .../__snapshots__/CardAssetItem.test.tsx.snap | 180 ++++++++------ .../EarnLendingBalance.test.tsx.snap | 36 +-- .../StakeButton/StakeButton.test.tsx | 235 ------------------ .../UI/Stake/components/StakeButton/index.tsx | 60 +---- .../TokenListItem/TokenListItem.test.tsx | 193 +++++++++++++- .../TokenList/TokenListItem/TokenListItem.tsx | 106 ++++++-- 10 files changed, 496 insertions(+), 437 deletions(-) diff --git a/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap b/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap index f094f0109ad..a13cbccb177 100644 --- a/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`AssetElement should render correctly 1`] = ` +exports[`AssetElement renders correctly 1`] = ` { const onPressMock = jest.fn(); const onLongPressMock = jest.fn(); + const onSecondaryBalancePressMock = jest.fn(); const erc20Token = { name: 'Dai', @@ -26,7 +31,11 @@ describe('AssetElement', () => { image: '', }; - it('should render correctly', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', () => { const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); @@ -205,4 +214,52 @@ describe('AssetElement', () => { }); expect(secondaryBalance.props.children).toBe('0.00%'); }); + + describe('onSecondaryBalancePress', () => { + it('calls onSecondaryBalancePress with asset when secondary balance is pressed', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(SECONDARY_BALANCE_BUTTON_TEST_ID)); + + expect(onSecondaryBalancePressMock).toHaveBeenCalledTimes(1); + expect(onSecondaryBalancePressMock).toHaveBeenCalledWith(erc20Token); + }); + + it('does not call onSecondaryBalancePress when handler is undefined', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(SECONDARY_BALANCE_BUTTON_TEST_ID)); + + expect(onSecondaryBalancePressMock).not.toHaveBeenCalled(); + }); + + it('does not call onSecondaryBalancePress when disabled prop is true', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(SECONDARY_BALANCE_BUTTON_TEST_ID)); + + expect(onSecondaryBalancePressMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/components/UI/AssetElement/index.tsx b/app/components/UI/AssetElement/index.tsx index 6a3a15eb925..b82a39d7310 100644 --- a/app/components/UI/AssetElement/index.tsx +++ b/app/components/UI/AssetElement/index.tsx @@ -20,7 +20,11 @@ import { TOKEN_BALANCE_LOADING_UPPERCASE, TOKEN_RATE_UNDEFINED, } from '../Tokens/constants'; -import { BALANCE_TEST_ID, SECONDARY_BALANCE_TEST_ID } from './index.constants'; +import { + BALANCE_TEST_ID, + SECONDARY_BALANCE_BUTTON_TEST_ID, + SECONDARY_BALANCE_TEST_ID, +} from './index.constants'; interface AssetElementProps { children?: React.ReactNode; @@ -35,6 +39,7 @@ interface AssetElementProps { privacyMode?: boolean; hideSecondaryBalanceInPrivacyMode?: boolean; disabled?: boolean; + onSecondaryBalancePress?: (asset: TokenI) => void; } const createStyles = (colors: Colors) => @@ -74,6 +79,7 @@ const AssetElement: React.FC = ({ privacyMode = false, hideSecondaryBalanceInPrivacyMode = true, disabled = false, + onSecondaryBalancePress, }) => { const { colors } = useTheme(); const styles = createStyles(colors); @@ -86,6 +92,13 @@ const AssetElement: React.FC = ({ onLongPress?.(asset); }; + const isSecondaryDisabled = disabled || !onSecondaryBalancePress; + + const handleOnSecondaryBalancePress = () => { + if (isSecondaryDisabled) return; + onSecondaryBalancePress?.(asset); + }; + // TODO: Use the SensitiveText component when it's available // when privacyMode is true, we should hide the balance and the fiat return ( @@ -119,25 +132,32 @@ const AssetElement: React.FC = ({ )} {secondaryBalance ? ( - - {secondaryBalance === TOKEN_BALANCE_LOADING || - secondaryBalance === TOKEN_BALANCE_LOADING_UPPERCASE ? ( - - ) : ( - secondaryBalance - )} - + + {secondaryBalance === TOKEN_BALANCE_LOADING || + secondaryBalance === TOKEN_BALANCE_LOADING_UPPERCASE ? ( + + ) : ( + secondaryBalance + )} + + ) : null} diff --git a/app/components/UI/Card/components/CardAssetItem/__snapshots__/CardAssetItem.test.tsx.snap b/app/components/UI/Card/components/CardAssetItem/__snapshots__/CardAssetItem.test.tsx.snap index 712491dc53e..c18f81a2891 100644 --- a/app/components/UI/Card/components/CardAssetItem/__snapshots__/CardAssetItem.test.tsx.snap +++ b/app/components/UI/Card/components/CardAssetItem/__snapshots__/CardAssetItem.test.tsx.snap @@ -502,22 +502,28 @@ exports[`CardAssetItem Component handles test network correctly 1`] = ` > $3,000.00 - - 1000000000000000000 ETH - + + 1000000000000000000 ETH + + @@ -978,22 +984,28 @@ exports[`CardAssetItem Component renders non-native token and matches snapshot 1 > $3,000.00 - - 1000000000000000000 USDC - + + 1000000000000000000 USDC + + @@ -1449,22 +1461,28 @@ exports[`CardAssetItem Component renders with all props and matches snapshot 1`] > $3,000.00 - - 1000000000000000000 ETH - + + 1000000000000000000 ETH + + @@ -1920,22 +1938,28 @@ exports[`CardAssetItem Component renders with privacy mode enabled and matches s > ••••••••• - - •••••• - + + •••••• + + @@ -2391,22 +2415,28 @@ exports[`CardAssetItem Component renders with required props and matches snapsho > $3,000.00 - - 1000000000000000000 ETH - + + 1000000000000000000 ETH + + diff --git a/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap b/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap index 28c8df8692b..5e6a3d05c22 100644 --- a/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap +++ b/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap @@ -460,22 +460,28 @@ exports[`EarnLendingBalance renders balance and buttons when user has lending po > $76.00 - - 32.05 ADAI - + + 32.05 ADAI + + ({ jest.mock('../../../Earn/selectors/featureFlags', () => ({ selectPooledStakingEnabledFlag: jest.fn().mockReturnValue(true), selectStablecoinLendingEnabledFlag: jest.fn().mockReturnValue(true), - selectIsMusdConversionFlowEnabledFlag: jest.fn().mockReturnValue(false), - selectMusdConversionPaymentTokensAllowlist: jest.fn().mockReturnValue({}), })); -jest.mock('../../../Earn/hooks/useMusdConversion', () => ({ - useMusdConversion: jest.fn(() => ({ - initiateConversion: jest.fn(), - error: null, - })), -})); - -jest.mock('../../../Earn/hooks/useMusdConversionTokens', () => ({ - useMusdConversionTokens: jest.fn(), -})); - -const mockUseMusdConversionTokens = - useMusdConversionTokens as jest.MockedFunction< - typeof useMusdConversionTokens - >; -mockUseMusdConversionTokens.mockReturnValue({ - isConversionToken: jest.fn().mockReturnValue(false), - tokenFilter: jest.fn(), - isMusdSupportedOnChain: jest.fn().mockReturnValue(true), - getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), - tokens: [], -}); - jest.mock('../../../../../selectors/earnController/earn', () => ({ earnSelectors: { selectPrimaryEarnExperienceTypeForAsset: jest.fn((_state, asset) => @@ -472,208 +441,4 @@ describe('StakeButton', () => { expect(queryByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)).toBeNull(); }); - - describe('mUSD Conversion', () => { - const mockInitiateConversion = jest.fn(); - - const useMusdConversionMock = jest.mocked(useMusdConversion); - const selectIsMusdConversionFlowEnabledFlagMock = jest.mocked( - selectIsMusdConversionFlowEnabledFlag, - ); - const selectMusdConversionPaymentTokensAllowlistMock = jest.mocked( - selectMusdConversionPaymentTokensAllowlist, - ); - - beforeEach(() => { - jest.clearAllMocks(); - mockInitiateConversion.mockResolvedValue('tx-123'); - useMusdConversionMock.mockReturnValue({ - initiateConversion: mockInitiateConversion, - error: null, - hasSeenConversionEducationScreen: true, - }); - mockUseMusdConversionTokens.mockReturnValue({ - isConversionToken: jest.fn().mockReturnValue(false), - tokenFilter: jest.fn(), - isMusdSupportedOnChain: jest.fn().mockReturnValue(true), - getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), - tokens: [], - }); - }); - - it('renders Convert CTA for convertible stablecoin when flag enabled', () => { - selectIsMusdConversionFlowEnabledFlagMock.mockReturnValue(true); - selectMusdConversionPaymentTokensAllowlistMock.mockReturnValue({ - '0x1': [MOCK_USDC_MAINNET_ASSET.address as Hex], - }); - - mockUseMusdConversionTokens.mockReturnValue({ - isConversionToken: jest.fn( - (asset) => - asset?.address?.toLowerCase() === - MOCK_USDC_MAINNET_ASSET.address.toLowerCase() && - asset?.chainId === MOCK_USDC_MAINNET_ASSET.chainId, - ), - tokenFilter: jest.fn(), - isMusdSupportedOnChain: jest.fn().mockReturnValue(true), - getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), - tokens: [], - }); - - const { getByText } = renderWithProvider( - , - { - state: STATE_MOCK, - }, - ); - - expect(getByText('Convert to mUSD')).toBeDefined(); - }); - - it('calls initiateConversion with correct parameters when Convert button pressed', async () => { - selectIsMusdConversionFlowEnabledFlagMock.mockReturnValue(true); - const mockAllowlist = { - '0x1': [MOCK_USDC_MAINNET_ASSET.address as Hex], - } as Record; - - selectMusdConversionPaymentTokensAllowlistMock.mockReturnValue( - mockAllowlist, - ); - - mockUseMusdConversionTokens.mockReturnValue({ - isConversionToken: jest.fn( - (asset) => - asset?.address?.toLowerCase() === - MOCK_USDC_MAINNET_ASSET.address.toLowerCase() && - asset?.chainId === MOCK_USDC_MAINNET_ASSET.chainId, - ), - tokenFilter: jest.fn(), - isMusdSupportedOnChain: jest.fn().mockReturnValue(true), - getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), - tokens: [], - }); - - const { getByTestId } = renderWithProvider( - , - { - state: STATE_MOCK, - }, - ); - - fireEvent.press(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)); - - await waitFor(() => { - expect(mockInitiateConversion).toHaveBeenCalledWith({ - outputChainId: '0x1', - preferredPaymentToken: { - address: '0xaBc', - chainId: '0x1', - }, - navigationStack: Routes.EARN.ROOT, - }); - }); - }); - - it('shows Alert when conversion fails', async () => { - const mockAlert = jest.spyOn(Alert, 'alert'); - const conversionError = new Error('Conversion failed'); - mockInitiateConversion.mockRejectedValue(conversionError); - - selectIsMusdConversionFlowEnabledFlagMock.mockReturnValue(true); - selectMusdConversionPaymentTokensAllowlistMock.mockReturnValue({ - '0x1': [MOCK_USDC_MAINNET_ASSET.address as Hex], - }); - - mockUseMusdConversionTokens.mockReturnValue({ - isConversionToken: jest.fn( - (asset) => - asset?.address?.toLowerCase() === - MOCK_USDC_MAINNET_ASSET.address.toLowerCase() && - asset?.chainId === MOCK_USDC_MAINNET_ASSET.chainId, - ), - tokenFilter: jest.fn(), - isMusdSupportedOnChain: jest.fn().mockReturnValue(true), - getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), - tokens: [], - }); - - const { getByTestId } = renderWithProvider( - , - { - state: STATE_MOCK, - }, - ); - - fireEvent.press(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)); - - await waitFor(() => { - expect(mockAlert).toHaveBeenCalledWith( - 'Conversion Failed', - expect.stringContaining('Conversion failed'), - expect.any(Array), - ); - }); - - mockAlert.mockRestore(); - }); - - it('renders button for convertible stablecoin even with zero balance', () => { - selectIsMusdConversionFlowEnabledFlagMock.mockReturnValue(true); - selectMusdConversionPaymentTokensAllowlistMock.mockReturnValue({ - '0x1': [MOCK_USDC_MAINNET_ASSET.address as Hex], - }); - - mockUseMusdConversionTokens.mockReturnValue({ - isConversionToken: jest.fn( - (asset) => - asset?.address?.toLowerCase() === - MOCK_USDC_MAINNET_ASSET.address.toLowerCase() && - asset?.chainId === MOCK_USDC_MAINNET_ASSET.chainId, - ), - tokenFilter: jest.fn(), - isMusdSupportedOnChain: jest.fn().mockReturnValue(true), - getMusdOutputChainId: jest.fn((chainId) => (chainId ?? '0x1') as Hex), - tokens: [], - }); - - const zeroBalanceAsset = { - ...MOCK_USDC_MAINNET_ASSET, - balance: '0', - }; - - const { getByTestId } = renderWithProvider( - , - { - state: STATE_MOCK, - }, - ); - - expect(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)).toBeDefined(); - }); - - it('does not render Convert CTA when flag disabled', () => { - selectIsMusdConversionFlowEnabledFlagMock.mockReturnValue(false); - - const { queryByText } = renderWithProvider( - , - { - state: STATE_MOCK, - }, - ); - - expect(queryByText('Convert')).toBeNull(); - }); - - it('does not render Convert CTA for non-convertible tokens', () => { - selectIsMusdConversionFlowEnabledFlagMock.mockReturnValue(true); - // Allowlist doesn't include ETH address, so ETH won't show Convert CTA - selectMusdConversionPaymentTokensAllowlistMock.mockReturnValue({ - '0x1': [MOCK_USDC_MAINNET_ASSET.address as Hex], - }); - - const { queryByText } = renderComponent(); - - expect(queryByText('Convert')).toBeNull(); - }); - }); }); diff --git a/app/components/UI/Stake/components/StakeButton/index.tsx b/app/components/UI/Stake/components/StakeButton/index.tsx index eb4b45e9736..fd69daf28d1 100644 --- a/app/components/UI/Stake/components/StakeButton/index.tsx +++ b/app/components/UI/Stake/components/StakeButton/index.tsx @@ -1,7 +1,7 @@ import { toHex } from '@metamask/controller-utils'; import { useNavigation } from '@react-navigation/native'; -import React, { useCallback } from 'react'; -import { Alert, StyleSheet, TouchableOpacity } from 'react-native'; +import React from 'react'; +import { StyleSheet, TouchableOpacity } from 'react-native'; import { useSelector } from 'react-redux'; import { WalletViewSelectorsIDs } from '../../../../../../e2e/selectors/wallet/WalletView.selectors'; import { strings } from '../../../../../../locales/i18n'; @@ -23,7 +23,6 @@ import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics'; import { EARN_EXPERIENCES } from '../../../Earn/constants/experiences'; import useEarnTokens from '../../../Earn/hooks/useEarnTokens'; import { - selectIsMusdConversionFlowEnabledFlag, selectPooledStakingEnabledFlag, selectStablecoinLendingEnabledFlag, } from '../../../Earn/selectors/featureFlags'; @@ -40,9 +39,6 @@ import { selectTrxStakingEnabled } from '../../../../../selectors/featureFlagCon import { isTronChainId } from '../../../../../core/Multichain/utils'; import useTronStakeApy from '../../../Earn/hooks/useTronStakeApy'; ///: END:ONLY_INCLUDE_IF -import { useMusdConversion } from '../../../Earn/hooks/useMusdConversion'; -import Logger from '../../../../../util/Logger'; -import { useMusdConversionTokens } from '../../../Earn/hooks/useMusdConversionTokens'; const styles = StyleSheet.create({ stakeButton: { @@ -72,9 +68,6 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => { const isStablecoinLendingEnabled = useSelector( selectStablecoinLendingEnabledFlag, ); - const isMusdConversionFlowEnabled = useSelector( - selectIsMusdConversionFlowEnabledFlag, - ); ///: BEGIN:ONLY_INCLUDE_IF(tron) const isTrxStakingEnabled = useSelector(selectTrxStakingEnabled); @@ -92,12 +85,6 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => { earnSelectors.selectPrimaryEarnExperienceTypeForAsset(state, asset), ); - const { initiateConversion } = useMusdConversion(); - const { isConversionToken, getMusdOutputChainId } = useMusdConversionTokens(); - - const isConvertibleStablecoin = - isMusdConversionFlowEnabled && isConversionToken(asset); - const areEarnExperiencesDisabled = !isPooledStakingEnabled && !isStablecoinLendingEnabled; @@ -219,43 +206,7 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => { }); }; - const handleConvertToMUSD = useCallback(async () => { - try { - if (!asset?.address || !asset?.chainId) { - throw new Error('Asset address or chain ID is not set'); - } - - const assetChainId = toHex(asset.chainId); - - await initiateConversion({ - outputChainId: getMusdOutputChainId(assetChainId), - preferredPaymentToken: { - address: toHex(asset.address), - chainId: assetChainId, - }, - navigationStack: Routes.EARN.ROOT, - }); - } catch (error) { - Logger.error( - error as Error, - '[mUSD Conversion] Failed to initiate conversion', - ); - - const errorMessage = - error instanceof Error ? error.message : 'Unknown error occurred'; - Alert.alert( - 'Conversion Failed', - `Unable to start mUSD conversion: ${errorMessage}`, - [{ text: 'OK' }], - ); - } - }, [asset.address, asset.chainId, initiateConversion, getMusdOutputChainId]); - const onEarnButtonPress = async () => { - if (isConvertibleStablecoin) { - return handleConvertToMUSD(); - } - if (primaryExperienceType === EARN_EXPERIENCES.POOLED_STAKING) { return handleStakeRedirect(); } @@ -267,8 +218,7 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => { if ( areEarnExperiencesDisabled || - (!isConvertibleStablecoin && // Show for convertible stablecoins even with 0 balance - primaryExperienceType !== EARN_EXPERIENCES.STABLECOIN_LENDING && + (primaryExperienceType !== EARN_EXPERIENCES.STABLECOIN_LENDING && !earnToken?.isETH && earnToken?.balanceMinimalUnit === '0') || (earnToken?.isETH && !isPooledStakingEnabled) @@ -276,10 +226,6 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => { return <>; const renderEarnButtonText = () => { - if (isConvertibleStablecoin) { - return strings('asset_overview.convert_to_musd'); - } - ///: BEGIN:ONLY_INCLUDE_IF(tron) if (isTronNative && isTrxStakingEnabled && tronApyPercent) { return `${strings('stake.earn')} ${tronApyPercent}`; diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx index 2b464ee4dbb..e6685c70974 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx @@ -7,8 +7,12 @@ import { useTokenPricePercentageChange } from '../../hooks/useTokenPricePercenta import { isTestNet } from '../../../../../util/networks'; import { formatWithThreshold } from '../../../../../util/assets'; import { TokenI } from '../../types'; -import { SECONDARY_BALANCE_TEST_ID } from '../../../AssetElement/index.constants'; +import { + SECONDARY_BALANCE_BUTTON_TEST_ID, + SECONDARY_BALANCE_TEST_ID, +} from '../../../AssetElement/index.constants'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import { fireEvent, waitFor } from '@testing-library/react-native'; // Mock dependencies jest.mock('@react-navigation/native', () => ({ @@ -41,21 +45,30 @@ jest.mock('../../../Earn/hooks/useEarnTokens', () => ({ default: () => ({ getEarnToken: jest.fn() }), })); +const mockInitiateConversion = jest.fn(); jest.mock('../../../Earn/hooks/useMusdConversion', () => ({ useMusdConversion: () => ({ - initiateConversion: jest.fn(), + initiateConversion: mockInitiateConversion, error: null, }), })); +import { useMusdConversionTokens } from '../../../Earn/hooks/useMusdConversionTokens'; + jest.mock('../../../Earn/hooks/useMusdConversionTokens', () => ({ useMusdConversionTokens: jest.fn(() => ({ isConversionToken: jest.fn().mockReturnValue(false), + getMusdOutputChainId: jest.fn().mockReturnValue('0xe708'), tokenFilter: jest.fn(), tokens: [], })), })); +const mockUseMusdConversionTokens = + useMusdConversionTokens as jest.MockedFunction< + typeof useMusdConversionTokens + >; + jest.mock('../../../../../selectors/earnController/earn', () => ({ earnSelectors: { selectPrimaryEarnExperienceTypeForAsset: jest.fn(() => 'pooled-staking'), @@ -68,13 +81,20 @@ jest.mock('../../../Stake/hooks/useStakingChain', () => ({ useStakingChainByChainId: () => ({ isStakingSupportedChain: false }), })); +import { selectIsMusdConversionFlowEnabledFlag } from '../../../Earn/selectors/featureFlags'; + jest.mock('../../../Earn/selectors/featureFlags', () => ({ - selectPooledStakingEnabledFlag: () => true, // Enable to show Earn button - selectStablecoinLendingEnabledFlag: () => false, - selectIsMusdConversionFlowEnabledFlag: () => false, - selectMusdConversionPaymentTokensAllowlist: () => ({}), + selectPooledStakingEnabledFlag: jest.fn(() => true), + selectStablecoinLendingEnabledFlag: jest.fn(() => false), + selectIsMusdConversionFlowEnabledFlag: jest.fn(() => false), + selectMusdConversionPaymentTokensAllowlist: jest.fn(() => ({})), })); +const mockSelectIsMusdConversionFlowEnabledFlag = + selectIsMusdConversionFlowEnabledFlag as jest.MockedFunction< + typeof selectIsMusdConversionFlowEnabledFlag + >; + jest.mock('../../util/deriveBalanceFromAssetMarketDetails', () => ({ deriveBalanceFromAssetMarketDetails: jest.fn(() => ({ balanceFiat: '$100.00', @@ -187,12 +207,33 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { aggregators: [], }; + interface PrepareMocksOptions { + asset?: TokenI; + pricePercentChange1d?: number; + isMusdConversionEnabled?: boolean; + isConversionToken?: boolean; + } + function prepareMocks({ asset, pricePercentChange1d = 5.67, - }: { asset?: TokenI; pricePercentChange1d?: number } = {}) { + isMusdConversionEnabled = false, + isConversionToken = false, + }: PrepareMocksOptions = {}) { jest.clearAllMocks(); + // mUSD conversion mocks + mockSelectIsMusdConversionFlowEnabledFlag.mockReturnValue( + isMusdConversionEnabled, + ); + mockUseMusdConversionTokens.mockReturnValue({ + isConversionToken: jest.fn().mockReturnValue(isConversionToken), + getMusdOutputChainId: jest.fn().mockReturnValue('0xe708'), + tokenFilter: jest.fn(), + isMusdSupportedOnChain: jest.fn().mockReturnValue(true), + tokens: [], + }); + // Default mock setup mockUseSelector.mockImplementation( (selector: (state: unknown) => unknown) => { @@ -398,4 +439,142 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { ); }); }); + + describe('mUSD Conversion', () => { + const usdcAsset = { + ...defaultAsset, + symbol: 'USDC', + name: 'USD Coin', + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + balance: '100', + balanceFiat: '$100.00', + }; + + const assetKey: FlashListAssetKey = { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: '0x1', + isStaked: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('displays "Convert to mUSD" CTA when asset is convertible stablecoin with positive balance', () => { + prepareMocks({ + asset: usdcAsset, + isMusdConversionEnabled: true, + isConversionToken: true, + }); + + const { getByText } = renderWithProvider( + , + ); + + expect(getByText('Convert to mUSD')).toBeOnTheScreen(); + }); + + it('displays percentage change when mUSD conversion flag is disabled', () => { + prepareMocks({ + asset: usdcAsset, + pricePercentChange1d: 2.5, + isMusdConversionEnabled: false, + isConversionToken: false, + }); + + const { getByText, queryByText } = renderWithProvider( + , + ); + + expect(getByText('+2.50%')).toBeOnTheScreen(); + expect(queryByText('Convert to mUSD')).toBeNull(); + }); + + it('displays percentage change when asset is not a convertible stablecoin', () => { + prepareMocks({ + asset: defaultAsset, + pricePercentChange1d: 3.2, + isMusdConversionEnabled: true, + isConversionToken: false, + }); + + const defaultAssetKey: FlashListAssetKey = { + address: '0x456', + chainId: '0x1', + isStaked: false, + }; + + const { getByText, queryByText } = renderWithProvider( + , + ); + + expect(getByText('+3.20%')).toBeOnTheScreen(); + expect(queryByText('Convert to mUSD')).toBeNull(); + }); + + it('calls initiateConversion with correct parameters when secondary balance is pressed', async () => { + prepareMocks({ + asset: usdcAsset, + isMusdConversionEnabled: true, + isConversionToken: true, + }); + + const { getByTestId } = renderWithProvider( + , + ); + + fireEvent.press(getByTestId(SECONDARY_BALANCE_BUTTON_TEST_ID)); + + await waitFor(() => { + expect(mockInitiateConversion).toHaveBeenCalledWith({ + outputChainId: '0xe708', + preferredPaymentToken: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: '0x1', + }, + navigationStack: 'EarnScreens', + }); + }); + }); + + it('does not display "Convert to mUSD" when asset balance is zero', () => { + const zeroBalanceAsset = { ...usdcAsset, balance: '0' }; + prepareMocks({ + asset: zeroBalanceAsset, + isMusdConversionEnabled: true, + isConversionToken: true, + }); + + const { queryByText } = renderWithProvider( + , + ); + + expect(queryByText('Convert to mUSD')).toBeNull(); + }); + }); }); diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx index 08a1fb75765..725570fd3f8 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.tsx @@ -42,6 +42,11 @@ import { selectIsStakeableToken } from '../../../Stake/selectors/stakeableTokens import { useMusdConversionTokens } from '../../../Earn/hooks/useMusdConversionTokens'; import { fontStyles } from '../../../../../styles/common'; import { Colors } from '../../../../../util/theme/models'; +import { strings } from '../../../../../../locales/i18n'; +import Routes from '../../../../../constants/navigation/Routes'; +import { useMusdConversion } from '../../../Earn/hooks/useMusdConversion'; +import { toHex } from '@metamask/controller-utils'; +import Logger from '../../../../../util/Logger'; export const ACCOUNT_TYPE_LABEL_TEST_ID = 'account-type-label'; @@ -115,13 +120,48 @@ export const TokenListItem = React.memo( selectIsMusdConversionFlowEnabledFlag, ); - const { isConversionToken } = useMusdConversionTokens(); + const { isConversionToken, getMusdOutputChainId } = + useMusdConversionTokens(); + + const { initiateConversion } = useMusdConversion(); const isConvertibleStablecoin = isMusdConversionFlowEnabled && isConversionToken(asset); + const shouldShowConvertToMusdCta = + isConvertibleStablecoin && Number(asset?.balance) > 0; + const pricePercentChange1d = useTokenPricePercentageChange(asset); + const handleConvertToMUSD = useCallback(async () => { + try { + if (!asset?.address || !asset?.chainId) { + throw new Error('Asset address or chain ID is not set'); + } + + const assetChainId = toHex(asset.chainId); + + await initiateConversion({ + outputChainId: getMusdOutputChainId(assetChainId), + preferredPaymentToken: { + address: toHex(asset.address), + chainId: assetChainId, + }, + navigationStack: Routes.EARN.ROOT, + }); + } catch (error) { + Logger.error( + error as Error, + '[mUSD Conversion] Failed to initiate conversion', + ); + } + }, [ + asset?.address, + asset?.chainId, + getMusdOutputChainId, + initiateConversion, + ]); + // Secondary balance shows percentage change (if available and not on testnet) const hasPercentageChange = !isTestNet(chainId) && @@ -130,23 +170,41 @@ export const TokenListItem = React.memo( pricePercentChange1d !== undefined && Number.isFinite(pricePercentChange1d); - // Determine the color for percentage change - let percentageColor = TextColor.Alternative; - if (hasPercentageChange) { - if (pricePercentChange1d === 0) { - percentageColor = TextColor.Alternative; - } else if (pricePercentChange1d > 0) { - percentageColor = TextColor.Success; - } else { - percentageColor = TextColor.Error; + const secondaryBalanceDisplay = useMemo(() => { + if (shouldShowConvertToMusdCta) { + return { + text: strings('earn.musd_conversion.convert_to_musd'), + color: TextColor.Primary, + onPress: handleConvertToMUSD, + }; } - } - const percentageText = hasPercentageChange - ? `${pricePercentChange1d >= 0 ? '+' : ''}${pricePercentChange1d.toFixed( - 2, - )}%` - : undefined; + if (!hasPercentageChange) { + return { + text: undefined, + color: TextColor.Alternative, + onPress: undefined, + }; + } + + const text = `${pricePercentChange1d >= 0 ? '+' : ''}${pricePercentChange1d.toFixed( + 2, + )}%`; + + let color = TextColor.Alternative; + if (pricePercentChange1d > 0) { + color = TextColor.Success; + } else if (pricePercentChange1d < 0) { + color = TextColor.Error; + } + + return { text, color, onPress: undefined }; + }, [ + handleConvertToMUSD, + hasPercentageChange, + pricePercentChange1d, + shouldShowConvertToMusdCta, + ]); const earnToken = getEarnToken(asset as TokenI); @@ -177,7 +235,8 @@ export const TokenListItem = React.memo( ); const renderEarnCta = useCallback(() => { - if (!asset) { + // For convertible stablecoins, we display the CTA in the AssetElement's secondary balance + if (!asset || isConvertibleStablecoin) { return null; } @@ -186,13 +245,7 @@ export const TokenListItem = React.memo( const shouldShowStablecoinLendingCta = earnToken && isStablecoinLendingEnabled; - const shouldShowMusdConvertCta = isConvertibleStablecoin; - - if ( - shouldShowStakeCta || - shouldShowStablecoinLendingCta || - shouldShowMusdConvertCta - ) { + if (shouldShowStakeCta || shouldShowStablecoinLendingCta) { // TODO: Rename to EarnCta return ; } @@ -218,10 +271,11 @@ export const TokenListItem = React.memo( onLongPress={asset.isNative ? null : showRemoveMenu} asset={asset} balance={asset.balanceFiat} - secondaryBalance={percentageText} - secondaryBalanceColor={percentageColor} + secondaryBalance={secondaryBalanceDisplay.text} + secondaryBalanceColor={secondaryBalanceDisplay.color} privacyMode={privacyMode} hideSecondaryBalanceInPrivacyMode={false} + onSecondaryBalancePress={secondaryBalanceDisplay.onPress} > Date: Tue, 6 Jan 2026 13:02:00 -0700 Subject: [PATCH 3/7] chore: replace npx with yarn (#24267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR replaces documentation and references of `npx` with `yarn` to enforce yarn.lock file versioning. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **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. --- .claude/commands/unit-test-coverage.md | 6 +++--- docs/perps/perps-architecture.md | 6 +++--- e2e/framework/config/global.setup.ts | 2 +- scripts/coverage-analysis.js | 8 ++++---- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.claude/commands/unit-test-coverage.md b/.claude/commands/unit-test-coverage.md index 051b5ad1c12..208c67a4d4b 100644 --- a/.claude/commands/unit-test-coverage.md +++ b/.claude/commands/unit-test-coverage.md @@ -74,7 +74,7 @@ flowchart TD "failedTests": [{ "file": "usePerps.test.tsx", "error": "Cannot read property 'data' of undefined", - "command": "npx jest usePerps.test.tsx --no-coverage" + "command": "yarn jest usePerps.test.tsx --no-coverage" }] } ``` @@ -316,11 +316,11 @@ cat scripts/reports/coverage-report-*.json | jq '.failedTests | length' cat scripts/reports/coverage-report-*.json | jq '.actionableRecommendations.filesNeedingImprovement[0]' # Debug failing test -npx jest path/to/test.tsx --no-coverage --verbose +yarn jest path/to/test.tsx --no-coverage --verbose # Type check & lint yarn lint:tsc -npx eslint path/to/test.tsx --fix +yarn eslint path/to/test.tsx --fix ``` ## Skip These Files diff --git a/docs/perps/perps-architecture.md b/docs/perps/perps-architecture.md index 9b5674bbf88..759fa3609b7 100644 --- a/docs/perps/perps-architecture.md +++ b/docs/perps/perps-architecture.md @@ -399,13 +399,13 @@ All user interactions are tracked via MetaMetrics events: ```bash # Format code -npx prettier --write 'app/components/UI/Perps/**/*.{ts,tsx}' +yarn prettier --write 'app/components/UI/Perps/**/*.{ts,tsx}' # Check for errors -npx eslint app/components/UI/Perps/**/*.{ts,tsx} +yarn eslint app/components/UI/Perps/**/*.{ts,tsx} # Run tests -npx jest app/components/UI/Perps/ --no-coverage +yarn jest app/components/UI/Perps/ --no-coverage ``` ## Testing diff --git a/e2e/framework/config/global.setup.ts b/e2e/framework/config/global.setup.ts index 3da49c4179a..d62c89b8cd1 100644 --- a/e2e/framework/config/global.setup.ts +++ b/e2e/framework/config/global.setup.ts @@ -69,7 +69,7 @@ async function globalSetup(config: FullConfig) { console.error('\n❌ Error: --project flag is required\n'); displayAvailableProjects(config); throw new Error( - 'Please specify a project name with --project flag. Example: npx playwright test --project dummy-test-local', + 'Please specify a project name with --project flag. Example: yarn playwright test --project dummy-test-local', ); } diff --git a/scripts/coverage-analysis.js b/scripts/coverage-analysis.js index 714e3314868..d324842fdab 100644 --- a/scripts/coverage-analysis.js +++ b/scripts/coverage-analysis.js @@ -231,7 +231,7 @@ function generateTestSuggestions(file, uncoveredLines = []) { */ function runTestsWithFallback(testFiles) { const testArgs = testFiles.join(' '); - const cmd = `npx jest ${testArgs} --coverage --coverageReporters=lcov --passWithNoTests`; + const cmd = `yarn jest ${testArgs} --coverage --coverageReporters=lcov --passWithNoTests`; try { // Phase 1: Try running all tests together (fast path) @@ -285,14 +285,14 @@ function runTestsWithFallback(testFiles) { error: failureDetails[file]?.length > 0 ? failureDetails[file].join('\n') : 'Test failed (see full output for details)', - command: `npx jest ${file} --no-coverage` + command: `yarn jest ${file} --no-coverage` })); // Phase 3: Re-run only passing tests with coverage if any passed if (passedTests.length > 0) { console.log(`🔄 Re-running ${passedTests.length} passing tests to generate coverage...\n`); const passingTestsArgs = passedTests.join(' '); - const coverageCmd = `npx jest ${passingTestsArgs} --coverage --coverageReporters=lcov --silent --passWithNoTests`; + const coverageCmd = `yarn jest ${passingTestsArgs} --coverage --coverageReporters=lcov --silent --passWithNoTests`; try { execSync(coverageCmd, { cwd: process.cwd(), stdio: 'pipe' }); @@ -638,7 +638,7 @@ function printCoverageSummary(stats, coverageResults, needsTests, failedTests) { console.log('\n📝 To re-run failed tests:'); const failedFilesArgs = failedTests.map(t => t.file).join(' '); - console.log(` npx jest ${failedFilesArgs} --no-coverage\n`); + console.log(` yarn jest ${failedFilesArgs} --no-coverage\n`); } } From 218f8fd1dd4d5722b5e11e0ed8ac01db78bad82e Mon Sep 17 00:00:00 2001 From: Wei Sun Date: Tue, 6 Jan 2026 12:12:35 -0800 Subject: [PATCH 4/7] feat: ota update modal (#24175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Implement OTA Update modal. On iOS, if there are updates available, users can reload the app to see the updates. On Android, the app crashes when we call reloadAsync so we let users know that they need to close and reopen the app to receive the updates ## **Changelog** CHANGELOG entry: Added OTA updates modal ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/24110 ## **Manual testing steps** ```gherkin Feature: OTA update modal In order to keep the app up to date without reinstalling As a user I want to be prompted to reload when a new OTA update is available Background: Given the app has launched And OTA updates are enabled Scenario: Display OTA update modal when a new update is available Given a new OTA update has been downloaded in the background When I am on the home screen Then I see the OTA update modal And the modal explains that a new version is ready to use Scenario: User chooses to update now Given the OTA update modal is visible When I tap the "Update now" button Then the app reloads to apply the new OTA update Scenario: User chooses to update later Given the OTA update modal is visible When drawer is dismissed Then the modal closes And the app continues using the current version without reloading ``` ## **Screenshots/Recordings** ### **Before** ### **After** | iOS | Android | | ------------- | ------------- | |Simulator Screenshot - iPhone 16 - 2026-01-06 at 03 29 25|Screenshot 2026-01-06 at 3 44 24 AM| ## **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. --- > [!NOTE] > Adds an OTA-driven update UX using a modal and removes render-blocking during update checks. > > - New `OTAUpdatesModal` bottom sheet with platform-specific copy and primary action (iOS reload via `reloadAsync`); tracked with `MetaMetricsEvents` and covered by tests > - Refactors `useOTAUpdates` to: respect feature flag, skip in `__DEV__`, check/fetch updates, and navigate to `OTAUpdatesModal` after interactions when `isNew` is true; no longer reloads automatically or gate-renders via `FoxLoader` > - Integrates hook in `App` and registers route `Routes.MODAL.OTA_UPDATES_MODAL`; updates `App.test.tsx` to remove loader assertions and mock `useOTAUpdates` > - Adds analytics events (`OTA_UPDATES_MODAL_VIEWED`, `OTA_UPDATES_MODAL_PRIMARY_ACTION_CLICKED`) and English i18n strings for modal content > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit bedde637d0fde1a1678ab80975fd446489c103e8. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: metamaskbot --- app/components/Nav/App/App.test.tsx | 32 +-- app/components/Nav/App/App.tsx | 21 +- .../OTAUpdatesModal/OTAUpdatesModal.test.tsx | 161 ++++++++++++ .../UI/OTAUpdatesModal/OTAUpdatesModal.tsx | 138 +++++++++++ app/components/UI/OTAUpdatesModal/index.ts | 2 + app/components/hooks/useOTAUpdates.test.ts | 232 +++++------------- app/components/hooks/useOTAUpdates.ts | 49 ++-- app/constants/navigation/Routes.ts | 1 + app/core/Analytics/MetaMetrics.events.ts | 10 + locales/languages/en.json | 7 + 10 files changed, 414 insertions(+), 239 deletions(-) create mode 100644 app/components/UI/OTAUpdatesModal/OTAUpdatesModal.test.tsx create mode 100644 app/components/UI/OTAUpdatesModal/OTAUpdatesModal.tsx create mode 100644 app/components/UI/OTAUpdatesModal/index.ts diff --git a/app/components/Nav/App/App.test.tsx b/app/components/Nav/App/App.test.tsx index 61ebb604309..8e6bc4b112b 100644 --- a/app/components/Nav/App/App.test.tsx +++ b/app/components/Nav/App/App.test.tsx @@ -31,7 +31,6 @@ import { KeyringTypes } from '@metamask/keyring-controller'; import { AccountDetailsIds } from '../../../../e2e/selectors/MultichainAccounts/AccountDetails.selectors'; import { AvatarAccountType } from '../../../component-library/components/Avatars/Avatar'; import AUTHENTICATION_TYPE from '../../../constants/userProperties'; -import { useOTAUpdates } from '../../hooks/useOTAUpdates'; const initialState: DeepPartial = { user: { @@ -83,16 +82,6 @@ jest.mock('../../hooks/useMetrics/useMetrics', () => ({ }), })); -jest.mock('../../hooks/useOTAUpdates', () => ({ - useOTAUpdates: jest.fn().mockReturnValue({ - isCheckingUpdates: false, - }), -})); - -const mockUseOTAUpdates = useOTAUpdates as jest.MockedFunction< - typeof useOTAUpdates ->; - jest.mock( '../../UI/FoxLoader', () => @@ -123,6 +112,10 @@ jest.mock('../../../core/OAuthService/OAuthLoginHandlers', () => ({ createLoginHandler: jest.fn(), })); +jest.mock('../../hooks/useOTAUpdates', () => ({ + useOTAUpdates: jest.fn(), +})); + // Mock the navigation hook const mockNavigate = jest.fn(); const mockReset = jest.fn(); @@ -269,9 +262,6 @@ describe('App', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseOTAUpdates.mockReturnValue({ - isCheckingUpdates: false, - }); mockNavigate.mockClear(); }); @@ -284,20 +274,6 @@ describe('App', () => { jest.useRealTimers(); }); - it('renders FoxLoader when OTA update check runs', () => { - mockUseOTAUpdates.mockReturnValue({ - isCheckingUpdates: true, - }); - - const { getByTestId } = renderScreen( - App, - { name: 'App' }, - { state: initialState }, - ); - - expect(getByTestId(MOCK_FOX_LOADER_ID)).toBeTruthy(); - }); - it('configures MetaMetrics instance and identifies user on startup', async () => { renderScreen(App, { name: 'App' }, { state: initialState }); await waitFor(() => { diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index 5e401008299..f9ff49786b0 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -56,6 +56,7 @@ import ConnectQRHardware from '../../Views/ConnectQRHardware'; import SelectHardwareWallet from '../../Views/ConnectHardware/SelectHardware'; import { AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS } from '../../../constants/error'; import { UpdateNeeded } from '../../../components/UI/UpdateNeeded'; +import { OTAUpdatesModal } from '../../UI/OTAUpdatesModal'; import NetworkSettings from '../../Views/Settings/NetworksSettings/NetworkSettings'; import ModalMandatory from '../../../component-library/components/Modals/ModalMandatory'; import { RestoreWallet } from '../../Views/RestoreWallet'; @@ -149,7 +150,6 @@ import MultichainAccountActions from '../../Views/MultichainAccounts/sheets/Mult import useInterval from '../../hooks/useInterval'; import { Duration } from '@metamask/utils'; import { selectSeedlessOnboardingLoginFlow } from '../../../selectors/seedlessOnboardingController'; -import { useOTAUpdates } from '../../hooks/useOTAUpdates'; import { SmartAccountUpdateModal } from '../../Views/confirmations/components/smart-account-update-modal'; import { PayWithModal } from '../../Views/confirmations/components/modals/pay-with-modal/pay-with-modal'; import { useMetrics } from '../../hooks/useMetrics'; @@ -161,6 +161,7 @@ import { useEmptyNavHeaderForConfirmations } from '../../Views/confirmations/hoo import { trackVaultCorruption } from '../../../util/analytics/vaultCorruptionTracking'; import SocialLoginIosUser from '../../Views/SocialLoginIosUser'; import AUTHENTICATION_TYPE from '../../../constants/userProperties'; +import { useOTAUpdates } from '../../hooks/useOTAUpdates'; const clearStackNavigatorOptions = { headerShown: false, @@ -516,6 +517,10 @@ const RootModalFlow = (props: RootModalFlowProps) => ( + { { ); }; -const AppContent: React.FC = () => { +const App: React.FC = () => { const navigation = useNavigation(); const routes = useNavigationState((state) => state.routes); const { toastRef } = useContext(ToastContext); @@ -1098,6 +1103,8 @@ const AppContent: React.FC = () => { selectSeedlessOnboardingLoginFlow, ); + useOTAUpdates(); + if (isFirstRender.current) { trace({ name: TraceName.NavInit, @@ -1280,14 +1287,4 @@ const AppContent: React.FC = () => { ); }; -const App: React.FC = () => { - const { isCheckingUpdates } = useOTAUpdates(); - - if (isCheckingUpdates) { - return ; - } - - return ; -}; - export default App; diff --git a/app/components/UI/OTAUpdatesModal/OTAUpdatesModal.test.tsx b/app/components/UI/OTAUpdatesModal/OTAUpdatesModal.test.tsx new file mode 100644 index 00000000000..b647a967413 --- /dev/null +++ b/app/components/UI/OTAUpdatesModal/OTAUpdatesModal.test.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react-native'; +import { Platform } from 'react-native'; +import { reloadAsync } from 'expo-updates'; +import OTAUpdatesModal from './OTAUpdatesModal'; +import Logger from '../../../util/Logger'; +import { MetaMetricsEvents } from '../../../core/Analytics'; + +jest.mock( + '../../../component-library/components/BottomSheets/BottomSheet', + () => { + const { View } = jest.requireActual('react-native'); + const { forwardRef, useImperativeHandle } = jest.requireActual('react'); + + const MockBottomSheet = forwardRef( + (props: { children: React.ReactNode }, ref: React.Ref) => { + useImperativeHandle(ref, () => ({ + onOpenBottomSheet: jest.fn(), + onCloseBottomSheet: jest.fn((callback?: () => void) => { + if (callback) callback(); + }), + })); + + return ( + + {props.children} + + ); + }, + ); + + return { + __esModule: true, + default: MockBottomSheet, + }; + }, +); + +jest.mock('expo-updates', () => ({ + reloadAsync: jest.fn(), +})); + +jest.mock('../../../util/metrics', () => ({ + __esModule: true, + default: jest.fn().mockReturnValue({}), +})); + +jest.mock('../../../util/Logger', () => ({ + log: jest.fn(), + error: jest.fn(), +})); + +jest.mock( + '../../../component-library/components/HeaderBase', + () => + function HeaderBaseMock({ children }: { children: React.ReactNode }) { + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>{children}; + }, +); + +const mockReloadAsync = reloadAsync as jest.MockedFunction; +const mockLoggerError = Logger.error as jest.MockedFunction< + typeof Logger.error +>; + +interface MockEventBuilder { + addProperties: jest.Mock; + build: jest.Mock; +} + +const mockCreateEventBuilder = jest.fn((event: string): MockEventBuilder => { + const builder: MockEventBuilder = { + addProperties: jest.fn(), + build: jest.fn(), + }; + + builder.addProperties.mockReturnValue(builder); + builder.build.mockReturnValue({ event }); + + return builder; +}); + +const mockTrackEvent = jest.fn(); + +jest.mock('../../hooks/useMetrics', () => ({ + useMetrics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +describe('OTAUpdatesModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + (Platform as unknown as { OS: string }).OS = 'ios'; + }); + + it('tracks view event on mount', () => { + render(); + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: MetaMetricsEvents.OTA_UPDATES_MODAL_VIEWED, + }), + ); + }); + + it('tracks primary action when primary button is pressed', async () => { + const { getByText } = render(); + + fireEvent.press(getByText('Reload')); + + await waitFor(() => { + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: MetaMetricsEvents.OTA_UPDATES_MODAL_PRIMARY_ACTION_CLICKED, + }), + ); + }); + }); + + it('reloads app when reload button is pressed on iOS', async () => { + const { getByText } = render(); + + fireEvent.press(getByText('Reload')); + + await waitFor(() => { + expect(mockReloadAsync).toHaveBeenCalledTimes(1); + }); + }); + + it('does not reload app when reload button is pressed on Android', async () => { + (Platform as unknown as { OS: string }).OS = 'android'; + + const { getByText } = render(); + + fireEvent.press(getByText('Got it')); + + await waitFor(() => { + expect(mockReloadAsync).not.toHaveBeenCalled(); + }); + }); + + it('logs error when reloadAsync throws', async () => { + const reloadError = new Error('Reload failed'); + + mockReloadAsync.mockRejectedValueOnce(reloadError); + + const { getByText } = render(); + + fireEvent.press(getByText('Reload')); + + await waitFor(() => { + expect(mockLoggerError).toHaveBeenCalledWith( + reloadError, + 'OTA Updates: Error reloading app after modal reload pressed', + ); + }); + }); +}); diff --git a/app/components/UI/OTAUpdatesModal/OTAUpdatesModal.tsx b/app/components/UI/OTAUpdatesModal/OTAUpdatesModal.tsx new file mode 100644 index 00000000000..1997ea1b6fb --- /dev/null +++ b/app/components/UI/OTAUpdatesModal/OTAUpdatesModal.tsx @@ -0,0 +1,138 @@ +import React, { useCallback, useRef, useEffect } from 'react'; +import { Image, Platform } from 'react-native'; +import { reloadAsync } from 'expo-updates'; +import { strings } from '../../../../locales/i18n'; +import Text, { + TextVariant, +} from '../../../component-library/components/Texts/Text'; +import { createNavigationDetails } from '../../../util/navigation/navUtils'; +import Routes from '../../../constants/navigation/Routes'; +import Logger from '../../../util/Logger'; +import Button, { + ButtonVariants, + ButtonWidthTypes, +} from '../../../component-library/components/Buttons/Button'; +import HeaderBase from '../../../component-library/components/HeaderBase'; +import { MetaMetricsEvents } from '../../../core/Analytics'; + +import { ScrollView } from 'react-native-gesture-handler'; +import generateDeviceAnalyticsMetaData from '../../../util/metrics'; +import { useMetrics } from '../../hooks/useMetrics'; +import { Box } from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import BottomSheet, { + BottomSheetRef, +} from '../../../component-library/components/BottomSheets/BottomSheet'; + +/* eslint-disable import/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ +const foxLogo = require('../../../images/branding/fox.png'); +const metamaskName = require('../../../images/branding/metamask-name.png'); + +export const createOTAUpdatesModalNavDetails = createNavigationDetails( + Routes.MODAL.ROOT_MODAL_FLOW, + Routes.MODAL.OTA_UPDATES_MODAL, +); + +const OTAUpdatesModal = () => { + const tw = useTailwind(); + const { trackEvent, createEventBuilder } = useMetrics(); + const bottomSheetRef = useRef(null); + + useEffect(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.OTA_UPDATES_MODAL_VIEWED) + .addProperties({ + ...generateDeviceAnalyticsMetaData(), + }) + .build(), + ); + }, [trackEvent, createEventBuilder]); + + const dismissBottomSheet = (cb?: () => void): void => + bottomSheetRef.current?.onCloseBottomSheet(cb); + + const onPress = useCallback(() => { + dismissBottomSheet(async () => { + trackEvent( + createEventBuilder( + MetaMetricsEvents.OTA_UPDATES_MODAL_PRIMARY_ACTION_CLICKED, + ) + .addProperties({ + ...generateDeviceAnalyticsMetaData(), + }) + .build(), + ); + + if (Platform.OS === 'ios') { + try { + await reloadAsync(); + } catch (error) { + Logger.error( + error as Error, + 'OTA Updates: Error reloading app after modal reload pressed', + ); + } + } + }); + }, [trackEvent, createEventBuilder]); + + const primaryActionLabel = + Platform.OS === 'ios' + ? strings('ota_update_modal.primary_action_reload') + : strings('ota_update_modal.primary_action_acknowledge'); + + const description = + Platform.OS === 'ios' + ? strings('ota_update_modal.description_ios') + : strings('ota_update_modal.description_android'); + + return ( + + + + + + + + + + {strings('ota_update_modal.title')} + + + {description} + + + +