From 9932c40651646ce1589ce428ff25190c3958a6c3 Mon Sep 17 00:00:00 2001 From: ryanml Date: Fri, 4 Jun 2021 23:33:58 -0700 Subject: [PATCH] Adding periodic reminder modal for backing up recovery phrase (#11021) * Adding recurring recovery phrase reminder modal * Refactoring per PR feedback --- app/_locales/en/messages.json | 24 +++++ app/scripts/controllers/app-state.js | 23 +++++ app/scripts/metamask-controller.js | 8 ++ app/scripts/migrations/061.js | 32 +++++++ app/scripts/migrations/061.test.js | 67 ++++++++++++++ app/scripts/migrations/index.js | 1 + shared/constants/time.js | 5 + ui/components/app/app-components.scss | 1 + .../app/recovery-phrase-reminder/index.js | 1 + .../app/recovery-phrase-reminder/index.scss | 10 ++ .../recovery-phrase-reminder.js | 91 +++++++++++++++++++ ui/components/ui/popover/index.scss | 4 + ui/components/ui/popover/popover.component.js | 25 +++-- ui/pages/home/home.component.js | 26 +++++- ui/pages/home/home.container.js | 9 ++ ui/selectors/selectors.js | 13 +++ ui/store/actions.js | 20 ++++ 17 files changed, 350 insertions(+), 10 deletions(-) create mode 100644 app/scripts/migrations/061.js create mode 100644 app/scripts/migrations/061.test.js create mode 100644 shared/constants/time.js create mode 100644 ui/components/app/recovery-phrase-reminder/index.js create mode 100644 ui/components/app/recovery-phrase-reminder/index.scss create mode 100644 ui/components/app/recovery-phrase-reminder/recovery-phrase-reminder.js diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index e18596d67..40f8bd8ac 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -1457,6 +1457,30 @@ "recipientAddressPlaceholder": { "message": "Search, public address (0x), or ENS" }, + "recoveryPhraseReminderBackupStart": { + "message": "Start here" + }, + "recoveryPhraseReminderConfirm": { + "message": "Got it" + }, + "recoveryPhraseReminderHasBackedUp": { + "message": "Always keep your Secret Recovery Phrase in a secure and secret place" + }, + "recoveryPhraseReminderHasNotBackedUp": { + "message": "Need to backup your Secret Recovery Phrase again?" + }, + "recoveryPhraseReminderItemOne": { + "message": "Never share your Secret Recovery Phrase with anyone" + }, + "recoveryPhraseReminderItemTwo": { + "message": "The MetaMask team will never ask for your Secret Recovery Phrase" + }, + "recoveryPhraseReminderSubText": { + "message": "Your Secret Recovery Phrase controls all of your accounts." + }, + "recoveryPhraseReminderTitle": { + "message": "Protect your funds" + }, "reject": { "message": "Reject" }, diff --git a/app/scripts/controllers/app-state.js b/app/scripts/controllers/app-state.js index ce92798e0..48f983d7a 100644 --- a/app/scripts/controllers/app-state.js +++ b/app/scripts/controllers/app-state.js @@ -24,6 +24,8 @@ export default class AppStateController extends EventEmitter { connectedStatusPopoverHasBeenShown: true, defaultHomeActiveTabName: null, browserEnvironment: {}, + recoveryPhraseReminderHasBeenShown: false, + recoveryPhraseReminderLastShown: new Date().getTime(), ...initState, }); this.timer = null; @@ -112,6 +114,27 @@ export default class AppStateController extends EventEmitter { }); } + /** + * Record that the user has been shown the recovery phrase reminder + * @returns {void} + */ + setRecoveryPhraseReminderHasBeenShown() { + this.store.updateState({ + recoveryPhraseReminderHasBeenShown: true, + }); + } + + /** + * Record the timestamp of the last time the user has seen the recovery phrase reminder + * @param {number} lastShown - timestamp when user was last shown the reminder + * @returns {void} + */ + setRecoveryPhraseReminderLastShown(lastShown) { + this.store.updateState({ + recoveryPhraseReminderLastShown: lastShown, + }); + } + /** * Sets the last active time to the current time * @returns {void} diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 5fbd164bb..2fadc8a97 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -780,6 +780,14 @@ export default class MetamaskController extends EventEmitter { this.appStateController.setConnectedStatusPopoverHasBeenShown, this.appStateController, ), + setRecoveryPhraseReminderHasBeenShown: nodeify( + this.appStateController.setRecoveryPhraseReminderHasBeenShown, + this.appStateController, + ), + setRecoveryPhraseReminderLastShown: nodeify( + this.appStateController.setRecoveryPhraseReminderLastShown, + this.appStateController, + ), // EnsController tryReverseResolveAddress: nodeify( diff --git a/app/scripts/migrations/061.js b/app/scripts/migrations/061.js new file mode 100644 index 000000000..2937c6ed0 --- /dev/null +++ b/app/scripts/migrations/061.js @@ -0,0 +1,32 @@ +import { cloneDeep } from 'lodash'; + +const version = 61; + +/** + * Initialize attributes related to recovery seed phrase reminder + */ +export default { + version, + async migrate(originalVersionedData) { + const versionedData = cloneDeep(originalVersionedData); + versionedData.meta.version = version; + const state = versionedData.data; + const newState = transformState(state); + versionedData.data = newState; + return versionedData; + }, +}; + +function transformState(state) { + const currentTime = new Date().getTime(); + if (state.AppStateController) { + state.AppStateController.recoveryPhraseReminderHasBeenShown = false; + state.AppStateController.recoveryPhraseReminderLastShown = currentTime; + } else { + state.AppStateController = { + recoveryPhraseReminderHasBeenShown: false, + recoveryPhraseReminderLastShown: currentTime, + }; + } + return state; +} diff --git a/app/scripts/migrations/061.test.js b/app/scripts/migrations/061.test.js new file mode 100644 index 000000000..2c0c45510 --- /dev/null +++ b/app/scripts/migrations/061.test.js @@ -0,0 +1,67 @@ +import { strict as assert } from 'assert'; +import sinon from 'sinon'; +import migration61 from './061'; + +describe('migration #61', function () { + let dateStub; + + beforeEach(function () { + dateStub = sinon.stub(Date.prototype, 'getTime').returns(1621580400000); + }); + + afterEach(function () { + dateStub.restore(); + }); + + it('should update the version metadata', async function () { + const oldStorage = { + meta: { + version: 60, + }, + data: {}, + }; + + const newStorage = await migration61.migrate(oldStorage); + assert.deepEqual(newStorage.meta, { + version: 61, + }); + }); + + it('should set recoveryPhraseReminderHasBeenShown to false and recoveryPhraseReminderLastShown to the current time', async function () { + const oldStorage = { + meta: {}, + data: { + AppStateController: { + existingProperty: 'foo', + }, + }, + }; + + const newStorage = await migration61.migrate(oldStorage); + assert.deepEqual(newStorage.data, { + AppStateController: { + recoveryPhraseReminderHasBeenShown: false, + recoveryPhraseReminderLastShown: 1621580400000, + existingProperty: 'foo', + }, + }); + }); + + it('should initialize AppStateController if it does not exist', async function () { + const oldStorage = { + meta: {}, + data: { + existingProperty: 'foo', + }, + }; + + const newStorage = await migration61.migrate(oldStorage); + assert.deepEqual(newStorage.data, { + existingProperty: 'foo', + AppStateController: { + recoveryPhraseReminderHasBeenShown: false, + recoveryPhraseReminderLastShown: 1621580400000, + }, + }); + }); +}); diff --git a/app/scripts/migrations/index.js b/app/scripts/migrations/index.js index 682ba6d08..175ee0044 100644 --- a/app/scripts/migrations/index.js +++ b/app/scripts/migrations/index.js @@ -65,6 +65,7 @@ const migrations = [ require('./058').default, require('./059').default, require('./060').default, + require('./061').default, ]; export default migrations; diff --git a/shared/constants/time.js b/shared/constants/time.js new file mode 100644 index 000000000..71cd6f0ed --- /dev/null +++ b/shared/constants/time.js @@ -0,0 +1,5 @@ +export const MILLISECOND = 1; +export const SECOND = MILLISECOND * 1000; +export const MINUTE = SECOND * 60; +export const HOUR = MINUTE * 60; +export const DAY = HOUR * 24; diff --git a/ui/components/app/app-components.scss b/ui/components/app/app-components.scss index 8110d8341..89f2b9760 100644 --- a/ui/components/app/app-components.scss +++ b/ui/components/app/app-components.scss @@ -23,6 +23,7 @@ @import 'permission-page-container/index'; @import 'permissions-connect-footer/index'; @import 'permissions-connect-header/index'; +@import 'recovery-phrase-reminder/index'; @import 'selected-account/index'; @import 'sidebars/index'; @import 'signature-request/index'; diff --git a/ui/components/app/recovery-phrase-reminder/index.js b/ui/components/app/recovery-phrase-reminder/index.js new file mode 100644 index 000000000..35b8d2da3 --- /dev/null +++ b/ui/components/app/recovery-phrase-reminder/index.js @@ -0,0 +1 @@ +export { default } from './recovery-phrase-reminder'; diff --git a/ui/components/app/recovery-phrase-reminder/index.scss b/ui/components/app/recovery-phrase-reminder/index.scss new file mode 100644 index 000000000..2f50b8da6 --- /dev/null +++ b/ui/components/app/recovery-phrase-reminder/index.scss @@ -0,0 +1,10 @@ +.recovery-phrase-reminder { + &__list { + list-style: disc; + padding-left: 20px; + + li { + margin-bottom: 5px; + } + } +} diff --git a/ui/components/app/recovery-phrase-reminder/recovery-phrase-reminder.js b/ui/components/app/recovery-phrase-reminder/recovery-phrase-reminder.js new file mode 100644 index 000000000..1b8b66ba3 --- /dev/null +++ b/ui/components/app/recovery-phrase-reminder/recovery-phrase-reminder.js @@ -0,0 +1,91 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useHistory } from 'react-router-dom'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +// Components +import Box from '../../ui/box'; +import Button from '../../ui/button'; +import Popover from '../../ui/popover'; +import Typography from '../../ui/typography'; +// Helpers +import { + COLORS, + DISPLAY, + TEXT_ALIGN, + TYPOGRAPHY, + BLOCK_SIZES, + FONT_WEIGHT, + JUSTIFY_CONTENT, +} from '../../../helpers/constants/design-system'; +import { INITIALIZE_BACKUP_SEED_PHRASE_ROUTE } from '../../../helpers/constants/routes'; + +export default function RecoveryPhraseReminder({ onConfirm, hasBackedUp }) { + const t = useI18nContext(); + const history = useHistory(); + + const handleBackUp = () => { + history.push(INITIALIZE_BACKUP_SEED_PHRASE_ROUTE); + }; + + return ( + + + + {t('recoveryPhraseReminderSubText')} + + +
    +
  • + + {t('recoveryPhraseReminderItemOne')} + +
  • +
  • {t('recoveryPhraseReminderItemTwo')}
  • +
  • + {hasBackedUp ? ( + t('recoveryPhraseReminderHasBackedUp') + ) : ( + <> + {t('recoveryPhraseReminderHasNotBackedUp')} + + + + + )} +
  • +
+
+ + + + + +
+
+ ); +} + +RecoveryPhraseReminder.propTypes = { + hasBackedUp: PropTypes.bool.isRequired, + onConfirm: PropTypes.func.isRequired, +}; diff --git a/ui/components/ui/popover/index.scss b/ui/components/ui/popover/index.scss index d2869b484..3f48a363c 100644 --- a/ui/components/ui/popover/index.scss +++ b/ui/components/ui/popover/index.scss @@ -47,6 +47,10 @@ margin-right: 24px; } } + + &.center { + justify-content: center; + } } &__subtitle { diff --git a/ui/components/ui/popover/popover.component.js b/ui/components/ui/popover/popover.component.js index 6053c9d05..c4bae4ffc 100644 --- a/ui/components/ui/popover/popover.component.js +++ b/ui/components/ui/popover/popover.component.js @@ -17,6 +17,7 @@ const Popover = ({ showArrow, CustomBackground, popoverRef, + centerTitle, }) => { const t = useI18nContext(); return ( @@ -32,7 +33,12 @@ const Popover = ({ > {showArrow ?
: null}
-
+

{onBack ? (

{subtitle ? (

{subtitle}

@@ -76,7 +84,7 @@ Popover.propTypes = { footer: PropTypes.node, footerClassName: PropTypes.string, onBack: PropTypes.func, - onClose: PropTypes.func.isRequired, + onClose: PropTypes.func, CustomBackground: PropTypes.func, contentClassName: PropTypes.string, className: PropTypes.string, @@ -84,6 +92,7 @@ Popover.propTypes = { popoverRef: PropTypes.shape({ current: PropTypes.instanceOf(window.Element), }), + centerTitle: PropTypes.bool, }; export default class PopoverPortal extends PureComponent { diff --git a/ui/pages/home/home.component.js b/ui/pages/home/home.component.js index faf86eb91..9ff9078f7 100644 --- a/ui/pages/home/home.component.js +++ b/ui/pages/home/home.component.js @@ -14,6 +14,7 @@ import ConnectedAccounts from '../connected-accounts'; import { Tabs, Tab } from '../../components/ui/tabs'; import { EthOverview } from '../../components/app/wallet-overview'; import WhatsNewPopup from '../../components/app/whats-new-popup'; +import RecoveryPhraseReminder from '../../components/app/recovery-phrase-reminder'; import { ASSET_ROUTE, @@ -76,6 +77,10 @@ export default class Home extends PureComponent { showWhatsNewPopup: PropTypes.bool.isRequired, hideWhatsNewPopup: PropTypes.func.isRequired, notificationsToShow: PropTypes.bool.isRequired, + showRecoveryPhraseReminder: PropTypes.bool.isRequired, + setRecoveryPhraseReminderHasBeenShown: PropTypes.func.isRequired, + setRecoveryPhraseReminderLastShown: PropTypes.func.isRequired, + seedPhraseBackedUp: PropTypes.bool.isRequired, }; state = { @@ -163,6 +168,15 @@ export default class Home extends PureComponent { } } + onRecoveryPhraseReminderClose = () => { + const { + setRecoveryPhraseReminderHasBeenShown, + setRecoveryPhraseReminderLastShown, + } = this.props; + setRecoveryPhraseReminderHasBeenShown(true); + setRecoveryPhraseReminderLastShown(new Date().getTime()); + }; + renderNotifications() { const { t } = this.context; const { @@ -325,6 +339,8 @@ export default class Home extends PureComponent { notificationsToShow, showWhatsNewPopup, hideWhatsNewPopup, + seedPhraseBackedUp, + showRecoveryPhraseReminder, } = this.props; if (forgottenPassword) { @@ -333,6 +349,8 @@ export default class Home extends PureComponent { return null; } + const showWhatsNew = notificationsToShow && showWhatsNewPopup; + return (
@@ -342,8 +360,12 @@ export default class Home extends PureComponent { exact />
- {notificationsToShow && showWhatsNewPopup ? ( - + {showWhatsNew ? : null} + {!showWhatsNew && showRecoveryPhraseReminder ? ( + ) : null} {isPopup && !connectedStatusPopoverHasBeenShown ? this.renderPopover() diff --git a/ui/pages/home/home.container.js b/ui/pages/home/home.container.js index f5ff5a966..2b989ae03 100644 --- a/ui/pages/home/home.container.js +++ b/ui/pages/home/home.container.js @@ -14,6 +14,7 @@ import { getInfuraBlocked, getShowWhatsNewPopup, getSortedNotificationsToShow, + getShowRecoveryPhraseReminder, } from '../../selectors'; import { @@ -25,6 +26,8 @@ import { setDefaultHomeActiveTabName, setWeb3ShimUsageAlertDismissed, setAlertEnabledness, + setRecoveryPhraseReminderHasBeenShown, + setRecoveryPhraseReminderLastShown, } from '../../store/actions'; import { setThreeBoxLastUpdated, hideWhatsNewPopup } from '../../ducks/app/app'; import { getWeb3ShimUsageAlertEnabledness } from '../../ducks/metamask/metamask'; @@ -107,6 +110,8 @@ const mapStateToProps = (state) => { infuraBlocked: getInfuraBlocked(state), notificationsToShow: getSortedNotificationsToShow(state).length > 0, showWhatsNewPopup: getShowWhatsNewPopup(state), + showRecoveryPhraseReminder: getShowRecoveryPhraseReminder(state), + seedPhraseBackedUp, }; }; @@ -132,6 +137,10 @@ const mapDispatchToProps = (dispatch) => ({ disableWeb3ShimUsageAlert: () => setAlertEnabledness(ALERT_TYPES.web3ShimUsage, false), hideWhatsNewPopup: () => dispatch(hideWhatsNewPopup()), + setRecoveryPhraseReminderHasBeenShown: () => + dispatch(setRecoveryPhraseReminderHasBeenShown()), + setRecoveryPhraseReminderLastShown: (lastShown) => + dispatch(setRecoveryPhraseReminderLastShown(lastShown)), }); export default compose( diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index 70c249ed1..76b7d40c2 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -23,6 +23,7 @@ import { import { TEMPLATED_CONFIRMATION_MESSAGE_TYPES } from '../pages/confirmation/templates'; import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils'; +import { DAY } from '../../shared/constants/time'; import { getNativeCurrency } from './send'; /** @@ -563,3 +564,15 @@ export function getSortedNotificationsToShow(state) { ); return notificationsSortedByDate; } + +export function getShowRecoveryPhraseReminder(state) { + const { + recoveryPhraseReminderLastShown, + recoveryPhraseReminderHasBeenShown, + } = state.metamask; + + const currentTime = new Date().getTime(); + const frequency = recoveryPhraseReminderHasBeenShown ? DAY * 90 : DAY * 2; + + return currentTime - recoveryPhraseReminderLastShown >= frequency; +} diff --git a/ui/store/actions.js b/ui/store/actions.js index d701d20ad..2620a9df8 100644 --- a/ui/store/actions.js +++ b/ui/store/actions.js @@ -2570,6 +2570,26 @@ export function setConnectedStatusPopoverHasBeenShown() { }; } +export function setRecoveryPhraseReminderHasBeenShown() { + return () => { + background.setRecoveryPhraseReminderHasBeenShown((err) => { + if (err) { + throw new Error(err.message); + } + }); + }; +} + +export function setRecoveryPhraseReminderLastShown(lastShown) { + return () => { + background.setRecoveryPhraseReminderLastShown(lastShown, (err) => { + if (err) { + throw new Error(err.message); + } + }); + }; +} + export async function setAlertEnabledness(alertId, enabledness) { await promisifiedBackground.setAlertEnabledness(alertId, enabledness); }