diff --git a/.eslintrc.js b/.eslintrc.js index 97d52b6637c..846158a741e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -307,7 +307,7 @@ module.exports = { { files: [ '**/__snapshots__/*.snap', - 'app/scripts/controllers/app-state.test.js', + 'app/scripts/controllers/app-state-controller.test.ts', 'app/scripts/controllers/mmi-controller.test.ts', 'app/scripts/controllers/alert-controller.test.ts', 'app/scripts/metamask-controller.actions.test.js', diff --git a/app/scripts/controllers/app-state-controller.test.ts b/app/scripts/controllers/app-state-controller.test.ts new file mode 100644 index 00000000000..740c4a7d33f --- /dev/null +++ b/app/scripts/controllers/app-state-controller.test.ts @@ -0,0 +1,730 @@ +import { ControllerMessenger } from '@metamask/base-controller'; +import { Browser } from 'webextension-polyfill'; +import { + ENVIRONMENT_TYPE_POPUP, + ORIGIN_METAMASK, + POLLING_TOKEN_ENVIRONMENT_TYPES, +} from '../../../shared/constants/app'; +import { AppStateController } from './app-state-controller'; +import type { + AllowedActions, + AllowedEvents, + AppStateControllerActions, + AppStateControllerEvents, + AppStateControllerState, +} from './app-state-controller'; +import { PreferencesControllerState } from './preferences-controller'; + +jest.mock('webextension-polyfill'); + +const mockIsManifestV3 = jest.fn().mockReturnValue(false); +jest.mock('../../../shared/modules/mv3.utils', () => ({ + get isManifestV3() { + return mockIsManifestV3(); + }, +})); + +let appStateController: AppStateController; +let controllerMessenger: ControllerMessenger< + AppStateControllerActions | AllowedActions, + AppStateControllerEvents | AllowedEvents +>; + +const extensionMock = { + alarms: { + getAll: jest.fn(() => Promise.resolve([])), + create: jest.fn(), + clear: jest.fn(), + onAlarm: { + addListener: jest.fn(), + }, + }, +} as unknown as jest.Mocked; + +describe('AppStateController', () => { + const createAppStateController = ( + initState: Partial = {}, + ): { + appStateController: AppStateController; + controllerMessenger: typeof controllerMessenger; + } => { + controllerMessenger = new ControllerMessenger(); + jest.spyOn(ControllerMessenger.prototype, 'call'); + const appStateMessenger = controllerMessenger.getRestricted({ + name: 'AppStateController', + allowedActions: [ + `ApprovalController:addRequest`, + `ApprovalController:acceptRequest`, + `PreferencesController:getState`, + ], + allowedEvents: [ + `PreferencesController:stateChange`, + `KeyringController:qrKeyringStateChange`, + ], + }); + controllerMessenger.registerActionHandler( + 'PreferencesController:getState', + jest.fn().mockReturnValue({ + preferences: { + autoLockTimeLimit: 0, + }, + }), + ); + controllerMessenger.registerActionHandler( + 'ApprovalController:addRequest', + jest.fn().mockReturnValue({ + catch: jest.fn(), + }), + ); + appStateController = new AppStateController({ + addUnlockListener: jest.fn(), + isUnlocked: jest.fn(() => true), + initState, + onInactiveTimeout: jest.fn(), + messenger: appStateMessenger, + extension: extensionMock, + }); + + return { appStateController, controllerMessenger }; + }; + + const createIsUnlockedMock = (isUnlocked: boolean) => { + return jest + .spyOn( + appStateController as unknown as { isUnlocked: () => boolean }, + 'isUnlocked', + ) + .mockReturnValue(isUnlocked); + }; + + beforeEach(() => { + ({ appStateController } = createAppStateController()); + }); + + describe('setOutdatedBrowserWarningLastShown', () => { + it('sets the last shown time', () => { + ({ appStateController } = createAppStateController()); + const timestamp: number = Date.now(); + + appStateController.setOutdatedBrowserWarningLastShown(timestamp); + + expect( + appStateController.store.getState().outdatedBrowserWarningLastShown, + ).toStrictEqual(timestamp); + }); + + it('sets outdated browser warning last shown timestamp', () => { + const lastShownTimestamp: number = Date.now(); + ({ appStateController } = createAppStateController()); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + appStateController.setOutdatedBrowserWarningLastShown(lastShownTimestamp); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + outdatedBrowserWarningLastShown: lastShownTimestamp, + }); + + updateStateSpy.mockRestore(); + }); + }); + + describe('getUnlockPromise', () => { + it('waits for unlock if the extension is locked', async () => { + ({ appStateController } = createAppStateController()); + const isUnlockedMock = createIsUnlockedMock(false); + const waitForUnlockSpy = jest.spyOn(appStateController, 'waitForUnlock'); + + appStateController.getUnlockPromise(true); + expect(isUnlockedMock).toHaveBeenCalled(); + expect(waitForUnlockSpy).toHaveBeenCalledWith(expect.any(Function), true); + }); + + it('resolves immediately if the extension is already unlocked', async () => { + ({ appStateController } = createAppStateController()); + const isUnlockedMock = createIsUnlockedMock(true); + + await expect( + appStateController.getUnlockPromise(false), + ).resolves.toBeUndefined(); + + expect(isUnlockedMock).toHaveBeenCalled(); + }); + }); + + describe('waitForUnlock', () => { + it('resolves immediately if already unlocked', async () => { + const emitSpy = jest.spyOn(appStateController, 'emit'); + const resolveFn: () => void = jest.fn(); + appStateController.waitForUnlock(resolveFn, false); + expect(emitSpy).toHaveBeenCalledWith('updateBadge'); + expect(controllerMessenger.call).toHaveBeenCalledTimes(1); + }); + + it('creates approval request when waitForUnlock is called with shouldShowUnlockRequest as true', async () => { + createIsUnlockedMock(false); + + const resolveFn: () => void = jest.fn(); + appStateController.waitForUnlock(resolveFn, true); + + expect(controllerMessenger.call).toHaveBeenCalledTimes(2); + expect(controllerMessenger.call).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + expect.objectContaining({ + id: expect.any(String), + origin: ORIGIN_METAMASK, + type: 'unlock', + }), + true, + ); + }); + }); + + describe('handleUnlock', () => { + beforeEach(() => { + createIsUnlockedMock(false); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('accepts approval request revolving all the related promises', async () => { + const emitSpy = jest.spyOn(appStateController, 'emit'); + const resolveFn: () => void = jest.fn(); + appStateController.waitForUnlock(resolveFn, true); + + appStateController.handleUnlock(); + + expect(emitSpy).toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledWith('updateBadge'); + expect(controllerMessenger.call).toHaveBeenCalled(); + expect(controllerMessenger.call).toHaveBeenCalledWith( + 'ApprovalController:acceptRequest', + expect.any(String), + ); + }); + }); + + describe('setDefaultHomeActiveTabName', () => { + it('sets the default home tab name', () => { + appStateController.setDefaultHomeActiveTabName('testTabName'); + expect(appStateController.store.getState().defaultHomeActiveTabName).toBe( + 'testTabName', + ); + }); + }); + + describe('setConnectedStatusPopoverHasBeenShown', () => { + it('sets connected status popover as shown', () => { + appStateController.setConnectedStatusPopoverHasBeenShown(); + expect( + appStateController.store.getState().connectedStatusPopoverHasBeenShown, + ).toBe(true); + }); + }); + + describe('setRecoveryPhraseReminderHasBeenShown', () => { + it('sets recovery phrase reminder as shown', () => { + appStateController.setRecoveryPhraseReminderHasBeenShown(); + expect( + appStateController.store.getState().recoveryPhraseReminderHasBeenShown, + ).toBe(true); + }); + }); + + describe('setRecoveryPhraseReminderLastShown', () => { + it('sets the last shown time of recovery phrase reminder', () => { + const timestamp: number = Date.now(); + appStateController.setRecoveryPhraseReminderLastShown(timestamp); + + expect( + appStateController.store.getState().recoveryPhraseReminderLastShown, + ).toBe(timestamp); + }); + }); + + describe('setLastActiveTime', () => { + it('sets the last active time to the current time', () => { + const spy = jest.spyOn( + appStateController as unknown as { _resetTimer: () => void }, + '_resetTimer', + ); + appStateController.setLastActiveTime(); + + expect(spy).toHaveBeenCalled(); + }); + + it('sets the timer if timeoutMinutes is set', () => { + const timeout = Date.now(); + controllerMessenger.publish( + 'PreferencesController:stateChange', + { + preferences: { autoLockTimeLimit: timeout }, + } as unknown as PreferencesControllerState, + [], + ); + const spy = jest.spyOn( + appStateController as unknown as { _resetTimer: () => void }, + '_resetTimer', + ); + appStateController.setLastActiveTime(); + + expect(spy).toHaveBeenCalled(); + }); + }); + + describe('setBrowserEnvironment', () => { + it('sets the current browser and OS environment', () => { + appStateController.setBrowserEnvironment('Windows', 'Chrome'); + expect( + appStateController.store.getState().browserEnvironment, + ).toStrictEqual({ + os: 'Windows', + browser: 'Chrome', + }); + }); + }); + + describe('addPollingToken', () => { + it('adds a pollingToken for a given environmentType', () => { + const pollingTokenType = + POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_POPUP]; + appStateController.addPollingToken('token1', pollingTokenType); + expect(appStateController.store.getState()[pollingTokenType]).toContain( + 'token1', + ); + }); + }); + + describe('removePollingToken', () => { + it('removes a pollingToken for a given environmentType', () => { + const pollingTokenType = + POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_POPUP]; + appStateController.addPollingToken('token1', pollingTokenType); + appStateController.removePollingToken('token1', pollingTokenType); + expect( + appStateController.store.getState()[pollingTokenType], + ).not.toContain('token1'); + }); + }); + + describe('clearPollingTokens', () => { + it('clears all pollingTokens', () => { + appStateController.addPollingToken('token1', 'popupGasPollTokens'); + appStateController.addPollingToken('token2', 'notificationGasPollTokens'); + appStateController.addPollingToken('token3', 'fullScreenGasPollTokens'); + appStateController.clearPollingTokens(); + + expect( + appStateController.store.getState().popupGasPollTokens, + ).toStrictEqual([]); + expect( + appStateController.store.getState().notificationGasPollTokens, + ).toStrictEqual([]); + expect( + appStateController.store.getState().fullScreenGasPollTokens, + ).toStrictEqual([]); + }); + }); + + describe('setShowTestnetMessageInDropdown', () => { + it('sets whether the testnet dismissal link should be shown in the network dropdown', () => { + appStateController.setShowTestnetMessageInDropdown(true); + expect( + appStateController.store.getState().showTestnetMessageInDropdown, + ).toBe(true); + + appStateController.setShowTestnetMessageInDropdown(false); + expect( + appStateController.store.getState().showTestnetMessageInDropdown, + ).toBe(false); + }); + }); + + describe('setShowBetaHeader', () => { + it('sets whether the beta notification heading on the home page', () => { + appStateController.setShowBetaHeader(true); + expect(appStateController.store.getState().showBetaHeader).toBe(true); + + appStateController.setShowBetaHeader(false); + expect(appStateController.store.getState().showBetaHeader).toBe(false); + }); + }); + + describe('setCurrentPopupId', () => { + it('sets the currentPopupId in the appState', () => { + const popupId = 12345; + + appStateController.setCurrentPopupId(popupId); + expect(appStateController.store.getState().currentPopupId).toBe(popupId); + }); + }); + + describe('getCurrentPopupId', () => { + it('retrieves the currentPopupId saved in the appState', () => { + const popupId = 54321; + + appStateController.setCurrentPopupId(popupId); + expect(appStateController.getCurrentPopupId()).toBe(popupId); + }); + }); + + describe('setFirstTimeUsedNetwork', () => { + it('updates the array of the first time used networks', () => { + const chainId = '0x1'; + + appStateController.setFirstTimeUsedNetwork(chainId); + expect(appStateController.store.getState().usedNetworks[chainId]).toBe( + true, + ); + }); + }); + + describe('setLastInteractedConfirmationInfo', () => { + it('sets information about last confirmation user has interacted with', () => { + const lastInteractedConfirmationInfo = { + id: '123', + chainId: '0x1', + timestamp: new Date().getTime(), + }; + appStateController.setLastInteractedConfirmationInfo( + lastInteractedConfirmationInfo, + ); + expect(appStateController.getLastInteractedConfirmationInfo()).toBe( + lastInteractedConfirmationInfo, + ); + + appStateController.setLastInteractedConfirmationInfo(undefined); + expect(appStateController.getLastInteractedConfirmationInfo()).toBe( + undefined, + ); + }); + }); + + describe('setSnapsInstallPrivacyWarningShownStatus', () => { + it('updates the status of snaps install privacy warning', () => { + ({ appStateController } = createAppStateController()); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + appStateController.setSnapsInstallPrivacyWarningShownStatus(true); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + snapsInstallPrivacyWarningShown: true, + }); + + updateStateSpy.mockRestore(); + }); + }); + + describe('institutional', () => { + it('set the interactive replacement token with a url and the old refresh token', () => { + ({ appStateController } = createAppStateController()); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + const mockParams = { + url: 'https://example.com', + oldRefreshToken: 'old', + }; + + appStateController.showInteractiveReplacementTokenBanner(mockParams); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + interactiveReplacementToken: mockParams, + }); + + updateStateSpy.mockRestore(); + }); + + it('set the setCustodianDeepLink with the fromAddress and custodyId', () => { + ({ appStateController } = createAppStateController()); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + const mockParams = { + fromAddress: '0x', + custodyId: 'custodyId', + }; + + appStateController.setCustodianDeepLink(mockParams); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + custodianDeepLink: mockParams, + }); + + updateStateSpy.mockRestore(); + }); + + it('set the setNoteToTraderMessage with a message', () => { + ({ appStateController } = createAppStateController()); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + const mockParams = 'some message'; + + appStateController.setNoteToTraderMessage(mockParams); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + noteToTraderMessage: mockParams, + }); + + updateStateSpy.mockRestore(); + }); + }); + + describe('setSurveyLinkLastClickedOrClosed', () => { + it('set the surveyLinkLastClickedOrClosed time', () => { + ({ appStateController } = createAppStateController()); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + const mockParams = Date.now(); + + appStateController.setSurveyLinkLastClickedOrClosed(mockParams); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + surveyLinkLastClickedOrClosed: mockParams, + }); + + updateStateSpy.mockRestore(); + }); + }); + + describe('setOnboardingDate', () => { + it('set the onboardingDate', () => { + ({ appStateController } = createAppStateController()); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + appStateController.setOnboardingDate(); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + + updateStateSpy.mockRestore(); + }); + }); + + describe('setLastViewedUserSurvey', () => { + it('set the lastViewedUserSurvey with id 1', () => { + ({ appStateController } = createAppStateController()); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + const mockParams = 1; + + appStateController.setLastViewedUserSurvey(mockParams); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + lastViewedUserSurvey: mockParams, + }); + + updateStateSpy.mockRestore(); + }); + }); + + describe('setNewPrivacyPolicyToastClickedOrClosed', () => { + it('set the newPrivacyPolicyToastClickedOrClosed to true', () => { + ({ appStateController } = createAppStateController()); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + appStateController.setNewPrivacyPolicyToastClickedOrClosed(); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect( + appStateController.store.getState() + .newPrivacyPolicyToastClickedOrClosed, + ).toStrictEqual(true); + + updateStateSpy.mockRestore(); + }); + }); + + describe('setNewPrivacyPolicyToastShownDate', () => { + it('set the newPrivacyPolicyToastShownDate', () => { + ({ appStateController } = createAppStateController()); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + const mockParams = Date.now(); + + appStateController.setNewPrivacyPolicyToastShownDate(mockParams); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + newPrivacyPolicyToastShownDate: mockParams, + }); + expect( + appStateController.store.getState().newPrivacyPolicyToastShownDate, + ).toStrictEqual(mockParams); + + updateStateSpy.mockRestore(); + }); + }); + + describe('setTermsOfUseLastAgreed', () => { + it('set the termsOfUseLastAgreed timestamp', () => { + ({ appStateController } = createAppStateController()); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + const mockParams = Date.now(); + + appStateController.setTermsOfUseLastAgreed(mockParams); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + termsOfUseLastAgreed: mockParams, + }); + expect( + appStateController.store.getState().termsOfUseLastAgreed, + ).toStrictEqual(mockParams); + + updateStateSpy.mockRestore(); + }); + }); + + describe('onPreferencesStateChange', () => { + it('should update the timeoutMinutes with the autoLockTimeLimit', () => { + ({ appStateController, controllerMessenger } = + createAppStateController()); + const timeout = Date.now(); + + controllerMessenger.publish( + 'PreferencesController:stateChange', + { + preferences: { autoLockTimeLimit: timeout }, + } as unknown as PreferencesControllerState, + [], + ); + + expect(appStateController.store.getState().timeoutMinutes).toStrictEqual( + timeout, + ); + }); + }); + + describe('isManifestV3', () => { + it('creates alarm when isManifestV3 is true', () => { + mockIsManifestV3.mockReturnValue(true); + ({ appStateController } = createAppStateController()); + + const timeout = Date.now(); + controllerMessenger.publish( + 'PreferencesController:stateChange', + { + preferences: { autoLockTimeLimit: timeout }, + } as unknown as PreferencesControllerState, + [], + ); + const spy = jest.spyOn( + appStateController as unknown as { _resetTimer: () => void }, + '_resetTimer', + ); + appStateController.setLastActiveTime(); + + expect(spy).toHaveBeenCalled(); + expect(extensionMock.alarms.clear).toHaveBeenCalled(); + expect(extensionMock.alarms.onAlarm.addListener).toHaveBeenCalled(); + }); + }); + + describe('AppStateController:getState', () => { + it('should return the current state of the property', () => { + expect( + appStateController.store.getState().recoveryPhraseReminderHasBeenShown, + ).toStrictEqual(false); + expect( + controllerMessenger.call('AppStateController:getState') + .recoveryPhraseReminderHasBeenShown, + ).toStrictEqual(false); + }); + }); + + describe('AppStateController:stateChange', () => { + it('subscribers will recieve the state when published', () => { + expect( + appStateController.store.getState().surveyLinkLastClickedOrClosed, + ).toStrictEqual(null); + const timeNow = Date.now(); + controllerMessenger.subscribe( + 'AppStateController:stateChange', + (state: Partial) => { + if (typeof state.surveyLinkLastClickedOrClosed === 'number') { + appStateController.setSurveyLinkLastClickedOrClosed( + state.surveyLinkLastClickedOrClosed, + ); + } + }, + ); + + controllerMessenger.publish( + 'AppStateController:stateChange', + { + surveyLinkLastClickedOrClosed: timeNow, + } as unknown as AppStateControllerState, + [], + ); + + expect( + appStateController.store.getState().surveyLinkLastClickedOrClosed, + ).toStrictEqual(timeNow); + expect( + controllerMessenger.call('AppStateController:getState') + .surveyLinkLastClickedOrClosed, + ).toStrictEqual(timeNow); + }); + + it('state will be published when there is state change', () => { + expect( + appStateController.store.getState().surveyLinkLastClickedOrClosed, + ).toStrictEqual(null); + const timeNow = Date.now(); + controllerMessenger.subscribe( + 'AppStateController:stateChange', + (state: Partial) => { + expect(state.surveyLinkLastClickedOrClosed).toStrictEqual(timeNow); + }, + ); + + appStateController.setSurveyLinkLastClickedOrClosed(timeNow); + + expect( + appStateController.store.getState().surveyLinkLastClickedOrClosed, + ).toStrictEqual(timeNow); + expect( + controllerMessenger.call('AppStateController:getState') + .surveyLinkLastClickedOrClosed, + ).toStrictEqual(timeNow); + }); + }); +}); diff --git a/app/scripts/controllers/app-state-controller.ts b/app/scripts/controllers/app-state-controller.ts new file mode 100644 index 00000000000..e76b8fe3888 --- /dev/null +++ b/app/scripts/controllers/app-state-controller.ts @@ -0,0 +1,874 @@ +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 { KeyringControllerQRKeyringStateChangeEvent } from '@metamask/keyring-controller'; +import { RestrictedControllerMessenger } from '@metamask/base-controller'; +import { + AcceptRequest, + AddApprovalRequest, +} from '@metamask/approval-controller'; +import { Json } from '@metamask/utils'; +import { Browser } from 'webextension-polyfill'; +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'; +// TODO: Remove restricted import +// eslint-disable-next-line import/no-restricted-paths +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'; +import { LastInteractedConfirmationInfo } from '../../../shared/types/confirm'; +import { SecurityAlertResponse } from '../lib/ppom/types'; +import type { + Preferences, + PreferencesControllerGetStateAction, + PreferencesControllerStateChangeEvent, +} from './preferences-controller'; + +export type AppStateControllerState = { + timeoutMinutes: number; + connectedStatusPopoverHasBeenShown: boolean; + defaultHomeActiveTabName: string | null; + browserEnvironment: Record; + popupGasPollTokens: string[]; + notificationGasPollTokens: string[]; + fullScreenGasPollTokens: string[]; + recoveryPhraseReminderHasBeenShown: boolean; + recoveryPhraseReminderLastShown: number; + outdatedBrowserWarningLastShown: number | null; + nftsDetectionNoticeDismissed: boolean; + showTestnetMessageInDropdown: boolean; + showBetaHeader: boolean; + showPermissionsTour: boolean; + showNetworkBanner: boolean; + showAccountBanner: boolean; + trezorModel: string | null; + currentPopupId?: number; + onboardingDate: number | null; + lastViewedUserSurvey: number | null; + newPrivacyPolicyToastClickedOrClosed: boolean | null; + newPrivacyPolicyToastShownDate: number | null; + // This key is only used for checking if the user had set advancedGasFee + // prior to Migration 92.3 where we split out the setting to support + // multiple networks. + hadAdvancedGasFeesSetPriorToMigration92_3: boolean; + qrHardware: Json; + nftsDropdownState: Json; + usedNetworks: Record; + surveyLinkLastClickedOrClosed: number | null; + signatureSecurityAlertResponses: Record; + // States used for displaying the changed network toast + switchedNetworkDetails: Record | null; + switchedNetworkNeverShowMessage: boolean; + currentExtensionPopupId: number; + lastInteractedConfirmationInfo?: LastInteractedConfirmationInfo; + termsOfUseLastAgreed?: number; + snapsInstallPrivacyWarningShown?: boolean; + interactiveReplacementToken?: { url: string; oldRefreshToken: string }; + noteToTraderMessage?: string; + custodianDeepLink?: { fromAddress: string; custodyId: string }; +}; + +const controllerName = 'AppStateController'; + +/** + * Returns the state of the {@link AppStateController}. + */ +export type AppStateControllerGetStateAction = { + type: 'AppStateController:getState'; + handler: () => AppStateControllerState; +}; + +/** + * Actions exposed by the {@link AppStateController}. + */ +export type AppStateControllerActions = AppStateControllerGetStateAction; + +/** + * Actions that this controller is allowed to call. + */ +export type AllowedActions = + | AddApprovalRequest + | AcceptRequest + | PreferencesControllerGetStateAction; + +/** + * Event emitted when the state of the {@link AppStateController} changes. + */ +export type AppStateControllerStateChangeEvent = { + type: 'AppStateController:stateChange'; + payload: [AppStateControllerState, []]; +}; + +/** + * Events emitted by {@link AppStateController}. + */ +export type AppStateControllerEvents = AppStateControllerStateChangeEvent; + +/** + * Events that this controller is allowed to subscribe. + */ +export type AllowedEvents = + | PreferencesControllerStateChangeEvent + | KeyringControllerQRKeyringStateChangeEvent; + +export type AppStateControllerMessenger = RestrictedControllerMessenger< + typeof controllerName, + AppStateControllerActions | AllowedActions, + AppStateControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; + +type PollingTokenType = + | 'popupGasPollTokens' + | 'notificationGasPollTokens' + | 'fullScreenGasPollTokens'; + +type AppStateControllerInitState = Partial< + Omit< + AppStateControllerState, + | 'qrHardware' + | 'nftsDropdownState' + | 'usedNetworks' + | 'surveyLinkLastClickedOrClosed' + | 'signatureSecurityAlertResponses' + | 'switchedNetworkDetails' + | 'switchedNetworkNeverShowMessage' + | 'currentExtensionPopupId' + > +>; + +type AppStateControllerOptions = { + addUnlockListener: (callback: () => void) => void; + isUnlocked: () => boolean; + initState?: AppStateControllerInitState; + onInactiveTimeout?: () => void; + messenger: AppStateControllerMessenger; + extension: Browser; +}; + +const getDefaultAppStateControllerState = ( + initState?: AppStateControllerInitState, +): AppStateControllerState => ({ + timeoutMinutes: DEFAULT_AUTO_LOCK_TIME_LIMIT, + connectedStatusPopoverHasBeenShown: true, + defaultHomeActiveTabName: null, + browserEnvironment: {}, + popupGasPollTokens: [], + notificationGasPollTokens: [], + fullScreenGasPollTokens: [], + recoveryPhraseReminderHasBeenShown: false, + recoveryPhraseReminderLastShown: new Date().getTime(), + outdatedBrowserWarningLastShown: null, + nftsDetectionNoticeDismissed: false, + showTestnetMessageInDropdown: true, + showBetaHeader: isBeta(), + showPermissionsTour: true, + showNetworkBanner: true, + showAccountBanner: true, + trezorModel: null, + onboardingDate: null, + lastViewedUserSurvey: null, + newPrivacyPolicyToastClickedOrClosed: null, + newPrivacyPolicyToastShownDate: null, + hadAdvancedGasFeesSetPriorToMigration92_3: false, + ...initState, + qrHardware: {}, + nftsDropdownState: {}, + usedNetworks: { + '0x1': true, + '0x5': true, + '0x539': true, + }, + surveyLinkLastClickedOrClosed: null, + signatureSecurityAlertResponses: {}, + switchedNetworkDetails: null, + switchedNetworkNeverShowMessage: false, + currentExtensionPopupId: 0, +}); + +export class AppStateController extends EventEmitter { + private readonly extension: AppStateControllerOptions['extension']; + + private readonly onInactiveTimeout: () => void; + + store: ObservableStore; + + private timer: NodeJS.Timeout | null; + + isUnlocked: () => boolean; + + private readonly waitingForUnlock: { resolve: () => void }[]; + + private readonly messagingSystem: AppStateControllerMessenger; + + #approvalRequestId: string | null; + + constructor(opts: AppStateControllerOptions) { + const { + addUnlockListener, + isUnlocked, + initState, + onInactiveTimeout, + messenger, + extension, + } = opts; + super(); + + this.extension = extension; + this.onInactiveTimeout = onInactiveTimeout || (() => undefined); + this.store = new ObservableStore( + getDefaultAppStateControllerState(initState), + ); + this.timer = null; + + this.isUnlocked = isUnlocked; + this.waitingForUnlock = []; + addUnlockListener(this.handleUnlock.bind(this)); + + messenger.subscribe( + 'PreferencesController:stateChange', + ({ preferences }: { preferences: Partial }) => { + const currentState = this.store.getState(); + if ( + typeof preferences?.autoLockTimeLimit === 'number' && + currentState.timeoutMinutes !== preferences.autoLockTimeLimit + ) { + this._setInactiveTimeout(preferences.autoLockTimeLimit); + } + }, + ); + + messenger.subscribe( + 'KeyringController:qrKeyringStateChange', + (qrHardware: Json) => + this.store.updateState({ + qrHardware, + }), + ); + + const { preferences } = messenger.call('PreferencesController:getState'); + if (typeof preferences.autoLockTimeLimit === 'number') { + this._setInactiveTimeout(preferences.autoLockTimeLimit); + } + + this.messagingSystem = messenger; + this.messagingSystem.registerActionHandler( + 'AppStateController:getState', + () => this.store.getState(), + ); + this.store.subscribe((state: AppStateControllerState) => { + this.messagingSystem.publish('AppStateController:stateChange', state, []); + }); + this.#approvalRequestId = null; + } + + /** + * Get a Promise that resolves when the extension is unlocked. + * This Promise will never reject. + * + * @param shouldShowUnlockRequest - Whether the extension notification + * popup should be opened. + * @returns A promise that resolves when the extension is + * unlocked, or immediately if the extension is already unlocked. + */ + getUnlockPromise(shouldShowUnlockRequest: boolean): Promise { + 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 resolve - A Promise's resolve function that will + * be called when the extension is unlocked. + * @param shouldShowUnlockRequest - Whether the extension notification + * popup should be opened. + */ + waitForUnlock(resolve: () => void, shouldShowUnlockRequest: boolean): void { + 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(): void { + 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 defaultHomeActiveTabName - the tab name + */ + setDefaultHomeActiveTabName(defaultHomeActiveTabName: string | null): void { + this.store.updateState({ + defaultHomeActiveTabName, + }); + } + + /** + * Record that the user has seen the connected status info popover + */ + setConnectedStatusPopoverHasBeenShown(): void { + this.store.updateState({ + connectedStatusPopoverHasBeenShown: true, + }); + } + + /** + * Record that the user has been shown the recovery phrase reminder. + */ + setRecoveryPhraseReminderHasBeenShown(): void { + this.store.updateState({ + recoveryPhraseReminderHasBeenShown: true, + }); + } + + setSurveyLinkLastClickedOrClosed(time: number): void { + this.store.updateState({ + surveyLinkLastClickedOrClosed: time, + }); + } + + setOnboardingDate(): void { + this.store.updateState({ + onboardingDate: Date.now(), + }); + } + + setLastViewedUserSurvey(id: number) { + this.store.updateState({ + lastViewedUserSurvey: id, + }); + } + + setNewPrivacyPolicyToastClickedOrClosed(): void { + this.store.updateState({ + newPrivacyPolicyToastClickedOrClosed: true, + }); + } + + setNewPrivacyPolicyToastShownDate(time: number): void { + this.store.updateState({ + newPrivacyPolicyToastShownDate: time, + }); + } + + /** + * Record the timestamp of the last time the user has seen the recovery phrase reminder + * + * @param lastShown - timestamp when user was last shown the reminder. + */ + setRecoveryPhraseReminderLastShown(lastShown: number): void { + this.store.updateState({ + recoveryPhraseReminderLastShown: lastShown, + }); + } + + /** + * Record the timestamp of the last time the user has acceoted the terms of use + * + * @param lastAgreed - timestamp when user last accepted the terms of use + */ + setTermsOfUseLastAgreed(lastAgreed: number): void { + this.store.updateState({ + termsOfUseLastAgreed: lastAgreed, + }); + } + + /** + * Record if popover for snaps privacy warning has been shown + * on the first install of a snap. + * + * @param shown - shown status + */ + setSnapsInstallPrivacyWarningShownStatus(shown: boolean): void { + this.store.updateState({ + snapsInstallPrivacyWarningShown: shown, + }); + } + + /** + * Record the timestamp of the last time the user has seen the outdated browser warning + * + * @param lastShown - Timestamp (in milliseconds) of when the user was last shown the warning. + */ + setOutdatedBrowserWarningLastShown(lastShown: number): void { + this.store.updateState({ + outdatedBrowserWarningLastShown: lastShown, + }); + } + + /** + * Sets the last active time to the current time. + */ + setLastActiveTime(): void { + this._resetTimer(); + } + + /** + * Sets the inactive timeout for the app + * + * @param timeoutMinutes - The inactive timeout in minutes. + */ + private _setInactiveTimeout(timeoutMinutes: number): void { + 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 _resetTimer(): void { + const { timeoutMinutes } = this.store.getState(); + + if (this.timer) { + clearTimeout(this.timer); + } else if (isManifestV3) { + this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); + } + + if (!timeoutMinutes) { + return; + } + + // This is a temporary fix until we add a state migration. + // Due to a bug in ui/pages/settings/advanced-tab/advanced-tab.component.js, + // it was possible for timeoutMinutes to be saved as a string, as explained + // in PR 25109. `alarms.create` will fail in that case. We are + // converting this to a number here to prevent that failure. Once + // we add a migration to update the malformed state to the right type, + // we will remove this conversion. + const timeoutToSet = Number(timeoutMinutes); + + if (isManifestV3) { + this.extension.alarms.create(AUTO_LOCK_TIMEOUT_ALARM, { + delayInMinutes: timeoutToSet, + periodInMinutes: timeoutToSet, + }); + this.extension.alarms.onAlarm.addListener( + (alarmInfo: { name: string }) => { + if (alarmInfo.name === AUTO_LOCK_TIMEOUT_ALARM) { + this.onInactiveTimeout(); + this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); + } + }, + ); + } else { + this.timer = setTimeout( + () => this.onInactiveTimeout(), + timeoutToSet * MINUTE, + ); + } + } + + /** + * Sets the current browser and OS environment + * + * @param os + * @param browser + */ + setBrowserEnvironment(os: string, browser: string): void { + this.store.updateState({ browserEnvironment: { os, browser } }); + } + + /** + * Adds a pollingToken for a given environmentType + * + * @param pollingToken + * @param pollingTokenType + */ + addPollingToken( + pollingToken: string, + pollingTokenType: PollingTokenType, + ): void { + if ( + pollingTokenType.toString() !== + POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_BACKGROUND] + ) { + if (this.#isValidPollingTokenType(pollingTokenType)) { + this.#updatePollingTokens(pollingToken, pollingTokenType); + } + } + } + + /** + * Updates the polling token in the state. + * + * @param pollingToken + * @param pollingTokenType + */ + #updatePollingTokens( + pollingToken: string, + pollingTokenType: PollingTokenType, + ) { + const currentTokens: string[] = this.store.getState()[pollingTokenType]; + this.store.updateState({ + [pollingTokenType]: [...currentTokens, pollingToken], + }); + } + + /** + * removes a pollingToken for a given environmentType + * + * @param pollingToken + * @param pollingTokenType + */ + removePollingToken( + pollingToken: string, + pollingTokenType: PollingTokenType, + ): void { + if ( + pollingTokenType.toString() !== + POLLING_TOKEN_ENVIRONMENT_TYPES[ENVIRONMENT_TYPE_BACKGROUND] + ) { + const currentTokens: string[] = this.store.getState()[pollingTokenType]; + if (this.#isValidPollingTokenType(pollingTokenType)) { + this.store.updateState({ + [pollingTokenType]: currentTokens.filter( + (token: string) => token !== pollingToken, + ), + }); + } + } + } + + /** + * Validates whether the given polling token type is a valid one. + * + * @param pollingTokenType + * @returns true if valid, false otherwise. + */ + #isValidPollingTokenType(pollingTokenType: PollingTokenType): boolean { + const validTokenTypes: PollingTokenType[] = [ + 'popupGasPollTokens', + 'notificationGasPollTokens', + 'fullScreenGasPollTokens', + ]; + + return validTokenTypes.includes(pollingTokenType); + } + + /** + * clears all pollingTokens + */ + clearPollingTokens(): void { + this.store.updateState({ + popupGasPollTokens: [], + notificationGasPollTokens: [], + fullScreenGasPollTokens: [], + }); + } + + /** + * Sets whether the testnet dismissal link should be shown in the network dropdown + * + * @param showTestnetMessageInDropdown + */ + setShowTestnetMessageInDropdown(showTestnetMessageInDropdown: boolean): void { + this.store.updateState({ showTestnetMessageInDropdown }); + } + + /** + * Sets whether the beta notification heading on the home page + * + * @param showBetaHeader + */ + setShowBetaHeader(showBetaHeader: boolean): void { + this.store.updateState({ showBetaHeader }); + } + + /** + * Sets whether the permissions tour should be shown to the user + * + * @param showPermissionsTour + */ + setShowPermissionsTour(showPermissionsTour: boolean): void { + this.store.updateState({ showPermissionsTour }); + } + + /** + * Sets whether the Network Banner should be shown + * + * @param showNetworkBanner + */ + setShowNetworkBanner(showNetworkBanner: boolean): void { + this.store.updateState({ showNetworkBanner }); + } + + /** + * Sets whether the Account Banner should be shown + * + * @param showAccountBanner + */ + setShowAccountBanner(showAccountBanner: boolean): void { + this.store.updateState({ showAccountBanner }); + } + + /** + * Sets a unique ID for the current extension popup + * + * @param currentExtensionPopupId + */ + setCurrentExtensionPopupId(currentExtensionPopupId: number): void { + this.store.updateState({ currentExtensionPopupId }); + } + + /** + * Sets an object with networkName and appName + * or `null` if the message is meant to be cleared + * + * @param switchedNetworkDetails - Details about the network that MetaMask just switched to. + */ + setSwitchedNetworkDetails( + switchedNetworkDetails: { origin: string; networkClientId: string } | null, + ): void { + this.store.updateState({ switchedNetworkDetails }); + } + + /** + * Clears the switched network details in state + */ + clearSwitchedNetworkDetails(): void { + this.store.updateState({ switchedNetworkDetails: null }); + } + + /** + * Remembers if the user prefers to never see the + * network switched message again + * + * @param switchedNetworkNeverShowMessage + */ + setSwitchedNetworkNeverShowMessage( + switchedNetworkNeverShowMessage: boolean, + ): void { + this.store.updateState({ + switchedNetworkDetails: null, + switchedNetworkNeverShowMessage, + }); + } + + /** + * Sets a property indicating the model of the user's Trezor hardware wallet + * + * @param trezorModel - The Trezor model. + */ + setTrezorModel(trezorModel: string | null): void { + this.store.updateState({ trezorModel }); + } + + /** + * A setter for the `nftsDropdownState` property + * + * @param nftsDropdownState + */ + updateNftDropDownState(nftsDropdownState: Json): void { + this.store.updateState({ + nftsDropdownState, + }); + } + + /** + * Updates the array of the first time used networks + * + * @param chainId + */ + setFirstTimeUsedNetwork(chainId: string): void { + const currentState = this.store.getState(); + const { usedNetworks } = currentState; + usedNetworks[chainId] = true; + + this.store.updateState({ usedNetworks }); + } + + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) + /** + * Set the interactive replacement token with a url and the old refresh token + * + * @param opts + * @param opts.url + * @param opts.oldRefreshToken + */ + showInteractiveReplacementTokenBanner({ + url, + oldRefreshToken, + }: { + url: string; + oldRefreshToken: string; + }): void { + this.store.updateState({ + interactiveReplacementToken: { + url, + oldRefreshToken, + }, + }); + } + + /** + * Set the setCustodianDeepLink with the fromAddress and custodyId + * + * @param opts + * @param opts.fromAddress + * @param opts.custodyId + */ + setCustodianDeepLink({ + fromAddress, + custodyId, + }: { + fromAddress: string; + custodyId: string; + }): void { + this.store.updateState({ + custodianDeepLink: { fromAddress, custodyId }, + }); + } + + setNoteToTraderMessage(message: string): void { + this.store.updateState({ + noteToTraderMessage: message, + }); + } + + ///: END:ONLY_INCLUDE_IF + + getSignatureSecurityAlertResponse( + securityAlertId: string, + ): SecurityAlertResponse { + return this.store.getState().signatureSecurityAlertResponses[ + securityAlertId + ]; + } + + addSignatureSecurityAlertResponse( + securityAlertResponse: SecurityAlertResponse, + ): void { + const currentState = this.store.getState(); + const { signatureSecurityAlertResponses } = currentState; + if (securityAlertResponse.securityAlertId) { + this.store.updateState({ + signatureSecurityAlertResponses: { + ...signatureSecurityAlertResponses, + [String(securityAlertResponse.securityAlertId)]: + securityAlertResponse, + }, + }); + } + } + + /** + * A setter for the currentPopupId which indicates the id of popup window that's currently active + * + * @param currentPopupId + */ + setCurrentPopupId(currentPopupId: number): void { + this.store.updateState({ + currentPopupId, + }); + } + + /** + * The function returns information about the last confirmation user interacted with + */ + getLastInteractedConfirmationInfo(): + | LastInteractedConfirmationInfo + | undefined { + return this.store.getState().lastInteractedConfirmationInfo; + } + + /** + * Update the information about the last confirmation user interacted with + * + * @param lastInteractedConfirmationInfo + */ + setLastInteractedConfirmationInfo( + lastInteractedConfirmationInfo: LastInteractedConfirmationInfo | undefined, + ): void { + this.store.updateState({ + lastInteractedConfirmationInfo, + }); + } + + /** + * A getter to retrieve currentPopupId saved in the appState + */ + getCurrentPopupId(): number | undefined { + return this.store.getState().currentPopupId; + } + + private _requestApproval(): void { + // If we already have a pending request this is a no-op + if (this.#approvalRequestId) { + return; + } + this.#approvalRequestId = uuid(); + + this.messagingSystem + .call( + 'ApprovalController:addRequest', + { + id: this.#approvalRequestId, + origin: ORIGIN_METAMASK, + type: ApprovalType.Unlock, + }, + true, + ) + .catch(() => { + // If the promise fails, we allow a new popup to be triggered + this.#approvalRequestId = null; + }); + } + + // Override emit method to provide strong typing for events + emit(event: string) { + return super.emit(event); + } + + private _acceptApproval(): void { + if (!this.#approvalRequestId) { + return; + } + try { + this.messagingSystem.call( + 'ApprovalController:acceptRequest', + this.#approvalRequestId, + ); + } catch (error) { + log.error('Failed to unlock approval request', error); + } + + this.#approvalRequestId = null; + } +} diff --git a/app/scripts/controllers/app-state.d.ts b/app/scripts/controllers/app-state.d.ts deleted file mode 100644 index aa7ffc92eb3..00000000000 --- a/app/scripts/controllers/app-state.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { SecurityAlertResponse } from '../lib/ppom/types'; - -export type AppStateController = { - addSignatureSecurityAlertResponse( - securityAlertResponse: SecurityAlertResponse, - ): void; - getUnlockPromise(shouldShowUnlockRequest: boolean): Promise; - ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) - setCustodianDeepLink({ - fromAddress, - custodyId, - }: { - fromAddress: string; - custodyId: string; - }): void; - showInteractiveReplacementTokenBanner({ - oldRefreshToken, - url, - }: { - oldRefreshToken: string; - url: string; - }): void; - ///: END:ONLY_INCLUDE_IF -}; diff --git a/app/scripts/controllers/app-state.js b/app/scripts/controllers/app-state.js deleted file mode 100644 index 9dabf2313e5..00000000000 --- a/app/scripts/controllers/app-state.js +++ /dev/null @@ -1,651 +0,0 @@ -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'; -// TODO: Remove restricted import -// eslint-disable-next-line import/no-restricted-paths -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'; - -/** @typedef {import('../../../shared/types/confirm').LastInteractedConfirmationInfo} LastInteractedConfirmationInfo */ - -export default class AppStateController extends EventEmitter { - /** - * @param {object} opts - */ - constructor(opts = {}) { - const { - addUnlockListener, - isUnlocked, - initState, - onInactiveTimeout, - preferencesController, - messenger, - extension, - } = opts; - super(); - - this.extension = extension; - 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: null, - nftsDetectionNoticeDismissed: false, - showTestnetMessageInDropdown: true, - showBetaHeader: isBeta(), - showPermissionsTour: true, - showNetworkBanner: true, - showAccountBanner: true, - trezorModel: null, - currentPopupId: undefined, - onboardingDate: null, - lastViewedUserSurvey: null, - newPrivacyPolicyToastClickedOrClosed: null, - newPrivacyPolicyToastShownDate: null, - // This key is only used for checking if the user had set advancedGasFee - // prior to Migration 92.3 where we split out the setting to support - // multiple networks. - hadAdvancedGasFeesSetPriorToMigration92_3: false, - ...initState, - qrHardware: {}, - nftsDropdownState: {}, - usedNetworks: { - '0x1': true, - '0x5': true, - '0x539': true, - }, - surveyLinkLastClickedOrClosed: null, - signatureSecurityAlertResponses: {}, - // States used for displaying the changed network toast - switchedNetworkDetails: null, - switchedNetworkNeverShowMessage: false, - currentExtensionPopupId: 0, - lastInteractedConfirmationInfo: undefined, - }); - this.timer = null; - - this.isUnlocked = isUnlocked; - this.waitingForUnlock = []; - addUnlockListener(this.handleUnlock.bind(this)); - - messenger.subscribe( - 'PreferencesController:stateChange', - ({ preferences }) => { - const currentState = this.store.getState(); - if ( - preferences && - currentState.timeoutMinutes !== preferences.autoLockTimeLimit - ) { - this._setInactiveTimeout(preferences.autoLockTimeLimit); - } - }, - ); - - messenger.subscribe( - 'KeyringController:qrKeyringStateChange', - (qrHardware) => - this.store.updateState({ - qrHardware, - }), - ); - - const { preferences } = preferencesController.state; - - 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} 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, - }); - } - - setSurveyLinkLastClickedOrClosed(time) { - this.store.updateState({ - surveyLinkLastClickedOrClosed: time, - }); - } - - setOnboardingDate() { - this.store.updateState({ - onboardingDate: Date.now(), - }); - } - - setLastViewedUserSurvey(id) { - this.store.updateState({ - lastViewedUserSurvey: id, - }); - } - - setNewPrivacyPolicyToastClickedOrClosed() { - this.store.updateState({ - newPrivacyPolicyToastClickedOrClosed: true, - }); - } - - setNewPrivacyPolicyToastShownDate(time) { - this.store.updateState({ - newPrivacyPolicyToastShownDate: time, - }); - } - - /** - * 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, - }); - } - - /** - * 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, - }); - } - - /** - * 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) { - this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); - } - - if (!timeoutMinutes) { - return; - } - - // This is a temporary fix until we add a state migration. - // Due to a bug in ui/pages/settings/advanced-tab/advanced-tab.component.js, - // it was possible for timeoutMinutes to be saved as a string, as explained - // in PR 25109. `alarms.create` will fail in that case. We are - // converting this to a number here to prevent that failure. Once - // we add a migration to update the malformed state to the right type, - // we will remove this conversion. - const timeoutToSet = Number(timeoutMinutes); - - if (isManifestV3) { - this.extension.alarms.create(AUTO_LOCK_TIMEOUT_ALARM, { - delayInMinutes: timeoutToSet, - periodInMinutes: timeoutToSet, - }); - this.extension.alarms.onAlarm.addListener((alarmInfo) => { - if (alarmInfo.name === AUTO_LOCK_TIMEOUT_ALARM) { - this.onInactiveTimeout(); - this.extension.alarms.clear(AUTO_LOCK_TIMEOUT_ALARM); - } - }); - } else { - this.timer = setTimeout( - () => this.onInactiveTimeout(), - timeoutToSet * 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 permissions tour should be shown to the user - * - * @param showPermissionsTour - */ - setShowPermissionsTour(showPermissionsTour) { - this.store.updateState({ showPermissionsTour }); - } - - /** - * Sets whether the Network Banner should be shown - * - * @param showNetworkBanner - */ - setShowNetworkBanner(showNetworkBanner) { - this.store.updateState({ showNetworkBanner }); - } - - /** - * Sets whether the Account Banner should be shown - * - * @param showAccountBanner - */ - setShowAccountBanner(showAccountBanner) { - this.store.updateState({ showAccountBanner }); - } - - /** - * Sets a unique ID for the current extension popup - * - * @param currentExtensionPopupId - */ - setCurrentExtensionPopupId(currentExtensionPopupId) { - this.store.updateState({ currentExtensionPopupId }); - } - - /** - * Sets an object with networkName and appName - * or `null` if the message is meant to be cleared - * - * @param {{ origin: string, networkClientId: string } | null} switchedNetworkDetails - Details about the network that MetaMask just switched to. - */ - setSwitchedNetworkDetails(switchedNetworkDetails) { - this.store.updateState({ switchedNetworkDetails }); - } - - /** - * Clears the switched network details in state - */ - clearSwitchedNetworkDetails() { - this.store.updateState({ switchedNetworkDetails: null }); - } - - /** - * Remembers if the user prefers to never see the - * network switched message again - * - * @param {boolean} switchedNetworkNeverShowMessage - */ - setSwitchedNetworkNeverShowMessage(switchedNetworkNeverShowMessage) { - this.store.updateState({ - switchedNetworkDetails: null, - switchedNetworkNeverShowMessage, - }); - } - - /** - * 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_IF(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, - }, - }); - } - - /** - * Set the setCustodianDeepLink with the fromAddress and custodyId - * - * @param {object} opts - * @param opts.fromAddress - * @param opts.custodyId - * @returns {void} - */ - setCustodianDeepLink({ fromAddress, custodyId }) { - this.store.updateState({ - custodianDeepLink: { fromAddress, custodyId }, - }); - } - - setNoteToTraderMessage(message) { - this.store.updateState({ - noteToTraderMessage: message, - }); - } - - ///: END:ONLY_INCLUDE_IF - - getSignatureSecurityAlertResponse(securityAlertId) { - return this.store.getState().signatureSecurityAlertResponses[ - securityAlertId - ]; - } - - addSignatureSecurityAlertResponse(securityAlertResponse) { - const currentState = this.store.getState(); - const { signatureSecurityAlertResponses } = currentState; - this.store.updateState({ - signatureSecurityAlertResponses: { - ...signatureSecurityAlertResponses, - [securityAlertResponse.securityAlertId]: securityAlertResponse, - }, - }); - } - - /** - * A setter for the currentPopupId which indicates the id of popup window that's currently active - * - * @param currentPopupId - */ - setCurrentPopupId(currentPopupId) { - this.store.updateState({ - currentPopupId, - }); - } - - /** - * The function returns information about the last confirmation user interacted with - * - * @type {LastInteractedConfirmationInfo}: Information about the last confirmation user interacted with. - */ - getLastInteractedConfirmationInfo() { - return this.store.getState().lastInteractedConfirmationInfo; - } - - /** - * Update the information about the last confirmation user interacted with - * - * @type {LastInteractedConfirmationInfo} - information about transaction user last interacted with. - */ - setLastInteractedConfirmationInfo(lastInteractedConfirmationInfo) { - this.store.updateState({ - lastInteractedConfirmationInfo, - }); - } - - /** - * A getter to retrieve currentPopupId saved in the appState - */ - getCurrentPopupId() { - return this.store.getState().currentPopupId; - } - - _requestApproval() { - // If we already have a pending request this is a no-op - if (this._approvalRequestId) { - return; - } - this._approvalRequestId = uuid(); - - this.messagingSystem - .call( - 'ApprovalController:addRequest', - { - id: this._approvalRequestId, - origin: ORIGIN_METAMASK, - type: ApprovalType.Unlock, - }, - true, - ) - .catch(() => { - // If the promise fails, we allow a new popup to be triggered - this._approvalRequestId = null; - }); - } - - _acceptApproval() { - if (!this._approvalRequestId) { - return; - } - try { - this.messagingSystem.call( - 'ApprovalController:acceptRequest', - this._approvalRequestId, - ); - } catch (error) { - log.error('Failed to unlock approval request', error); - } - - this._approvalRequestId = null; - } -} diff --git a/app/scripts/controllers/app-state.test.js b/app/scripts/controllers/app-state.test.js deleted file mode 100644 index 46fe87d29ad..00000000000 --- a/app/scripts/controllers/app-state.test.js +++ /dev/null @@ -1,396 +0,0 @@ -import { ObservableStore } from '@metamask/obs-store'; -import { ORIGIN_METAMASK } from '../../../shared/constants/app'; -import AppStateController from './app-state'; - -let appStateController, mockStore; - -describe('AppStateController', () => { - mockStore = new ObservableStore(); - const createAppStateController = (initState = {}) => { - return new AppStateController({ - addUnlockListener: jest.fn(), - isUnlocked: jest.fn(() => true), - initState, - onInactiveTimeout: jest.fn(), - showUnlockRequest: jest.fn(), - preferencesController: { - state: { - preferences: { - autoLockTimeLimit: 0, - }, - }, - }, - messenger: { - call: jest.fn(() => ({ - catch: jest.fn(), - })), - subscribe: jest.fn(), - }, - }); - }; - - beforeEach(() => { - appStateController = createAppStateController({ store: mockStore }); - }); - - describe('setOutdatedBrowserWarningLastShown', () => { - it('sets the last shown time', () => { - appStateController = createAppStateController(); - const date = new Date(); - - appStateController.setOutdatedBrowserWarningLastShown(date); - - expect( - appStateController.store.getState().outdatedBrowserWarningLastShown, - ).toStrictEqual(date); - }); - - it('sets outdated browser warning last shown timestamp', () => { - const lastShownTimestamp = Date.now(); - appStateController = createAppStateController(); - const updateStateSpy = jest.spyOn( - appStateController.store, - 'updateState', - ); - - appStateController.setOutdatedBrowserWarningLastShown(lastShownTimestamp); - - expect(updateStateSpy).toHaveBeenCalledTimes(1); - expect(updateStateSpy).toHaveBeenCalledWith({ - outdatedBrowserWarningLastShown: lastShownTimestamp, - }); - - updateStateSpy.mockRestore(); - }); - }); - - describe('getUnlockPromise', () => { - it('waits for unlock if the extension is locked', async () => { - appStateController = createAppStateController(); - const isUnlockedMock = jest - .spyOn(appStateController, 'isUnlocked') - .mockReturnValue(false); - const waitForUnlockSpy = jest.spyOn(appStateController, 'waitForUnlock'); - - appStateController.getUnlockPromise(true); - expect(isUnlockedMock).toHaveBeenCalled(); - expect(waitForUnlockSpy).toHaveBeenCalledWith(expect.any(Function), true); - }); - - it('resolves immediately if the extension is already unlocked', async () => { - appStateController = createAppStateController(); - const isUnlockedMock = jest - .spyOn(appStateController, 'isUnlocked') - .mockReturnValue(true); - - await expect( - appStateController.getUnlockPromise(false), - ).resolves.toBeUndefined(); - - expect(isUnlockedMock).toHaveBeenCalled(); - }); - }); - - describe('waitForUnlock', () => { - it('resolves immediately if already unlocked', async () => { - const emitSpy = jest.spyOn(appStateController, 'emit'); - const resolveFn = jest.fn(); - appStateController.waitForUnlock(resolveFn, false); - expect(emitSpy).toHaveBeenCalledWith('updateBadge'); - expect(appStateController.messagingSystem.call).toHaveBeenCalledTimes(0); - }); - - it('creates approval request when waitForUnlock is called with shouldShowUnlockRequest as true', async () => { - jest.spyOn(appStateController, 'isUnlocked').mockReturnValue(false); - - const resolveFn = jest.fn(); - appStateController.waitForUnlock(resolveFn, true); - - expect(appStateController.messagingSystem.call).toHaveBeenCalledTimes(1); - expect(appStateController.messagingSystem.call).toHaveBeenCalledWith( - 'ApprovalController:addRequest', - expect.objectContaining({ - id: expect.any(String), - origin: ORIGIN_METAMASK, - type: 'unlock', - }), - true, - ); - }); - }); - - describe('handleUnlock', () => { - beforeEach(() => { - jest.spyOn(appStateController, 'isUnlocked').mockReturnValue(false); - }); - afterEach(() => { - jest.clearAllMocks(); - }); - it('accepts approval request revolving all the related promises', async () => { - const emitSpy = jest.spyOn(appStateController, 'emit'); - const resolveFn = jest.fn(); - appStateController.waitForUnlock(resolveFn, true); - - appStateController.handleUnlock(); - - expect(emitSpy).toHaveBeenCalled(); - expect(emitSpy).toHaveBeenCalledWith('updateBadge'); - expect(appStateController.messagingSystem.call).toHaveBeenCalled(); - expect(appStateController.messagingSystem.call).toHaveBeenCalledWith( - 'ApprovalController:acceptRequest', - expect.any(String), - ); - }); - }); - - describe('setDefaultHomeActiveTabName', () => { - it('sets the default home tab name', () => { - appStateController.setDefaultHomeActiveTabName('testTabName'); - expect(appStateController.store.getState().defaultHomeActiveTabName).toBe( - 'testTabName', - ); - }); - }); - - describe('setConnectedStatusPopoverHasBeenShown', () => { - it('sets connected status popover as shown', () => { - appStateController.setConnectedStatusPopoverHasBeenShown(); - expect( - appStateController.store.getState().connectedStatusPopoverHasBeenShown, - ).toBe(true); - }); - }); - - describe('setRecoveryPhraseReminderHasBeenShown', () => { - it('sets recovery phrase reminder as shown', () => { - appStateController.setRecoveryPhraseReminderHasBeenShown(); - expect( - appStateController.store.getState().recoveryPhraseReminderHasBeenShown, - ).toBe(true); - }); - }); - - describe('setRecoveryPhraseReminderLastShown', () => { - it('sets the last shown time of recovery phrase reminder', () => { - const timestamp = Date.now(); - appStateController.setRecoveryPhraseReminderLastShown(timestamp); - - expect( - appStateController.store.getState().recoveryPhraseReminderLastShown, - ).toBe(timestamp); - }); - }); - - describe('setLastActiveTime', () => { - it('sets the last active time to the current time', () => { - const spy = jest.spyOn(appStateController, '_resetTimer'); - appStateController.setLastActiveTime(); - - expect(spy).toHaveBeenCalled(); - }); - }); - - describe('setBrowserEnvironment', () => { - it('sets the current browser and OS environment', () => { - appStateController.setBrowserEnvironment('Windows', 'Chrome'); - expect( - appStateController.store.getState().browserEnvironment, - ).toStrictEqual({ - os: 'Windows', - browser: 'Chrome', - }); - }); - }); - - describe('addPollingToken', () => { - it('adds a pollingToken for a given environmentType', () => { - const pollingTokenType = 'popupGasPollTokens'; - appStateController.addPollingToken('token1', pollingTokenType); - expect(appStateController.store.getState()[pollingTokenType]).toContain( - 'token1', - ); - }); - }); - - describe('removePollingToken', () => { - it('removes a pollingToken for a given environmentType', () => { - const pollingTokenType = 'popupGasPollTokens'; - appStateController.addPollingToken('token1', pollingTokenType); - appStateController.removePollingToken('token1', pollingTokenType); - expect( - appStateController.store.getState()[pollingTokenType], - ).not.toContain('token1'); - }); - }); - - describe('clearPollingTokens', () => { - it('clears all pollingTokens', () => { - appStateController.addPollingToken('token1', 'popupGasPollTokens'); - appStateController.addPollingToken('token2', 'notificationGasPollTokens'); - appStateController.addPollingToken('token3', 'fullScreenGasPollTokens'); - appStateController.clearPollingTokens(); - - expect( - appStateController.store.getState().popupGasPollTokens, - ).toStrictEqual([]); - expect( - appStateController.store.getState().notificationGasPollTokens, - ).toStrictEqual([]); - expect( - appStateController.store.getState().fullScreenGasPollTokens, - ).toStrictEqual([]); - }); - }); - - describe('setShowTestnetMessageInDropdown', () => { - it('sets whether the testnet dismissal link should be shown in the network dropdown', () => { - appStateController.setShowTestnetMessageInDropdown(true); - expect( - appStateController.store.getState().showTestnetMessageInDropdown, - ).toBe(true); - - appStateController.setShowTestnetMessageInDropdown(false); - expect( - appStateController.store.getState().showTestnetMessageInDropdown, - ).toBe(false); - }); - }); - - describe('setShowBetaHeader', () => { - it('sets whether the beta notification heading on the home page', () => { - appStateController.setShowBetaHeader(true); - expect(appStateController.store.getState().showBetaHeader).toBe(true); - - appStateController.setShowBetaHeader(false); - expect(appStateController.store.getState().showBetaHeader).toBe(false); - }); - }); - - describe('setCurrentPopupId', () => { - it('sets the currentPopupId in the appState', () => { - const popupId = 'popup1'; - - appStateController.setCurrentPopupId(popupId); - expect(appStateController.store.getState().currentPopupId).toBe(popupId); - }); - }); - - describe('getCurrentPopupId', () => { - it('retrieves the currentPopupId saved in the appState', () => { - const popupId = 'popup1'; - - appStateController.setCurrentPopupId(popupId); - expect(appStateController.getCurrentPopupId()).toBe(popupId); - }); - }); - - describe('setFirstTimeUsedNetwork', () => { - it('updates the array of the first time used networks', () => { - const chainId = '0x1'; - - appStateController.setFirstTimeUsedNetwork(chainId); - expect(appStateController.store.getState().usedNetworks[chainId]).toBe( - true, - ); - }); - }); - - describe('setLastInteractedConfirmationInfo', () => { - it('sets information about last confirmation user has interacted with', () => { - const lastInteractedConfirmationInfo = { - id: '123', - chainId: '0x1', - timestamp: new Date().getTime(), - }; - appStateController.setLastInteractedConfirmationInfo( - lastInteractedConfirmationInfo, - ); - expect(appStateController.getLastInteractedConfirmationInfo()).toBe( - lastInteractedConfirmationInfo, - ); - - appStateController.setLastInteractedConfirmationInfo(undefined); - expect(appStateController.getLastInteractedConfirmationInfo()).toBe( - undefined, - ); - }); - }); - - describe('setSnapsInstallPrivacyWarningShownStatus', () => { - it('updates the status of snaps install privacy warning', () => { - appStateController = createAppStateController(); - const updateStateSpy = jest.spyOn( - appStateController.store, - 'updateState', - ); - - appStateController.setSnapsInstallPrivacyWarningShownStatus(true); - - expect(updateStateSpy).toHaveBeenCalledTimes(1); - expect(updateStateSpy).toHaveBeenCalledWith({ - snapsInstallPrivacyWarningShown: true, - }); - - updateStateSpy.mockRestore(); - }); - }); - - describe('institutional', () => { - it('set the interactive replacement token with a url and the old refresh token', () => { - appStateController = createAppStateController(); - const updateStateSpy = jest.spyOn( - appStateController.store, - 'updateState', - ); - - const mockParams = { url: 'https://example.com', oldRefreshToken: 'old' }; - - appStateController.showInteractiveReplacementTokenBanner(mockParams); - - expect(updateStateSpy).toHaveBeenCalledTimes(1); - expect(updateStateSpy).toHaveBeenCalledWith({ - interactiveReplacementToken: mockParams, - }); - - updateStateSpy.mockRestore(); - }); - - it('set the setCustodianDeepLink with the fromAddress and custodyId', () => { - appStateController = createAppStateController(); - const updateStateSpy = jest.spyOn( - appStateController.store, - 'updateState', - ); - - const mockParams = { fromAddress: '0x', custodyId: 'custodyId' }; - - appStateController.setCustodianDeepLink(mockParams); - - expect(updateStateSpy).toHaveBeenCalledTimes(1); - expect(updateStateSpy).toHaveBeenCalledWith({ - custodianDeepLink: mockParams, - }); - - updateStateSpy.mockRestore(); - }); - - it('set the setNoteToTraderMessage with a message', () => { - appStateController = createAppStateController(); - const updateStateSpy = jest.spyOn( - appStateController.store, - 'updateState', - ); - - const mockParams = 'some message'; - - appStateController.setNoteToTraderMessage(mockParams); - - expect(updateStateSpy).toHaveBeenCalledTimes(1); - expect(updateStateSpy).toHaveBeenCalledWith({ - noteToTraderMessage: mockParams, - }); - - updateStateSpy.mockRestore(); - }); - }); -}); diff --git a/app/scripts/controllers/mmi-controller.test.ts b/app/scripts/controllers/mmi-controller.test.ts index 0c4aa2d5d87..7fb87c6d143 100644 --- a/app/scripts/controllers/mmi-controller.test.ts +++ b/app/scripts/controllers/mmi-controller.test.ts @@ -18,7 +18,7 @@ import { TEST_NETWORK_TICKER_MAP, } from '../../../shared/constants/network'; import MMIController from './mmi-controller'; -import AppStateController from './app-state'; +import { AppStateController } from './app-state-controller'; import { ControllerMessenger } from '@metamask/base-controller'; import { mmiKeyringBuilderFactory } from '../mmi-keyring-builder-factory'; import MetaMetricsController from './metametrics'; @@ -246,14 +246,14 @@ describe('MMIController', function () { initState: {}, onInactiveTimeout: jest.fn(), showUnlockRequest: jest.fn(), - preferencesController: { - state: { + messenger: { + ...mockMessenger, + call: jest.fn().mockReturnValue({ preferences: { autoLockTimeLimit: 0, }, - }, - }, - messenger: mockMessenger, + }) + } }), networkController, permissionController, diff --git a/app/scripts/controllers/mmi-controller.ts b/app/scripts/controllers/mmi-controller.ts index 571c000106b..65cdac69ba0 100644 --- a/app/scripts/controllers/mmi-controller.ts +++ b/app/scripts/controllers/mmi-controller.ts @@ -47,9 +47,9 @@ import { import { getCurrentChainId } from '../../../ui/selectors'; import MetaMetricsController from './metametrics'; import { getPermissionBackgroundApiMethods } from './permissions'; -import { PreferencesController } from './preferences-controller'; import AccountTrackerController from './account-tracker-controller'; -import { AppStateController } from './app-state'; +import { AppStateController } from './app-state-controller'; +import { PreferencesController } from './preferences-controller'; type UpdateCustodianTransactionsParameters = { keyring: CustodyKeyring; diff --git a/app/scripts/lib/ppom/ppom-middleware.ts b/app/scripts/lib/ppom/ppom-middleware.ts index 3b393897b2e..7eb8dc0cc5a 100644 --- a/app/scripts/lib/ppom/ppom-middleware.ts +++ b/app/scripts/lib/ppom/ppom-middleware.ts @@ -12,7 +12,7 @@ import { detectSIWE } from '@metamask/controller-utils'; import { MESSAGE_TYPE } from '../../../../shared/constants/app'; import { SIGNING_METHODS } from '../../../../shared/constants/transaction'; import { PreferencesController } from '../../controllers/preferences-controller'; -import { AppStateController } from '../../controllers/app-state'; +import { AppStateController } from '../../controllers/app-state-controller'; import { LOADING_SECURITY_ALERT_RESPONSE } from '../../../../shared/constants/security-provider'; // eslint-disable-next-line import/no-restricted-paths import { getProviderConfig } from '../../../../ui/ducks/metamask/metamask'; diff --git a/app/scripts/lib/ppom/ppom-util.test.ts b/app/scripts/lib/ppom/ppom-util.test.ts index f6a0d3a1213..ea62c3b8853 100644 --- a/app/scripts/lib/ppom/ppom-util.test.ts +++ b/app/scripts/lib/ppom/ppom-util.test.ts @@ -15,7 +15,7 @@ import { BlockaidResultType, SecurityAlertSource, } from '../../../../shared/constants/security-provider'; -import { AppStateController } from '../../controllers/app-state'; +import { AppStateController } from '../../controllers/app-state-controller'; import { generateSecurityAlertId, isChainSupported, diff --git a/app/scripts/lib/ppom/ppom-util.ts b/app/scripts/lib/ppom/ppom-util.ts index 73999061a91..7662c364b65 100644 --- a/app/scripts/lib/ppom/ppom-util.ts +++ b/app/scripts/lib/ppom/ppom-util.ts @@ -15,7 +15,7 @@ import { SecurityAlertSource, } from '../../../../shared/constants/security-provider'; import { SIGNING_METHODS } from '../../../../shared/constants/transaction'; -import { AppStateController } from '../../controllers/app-state'; +import { AppStateController } from '../../controllers/app-state-controller'; import { SecurityAlertResponse } from './types'; import { getSecurityAlertsAPISupportedChainIds, diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 75a0da28157..fae6eedc2ab 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -297,7 +297,7 @@ import { AccountOrderController } from './controllers/account-order'; import createOnboardingMiddleware from './lib/createOnboardingMiddleware'; import { isStreamWritable, setupMultiplex } from './lib/stream-utils'; import { PreferencesController } from './controllers/preferences-controller'; -import AppStateController from './controllers/app-state'; +import { AppStateController } from './controllers/app-state-controller'; import { AlertController } from './controllers/alert-controller'; import OnboardingController from './controllers/onboarding'; import Backup from './lib/backup'; @@ -846,12 +846,12 @@ export default class MetamaskController extends EventEmitter { isUnlocked: this.isUnlocked.bind(this), initState: initState.AppStateController, onInactiveTimeout: () => this.setLocked(), - preferencesController: this.preferencesController, messenger: this.controllerMessenger.getRestricted({ name: 'AppStateController', allowedActions: [ `${this.approvalController.name}:addRequest`, `${this.approvalController.name}:acceptRequest`, + `PreferencesController:getState`, ], allowedEvents: [ `KeyringController:qrKeyringStateChange`, diff --git a/development/ts-migration-dashboard/files-to-convert.json b/development/ts-migration-dashboard/files-to-convert.json index e21cc6b03a0..5de1f953bb8 100644 --- a/development/ts-migration-dashboard/files-to-convert.json +++ b/development/ts-migration-dashboard/files-to-convert.json @@ -4,7 +4,6 @@ "app/scripts/constants/contracts.js", "app/scripts/constants/on-ramp.js", "app/scripts/contentscript.js", - "app/scripts/controllers/app-state.js", "app/scripts/controllers/cached-balances.js", "app/scripts/controllers/cached-balances.test.js", "app/scripts/controllers/ens/ens.js", diff --git a/shared/constants/mmi-controller.ts b/shared/constants/mmi-controller.ts index a57a1eea210..67be9f72cee 100644 --- a/shared/constants/mmi-controller.ts +++ b/shared/constants/mmi-controller.ts @@ -9,7 +9,7 @@ import { NetworkController } from '@metamask/network-controller'; import { PreferencesController } from '../../app/scripts/controllers/preferences-controller'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths -import { AppStateController } from '../../app/scripts/controllers/app-state'; +import { AppStateController } from '../../app/scripts/controllers/app-state-controller'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import AccountTrackerController from '../../app/scripts/controllers/account-tracker-controller';