From 12c3e59d02c113dbb307c78848aca78812bd0c81 Mon Sep 17 00:00:00 2001 From: Frank von Hoven <141057783+frankvonhoven@users.noreply.github.com> Date: Sun, 10 Nov 2024 13:04:06 -0600 Subject: [PATCH 01/11] feat: 1957 crash screen redesign (#12015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Refactors ErrorBoundary to match [updated designs in Figma](https://www.figma.com/design/DM5G1pyp74sMJwyKbw1KiR/Error-message-and-bug-report?node-id=1-5473&node-type=section&t=98CWfEC4JrKwpNCE-0) ## **Related issues** Fixes: [#1957](https://github.com/MetaMask/mobile-planning/issues/1957) ## **Manual testing steps** 1. Go WalletView 2. Add: ``` useEffect(() => { throw new Error('Test Error'); }, []); ``` 3. Restart the app 4. Dismiss the Red Screen 5. See the new Error Screen ## TESTING THE BUILD: 1. Create a QA build in Bitrise 2. force an error 3. report the error 4. check [Sentry](https://metamask.sentry.io/feedback/?feedbackSlug=test-metamask-mobile%3A6031686409&mailbox=ignored&project=2651591&referrer=feedback_list_page&statsPeriod=30d) 5. Should see user feedback in the Sentry report 6. Example: ![image](https://github.com/user-attachments/assets/c1737758-5739-48f9-96d1-798e523f5be3) ## **Screenshots/Recordings** ### **Before** image ### **After** https://github.com/user-attachments/assets/7dbe5e04-0152-41bc-984a-37cba98c4eeb ## **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: Daniel Cross --- app/components/Views/ErrorBoundary/index.js | 387 +++++++++---- .../Views/ErrorBoundary/warning-icon.png | Bin 0 -> 1662 bytes .../Login/__snapshots__/index.test.tsx.snap | 533 ++++++++---------- locales/languages/en.json | 14 +- 4 files changed, 529 insertions(+), 405 deletions(-) create mode 100644 app/components/Views/ErrorBoundary/warning-icon.png diff --git a/app/components/Views/ErrorBoundary/index.js b/app/components/Views/ErrorBoundary/index.js index a0730857d36..f300d534ea3 100644 --- a/app/components/Views/ErrorBoundary/index.js +++ b/app/components/Views/ErrorBoundary/index.js @@ -1,12 +1,17 @@ -import React, { Component, useCallback } from 'react'; +import React, { Component } from 'react'; import { Text, TouchableOpacity, View, StyleSheet, - Image, Linking, Alert, + Platform, + Modal, + KeyboardAvoidingView, + DevSettings, + Image, + TextInput, } from 'react-native'; import PropTypes from 'prop-types'; import { lastEventId as getLatestSentryId } from '@sentry/react-native'; @@ -16,39 +21,49 @@ import Logger from '../../../util/Logger'; import { fontStyles } from '../../../styles/common'; import { ScrollView } from 'react-native-gesture-handler'; import { strings } from '../../../../locales/i18n'; -import Icon from 'react-native-vector-icons/FontAwesome'; +import CLIcon, { + IconColor, + IconName, + IconSize, +} from '../../../component-library/components/Icons/Icon'; import ClipboardManager from '../../../core/ClipboardManager'; import { mockTheme, ThemeContext, useTheme } from '../../../util/theme'; import { SafeAreaView } from 'react-native-safe-area-context'; +import BannerAlert from '../../../component-library/components/Banners/Banner/variants/BannerAlert'; +import { BannerAlertSeverity } from '../../../component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert.types'; +import CLText, { + TextVariant, +} from '../../../component-library/components/Texts/Text'; import { MetaMetricsEvents, withMetricsAwareness, } from '../../../components/hooks/useMetrics'; +import AppConstants from '../../../core/AppConstants'; +import { useSelector } from 'react-redux'; // eslint-disable-next-line import/no-commonjs -const metamaskErrorImage = require('../../../images/metamask-error.png'); +const WarningIcon = require('./warning-icon.png'); const createStyles = (colors) => StyleSheet.create({ container: { flex: 1, + paddingHorizontal: 8, backgroundColor: colors.background.default, }, - content: { - paddingHorizontal: 24, - flex: 1, - }, header: { alignItems: 'center', + paddingTop: 20, }, errorImage: { - width: 50, - height: 50, - marginTop: 24, + width: 32, + height: 32, }, title: { color: colors.text.default, fontSize: 24, + paddingTop: 10, + paddingBottom: 20, lineHeight: 34, ...fontStyles.bold, }, @@ -60,23 +75,36 @@ const createStyles = (colors) => textAlign: 'center', ...fontStyles.normal, }, - errorContainer: { + errorMessageContainer: { + flexShrink: 1, backgroundColor: colors.error.muted, borderRadius: 8, - marginTop: 24, + marginTop: 10, + padding: 10, }, error: { - color: colors.text.default, + color: colors.error.default, padding: 8, fontSize: 14, lineHeight: 20, ...fontStyles.normal, }, button: { - marginTop: 24, + marginTop: 16, borderColor: colors.primary.default, borderWidth: 1, - borderRadius: 50, + borderRadius: 48, + height: 48, + padding: 12, + paddingHorizontal: 34, + }, + blueButton: { + marginTop: 16, + borderColor: colors.primary.default, + backgroundColor: colors.primary.default, + borderWidth: 1, + borderRadius: 48, + height: 48, padding: 12, paddingHorizontal: 34, }, @@ -85,6 +113,55 @@ const createStyles = (colors) => textAlign: 'center', ...fontStyles.normal, }, + blueButtonText: { + color: colors.background.default, + textAlign: 'center', + ...fontStyles.normal, + }, + submitButton: { + width: '45%', + backgroundColor: colors.primary.default, + marginTop: 24, + borderColor: colors.primary.default, + borderWidth: 1, + borderRadius: 48, + height: 48, + padding: 12, + paddingHorizontal: 34, + }, + cancelButton: { + width: '45%', + marginTop: 24, + borderColor: colors.primary.default, + borderWidth: 1, + borderRadius: 48, + height: 48, + padding: 12, + paddingHorizontal: 34, + }, + buttonsContainer: { + flexGrow: 1, + bottom: 10, + justifyContent: 'flex-end', + }, + modalButtonsWrapper: { + flex: 1, + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'flex-end', + bottom: 24, + paddingHorizontal: 10, + }, + feedbackInput: { + borderColor: colors.primary.default, + minHeight: 175, + minWidth: '100%', + paddingHorizontal: 16, + paddingTop: 10, + borderRadius: 10, + borderWidth: 1, + marginTop: 20, + }, textContainer: { marginTop: 24, }, @@ -105,120 +182,216 @@ const createStyles = (colors) => reportStep: { marginTop: 14, }, + banner: { + width: '100%', + marginTop: 20, + paddingHorizontal: 16, + }, + keyboardViewContainer: { flex: 1, justifyContent: 'flex-end' }, + modalWrapper: { flex: 1, justifyContent: 'space-between' }, + modalTopContainer: { flex: 1, paddingTop: '20%', paddingHorizontal: 16 }, + closeIconWrapper: { + position: 'absolute', + right: 0, + top: 2, + bottom: 0, + }, + modalTitleWrapper: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + modalTitleText: { paddingTop: 0 }, + errorBoxTitle: { fontWeight: '600' }, + contentContainer: { + justifyContent: 'space-between', + flex: 1, + paddingHorizontal: 16, + }, + errorContentWrapper: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 20, + }, + row: { flexDirection: 'row' }, + copyText: { + color: colors.primary.default, + fontSize: 14, + paddingLeft: 5, + fontWeight: '500', + }, + infoBanner: { marginBottom: 20 }, + hitSlop: { top: 50, right: 50, bottom: 50, left: 50 }, }); -const UserFeedbackSection = ({ styles, sentryId }) => { - /** - * Prompt bug report form - */ - const promptBugReport = useCallback(() => { - Alert.prompt( - strings('error_screen.bug_report_prompt_title'), - strings('error_screen.bug_report_prompt_description'), - [ - { text: strings('error_screen.cancel'), style: 'cancel' }, - { - text: strings('error_screen.send'), - onPress: (comments = '') => { - // Send Sentry feedback - captureSentryFeedback({ sentryId, comments }); - Alert.alert(strings('error_screen.bug_report_thanks')); - }, - }, - ], - ); - }, [sentryId]); - - return ( - - - {' '} - {strings('error_screen.submit_ticket_8')}{' '} - - {strings('error_screen.submit_ticket_6')} - {' '} - {strings('error_screen.submit_ticket_9')} - +export const Fallback = (props) => { + const { colors } = useTheme(); + const styles = createStyles(colors); + const [modalVisible, setModalVisible] = React.useState(false); + const [feedback, setFeedback] = React.useState(''); + const dataCollectionForMarketing = useSelector( + (state) => state.security.dataCollectionForMarketing, ); -}; -UserFeedbackSection.propTypes = { - styles: PropTypes.object, - sentryId: PropTypes.string, -}; + const toggleModal = () => { + setModalVisible((visible) => !visible); + setFeedback(''); + }; + const handleContactSupport = () => + Linking.openURL(AppConstants.REVIEW_PROMPT.SUPPORT); + const handleTryAgain = () => DevSettings.reload(); -const Fallback = (props) => { - const { colors } = useTheme(); - const styles = createStyles(colors); + const handleSubmit = () => { + toggleModal(); + captureSentryFeedback({ sentryId: props.sentryId, comments: feedback }); + Alert.alert(strings('error_screen.bug_report_thanks')); + }; return ( - + - + {strings('error_screen.title')} - {strings('error_screen.subtitle')} - - - {props.errorMessage} - - - - - {' '} - {strings('error_screen.try_again_button')} + {strings('error_screen.subtitle')}} + /> + + {strings('error_screen.save_seedphrase_1')}{' '} + + {strings('error_screen.save_seedphrase_2')} + {' '} + {strings('error_screen.save_seedphrase_3')} + } + /> + + + {strings('error_screen.error_message')} + + + + {strings('error_screen.copy')} - - - {strings('error_screen.submit_ticket_1')} - - - - - {' '} - {strings('error_screen.submit_ticket_2')} - - - - - {' '} - - {strings('error_screen.submit_ticket_3')} - {' '} - {strings('error_screen.submit_ticket_4')} + + + {props.errorMessage} + + + + {dataCollectionForMarketing && ( + + + {strings('error_screen.describe')} + + + )} + + + {strings('error_screen.contact_support')} - - - - {' '} - {strings('error_screen.submit_ticket_5')}{' '} - - {strings('error_screen.submit_ticket_6')} - {' '} - {strings('error_screen.submit_ticket_7')} + + + + {strings('error_screen.try_again')} - - - - {strings('error_screen.save_seedphrase_1')}{' '} - - {strings('error_screen.save_seedphrase_2')} - {' '} - {strings('error_screen.save_seedphrase_3')} - + - + + + + + + + {strings('error_screen.modal_title')} + + + + + + + + + + + {strings('error_screen.cancel')} + + + + + {strings('error_screen.submit')} + + + + + + + ); }; Fallback.propTypes = { errorMessage: PropTypes.string, - resetError: PropTypes.func, showExportSeedphrase: PropTypes.func, copyErrorToClipboard: PropTypes.func, - openTicket: PropTypes.func, sentryId: PropTypes.string, }; diff --git a/app/components/Views/ErrorBoundary/warning-icon.png b/app/components/Views/ErrorBoundary/warning-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..91a9fdd9fd927b48d4a29ccb12dfe4daec53100d GIT binary patch literal 1662 zcmV-^27&pBP)a_nSR3M;U6?9kkN>z%#X$^7ikII(@7H1e)zcD?iVoA14OGp^A+fiV{Fk5Kw* z&dan`tBq-s!8;!Ea?Hz&nK9v|pBQO#fDZ9;!HT255ys%1*m1mh7bds|tt&hjKw!3( zhG2^!;KKm2Ap%+_umz2MV?gTy{;-j)C$rg%b-P^_1OY>Hl%1WO_`Atu;xa^=Rvm0H z42(S)jYgTys|9svW3wSIby{VxMI4|7-g3QOHyS_`v~e(^ORI{61x7TRO{W1w3B!;% z5YQ@uErJ^@`u%?PZ9n{o9slq-oBVUgMt^A5=pFF?|6F3Ww_{WK^4jpMU8Z@8J<8ySB4*~1_ z_P%*W!xuO4F<8Jw(#D1kXTu3qKxHN*0%i2wV1Y0IaPiBRF3t3b5ep>?}Qxo<63X$Dfc3;qD`9KKV2~$32F_p{aSDEJHC5Dh-E(1rt-fEQSDv zfldo_P(_lvOyb_cc1dZGtjYwVFyZZG#IU`a+W-LCwp)$#985SrKet^{nhm9yU~`EW z#}Gr=CA!2=dmqjMumP-y@3SPAS%nEk%fqdyF0l-FKvmdfmJ!KPOyEBR19WwDcu;78 z0ECc7gxdSy4R?OJN;_*BE~?7}8^8(!dYlbiDuBXF$m0@a*jyqEaQXZdojrRQADd9n zWgZn}Lz&?K7HEG2;-$Jon;4E?{;xe&0hft;30z)Yx?JWl6+j^)BrJ$b^{rMbJyt{D zs^F8Yc-RQ2j8lN5PDnXXlnF*ht-8bm2MdY<22fq5LnL#%%-lvuTw>$-GR!EHThkpz|ju z?mUAOLG0W<}EBGOfuMs}T}66PfB&FNx zg(G$_22e&LxLl$vhUok<#n1!{2{}oFXR^3VEDmc$Qfq`nNOC7m??MbskbfHnaIis} ziZe06>Jn8klv81M4b^3W4MkINw~XMLC8`2cMGR#PsiG$cpbE?@+N#UcF~RB*Wh)m= znW+GkaXmUvWk~5#ahPdZkP-Y$QpLwA(tQD`xUJ8AEwy^3GNJM+(tV*!Doz}vOt7(_ z%qr4-p-d`HW`dOkMOTsX0NJFkzWXzNa9d`n2-H-ZLnPx=T+9S)9pE1qx>jo96fRMo z&X$F`xB@zZU9!rT1q_Sar2rOfuy|v@o?ybhvBlnA=G@o(VEFG%Y5)MofBgN5e4t<| z?m!qpyGIbNrphXk4fO}N-}uK5s_T(=Glq5 zY6Z`XXSX1`MVOy-ZLP7*Hi{zazT{OYc*b_SooBOnoFkMcP_PKpB%t>LQ(8=?r>TqN zCzv6%=>0(p2EaikPDqjr9ol^KoO_#)nr43gy0a=kpCYxtHH#qvTDZ-SHUmSFhJXy` z#x3@4DGuS|YX9TpHlTBuG+60hX!lxTkUFi0G0^#GBKTIk1u@5c9PXOiIsgCw07*qo IM6N<$f~jTl^8f$< literal 0 HcmV?d00001 diff --git a/app/components/Views/Login/__snapshots__/index.test.tsx.snap b/app/components/Views/Login/__snapshots__/index.test.tsx.snap index df38946a89c..4beae542b9e 100644 --- a/app/components/Views/Login/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/Login/__snapshots__/index.test.tsx.snap @@ -6,401 +6,344 @@ exports[`Login should render correctly 1`] = ` { "backgroundColor": "#ffffff", "flex": 1, + "paddingHorizontal": 8, } } > - - - + + - + + + + - + - An error occurred - + } + > Your information can't be shown. Don’t worry, your wallet and funds are safe. + + - - View: Login -TypeError: (0 , _reactNativeDeviceInfo.getTotalMemorySync) is not a function - + width={24} + /> - + If you keep getting this error, + - -  - - - Try again + save your Secret Recovery Phrase - + + and re-install the app. Remember: without your Secret Recovery Phrase, you can't restore your wallet. + - + + - + + - - Please report this issue so we can fix it: - - - + + Copy + + + + + + - -  - - - Take a screenshot of this screen. - - - -  - - - - Copy - - - the error message to clipboard. - - - -  - - - Submit a ticket - - - here. - - - Please include the error message and the screenshot. - - - -  - - - Send us a bug report - - - here. - - - Please include details about what happened. + View: Login +TypeError: (0 , _reactNativeDeviceInfo.getTotalMemorySync) is not a function + + + + - If this error persists, - - + + + - save your Secret Recovery Phrase - - - & re-install the app. Note: you can NOT restore your wallet without your Secret Recovery Phrase. + } + > + Try again - + - + + `; diff --git a/locales/languages/en.json b/locales/languages/en.json index 35616e1495c..26eba78c321 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -2930,13 +2930,21 @@ "bug_report_prompt_title": "Tell us what happened", "bug_report_prompt_description": "Add details so we can figure out what went wrong.", "bug_report_thanks": "Thanks! We’ll take a look soon.", - "save_seedphrase_1": "If this error persists,", + "save_seedphrase_1": "If you keep getting this error,", "save_seedphrase_2": "save your Secret Recovery Phrase", - "save_seedphrase_3": "& re-install the app. Note: you can NOT restore your wallet without your Secret Recovery Phrase.", + "save_seedphrase_3": "and re-install the app. Remember: without your Secret Recovery Phrase, you can't restore your wallet.", "copied_clipboard": "Copied to clipboard", "ok": "OK", "cancel": "Cancel", - "send": "Send" + "send": "Send", + "submit": "Submit", + "modal_title": "Describe what happened", + "modal_placeholder": "Sharing details like how we can reproduce the bug will help us fix the problem.", + "error_message": "Error message:", + "copy": "Copy", + "describe": "Describe what happened", + "try_again": "Try again", + "contact_support": "Contact support" }, "whats_new": { "title": "What's new", From 61ab41a11b9ebfd9a37a74e6ba4fbadbeb8b3e7d Mon Sep 17 00:00:00 2001 From: Vince Howard Date: Mon, 11 Nov 2024 08:30:57 -0700 Subject: [PATCH 02/11] fix: privacy mode is enabled in account selector by params (#12235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Current Issue: The `AccountSelectorList` subscribed to the `privacyMode` selector at a component level. It had the unintended consequence of hiding the balance when choosing an account during the send flow. Solution: Passing it as a param to allow fine tuned control over hidden balances in the `AccountSelector` component ## **Related issues** Follow Fix: [#3240](https://github.com/MetaMask/MetaMask-planning/issues/3420) ## **Manual testing steps** 1. Click on the privacy mode button on the home screen 2. Click on the account selector to verify your balance is hidden 3. Click on the send flow and attempt to switch accounts - confirm that your balance isn't hidden here while pivacy mode is enabled ## **Screenshots/Recordings** | Before | After | |:---:|:---:| |![before](https://github.com/user-attachments/assets/ee0c13bb-c636-4a1a-9b87-64f1da16eca4)|![after](https://github.com/user-attachments/assets/3606b2e9-d853-4a84-8177-b7437487ae21)| ### **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. --- .../AccountSelector.test.tsx | 51 +- .../AccountSelectorList.tsx | 3 +- .../AccountSelectorList.types.ts | 4 + .../UI/WalletAccount/WalletAccount.test.tsx | 63 +- .../UI/WalletAccount/WalletAccount.tsx | 8 +- .../AccountSelector/AccountSelector.test.tsx | 165 ++++++ .../Views/AccountSelector/AccountSelector.tsx | 12 +- .../AccountSelector/AccountSelector.types.ts | 4 + .../AccountSelector.test.tsx.snap | 548 ++++++++++++++++++ 9 files changed, 846 insertions(+), 12 deletions(-) create mode 100644 app/components/Views/AccountSelector/AccountSelector.test.tsx create mode 100644 app/components/Views/AccountSelector/__snapshots__/AccountSelector.test.tsx.snap diff --git a/app/components/UI/AccountSelectorList/AccountSelector.test.tsx b/app/components/UI/AccountSelectorList/AccountSelector.test.tsx index 0d8fde99345..506d7e6e37d 100644 --- a/app/components/UI/AccountSelectorList/AccountSelector.test.tsx +++ b/app/components/UI/AccountSelectorList/AccountSelector.test.tsx @@ -16,6 +16,7 @@ import { } from '../../../util/test/accountsControllerTestUtils'; import { mockNetworkState } from '../../../util/test/network'; import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { AccountSelectorListProps } from './AccountSelectorList.types'; const BUSINESS_ACCOUNT = '0xC4955C0d639D99699Bfd7Ec54d9FaFEe40e4D272'; const PERSONAL_ACCOUNT = '0xd018538C87232FF95acbCe4870629b75640a78E7'; @@ -82,8 +83,9 @@ const initialState = { const onSelectAccount = jest.fn(); const onRemoveImportedAccount = jest.fn(); - -const AccountSelectorListUseAccounts = () => { +const AccountSelectorListUseAccounts: React.FC = ({ + privacyMode = false, +}) => { const { accounts, ensByAccountAddress } = useAccounts(); return ( { accounts={accounts} ensByAccountAddress={ensByAccountAddress} isRemoveAccountEnabled + privacyMode={privacyMode} /> ); }; @@ -118,7 +121,7 @@ const renderComponent = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any state: any = {}, AccountSelectorListTest = AccountSelectorListUseAccounts, -) => renderWithProvider(, { state }); +) => renderWithProvider(, { state }); describe('AccountSelectorList', () => { beforeEach(() => { @@ -238,4 +241,46 @@ describe('AccountSelectorList', () => { expect(snapTag).toBeDefined(); }); }); + it('Text is not hidden when privacy mode is off', async () => { + const state = { + ...initialState, + privacyMode: false, + }; + + const { queryByTestId } = renderComponent(state); + + await waitFor(() => { + const businessAccountItem = queryByTestId( + `${AccountListViewSelectorsIDs.ACCOUNT_BALANCE_BY_ADDRESS_TEST_ID}-${BUSINESS_ACCOUNT}`, + ); + + expect(within(businessAccountItem).getByText(regex.eth(1))).toBeDefined(); + expect( + within(businessAccountItem).getByText(regex.usd(3200)), + ).toBeDefined(); + + expect(within(businessAccountItem).queryByText('••••••')).toBeNull(); + }); + }); + it('Text is hidden when privacy mode is on', async () => { + const state = { + ...initialState, + privacyMode: true, + }; + + const { queryByTestId } = renderComponent(state); + + await waitFor(() => { + const businessAccountItem = queryByTestId( + `${AccountListViewSelectorsIDs.ACCOUNT_BALANCE_BY_ADDRESS_TEST_ID}-${BUSINESS_ACCOUNT}`, + ); + + expect(within(businessAccountItem).queryByText(regex.eth(1))).toBeNull(); + expect( + within(businessAccountItem).queryByText(regex.usd(3200)), + ).toBeNull(); + + expect(within(businessAccountItem).getByText('••••••')).toBeDefined(); + }); + }); }); diff --git a/app/components/UI/AccountSelectorList/AccountSelectorList.tsx b/app/components/UI/AccountSelectorList/AccountSelectorList.tsx index f2364e63402..30b8241836f 100644 --- a/app/components/UI/AccountSelectorList/AccountSelectorList.tsx +++ b/app/components/UI/AccountSelectorList/AccountSelectorList.tsx @@ -14,7 +14,6 @@ import Cell, { } from '../../../component-library/components/Cells/Cell'; import { InternalAccount } from '@metamask/keyring-api'; import { useStyles } from '../../../component-library/hooks'; -import { selectPrivacyMode } from '../../../selectors/preferencesController'; import { TextColor } from '../../../component-library/components/Texts/Text'; import SensitiveText, { SensitiveTextLength, @@ -52,6 +51,7 @@ const AccountSelectorList = ({ isSelectionDisabled, isRemoveAccountEnabled = false, isAutoScrollEnabled = true, + privacyMode = false, ...props }: AccountSelectorListProps) => { const { navigate } = useNavigation(); @@ -72,7 +72,6 @@ const AccountSelectorList = ({ ); const internalAccounts = useSelector(selectInternalAccounts); - const privacyMode = useSelector(selectPrivacyMode); const getKeyExtractor = ({ address }: Account) => address; const renderAccountBalances = useCallback( diff --git a/app/components/UI/AccountSelectorList/AccountSelectorList.types.ts b/app/components/UI/AccountSelectorList/AccountSelectorList.types.ts index 4059c710cc9..a2f651c718e 100644 --- a/app/components/UI/AccountSelectorList/AccountSelectorList.types.ts +++ b/app/components/UI/AccountSelectorList/AccountSelectorList.types.ts @@ -56,4 +56,8 @@ export interface AccountSelectorListProps * Optional boolean to enable removing accounts. */ isRemoveAccountEnabled?: boolean; + /** + * Optional boolean to indicate if privacy mode is enabled. + */ + privacyMode?: boolean; } diff --git a/app/components/UI/WalletAccount/WalletAccount.test.tsx b/app/components/UI/WalletAccount/WalletAccount.test.tsx index 621c000a565..d9a1de2c7d4 100644 --- a/app/components/UI/WalletAccount/WalletAccount.test.tsx +++ b/app/components/UI/WalletAccount/WalletAccount.test.tsx @@ -57,6 +57,9 @@ const mockInitialState: DeepPartial = { engine: { backgroundState: { ...backgroundState, + PreferencesController: { + privacyMode: false, + }, AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, NetworkController: { ...mockNetworkState({ @@ -101,14 +104,21 @@ jest.mock('../../../util/ENSUtils', () => ({ }), })); +const mockSelector = jest + .fn() + .mockImplementation((callback) => callback(mockInitialState)); + jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), - useSelector: jest - .fn() - .mockImplementation((callback) => callback(mockInitialState)), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + useSelector: (selector: any) => mockSelector(selector), })); describe('WalletAccount', () => { + beforeEach(() => { + mockSelector.mockImplementation((callback) => callback(mockInitialState)); + }); + it('renders correctly', () => { const { toJSON } = renderWithProvider(, { state: mockInitialState, @@ -132,7 +142,9 @@ describe('WalletAccount', () => { fireEvent.press(getByTestId(WalletViewSelectorsIDs.ACCOUNT_ICON)); expect(mockNavigate).toHaveBeenCalledWith( - ...createAccountSelectorNavDetails({}), + ...createAccountSelectorNavDetails({ + privacyMode: false, + }), ); }); it('displays the correct account name', () => { @@ -164,4 +176,47 @@ describe('WalletAccount', () => { expect(getByText(customAccountName)).toBeDefined(); }); }); + + it('should navigate to account selector with privacy mode disabled', () => { + const { getByTestId } = renderWithProvider(, { + state: mockInitialState, + }); + + fireEvent.press(getByTestId(WalletViewSelectorsIDs.ACCOUNT_ICON)); + expect(mockNavigate).toHaveBeenCalledWith( + ...createAccountSelectorNavDetails({ + privacyMode: false, + }), + ); + }); + + it('should navigate to account selector with privacy mode enabled', () => { + const stateWithPrivacyMode = { + ...mockInitialState, + engine: { + ...mockInitialState.engine, + backgroundState: { + ...mockInitialState.engine?.backgroundState, + PreferencesController: { + privacyMode: true, + }, + }, + }, + }; + + mockSelector.mockImplementation((callback) => + callback(stateWithPrivacyMode), + ); + + const { getByTestId } = renderWithProvider(, { + state: stateWithPrivacyMode, + }); + + fireEvent.press(getByTestId(WalletViewSelectorsIDs.ACCOUNT_ICON)); + expect(mockNavigate).toHaveBeenCalledWith( + ...createAccountSelectorNavDetails({ + privacyMode: true, + }), + ); + }); }); diff --git a/app/components/UI/WalletAccount/WalletAccount.tsx b/app/components/UI/WalletAccount/WalletAccount.tsx index 5c123f6d45a..d42f757971b 100644 --- a/app/components/UI/WalletAccount/WalletAccount.tsx +++ b/app/components/UI/WalletAccount/WalletAccount.tsx @@ -5,6 +5,7 @@ import { useNavigation } from '@react-navigation/native'; import { View } from 'react-native'; // External dependencies +import { selectPrivacyMode } from '../../../selectors/preferencesController'; import { IconName } from '../../../component-library/components/Icons/Icon'; import PickerAccount from '../../../component-library/components/Pickers/PickerAccount'; import { AvatarAccountType } from '../../../component-library/components/Avatars/Avatar/variants/AvatarAccount'; @@ -34,6 +35,7 @@ const WalletAccount = ({ style }: WalletAccountProps, ref: React.Ref) => { const yourAccountRef = useRef(null); const accountActionsRef = useRef(null); const selectedAccount = useSelector(selectSelectedInternalAccount); + const privacyMode = useSelector(selectPrivacyMode); const { ensName } = useEnsNameByAddress(selectedAccount?.address); const defaultName = selectedAccount?.metadata?.name; const accountName = useMemo( @@ -78,7 +80,11 @@ const WalletAccount = ({ style }: WalletAccountProps, ref: React.Ref) => { accountName={accountName} accountAvatarType={accountAvatarType} onPress={() => { - navigate(...createAccountSelectorNavDetails({})); + navigate( + ...createAccountSelectorNavDetails({ + privacyMode, + }), + ); }} accountTypeLabel={ getLabelTextByAddress(selectedAccount?.address) || undefined diff --git a/app/components/Views/AccountSelector/AccountSelector.test.tsx b/app/components/Views/AccountSelector/AccountSelector.test.tsx new file mode 100644 index 00000000000..c0382a68803 --- /dev/null +++ b/app/components/Views/AccountSelector/AccountSelector.test.tsx @@ -0,0 +1,165 @@ +import React from 'react'; +import { screen } from '@testing-library/react-native'; +import AccountSelector from './AccountSelector'; +import { renderScreen } from '../../../util/test/renderWithProvider'; +import { AccountListViewSelectorsIDs } from '../../../../e2e/selectors/AccountListView.selectors'; +import Routes from '../../../constants/navigation/Routes'; +import { + AccountSelectorParams, + AccountSelectorProps, +} from './AccountSelector.types'; +import { + MOCK_ACCOUNTS_CONTROLLER_STATE, + expectedUuid2, +} from '../../../util/test/accountsControllerTestUtils'; + +const mockAccounts = [ + { + address: '0xc4966c0d659d99699bfd7eb54d8fafee40e4a756', + balance: '0x0', + name: 'Account 1', + }, + { + address: '0x2B5634C42055806a59e9107ED44D43c426E58258', + balance: '0x0', + name: 'Account 2', + }, +]; + +const mockEnsByAccountAddress = { + '0xc4966c0d659d99699bfd7eb54d8fafee40e4a756': 'test.eth', +}; + +const mockInitialState = { + engine: { + backgroundState: { + KeyringController: { + keyrings: [ + { + type: 'HD Key Tree', + accounts: [ + '0xc4966c0d659d99699bfd7eb54d8fafee40e4a756', + '0x2B5634C42055806a59e9107ED44D43c426E58258', + ], + }, + ], + }, + AccountsController: { + ...MOCK_ACCOUNTS_CONTROLLER_STATE, + internalAccounts: { + ...MOCK_ACCOUNTS_CONTROLLER_STATE.internalAccounts, + accounts: { + ...MOCK_ACCOUNTS_CONTROLLER_STATE.internalAccounts.accounts, + [expectedUuid2]: { + ...MOCK_ACCOUNTS_CONTROLLER_STATE.internalAccounts.accounts[ + expectedUuid2 + ], + methods: [], + }, + }, + }, + }, + }, + }, + accounts: { + reloadAccounts: false, + }, + settings: { + useBlockieIcon: false, + }, +}; + +// Mock the Redux dispatch +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => mockDispatch, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + useSelector: (selector: any) => selector(mockInitialState), +})); + +jest.mock('../../../components/hooks/useAccounts', () => ({ + useAccounts: jest.fn().mockReturnValue({ + accounts: mockAccounts, + ensByAccountAddress: mockEnsByAccountAddress, + isLoading: false, + }), +})); + +jest.mock('../../../core/Engine', () => ({ + setSelectedAddress: jest.fn(), +})); + +const mockTrackEvent = jest.fn(); +jest.mock('../../../components/hooks/useMetrics', () => ({ + useMetrics: () => ({ + trackEvent: mockTrackEvent, + }), +})); + +const mockRoute: AccountSelectorProps['route'] = { + params: { + onSelectAccount: jest.fn((address: string) => address), + checkBalanceError: (balance: string) => balance, + privacyMode: false, + } as AccountSelectorParams, +}; + +const AccountSelectorWrapper = () => ; + +describe('AccountSelector', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render correctly', () => { + const wrapper = renderScreen( + AccountSelectorWrapper, + { + name: Routes.SHEET.ACCOUNT_SELECTOR, + options: {}, + }, + { + state: mockInitialState, + }, + mockRoute.params, + ); + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + it('should display accounts list', () => { + renderScreen( + AccountSelectorWrapper, + { + name: Routes.SHEET.ACCOUNT_SELECTOR, + }, + { + state: mockInitialState, + }, + mockRoute.params, + ); + + const accountsList = screen.getByTestId( + AccountListViewSelectorsIDs.ACCOUNT_LIST_ID, + ); + expect(accountsList).toBeDefined(); + }); + + it('should display add account button', () => { + renderScreen( + AccountSelectorWrapper, + { + name: Routes.SHEET.ACCOUNT_SELECTOR, + }, + { + state: mockInitialState, + }, + mockRoute.params, + ); + + const addButton = screen.getByTestId( + AccountListViewSelectorsIDs.ACCOUNT_LIST_ADD_BUTTON_ID, + ); + expect(addButton).toBeDefined(); + }); +}); diff --git a/app/components/Views/AccountSelector/AccountSelector.tsx b/app/components/Views/AccountSelector/AccountSelector.tsx index 7bb4d67a150..e5b12aea6ed 100644 --- a/app/components/Views/AccountSelector/AccountSelector.tsx +++ b/app/components/Views/AccountSelector/AccountSelector.tsx @@ -39,7 +39,8 @@ import { useMetrics } from '../../../components/hooks/useMetrics'; const AccountSelector = ({ route }: AccountSelectorProps) => { const dispatch = useDispatch(); const { trackEvent } = useMetrics(); - const { onSelectAccount, checkBalanceError } = route.params || {}; + const { onSelectAccount, checkBalanceError, privacyMode } = + route.params || {}; const { reloadAccounts } = useSelector((state: RootState) => state.accounts); // TODO: Replace "any" with type @@ -92,6 +93,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { accounts={accounts} ensByAccountAddress={ensByAccountAddress} isRemoveAccountEnabled + privacyMode={privacyMode} testID={AccountListViewSelectorsIDs.ACCOUNT_LIST_ID} /> @@ -106,7 +108,13 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { ), - [accounts, _onSelectAccount, ensByAccountAddress, onRemoveImportedAccount], + [ + accounts, + _onSelectAccount, + ensByAccountAddress, + onRemoveImportedAccount, + privacyMode, + ], ); const renderAddAccountActions = useCallback( diff --git a/app/components/Views/AccountSelector/AccountSelector.types.ts b/app/components/Views/AccountSelector/AccountSelector.types.ts index 99f79c3bc40..628d72b288d 100644 --- a/app/components/Views/AccountSelector/AccountSelector.types.ts +++ b/app/components/Views/AccountSelector/AccountSelector.types.ts @@ -35,6 +35,10 @@ export interface AccountSelectorParams { * @param balance - The ticker balance of an account in wei and hex string format. */ checkBalanceError?: UseAccountsParams['checkBalanceError']; + /** + * Optional boolean to indicate if privacy mode is enabled. + */ + privacyMode?: boolean; } /** diff --git a/app/components/Views/AccountSelector/__snapshots__/AccountSelector.test.tsx.snap b/app/components/Views/AccountSelector/__snapshots__/AccountSelector.test.tsx.snap new file mode 100644 index 00000000000..9b93f6abe02 --- /dev/null +++ b/app/components/Views/AccountSelector/__snapshots__/AccountSelector.test.tsx.snap @@ -0,0 +1,548 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AccountSelector should render correctly 1`] = ` + + + + + + + + + + + + + AccountSelector + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Accounts + + + + + + + + + + Add account or hardware wallet + + + + + + + + + + + + + + + + +`; From 940258eefb64bc1afb0a03b971b49ccf4e58324f Mon Sep 17 00:00:00 2001 From: Brian Bergeron Date: Mon, 11 Nov 2024 08:28:32 -0800 Subject: [PATCH 03/11] feat: multichain polling hook (#12171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds a `usePolling` hook that handles starting and stopping polling loops for polling controllers. This will be used to poll across chains, and eventually make more polling UI based so we're only polling data when UI components require it. ## **Related issues** ## **Manual testing steps** ## **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. --------- Co-authored-by: Salah-Eddine Saakoun Co-authored-by: sahar-fehri --- app/components/hooks/usePolling.test.ts | 38 +++++++++++++++++ app/components/hooks/usePolling.ts | 55 +++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 app/components/hooks/usePolling.test.ts create mode 100644 app/components/hooks/usePolling.ts diff --git a/app/components/hooks/usePolling.test.ts b/app/components/hooks/usePolling.test.ts new file mode 100644 index 00000000000..6accee1e7b3 --- /dev/null +++ b/app/components/hooks/usePolling.test.ts @@ -0,0 +1,38 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import usePolling from './usePolling'; + +describe('usePolling', () => { + + it('Should start/stop polling when inputs are added/removed, and stop on dismount', async () => { + + const inputs = ['foo', 'bar']; + const mockStartPolling = jest.fn().mockImplementation((input) => `${input}_token`); + const mockStopPollingByPollingToken = jest.fn(); + + const { unmount, rerender } = renderHook(() => + usePolling({ + startPolling: mockStartPolling, + stopPollingByPollingToken: mockStopPollingByPollingToken, + input: inputs, + }) + ); + + // All inputs should start polling + for (const input of inputs) { + expect(mockStartPolling).toHaveBeenCalledWith(input); + } + + // Remove one input, and add another + inputs[0] = 'baz'; + rerender({ input: inputs }); + expect(mockStopPollingByPollingToken).toHaveBeenCalledWith('foo_token'); + expect(mockStartPolling).toHaveBeenCalledWith('baz'); + + // All inputs should stop polling on dismount + unmount(); + for (const input of inputs) { + expect(mockStopPollingByPollingToken).toHaveBeenCalledWith(`${input}_token`); + } + }); +}); diff --git a/app/components/hooks/usePolling.ts b/app/components/hooks/usePolling.ts new file mode 100644 index 00000000000..a7772399c6e --- /dev/null +++ b/app/components/hooks/usePolling.ts @@ -0,0 +1,55 @@ +import { useEffect, useRef } from 'react'; + +interface UsePollingOptions { + startPolling: (input: PollingInput) => string; + stopPollingByPollingToken: (pollingToken: string) => void; + input: PollingInput[]; +} + +// A hook that manages multiple polling loops of a polling controller. +// Callers provide an array of inputs, and the hook manages starting +// and stopping polling loops for each input. +const usePolling = ( + usePollingOptions: UsePollingOptions, +) => { + + const pollingTokens = useRef>(new Map()); + + useEffect(() => { + // start new polls + for (const input of usePollingOptions.input) { + const key = JSON.stringify(input); + if (!pollingTokens.current.has(key)) { + const token = usePollingOptions.startPolling(input); + pollingTokens.current.set(key, token); + } + } + + // stop existing polls + for (const [inputKey, token] of pollingTokens.current.entries()) { + const exists = usePollingOptions.input.some( + (i) => inputKey === JSON.stringify(i), + ); + + if (!exists) { + usePollingOptions.stopPollingByPollingToken(token); + pollingTokens.current.delete(inputKey); + } + } + }, + // stringified for deep equality + // eslint-disable-next-line react-hooks/exhaustive-deps + [usePollingOptions.input && JSON.stringify(usePollingOptions.input)]); + + // stop all polling on dismount + useEffect(() => () => { + for (const token of pollingTokens.current.values()) { + usePollingOptions.stopPollingByPollingToken(token); + } + }, + // Intentionally empty to trigger on dismount + // eslint-disable-next-line react-hooks/exhaustive-deps + []); +}; + +export default usePolling; From 722d4bb2e7beea1aa1d8e985e61dd2744de21c94 Mon Sep 17 00:00:00 2001 From: tommasini <46944231+tommasini@users.noreply.github.com> Date: Mon, 11 Nov 2024 20:35:20 +0000 Subject: [PATCH 04/11] chore: Remove navigation instrumentation (#12174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR removes navigation instrumentation of Sentry configuration. The reason why we are removing navigation instrumentation is that transaction.durations were not showing accurate values, since if a user backgrounded the app and opened, that time would be added to the transaction.duration, creating issues on our measurements. ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/12165 ## **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: Aslau Mario-Daniel --- app/components/Nav/App/index.js | 33 ++++++-- .../UI/WalletAccount/WalletAccount.tsx | 8 ++ .../Views/AccountSelector/AccountSelector.tsx | 5 +- app/components/Views/LockScreen/index.js | 17 +--- app/components/Views/Login/index.js | 35 +++++--- app/components/Views/Login/index.test.tsx | 2 +- .../Views/NetworkSelector/NetworkSelector.tsx | 30 ++++++- app/components/Views/Onboarding/index.js | 34 +++----- .../Views/Onboarding/index.test.tsx | 20 ----- app/core/Authentication/Authentication.ts | 14 ++++ app/core/EngineService/EngineService.ts | 10 +++ app/core/Performance/Performance.ts | 58 +++++++++---- app/core/Performance/UIStartup.test.ts | 84 +++++++++++++++++++ app/core/Performance/UIStartup.ts | 22 +++++ app/store/index.ts | 42 +++------- app/util/sentry/tags/index.ts | 3 +- app/util/sentry/utils.js | 37 ++++---- app/util/trace.ts | 41 +++++---- 18 files changed, 334 insertions(+), 161 deletions(-) create mode 100644 app/core/Performance/UIStartup.test.ts create mode 100644 app/core/Performance/UIStartup.ts diff --git a/app/components/Nav/App/index.js b/app/components/Nav/App/index.js index 4793a9745f9..91b7ff961ab 100644 --- a/app/components/Nav/App/index.js +++ b/app/components/Nav/App/index.js @@ -34,7 +34,6 @@ import SharedDeeplinkManager from '../../../core/DeeplinkManager/SharedDeeplinkM import branch from 'react-native-branch'; import AppConstants from '../../../core/AppConstants'; import Logger from '../../../util/Logger'; -import { routingInstrumentation } from '../../../util/sentry/utils'; import { connect, useDispatch } from 'react-redux'; import { CURRENT_APP_VERSION, @@ -138,7 +137,13 @@ import Engine from '../../../core/Engine'; import { CHAIN_IDS } from '@metamask/transaction-controller'; import { PopularList } from '../../../util/networks/customNetworks'; import { RpcEndpointType } from '@metamask/network-controller'; -import { trace, TraceName, TraceOperation } from '../../../util/trace'; +import { + endTrace, + trace, + TraceName, + TraceOperation, +} from '../../../util/trace'; +import getUIStartupSpan from '../../../core/Performance/UIStartup'; const clearStackNavigatorOptions = { headerShown: false, @@ -584,6 +589,12 @@ const App = (props) => { const sdkInit = useRef(); const [onboarded, setOnboarded] = useState(false); + trace({ + name: TraceName.NavInit, + parentContext: getUIStartupSpan(), + op: TraceOperation.NavInit, + }); + const triggerSetCurrentRoute = (route) => { dispatch(setCurrentRoute(route)); if (route === 'Wallet' || route === 'BrowserView') { @@ -599,9 +610,10 @@ const App = (props) => { setOnboarded(!!existingUser); try { if (existingUser) { + // This should only be called if the auth type is not password, which is not the case so consider removing it await trace( { - name: TraceName.BiometricAuthentication, + name: TraceName.AppStartBiometricAuthentication, op: TraceOperation.BiometricAuthentication, }, async () => { @@ -624,6 +636,7 @@ const App = (props) => { }), ); } + await Authentication.lockApp({ reset: false }); trackErrorAsAnalytics( 'App: Max Attempts Reached', @@ -632,9 +645,15 @@ const App = (props) => { ); } }; - appTriggeredAuth().catch((error) => { - Logger.error(error, 'App: Error in appTriggeredAuth'); - }); + appTriggeredAuth() + .catch((error) => { + Logger.error(error, 'App: Error in appTriggeredAuth'); + }) + .finally(() => { + endTrace({ name: TraceName.NavInit }); + + endTrace({ name: TraceName.UIStartup }); + }); }, [navigator, queueOfHandleDeeplinkFunctions]); const handleDeeplink = useCallback(({ error, params, uri }) => { @@ -684,8 +703,6 @@ const App = (props) => { }); if (!prevNavigator.current) { - // Setup navigator with Sentry instrumentation - routingInstrumentation.registerNavigationContainer(navigator); // Subscribe to incoming deeplinks // Branch.io documentation: https://help.branch.io/developers-hub/docs/react-native branch.subscribe((opts) => { diff --git a/app/components/UI/WalletAccount/WalletAccount.tsx b/app/components/UI/WalletAccount/WalletAccount.tsx index d42f757971b..63cf33fccdd 100644 --- a/app/components/UI/WalletAccount/WalletAccount.tsx +++ b/app/components/UI/WalletAccount/WalletAccount.tsx @@ -25,6 +25,9 @@ import Logger from '../../../util/Logger'; // Internal dependencies import styleSheet from './WalletAccount.styles'; import { WalletAccountProps } from './WalletAccount.types'; +import { TraceName, TraceOperation, trace } from '../../../util/trace'; +import { store } from '../../../store'; +import { getTraceTags } from '../../../util/sentry/tags'; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -80,6 +83,11 @@ const WalletAccount = ({ style }: WalletAccountProps, ref: React.Ref) => { accountName={accountName} accountAvatarType={accountAvatarType} onPress={() => { + trace({ + name: TraceName.AccountList, + tags: getTraceTags(store.getState()), + op: TraceOperation.AccountList, + }); navigate( ...createAccountSelectorNavDetails({ privacyMode, diff --git a/app/components/Views/AccountSelector/AccountSelector.tsx b/app/components/Views/AccountSelector/AccountSelector.tsx index e5b12aea6ed..656c54a8673 100644 --- a/app/components/Views/AccountSelector/AccountSelector.tsx +++ b/app/components/Views/AccountSelector/AccountSelector.tsx @@ -35,6 +35,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { setReloadAccounts } from '../../../actions/accounts'; import { RootState } from '../../../reducers'; import { useMetrics } from '../../../components/hooks/useMetrics'; +import { TraceName, endTrace } from '../../../util/trace'; const AccountSelector = ({ route }: AccountSelectorProps) => { const dispatch = useDispatch(); @@ -54,7 +55,9 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { const [screen, setScreen] = useState( AccountSelectorScreens.AccountSelector, ); - + useEffect(() => { + endTrace({ name: TraceName.AccountList }); + }, []); useEffect(() => { if (reloadAccounts) { dispatch(setReloadAccounts(false)); diff --git a/app/components/Views/LockScreen/index.js b/app/components/Views/LockScreen/index.js index 030bc9ace1d..676300ee1db 100644 --- a/app/components/Views/LockScreen/index.js +++ b/app/components/Views/LockScreen/index.js @@ -22,7 +22,6 @@ 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) => @@ -135,18 +134,10 @@ class LockScreen extends PureComponent { // Retrieve the credentials Logger.log('Lockscreen::unlockKeychain - getting credentials'); - await trace( - { - name: TraceName.BiometricAuthentication, - op: TraceOperation.BiometricAuthentication, - }, - async () => { - await Authentication.appTriggeredAuth({ - bioStateMachineId, - disableAutoLogout: true, - }); - }, - ); + await Authentication.appTriggeredAuth({ + bioStateMachineId, + disableAutoLogout: true, + }); this.setState({ ready: true }); Logger.log('Lockscreen::unlockKeychain - state: ready'); diff --git a/app/components/Views/Login/index.js b/app/components/Views/Login/index.js index 49771339141..20664341975 100644 --- a/app/components/Views/Login/index.js +++ b/app/components/Views/Login/index.js @@ -72,6 +72,8 @@ import Label from '../../../component-library/components/Form/Label'; import HelpText, { HelpTextSeverity, } from '../../../component-library/components/Form/HelpText'; +import { getTraceTags } from '../../../util/sentry/tags'; +import { store } from '../../../store'; const deviceHeight = Device.getDeviceHeight(); const breakPoint = deviceHeight < 700; @@ -250,11 +252,19 @@ class Login extends PureComponent { fieldRef = React.createRef(); + parentSpan = trace({ + name: TraceName.Login, + op: TraceOperation.Login, + tags: getTraceTags(store.getState()), + }); + async componentDidMount() { trace({ - name: TraceName.LoginToPasswordEntry, - op: TraceOperation.LoginToPasswordEntry, + name: TraceName.LoginUserInteraction, + op: TraceOperation.Login, + parentContext: this.parentSpan, }); + this.props.metrics.trackEvent(MetaMetricsEvents.LOGIN_SCREEN_VIEWED); BackHandler.addEventListener('hardwareBackPress', this.handleBackPress); @@ -365,6 +375,7 @@ class Login extends PureComponent { }; onLogin = async () => { + endTrace({ name: TraceName.LoginUserInteraction }); const { password } = this.state; const { current: field } = this.fieldRef; const locked = !passwordRequirementsMet(password); @@ -381,13 +392,13 @@ class Login extends PureComponent { await trace( { name: TraceName.AuthenticateUser, - op: TraceOperation.AuthenticateUser, + op: TraceOperation.Login, + parentContext: this.parentSpan, }, async () => { await Authentication.userEntryAuth(password, authType); }, ); - Keyboard.dismiss(); // Get onboarding wizard state @@ -447,17 +458,20 @@ class Login extends PureComponent { } Logger.error(e, 'Failed to unlock'); } + endTrace({ name: TraceName.Login }); }; tryBiometric = async (e) => { if (e) e.preventDefault(); + endTrace({ name: TraceName.LoginUserInteraction }); const { current: field } = this.fieldRef; field?.blur(); try { await trace( { - name: TraceName.BiometricAuthentication, - op: TraceOperation.BiometricAuthentication, + name: TraceName.LoginBiometricAuthentication, + op: TraceOperation.Login, + parentContext: this.parentSpan, }, async () => { await Authentication.appTriggeredAuth(); @@ -480,11 +494,6 @@ class Login extends PureComponent { field?.blur(); }; - triggerLogIn = () => { - endTrace({ name: TraceName.LoginToPasswordEntry }); - this.onLogin(); - }; - toggleWarningModal = () => { const { navigation } = this.props; navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { @@ -587,7 +596,7 @@ class Login extends PureComponent { value={this.state.password} baseColor={colors.border.default} tintColor={colors.primary.default} - onSubmitEditing={this.triggerLogIn} + onSubmitEditing={this.onLogin} endAccessory={ { .mockImplementation(() => undefined); const { toJSON } = renderWithProvider(); expect(toJSON()).toMatchSnapshot(); - expect(spyFetch).toHaveBeenCalledTimes(1); + expect(spyFetch).toHaveBeenCalledTimes(2); }); }); diff --git a/app/components/Views/NetworkSelector/NetworkSelector.tsx b/app/components/Views/NetworkSelector/NetworkSelector.tsx index 27b0aad2a83..9ed751604df 100644 --- a/app/components/Views/NetworkSelector/NetworkSelector.tsx +++ b/app/components/Views/NetworkSelector/NetworkSelector.tsx @@ -89,6 +89,14 @@ import { useNetworkInfo } from '../../../selectors/selectedNetworkController'; import { NetworkConfiguration } from '@metamask/network-controller'; import Logger from '../../../util/Logger'; import RpcSelectionModal from './RpcSelectionModal/RpcSelectionModal'; +import { + TraceName, + TraceOperation, + endTrace, + trace, +} from '../../../util/trace'; +import { getTraceTags } from '../../../util/sentry/tags'; +import { store } from '../../../store'; interface infuraNetwork { name: string; @@ -130,7 +138,11 @@ const NetworkSelector = () => { // origin is defined if network selector is opened from a dapp const origin = route.params?.hostInfo?.metadata?.origin || ''; - + const parentSpan = trace({ + name: TraceName.NetworkSwitch, + tags: getTraceTags(store.getState()), + op: TraceOperation.NetworkSwitch, + }); const { chainId: selectedChainId, rpcUrl: selectedRpcUrl, @@ -234,7 +246,11 @@ const NetworkSelector = () => { NetworkController, SelectedNetworkController, } = Engine.context; - + trace({ + name: TraceName.SwitchCustomNetwork, + parentContext: parentSpan, + op: TraceOperation.SwitchCustomNetwork, + }); if (networkConfiguration) { const { name: nickname, @@ -261,6 +277,8 @@ const NetworkSelector = () => { } sheetRef.current?.onCloseBottomSheet(); + endTrace({ name: TraceName.SwitchCustomNetwork }); + endTrace({ name: TraceName.NetworkSwitch }); trackEvent(MetaMetricsEvents.NETWORK_SWITCHED, { chain_id: getDecimalChainId(chainId), from_network: selectedNetworkName, @@ -346,6 +364,11 @@ const NetworkSelector = () => { // The only possible value types are mainnet, linea-mainnet, sepolia and linea-sepolia const onNetworkChange = (type: InfuraNetworkType) => { + trace({ + name: TraceName.SwitchBuiltInNetwork, + parentContext: parentSpan, + op: TraceOperation.SwitchBuiltInNetwork, + }); const { NetworkController, CurrencyRateController, @@ -383,7 +406,8 @@ const NetworkSelector = () => { } sheetRef.current?.onCloseBottomSheet(); - + endTrace({ name: TraceName.SwitchBuiltInNetwork }); + endTrace({ name: TraceName.NetworkSwitch }); trackEvent(MetaMetricsEvents.NETWORK_SWITCHED, { chain_id: getDecimalChainId(selectedChainId), from_network: selectedNetworkName, diff --git a/app/components/Views/Onboarding/index.js b/app/components/Views/Onboarding/index.js index 52ea24f6192..af9e6f634ad 100644 --- a/app/components/Views/Onboarding/index.js +++ b/app/components/Views/Onboarding/index.js @@ -277,30 +277,22 @@ class Onboarding extends PureComponent { onPressCreate = () => { const action = () => { - trace( - { - name: TraceName.CreateNewWalletToChoosePassword, - op: TraceOperation.CreateNewWalletToChoosePassword, - }, - () => { - const { metrics } = this.props; - if (metrics.isEnabled()) { - this.props.navigation.navigate('ChoosePassword', { + 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); - } 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/Onboarding/index.test.tsx b/app/components/Views/Onboarding/index.test.tsx index 1945e0fc2ee..9bb3fe0e865 100644 --- a/app/components/Views/Onboarding/index.test.tsx +++ b/app/components/Views/Onboarding/index.test.tsx @@ -1,10 +1,6 @@ import { renderScreen } from '../../../util/test/renderWithProvider'; import Onboarding from './'; import { backgroundState } from '../../../util/test/initial-root-state'; -import { OnboardingSelectorIDs } from '../../../../e2e/selectors/Onboarding/Onboarding.selectors'; -import { fireEvent } from '@testing-library/react-native'; -// eslint-disable-next-line import/no-namespace -import * as traceObj from '../../../util/trace'; const mockInitialState = { engine: { @@ -25,20 +21,4 @@ describe('Onboarding', () => { ); expect(toJSON()).toMatchSnapshot(); }); - it('must call trace when press start', () => { - const spyFetch = jest - .spyOn(traceObj, 'trace') - .mockImplementation(() => undefined); - const { getByTestId } = renderScreen( - Onboarding, - { name: 'Onboarding' }, - { - state: mockInitialState, - }, - ); - - const startButton = getByTestId(OnboardingSelectorIDs.NEW_WALLET_BUTTON); - fireEvent.press(startButton); - expect(spyFetch).toHaveBeenCalledTimes(1); - }); }); diff --git a/app/core/Authentication/Authentication.ts b/app/core/Authentication/Authentication.ts index 3d0329999ff..beba46cf7c6 100644 --- a/app/core/Authentication/Authentication.ts +++ b/app/core/Authentication/Authentication.ts @@ -31,6 +31,7 @@ import { import StorageWrapper from '../../store/storage-wrapper'; import NavigationService from '../NavigationService'; import Routes from '../../constants/navigation/Routes'; +import { TraceName, TraceOperation, endTrace, trace } from '../../util/trace'; /** * Holds auth data used to determine auth configuration @@ -401,7 +402,13 @@ class AuthenticationService { authData: AuthData, ): Promise => { try { + trace({ + name: TraceName.VaultCreation, + op: TraceOperation.VaultCreation, + }); await this.loginVaultCreation(password); + endTrace({ name: TraceName.VaultCreation }); + await this.storePassword(password, authData.currentAuthType); this.dispatchLogin(); this.authData = authData; @@ -436,6 +443,7 @@ class AuthenticationService { // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any const credentials: any = await SecureKeychain.getGenericPassword(); + const password = credentials?.password; if (!password) { throw new AuthenticationError( @@ -444,7 +452,13 @@ class AuthenticationService { this.authData, ); } + trace({ + name: TraceName.VaultCreation, + op: TraceOperation.VaultCreation, + }); await this.loginVaultCreation(password); + endTrace({ name: TraceName.VaultCreation }); + this.dispatchLogin(); this.store?.dispatch(authSuccess(bioStateMachineId)); this.dispatchPasswordSet(); diff --git a/app/core/EngineService/EngineService.ts b/app/core/EngineService/EngineService.ts index 219bfa107aa..dfe8e51c067 100644 --- a/app/core/EngineService/EngineService.ts +++ b/app/core/EngineService/EngineService.ts @@ -7,6 +7,9 @@ import { NO_VAULT_IN_BACKUP_ERROR, VAULT_CREATION_ERROR, } from '../../constants/error'; +import { getTraceTags } from '../../util/sentry/tags'; +import { trace, endTrace, TraceName, TraceOperation } from '../../util/trace'; +import getUIStartupSpan from '../Performance/UIStartup'; interface InitializeEngineResult { success: boolean; @@ -27,6 +30,12 @@ class EngineService { // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any initalizeEngine = (store: any) => { + trace({ + name: TraceName.EngineInitialization, + op: TraceOperation.EngineInitialization, + parentContext: getUIStartupSpan(), + tags: getTraceTags(store.getState()), + }); const reduxState = store.getState?.(); const state = reduxState?.engine?.backgroundState || {}; // TODO: Replace "any" with type @@ -34,6 +43,7 @@ class EngineService { const Engine = UntypedEngine as any; Engine.init(state); this.updateControllers(store, Engine); + endTrace({ name: TraceName.EngineInitialization }); }; // TODO: Replace "any" with type diff --git a/app/core/Performance/Performance.ts b/app/core/Performance/Performance.ts index d9d71d76340..78a3e54149c 100644 --- a/app/core/Performance/Performance.ts +++ b/app/core/Performance/Performance.ts @@ -1,6 +1,8 @@ /* eslint-disable no-console */ import performance, { PerformanceObserver } from 'react-native-performance'; import StorageWrapper from '../../store/storage-wrapper'; +import { TraceName, TraceOperation, endTrace, trace } from '../../util/trace'; +import getUIStartupSpan from './UIStartup'; import { isTest } from '../../util/test/utils'; /** @@ -12,13 +14,11 @@ async function setPerformanceValues(appStartTime: number) { } class Performance { + static appLaunchTime: number; /** * Measures app start and JS bundle loading times */ static setupPerformanceObservers = () => { - // Don't run in release mode - if (!isTest) return; - new PerformanceObserver((list) => { // Get measurement entries const entries = list.getEntries(); @@ -47,20 +47,48 @@ class Performance { // the total app start time is then the maximum of the two durations const appStartTime = Math.max(nativeLaunchDuration, jsBundleDuration); - // eslint-disable-next-line no-console - console.info(`-------------------------------------------------------`); - console.info(`---------------🕙 PERFORMANCE NUMBERS 🕙---------------`); - console.info(`-------------------------------------------------------`); - console.info(`NATIVE LAUNCH TIME - ${nativeLaunchDuration}ms`); - console.info(`JS BUNDLE LOAD TIME - ${jsBundleDuration}ms`); - console.info( - `APP START TIME = MAX(NATIVE LAUNCH TIME, JS BUNDLE LOAD TIME) - ${appStartTime}ms`, - ); - console.info(`-------------------------------------------------------`); - console.info(`-------------------------------------------------------`); + if (isTest) { + // eslint-disable-next-line no-console + console.info( + `-------------------------------------------------------`, + ); + console.info( + `---------------🕙 PERFORMANCE NUMBERS 🕙---------------`, + ); + console.info( + `-------------------------------------------------------`, + ); + console.info(`NATIVE LAUNCH TIME - ${nativeLaunchDuration}ms`); + console.info(`JS BUNDLE LOAD TIME - ${jsBundleDuration}ms`); + console.info( + `APP START TIME = MAX(NATIVE LAUNCH TIME, JS BUNDLE LOAD TIME) - ${appStartTime}ms`, + ); + console.info( + `-------------------------------------------------------`, + ); + console.info( + `-------------------------------------------------------`, + ); + + setPerformanceValues(appStartTime); + } + const now = Date.now(); + + const appLaunchTime = now - appStartTime; + this.appLaunchTime = appLaunchTime; - setPerformanceValues(appStartTime); + const parentSpan = getUIStartupSpan(appLaunchTime); + trace({ + name: TraceName.LoadScripts, + startTime: appLaunchTime, + parentContext: parentSpan, + op: TraceOperation.LoadScripts, + }); + endTrace({ + name: TraceName.LoadScripts, + timestamp: now, + }); } }).observe({ type: 'react-native-mark', buffered: true }); }; diff --git a/app/core/Performance/UIStartup.test.ts b/app/core/Performance/UIStartup.test.ts new file mode 100644 index 00000000000..4a8f86762ad --- /dev/null +++ b/app/core/Performance/UIStartup.test.ts @@ -0,0 +1,84 @@ +// eslint-disable-next-line import/no-namespace +import * as traceObj from '../../util/trace'; + +jest.mock('../../util/trace', () => ({ + trace: jest.fn(), + TraceName: { + UIStartup: 'UIStartup', + }, + TraceOperation: { + UIStartup: 'ui.startup', + }, +})); + +describe('getUIStartupSpan', () => { + let getUIStartupSpan: (startTime?: number) => traceObj.TraceContext; + + beforeEach(() => { + jest.clearAllMocks(); + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + getUIStartupSpan = require('./UIStartup').default; + }); + }); + + it('should call trace with correct parameters when UIStartupSpan is not set', () => { + const startTime = 12345; + const mockTraceContext = { + name: traceObj.TraceName.UIStartup, + startTime, + op: traceObj.TraceOperation.UIStartup, + }; + const spyFetch = jest + .spyOn(traceObj, 'trace') + .mockImplementation(() => mockTraceContext); + + getUIStartupSpan(startTime); + + expect(spyFetch).toHaveBeenCalledWith({ + name: traceObj.TraceName.UIStartup, + startTime, + op: traceObj.TraceOperation.UIStartup, + }); + }); + + it('should return the existing UIStartupSpan if already set', () => { + const startTime = 12345; + const mockTraceContext = { + name: traceObj.TraceName.UIStartup, + startTime, + op: traceObj.TraceOperation.UIStartup, + }; + + const spyFetch = jest + .spyOn(traceObj, 'trace') + .mockImplementation(() => mockTraceContext); + + // First call to set the UIStartupSpan + getUIStartupSpan(startTime); + + // Second call should return the same UIStartupSpan + const result = getUIStartupSpan(); + + expect(spyFetch).toHaveBeenCalledTimes(1); + expect(result).toBe(mockTraceContext); + }); + + it('should not call trace again if UIStartupSpan is already set', () => { + const startTime = 12345; + const mockTraceContext = { + name: traceObj.TraceName.UIStartup, + startTime, + op: traceObj.TraceOperation.UIStartup, + }; + jest.spyOn(traceObj, 'trace').mockImplementation(() => mockTraceContext); + + // First call to set the UIStartupSpan + getUIStartupSpan(startTime); + + // Second call should return the same UIStartupSpan without calling trace again + getUIStartupSpan(); + + expect(traceObj.trace).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/core/Performance/UIStartup.ts b/app/core/Performance/UIStartup.ts new file mode 100644 index 00000000000..78fdeb4aeda --- /dev/null +++ b/app/core/Performance/UIStartup.ts @@ -0,0 +1,22 @@ +import { + TraceContext, + TraceName, + TraceOperation, + trace, +} from '../../util/trace'; + +let UIStartupSpan: TraceContext; + +const getUIStartupSpan = (startTime?: number) => { + if (!UIStartupSpan) { + UIStartupSpan = trace({ + name: TraceName.UIStartup, + startTime, + op: TraceOperation.UIStartup, + }); + } + + return UIStartupSpan; +}; + +export default getUIStartupSpan; diff --git a/app/store/index.ts b/app/store/index.ts index 32b3be171d9..01200a0261f 100644 --- a/app/store/index.ts +++ b/app/store/index.ts @@ -15,7 +15,7 @@ import thunk from 'redux-thunk'; import persistConfig from './persistConfig'; import { AppStateEventProcessor } from '../core/AppStateEventListener'; -import { getTraceTags } from '../util/sentry/tags'; +import getUIStartupSpan from '../core/Performance/UIStartup'; // TODO: Improve type safety by using real Action types instead of `any` // TODO: Replace "any" with type @@ -28,6 +28,11 @@ const pReducer = persistReducer(persistConfig, rootReducer); // eslint-disable-next-line @typescript-eslint/no-explicit-any, import/no-mutable-exports let store: Store, persistor; const createStoreAndPersistor = async () => { + trace({ + name: TraceName.StoreInit, + parentContext: getUIStartupSpan(), + op: TraceOperation.StoreInit, + }); // Obtain the initial state from ReadOnlyNetworkStore for E2E tests. const initialState = isE2E ? await ReadOnlyNetworkStore.getState() @@ -49,11 +54,6 @@ const createStoreAndPersistor = async () => { middlewares.push(createReduxFlipperDebugger()); } - trace({ - name: TraceName.CreateStore, - op: TraceOperation.CreateStore, - }); - store = configureStore({ reducer: pReducer, middleware: middlewares, @@ -62,19 +62,10 @@ 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 = () => { - endTrace({ name: TraceName.StorageRehydration }); - /** * EngineService.initalizeEngine(store) with SES/lockdown: * Requires ethjs nested patches (lib->src) @@ -101,33 +92,20 @@ const createStoreAndPersistor = async () => { store.dispatch({ type: 'FETCH_FEATURE_FLAGS', }); - trace( - { - name: TraceName.EngineInitialization, - op: TraceOperation.EngineInitialization, - tags: getTraceTags(store.getState()), - }, - () => { - EngineService.initalizeEngine(store); - }, - ); + + EngineService.initalizeEngine(store); Authentication.init(store); AppStateEventProcessor.init(store); LockManagerService.init(store); + endTrace({ name: TraceName.StoreInit }); }; persistor = persistStore(store, null, onPersistComplete); }; (async () => { - await trace( - { - name: TraceName.UIStartup, - op: TraceOperation.UIStartup, - }, - async () => await createStoreAndPersistor(), - ); + await createStoreAndPersistor(); })(); export { store, persistor }; diff --git a/app/util/sentry/tags/index.ts b/app/util/sentry/tags/index.ts index 636663a71be..efe04f24968 100644 --- a/app/util/sentry/tags/index.ts +++ b/app/util/sentry/tags/index.ts @@ -7,13 +7,14 @@ import { selectTransactions } from '../../../selectors/transactionController'; import { selectPendingApprovals } from '../../../selectors/approvalController'; export function getTraceTags(state: RootState) { - if (!Object.keys(state?.engine?.backgroundState).length) return; if (!state?.engine?.backgroundState?.AccountsController) return; if (!state?.engine?.backgroundState?.NftController) return; if (!state?.engine?.backgroundState?.NotificationServicesController) return; if (!state?.engine?.backgroundState?.TokensController) return; if (!state?.engine?.backgroundState?.TransactionController) return; if (!state?.engine?.backgroundState?.NotificationServicesController) return; + if (!Object.keys(state?.engine?.backgroundState).length) return; + const unlocked = state.user.userLoggedIn; const accountCount = selectInternalAccounts(state).length; const nftCount = selectAllNftsFlat(state).length; diff --git a/app/util/sentry/utils.js b/app/util/sentry/utils.js index 83325f7db93..06937e5d3bc 100644 --- a/app/util/sentry/utils.js +++ b/app/util/sentry/utils.js @@ -7,7 +7,9 @@ import { regex } from '../regex'; import { AGREED, METRICS_OPT_IN } from '../../constants/storage'; import { isE2E } from '../test/utils'; import { store } from '../../store'; - +import { Performance } from '../../core/Performance'; +import Device from '../device'; +import { TraceName } from '../trace'; /** * This symbol matches all object properties when used in a mask */ @@ -222,13 +224,6 @@ const ERROR_URL_ALLOWLIST = [ 'codefi.network', 'segment.io', ]; -/**\ - * Required instrumentation for Sentry Performance to work with React Navigation - */ -export const routingInstrumentation = - new Sentry.ReactNavigationV5Instrumentation({ - enableTimeToInitialDisplay: true, - }); /** * Capture Sentry user feedback and associate ID of captured exception @@ -406,6 +401,18 @@ function rewriteReport(report) { * @returns {(event|null)} */ export function excludeEvents(event) { + // This is needed because store starts to initialise before performance observers completes to measure app start time + if (event?.transaction === TraceName.UIStartup && Device.isAndroid()) { + const appLaunchTime = Performance.appLaunchTime; + const formattedAppLaunchTime = (event.start_timestamp = Number( + `${appLaunchTime.toString().slice(0, 10)}.${appLaunchTime + .toString() + .slice(10)}`, + )); + if (event.start_timestamp !== formattedAppLaunchTime) { + event.start_timestamp = formattedAppLaunchTime; + } + } //Modify or drop event here if (event?.transaction === 'Route Change') { //Route change is dropped because is does not reflect a screen we can action on. @@ -496,20 +503,16 @@ export function setupSentry() { dsn, debug: isDev, environment, - integrations: - metricsOptIn === AGREED - ? [ - ...integrations, - new Sentry.reactNativeTracingIntegration({ - routingInstrumentation, - }), - ] - : integrations, + integrations, // Set tracesSampleRate to 1.0, as that ensures that every transaction will be sent to Sentry for development builds. tracesSampleRate: isDev || isQa ? 1.0 : 0.04, + profilesSampleRate: 1.0, beforeSend: (report) => rewriteReport(report), beforeBreadcrumb: (breadcrumb) => rewriteBreadcrumb(breadcrumb), beforeSendTransaction: (event) => excludeEvents(event), + enabled: metricsOptIn === AGREED, + // We need to deactivate this to have the same output consistently on IOS and Android + enableAutoPerformanceTracing: false, }); }; init(); diff --git a/app/util/trace.ts b/app/util/trace.ts index 1d79eb7ced5..4579b07918c 100644 --- a/app/util/trace.ts +++ b/app/util/trace.ts @@ -24,28 +24,37 @@ export enum TraceName { PPOMValidation = 'PPOM Validation', Signature = 'Signature', LoadScripts = 'Load Scripts', - SetupStore = 'Setup Store', - LoginToPasswordEntry = 'Login to Password Entry', + LoginUserInteraction = 'Login User Interaction', AuthenticateUser = 'Authenticate User', - BiometricAuthentication = 'Biometrics Authentication', + LoginBiometricAuthentication = 'Login Biometrics Authentication', + AppStartBiometricAuthentication = 'App start Biometrics Authentication', EngineInitialization = 'Engine Initialization', - CreateStore = 'Create Store', - CreateNewWalletToChoosePassword = 'Create New Wallet to Choose Password', - StorageRehydration = 'Storage Rehydration', - UIStartup = 'UIStartup', + UIStartup = 'UI Startup', + NavInit = 'Navigation Initialization', + Login = 'Login', + NetworkSwitch = 'Network Switch', + SwitchBuiltInNetwork = 'Switch to Built in Network', + SwitchCustomNetwork = 'Switch to Custom Network', + VaultCreation = 'Login Vault Creation', + AccountList = 'Account List', + StoreInit = 'Store Initialization', } export enum TraceOperation { - LoadScripts = 'custom.load.scripts', - SetupStore = 'custom.setup.store', - LoginToPasswordEntry = 'custom.login.to.password.entry', + LoadScripts = 'load.scripts', 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', + AuthenticateUser = 'authenticate.user', + EngineInitialization = 'engine.initialization', + StorageRehydration = 'storage.rehydration', + UIStartup = 'ui.startup', + NavInit = 'navigation.initialization', + NetworkSwitch = 'network.switch', + SwitchBuiltInNetwork = 'switch.to.built.in.network', + SwitchCustomNetwork = 'switch.to.custom.network', + VaultCreation = 'login.vault.creation', + AccountList = 'account.list', + StoreInit = 'store.initialization', + Login = 'login', } const ID_DEFAULT = 'default'; From 0d77c6e8955afc18d90a25040407b03b746587da Mon Sep 17 00:00:00 2001 From: Salim TOUBAL Date: Tue, 12 Nov 2024 12:42:15 +0100 Subject: [PATCH 05/11] fix: fix displayed selected rpc for linea (#12255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** the displayed selected rpc for linea is not the correct one ## **Related issues** Fixes: #12252 ## **Manual testing steps** 1. Go Network selector 2. add another rpc for linea 3. check the selected rpc on the network selector view ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/77b16939-5730-4d06-a9a0-e641e8e7f657 ## **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. --- .../NetworkSelector/NetworkSelector.test.tsx | 73 +++++++++++++++++++ .../Views/NetworkSelector/NetworkSelector.tsx | 7 +- .../NetworkSelector.test.tsx.snap | 70 ++++++++++++++++-- 3 files changed, 143 insertions(+), 7 deletions(-) diff --git a/app/components/Views/NetworkSelector/NetworkSelector.test.tsx b/app/components/Views/NetworkSelector/NetworkSelector.test.tsx index 077bded6d8c..970634f0db7 100644 --- a/app/components/Views/NetworkSelector/NetworkSelector.test.tsx +++ b/app/components/Views/NetworkSelector/NetworkSelector.test.tsx @@ -86,6 +86,46 @@ const initialState = { mainnet: { status: 'available', EIPS: { '1559': true } }, }, networkConfigurationsByChainId: { + '0x1': { + blockExplorerUrls: [], + chainId: '0x1', + defaultRpcEndpointIndex: 1, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'mainnet', + type: 'infura', + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + }, + { + name: 'public', + networkClientId: 'ea57f659-c004-4902-bfca-0c9688a43872', + type: 'custom', + url: 'https://mainnet-rpc.publicnode.com', + }, + ], + }, + '0xe708': { + blockExplorerUrls: [], + chainId: '0xe708', + defaultRpcEndpointIndex: 1, + name: 'Linea', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'linea-mainnet', + type: 'infura', + url: 'https://linea-mainnet.infura.io/v3/{infuraProjectId}', + }, + { + name: 'public', + networkClientId: 'ea57f659-c004-4902-bfca-0c9688a43877', + type: 'custom', + url: 'https://linea-rpc.publicnode.com', + }, + ], + }, '0xa86a': { blockExplorerUrls: ['https://snowtrace.io'], chainId: '0xa86a', @@ -459,4 +499,37 @@ describe('Network Selector', () => { fireEvent(testNetworksSwitch, 'onValueChange', false); expect(setShowTestNetworksSpy).toBeCalledWith(false); }); + + describe('renderLineaMainnet', () => { + it('renders the linea mainnet cell correctly', () => { + (isNetworkUiRedesignEnabled as jest.Mock).mockImplementation(() => true); + const { getByText } = renderComponent(initialState); + const lineaRpcUrl = getByText('https://linea-rpc.publicnode.com'); + const lineaCell = getByText('Linea'); + expect(lineaCell).toBeTruthy(); + expect(lineaRpcUrl).toBeTruthy(); + }); + }); + + describe('renderRpcUrl', () => { + it('renders the RPC URL correctly for avalanche', () => { + (isNetworkUiRedesignEnabled as jest.Mock).mockImplementation(() => true); + const { getByText } = renderComponent(initialState); + const avalancheRpcUrl = getByText('api.avax.network/ext/bc/C'); + const avalancheCell = getByText('Avalanche Mainnet C-Chain'); + expect(avalancheRpcUrl).toBeTruthy(); + expect(avalancheCell).toBeTruthy(); + }); + }); + + describe('renderMainnet', () => { + it('renders the mainnet cell correctly', () => { + (isNetworkUiRedesignEnabled as jest.Mock).mockImplementation(() => true); + const { getByText } = renderComponent(initialState); + const mainnetRpcUrl = getByText('https://mainnet-rpc.publicnode.com'); + const mainnetCell = getByText('Ethereum Mainnet'); + expect(mainnetCell).toBeTruthy(); + expect(mainnetRpcUrl).toBeTruthy(); + }); + }); }); diff --git a/app/components/Views/NetworkSelector/NetworkSelector.tsx b/app/components/Views/NetworkSelector/NetworkSelector.tsx index 9ed751604df..ea36deab166 100644 --- a/app/components/Views/NetworkSelector/NetworkSelector.tsx +++ b/app/components/Views/NetworkSelector/NetworkSelector.tsx @@ -84,7 +84,6 @@ import { isNetworkUiRedesignEnabled } from '../../../util/networks/isNetworkUiRe import { Hex } from '@metamask/utils'; import hideProtocolFromUrl from '../../../util/hideProtocolFromUrl'; import { CHAIN_IDS } from '@metamask/transaction-controller'; -import { LINEA_DEFAULT_RPC_URL } from '../../../constants/urls'; import { useNetworkInfo } from '../../../selectors/selectedNetworkController'; import { NetworkConfiguration } from '@metamask/network-controller'; import Logger from '../../../util/Logger'; @@ -509,6 +508,10 @@ const NetworkSelector = () => { const renderLineaMainnet = () => { const { name: lineaMainnetName, chainId } = Networks['linea-mainnet']; const name = networkConfigurations?.[chainId]?.name ?? lineaMainnetName; + const rpcUrl = + networkConfigurations?.[chainId]?.rpcEndpoints?.[ + networkConfigurations?.[chainId]?.defaultRpcEndpointIndex + ].url; if (isNetworkUiRedesignEnabled() && isNoSearchResults('linea-mainnet')) return null; @@ -529,7 +532,7 @@ const NetworkSelector = () => { onPress={() => onNetworkChange(LINEA_MAINNET)} style={styles.networkCell} buttonIcon={IconName.MoreVertical} - secondaryText={hideKeyFromUrl(LINEA_DEFAULT_RPC_URL)} + secondaryText={hideKeyFromUrl(rpcUrl)} buttonProps={{ onButtonClick: () => { openModal(chainId, false, LINEA_MAINNET, true); diff --git a/app/components/Views/NetworkSelector/__snapshots__/NetworkSelector.test.tsx.snap b/app/components/Views/NetworkSelector/__snapshots__/NetworkSelector.test.tsx.snap index 959570801e2..3773996bbf7 100644 --- a/app/components/Views/NetworkSelector/__snapshots__/NetworkSelector.test.tsx.snap +++ b/app/components/Views/NetworkSelector/__snapshots__/NetworkSelector.test.tsx.snap @@ -560,7 +560,7 @@ exports[`Network Selector renders correctly 1`] = ` } testID="cellbase-avatar-title" > - Ethereum Main Network + Ethereum Mainnet @@ -686,7 +686,7 @@ exports[`Network Selector renders correctly 1`] = ` } testID="cellbase-avatar-title" > - Linea Main Network + Linea @@ -1890,8 +1890,68 @@ exports[`Network Selector renders correctly when network UI redesign is enabled } testID="cellbase-avatar-title" > - Ethereum Main Network + Ethereum Mainnet + + + https://mainnet-rpc.publicnode.com + + + @@ -2080,7 +2140,7 @@ exports[`Network Selector renders correctly when network UI redesign is enabled } testID="cellbase-avatar-title" > - Linea Main Network + Linea - https://linea-mainnet.infura.io/v3 + https://linea-rpc.publicnode.com Date: Tue, 12 Nov 2024 08:58:27 -0500 Subject: [PATCH 06/11] feat(2808): improvements-and-small-features-and-small-fixes-that-still-needed-to-be-added-to-edit-permissions (#12168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adding missing features, small fixes and feedback from design QA. ## **Related issues** Fixes: ## **Manual testing steps** 1. set the feature flag export MM_MULTICHAIN_V1_ENABLED="1" 2. go to the dapp browser and open pancake swap and click Connect in pancake swap 3. change the permissions and check fixes and improvements in the commit names are present ## **Screenshots/Recordings** | Before | After | |--------------|--------------| | Screenshot 2024-04-18 at 3 56 43 PM |Screenshot 2024-04-18 at 3 56 43 PM | ## **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. --- .../PermissionsSummary.styles.ts | 4 +- .../PermissionsSummary/PermissionsSummary.tsx | 6 +- .../PermissionsSummary.test.tsx.snap | 20 +- .../UI/Tabs/TabThumbnail/TabThumbnail.tsx | 49 ++-- .../__snapshots__/TabThumbnail.test.tsx.snap | 277 ------------------ .../UI/Tabs/__snapshots__/index.test.tsx.snap | 277 ------------------ .../Views/AccountConnect/AccountConnect.tsx | 1 + .../AccountConnectMultiSelector.styles.ts | 2 +- .../AccountPermissionsConnected.styles.ts | 3 +- .../NetworkConnectMultiSelector.styles.ts | 3 +- sonar-project.properties | 2 +- 11 files changed, 60 insertions(+), 584 deletions(-) diff --git a/app/components/UI/PermissionsSummary/PermissionsSummary.styles.ts b/app/components/UI/PermissionsSummary/PermissionsSummary.styles.ts index 84e03643ab3..3c25f4830a7 100644 --- a/app/components/UI/PermissionsSummary/PermissionsSummary.styles.ts +++ b/app/components/UI/PermissionsSummary/PermissionsSummary.styles.ts @@ -30,6 +30,9 @@ const createStyles = (params: { marginRight: 24, marginLeft: 24, }, + bottomButtonsContainer: { + marginTop: 16, + }, actionButtonsContainer: { flex: 0, flexDirection: 'row', @@ -118,7 +121,6 @@ const createStyles = (params: { walletIcon: { alignSelf: 'flex-start' }, dataIcon: { alignSelf: 'flex-start' }, disconnectAllContainer: { - marginTop: 16, marginHorizontal: 24, flexDirection: 'row', }, diff --git a/app/components/UI/PermissionsSummary/PermissionsSummary.tsx b/app/components/UI/PermissionsSummary/PermissionsSummary.tsx index 715e887d48a..f48936e74ef 100644 --- a/app/components/UI/PermissionsSummary/PermissionsSummary.tsx +++ b/app/components/UI/PermissionsSummary/PermissionsSummary.tsx @@ -389,7 +389,7 @@ const PermissionsSummary = ({ {!isNetworkSwitch && renderAccountPermissionsRequestInfoCard()} {renderNetworkPermissionsRequestInfoCard()} - + {isAlreadyConnected && isDisconnectAllShown && (