Skip to content

Commit

Permalink
Adding recurring recovery phrase reminder modal
Browse files Browse the repository at this point in the history
  • Loading branch information
ryanml committed May 19, 2021
1 parent 3540a5b commit 603e651
Show file tree
Hide file tree
Showing 16 changed files with 376 additions and 10 deletions.
24 changes: 24 additions & 0 deletions app/_locales/en/messages.json
Expand Up @@ -1422,6 +1422,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"
},
Expand Down
97 changes: 97 additions & 0 deletions app/scripts/controllers/app-state.js
@@ -1,6 +1,14 @@
import EventEmitter from 'events';
import { ObservableStore } from '@metamask/obs-store';
import { METAMASK_CONTROLLER_EVENTS } from '../metamask-controller';
import * as time from '../../../shared/constants/time';

// 1 hour
const REMINDER_CHECK_INTERVAL = time.HOUR;
// 2 days
const INITIAL_REMINDER_FREQUENCY = time.DAY * 2;
// 90 days
const FOLLOWUP_REMINDER_FREQUENCY = time.DAY * 90;

export default class AppStateController extends EventEmitter {
/**
Expand All @@ -24,9 +32,13 @@ export default class AppStateController extends EventEmitter {
connectedStatusPopoverHasBeenShown: true,
defaultHomeActiveTabName: null,
browserEnvironment: {},
recoveryPhraseReminderHasBeenShown: false,
recoveryPhraseReminderLastShown: 0,
shouldShowRecoveryPhraseReminder: false,
...initState,
});
this.timer = null;
this.interval = REMINDER_CHECK_INTERVAL;

this.isUnlocked = isUnlocked;
this.waitingForUnlock = [];
Expand All @@ -45,6 +57,19 @@ export default class AppStateController extends EventEmitter {
this._setInactiveTimeout(preferences.autoLockTimeLimit);
}

/* eslint-disable accessor-pairs */
/**
* @type {number}
*/
set interval(interval) {
if (this._handleReminderCheck) {
clearInterval(this._handleReminderCheck);
}
this._handleReminderCheck = setInterval(() => {
this._checkShouldShowRecoveryPhraseReminder();
}, interval);
}

/**
* Get a Promise that resolves when the extension is unlocked.
* This Promise will never reject.
Expand Down Expand Up @@ -112,6 +137,16 @@ export default class AppStateController extends EventEmitter {
});
}

/**
* Record that the user has been shown the recovery phrase reminder
*/
setRecoveryPhraseReminderHasBeenShown() {
this.store.updateState({
recoveryPhraseReminderHasBeenShown: true,
shouldShowRecoveryPhraseReminder: false,
});
}

/**
* Sets the last active time to the current time
* @returns {void}
Expand All @@ -134,6 +169,68 @@ export default class AppStateController extends EventEmitter {
this._resetTimer();
}

_checkShouldShowRecoveryPhraseReminder() {
const {
recoveryPhraseReminderHasBeenShown,
recoveryPhraseReminderLastShown,
} = this.store.getState();
// Capture the current timestamp
const currentTime = new Date().getTime();

// For the first interval run, set recoveryPhraseReminderLastShown to the current time.
if (recoveryPhraseReminderLastShown === 0) {
this.store.updateState({
recoveryPhraseReminderLastShown: currentTime,
});
return;
}

// Wait 2 days before the first display
if (!recoveryPhraseReminderHasBeenShown) {
if (
currentTime - recoveryPhraseReminderLastShown >=
INITIAL_REMINDER_FREQUENCY
) {
this._setShouldShowRecoveryPhraseReminder(true);
this._setRecoveryPhraseReminderLastShown(currentTime);
}
return;
}

// For subsequent displays, wait 90 days
if (
currentTime - recoveryPhraseReminderLastShown >=
FOLLOWUP_REMINDER_FREQUENCY
) {
this._setShouldShowRecoveryPhraseReminder(true);
this._setRecoveryPhraseReminderLastShown(currentTime);
}
}

/**
* 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}
* @private
*/
_setRecoveryPhraseReminderLastShown(lastShown) {
this.store.updateState({
recoveryPhraseReminderLastShown: lastShown,
});
}

/**
* Set whether or not the user should be shown the recovery phrase reminder
* @param {boolean} shouldShow - whether or not the reminder should be shown
* @returns {void}
* @private
*/
_setShouldShowRecoveryPhraseReminder(shouldShow) {
this.store.updateState({
shouldShowRecoveryPhraseReminder: shouldShow,
});
}

/**
* Resets the internal inactive timer
*
Expand Down
4 changes: 4 additions & 0 deletions app/scripts/metamask-controller.js
Expand Up @@ -762,6 +762,10 @@ export default class MetamaskController extends EventEmitter {
this.appStateController.setConnectedStatusPopoverHasBeenShown,
this.appStateController,
),
setRecoveryPhraseReminderHasBeenShown: nodeify(
this.appStateController.setRecoveryPhraseReminderHasBeenShown,
this.appStateController,
),

// EnsController
tryReverseResolveAddress: nodeify(
Expand Down
33 changes: 33 additions & 0 deletions app/scripts/migrations/061.js
@@ -0,0 +1,33 @@
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) {
if (state.AppStateController) {
state.AppStateController.recoveryPhraseReminderHasBeenShown = false;
state.AppStateController.recoveryPhraseReminderLastShown = 0;
state.AppStateController.shouldShowRecoveryPhraseReminder = false;
} else {
state.AppStateController = {
recoveryPhraseReminderHasBeenShown: false,
recoveryPhraseReminderLastShown: 0,
shouldShowRecoveryPhraseReminder: false,
};
}
return state;
}
58 changes: 58 additions & 0 deletions app/scripts/migrations/061.test.js
@@ -0,0 +1,58 @@
import { strict as assert } from 'assert';
import migration61 from './061';

describe('migration #61', function () {
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, recoveryPhraseReminderLastShown to 0, and shouldShowRecoveryPhraseReminder to false', async function () {
const oldStorage = {
meta: {},
data: {
AppStateController: {
existingProperty: 'foo',
},
},
};

const newStorage = await migration61.migrate(oldStorage);
assert.deepEqual(newStorage.data, {
AppStateController: {
recoveryPhraseReminderHasBeenShown: false,
recoveryPhraseReminderLastShown: 0,
shouldShowRecoveryPhraseReminder: false,
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: 0,
shouldShowRecoveryPhraseReminder: false,
},
});
});
});
1 change: 1 addition & 0 deletions app/scripts/migrations/index.js
Expand Up @@ -65,6 +65,7 @@ const migrations = [
require('./058').default,
require('./059').default,
require('./060').default,
require('./061').default,
];

export default migrations;
5 changes: 5 additions & 0 deletions 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;
1 change: 1 addition & 0 deletions ui/components/app/app-components.scss
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions ui/components/app/recovery-phrase-reminder/index.js
@@ -0,0 +1 @@
export { default } from './recovery-phrase-reminder';
10 changes: 10 additions & 0 deletions 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;
}
}
}
@@ -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 (
<Popover centerTitle title={t('recoveryPhraseReminderTitle')}>
<Box padding={[0, 4, 6, 4]} className="recovery-phrase-reminder">
<Typography
color={COLORS.BLACK}
align={TEXT_ALIGN.CENTER}
variant={TYPOGRAPHY.Paragraph}
boxProps={{ marginTop: 0, marginBottom: 4 }}
>
{t('recoveryPhraseReminderSubText')}
</Typography>
<Box margin={[4, 0, 8, 0]}>
<ul className="recovery-phrase-reminder__list">
<li>
<Typography
tag="span"
color={COLORS.BLACK}
fontWeight={FONT_WEIGHT.BOLD}
>
{t('recoveryPhraseReminderItemOne')}
</Typography>
</li>
<li>{t('recoveryPhraseReminderItemTwo')}</li>
<li>
{hasBackedUp ? (
t('recoveryPhraseReminderHasBackedUp')
) : (
<>
{t('recoveryPhraseReminderHasNotBackedUp')}
<Box display={DISPLAY.INLINE_BLOCK} marginLeft={1}>
<Button
type="link"
onClick={handleBackUp}
style={{
fontSize: 'inherit',
padding: 0,
}}
>
{t('recoveryPhraseReminderBackupStart')}
</Button>
</Box>
</>
)}
</li>
</ul>
</Box>
<Box justifyContent={JUSTIFY_CONTENT.CENTER}>
<Box width={BLOCK_SIZES.TWO_FIFTHS}>
<Button rounded type="primary" onClick={onConfirm}>
{t('recoveryPhraseReminderConfirm')}
</Button>
</Box>
</Box>
</Box>
</Popover>
);
}

RecoveryPhraseReminder.propTypes = {
hasBackedUp: PropTypes.bool.isRequired,
onConfirm: PropTypes.func.isRequired,
};

0 comments on commit 603e651

Please sign in to comment.