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**
### **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