1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-01 13:47:06 +01:00
metamask-extension/app/scripts/controllers/app-state.js
Elliot Winkler bd12ea733a
Fix autolock field to accept decimals in Firefox (#19653)
The autolock field on the Settings screen — the field that allows users
to set the duration that MetaMask will wait for until automatically
locking — does not always accept decimal numbers. This breaks the e2e
test for this feature as it attempts to set this field to "0.1".

More specifically, the React component responsible for this field passes
whatever the user inputs through the `Number` function immediately and
then uses this to repopulate the input. Therefore, if the user enters
"3" followed by a ".", `Number("3.")` will be called. This evaluates to
the number 3, and "3" becomes the new value of the field. As a result,
the "." can never be typed.

Curiously, this behavior only happens in Firefox; Chrome seems to
keep the "." in the input field when it's typed. This happens because
`onChange` event doesn't seem to get fired until a number is typed
*after* the ".". This may be due to underlying differences in the DOM
between Chrome and Firefox.

Regardless, always passing the input through `Number` creates other odd
behavior, such as the fact that the input can never be cleared (because
`Number("")` evaluates to 0).

This commit solves these problems by saving the "raw" version of the
user's input as well as the normalized version. The raw version is
always used to populate the input, whereas the normalized version is
saved in state.
2023-06-22 10:29:24 -06:00

470 lines
12 KiB
JavaScript

import EventEmitter from 'events';
import { ObservableStore } from '@metamask/obs-store';
import { v4 as uuid } from 'uuid';
import log from 'loglevel';
import { ApprovalType } from '@metamask/controller-utils';
import { METAMASK_CONTROLLER_EVENTS } from '../metamask-controller';
import { MINUTE } from '../../../shared/constants/time';
import { AUTO_LOCK_TIMEOUT_ALARM } from '../../../shared/constants/alarms';
import { isManifestV3 } from '../../../shared/modules/mv3.utils';
import { isBeta } from '../../../ui/helpers/utils/build-types';
import {
ENVIRONMENT_TYPE_BACKGROUND,
POLLING_TOKEN_ENVIRONMENT_TYPES,
ORIGIN_METAMASK,
} from '../../../shared/constants/app';
import { DEFAULT_AUTO_LOCK_TIME_LIMIT } from '../../../shared/constants/preferences';
export default class AppStateController extends EventEmitter {
/**
* @param {object} opts
*/
constructor(opts = {}) {
const {
addUnlockListener,
isUnlocked,
initState,
onInactiveTimeout,
preferencesStore,
qrHardwareStore,
messenger,
} = opts;
super();
this.onInactiveTimeout = onInactiveTimeout || (() => undefined);
this.store = new ObservableStore({
timeoutMinutes: DEFAULT_AUTO_LOCK_TIME_LIMIT,
connectedStatusPopoverHasBeenShown: true,
defaultHomeActiveTabName: null,
browserEnvironment: {},
popupGasPollTokens: [],
notificationGasPollTokens: [],
fullScreenGasPollTokens: [],
recoveryPhraseReminderHasBeenShown: false,
recoveryPhraseReminderLastShown: new Date().getTime(),
outdatedBrowserWarningLastShown: new Date().getTime(),
nftsDetectionNoticeDismissed: false,
showTestnetMessageInDropdown: true,
showBetaHeader: isBeta(),
showProductTour: true,
trezorModel: null,
currentPopupId: undefined,
...initState,
qrHardware: {},
nftsDropdownState: {},
usedNetworks: {
'0x1': true,
'0x5': true,
'0x539': true,
},
serviceWorkerLastActiveTime: 0,
});
this.timer = null;
this.isUnlocked = isUnlocked;
this.waitingForUnlock = [];
addUnlockListener(this.handleUnlock.bind(this));
preferencesStore.subscribe(({ preferences }) => {
const currentState = this.store.getState();
if (currentState.timeoutMinutes !== preferences.autoLockTimeLimit) {
this._setInactiveTimeout(preferences.autoLockTimeLimit);
}
});
qrHardwareStore.subscribe((state) => {
this.store.updateState({ qrHardware: state });
});
const { preferences } = preferencesStore.getState();
this._setInactiveTimeout(preferences.autoLockTimeLimit);
this.messagingSystem = messenger;
this._approvalRequestId = null;
}
/**
* Get a Promise that resolves when the extension is unlocked.
* This Promise will never reject.
*
* @param {boolean} shouldShowUnlockRequest - Whether the extension notification
* popup should be opened.
* @returns {Promise<void>} A promise that resolves when the extension is
* unlocked, or immediately if the extension is already unlocked.
*/
getUnlockPromise(shouldShowUnlockRequest) {
return new Promise((resolve) => {
if (this.isUnlocked()) {
resolve();
} else {
this.waitForUnlock(resolve, shouldShowUnlockRequest);
}
});
}
/**
* Adds a Promise's resolve function to the waitingForUnlock queue.
* Also opens the extension popup if specified.
*
* @param {Promise.resolve} resolve - A Promise's resolve function that will
* be called when the extension is unlocked.
* @param {boolean} shouldShowUnlockRequest - Whether the extension notification
* popup should be opened.
*/
waitForUnlock(resolve, shouldShowUnlockRequest) {
this.waitingForUnlock.push({ resolve });
this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE);
if (shouldShowUnlockRequest) {
this._requestApproval();
}
}
/**
* Drains the waitingForUnlock queue, resolving all the related Promises.
*/
handleUnlock() {
if (this.waitingForUnlock.length > 0) {
while (this.waitingForUnlock.length > 0) {
this.waitingForUnlock.shift().resolve();
}
this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE);
}
this._acceptApproval();
}
/**
* Sets the default home tab
*
* @param {string} [defaultHomeActiveTabName] - the tab name
*/
setDefaultHomeActiveTabName(defaultHomeActiveTabName) {
this.store.updateState({
defaultHomeActiveTabName,
});
}
/**
* Record that the user has seen the connected status info popover
*/
setConnectedStatusPopoverHasBeenShown() {
this.store.updateState({
connectedStatusPopoverHasBeenShown: true,
});
}
/**
* Record that the user has been shown the recovery phrase reminder.
*/
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.
*/
setRecoveryPhraseReminderLastShown(lastShown) {
this.store.updateState({
recoveryPhraseReminderLastShown: lastShown,
});
}
/**
* Record the timestamp of the last time the user has acceoted the terms of use
*
* @param {number} lastAgreed - timestamp when user last accepted the terms of use
*/
setTermsOfUseLastAgreed(lastAgreed) {
this.store.updateState({
termsOfUseLastAgreed: lastAgreed,
});
}
///: BEGIN:ONLY_INCLUDE_IN(snaps)
/**
* Record if popover for snaps privacy warning has been shown
* on the first install of a snap.
*
* @param {boolean} shown - shown status
*/
setSnapsInstallPrivacyWarningShownStatus(shown) {
this.store.updateState({
snapsInstallPrivacyWarningShown: shown,
});
}
///: END:ONLY_INCLUDE_IN
/**
* Record the timestamp of the last time the user has seen the outdated browser warning
*
* @param {number} lastShown - Timestamp (in milliseconds) of when the user was last shown the warning.
*/
setOutdatedBrowserWarningLastShown(lastShown) {
this.store.updateState({
outdatedBrowserWarningLastShown: lastShown,
});
}
/**
* Sets the last active time to the current time.
*/
setLastActiveTime() {
this._resetTimer();
}
/**
* Sets the inactive timeout for the app
*
* @private
* @param {number} timeoutMinutes - The inactive timeout in minutes.
*/
_setInactiveTimeout(timeoutMinutes) {
this.store.updateState({
timeoutMinutes,
});
this._resetTimer();
}
/**
* Resets the internal inactive timer
*
* If the {@code timeoutMinutes} state is falsy (i.e., zero) then a new
* timer will not be created.
*
* @private
*/
/* eslint-disable no-undef */
_resetTimer() {
const { timeoutMinutes } = this.store.getState();
if (this.timer) {
clearTimeout(this.timer);
} else if (isManifestV3) {
chrome.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM);
}
if (!timeoutMinutes) {
return;
}
if (isManifestV3) {
chrome.alarms.create(AUTO_LOCK_TIMEOUT_ALARM, {
delayInMinutes: timeoutMinutes,
periodInMinutes: timeoutMinutes,
});
chrome.alarms.onAlarm.addListener((alarmInfo) => {
if (alarmInfo.name === AUTO_LOCK_TIMEOUT_ALARM) {
this.onInactiveTimeout();
chrome.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM);
}
});
} else {
this.timer = setTimeout(
() => this.onInactiveTimeout(),
timeoutMinutes * MINUTE,
);
}
}
/**
* Sets the current browser and OS environment
*
* @param os
* @param browser
*/
setBrowserEnvironment(os, browser) {
this.store.updateState({ browserEnvironment: { os, browser } });
}
/**
* Adds a pollingToken for a given environmentType
*
* @param pollingToken
* @param pollingTokenType
*/
addPollingToken(pollingToken, pollingTokenType) {
if (
pollingTokenType !==
POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_BACKGROUND]
) {
const prevState = this.store.getState()[pollingTokenType];
this.store.updateState({
[pollingTokenType]: [...prevState, pollingToken],
});
}
}
/**
* removes a pollingToken for a given environmentType
*
* @param pollingToken
* @param pollingTokenType
*/
removePollingToken(pollingToken, pollingTokenType) {
if (
pollingTokenType !==
POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_BACKGROUND]
) {
const prevState = this.store.getState()[pollingTokenType];
this.store.updateState({
[pollingTokenType]: prevState.filter((token) => token !== pollingToken),
});
}
}
/**
* clears all pollingTokens
*/
clearPollingTokens() {
this.store.updateState({
popupGasPollTokens: [],
notificationGasPollTokens: [],
fullScreenGasPollTokens: [],
});
}
/**
* Sets whether the testnet dismissal link should be shown in the network dropdown
*
* @param showTestnetMessageInDropdown
*/
setShowTestnetMessageInDropdown(showTestnetMessageInDropdown) {
this.store.updateState({ showTestnetMessageInDropdown });
}
/**
* Sets whether the beta notification heading on the home page
*
* @param showBetaHeader
*/
setShowBetaHeader(showBetaHeader) {
this.store.updateState({ showBetaHeader });
}
/**
* Sets whether the product tour should be shown
*
* @param showProductTour
*/
setShowProductTour(showProductTour) {
this.store.updateState({ showProductTour });
}
/**
* Sets a property indicating the model of the user's Trezor hardware wallet
*
* @param trezorModel - The Trezor model.
*/
setTrezorModel(trezorModel) {
this.store.updateState({ trezorModel });
}
/**
* A setter for the `nftsDropdownState` property
*
* @param nftsDropdownState
*/
updateNftDropDownState(nftsDropdownState) {
this.store.updateState({
nftsDropdownState,
});
}
/**
* Updates the array of the first time used networks
*
* @param chainId
* @returns {void}
*/
setFirstTimeUsedNetwork(chainId) {
const currentState = this.store.getState();
const { usedNetworks } = currentState;
usedNetworks[chainId] = true;
this.store.updateState({ usedNetworks });
}
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
/**
* Set the interactive replacement token with a url and the old refresh token
*
* @param {object} opts
* @param opts.url
* @param opts.oldRefreshToken
* @returns {void}
*/
showInteractiveReplacementTokenBanner({ url, oldRefreshToken }) {
this.store.updateState({
interactiveReplacementToken: {
url,
oldRefreshToken,
},
});
}
///: END:ONLY_INCLUDE_IN
/**
* A setter for the currentPopupId which indicates the id of popup window that's currently active
*
* @param currentPopupId
*/
setCurrentPopupId(currentPopupId) {
this.store.updateState({
currentPopupId,
});
}
/**
* A getter to retrieve currentPopupId saved in the appState
*/
getCurrentPopupId() {
return this.store.getState().currentPopupId;
}
setServiceWorkerLastActiveTime(serviceWorkerLastActiveTime) {
this.store.updateState({
serviceWorkerLastActiveTime,
});
}
_requestApproval() {
this._approvalRequestId = uuid();
this.messagingSystem
.call(
'ApprovalController:addRequest',
{
id: this._approvalRequestId,
origin: ORIGIN_METAMASK,
type: ApprovalType.Unlock,
},
true,
)
.catch(() => {
// Intentionally ignored as promise not currently used
});
}
_acceptApproval() {
if (!this._approvalRequestId) {
log.error('Attempted to accept missing unlock approval request');
return;
}
try {
this.messagingSystem.call(
'ApprovalController:acceptRequest',
this._approvalRequestId,
);
} catch (error) {
log.error('Failed to accept transaction approval request', error);
}
this._approvalRequestId = null;
}
}