diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 18c0c5a3f8e..f52bf2f8b65 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -71,6 +71,7 @@ /packages/eip-5792-middleware @MetaMask/wallet-integrations ## Core Platform Team +/packages/ui-state-controller @MetaMask/core-platform /packages/base-controller @MetaMask/core-platform /packages/build-utils @MetaMask/core-platform /packages/composable-controller @MetaMask/core-platform diff --git a/README.md b/README.md index 1efed86de9d..f091822eac7 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/subscription-controller`](packages/subscription-controller) - [`@metamask/transaction-controller`](packages/transaction-controller) - [`@metamask/transaction-pay-controller`](packages/transaction-pay-controller) +- [`@metamask/ui-state-controller`](packages/ui-state-controller) - [`@metamask/user-operation-controller`](packages/user-operation-controller) @@ -166,6 +167,7 @@ linkStyle default opacity:0.5 subscription_controller(["@metamask/subscription-controller"]); transaction_controller(["@metamask/transaction-controller"]); transaction_pay_controller(["@metamask/transaction-pay-controller"]); + ui_state_controller(["@metamask/ui-state-controller"]); user_operation_controller(["@metamask/user-operation-controller"]); account_tree_controller --> accounts_controller; account_tree_controller --> base_controller; @@ -181,16 +183,29 @@ linkStyle default opacity:0.5 address_book_controller --> base_controller; address_book_controller --> controller_utils; address_book_controller --> messenger; + ai_controllers --> base_controller; + ai_controllers --> messenger; analytics_controller --> base_controller; analytics_controller --> messenger; + analytics_data_regulation_controller --> base_controller; + analytics_data_regulation_controller --> controller_utils; + analytics_data_regulation_controller --> messenger; announcement_controller --> base_controller; announcement_controller --> messenger; app_metadata_controller --> base_controller; app_metadata_controller --> messenger; approval_controller --> base_controller; approval_controller --> messenger; + assets_controller --> account_tree_controller; assets_controller --> base_controller; + assets_controller --> controller_utils; + assets_controller --> core_backend; + assets_controller --> keyring_controller; assets_controller --> messenger; + assets_controller --> network_controller; + assets_controller --> network_enablement_controller; + assets_controller --> permission_controller; + assets_controller --> polling_controller; assets_controllers --> account_tree_controller; assets_controllers --> accounts_controller; assets_controllers --> approval_controller; @@ -201,11 +216,13 @@ linkStyle default opacity:0.5 assets_controllers --> messenger; assets_controllers --> multichain_account_service; assets_controllers --> network_controller; + assets_controllers --> network_enablement_controller; assets_controllers --> permission_controller; assets_controllers --> phishing_controller; assets_controllers --> polling_controller; assets_controllers --> preferences_controller; assets_controllers --> profile_sync_controller; + assets_controllers --> storage_service; assets_controllers --> transaction_controller; base_controller --> messenger; base_controller --> json_rpc_engine; @@ -412,12 +429,11 @@ linkStyle default opacity:0.5 subscription_controller --> polling_controller; subscription_controller --> profile_sync_controller; subscription_controller --> transaction_controller; - token_search_discovery_controller --> base_controller; - token_search_discovery_controller --> messenger; transaction_controller --> accounts_controller; transaction_controller --> approval_controller; transaction_controller --> base_controller; transaction_controller --> controller_utils; + transaction_controller --> core_backend; transaction_controller --> gas_fee_controller; transaction_controller --> messenger; transaction_controller --> network_controller; @@ -434,6 +450,8 @@ linkStyle default opacity:0.5 transaction_pay_controller --> network_controller; transaction_pay_controller --> remote_feature_flag_controller; transaction_pay_controller --> transaction_controller; + ui_state_controller --> base_controller; + ui_state_controller --> messenger; user_operation_controller --> approval_controller; user_operation_controller --> base_controller; user_operation_controller --> controller_utils; diff --git a/packages/ui-state-controller/CHANGELOG.md b/packages/ui-state-controller/CHANGELOG.md new file mode 100644 index 00000000000..81baed155d6 --- /dev/null +++ b/packages/ui-state-controller/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release of `@metamask/ui-state-controller` ([#7808](https://github.com/MetaMask/core/pull/7808)) + - `UiStateController` for managing client (UI) open/closed state (formerly `ClientStateController`) + - `UiStateController:setClientOpen` messenger action for platform code to call + - `UiStateController:stateChange` event for controllers to subscribe to lifecycle changes + - `isUiOpen` state property (not persisted - always starts as `false`) + - `uiStateControllerSelectors.selectIsUiOpen` selector for derived state access + - Full TypeScript support with exported types + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/ui-state-controller/LICENSE b/packages/ui-state-controller/LICENSE new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/ui-state-controller/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/ui-state-controller/README.md b/packages/ui-state-controller/README.md new file mode 100644 index 00000000000..f822945ba70 --- /dev/null +++ b/packages/ui-state-controller/README.md @@ -0,0 +1,151 @@ +# `@metamask/ui-state-controller` + +Provides a centralized way for controllers to respond to application lifecycle changes. + +## Installation + +`yarn add @metamask/ui-state-controller` + +or + +`npm install @metamask/ui-state-controller` + +## Usage + +### Basic Setup + +```typescript +import { Messenger } from '@metamask/messenger'; +import { + UiStateController, + UiStateControllerActions, + UiStateControllerEvents, +} from '@metamask/ui-state-controller'; + +const rootMessenger = new Messenger< + 'Root', + UiStateControllerActions, + UiStateControllerEvents +>({ namespace: 'Root' }); + +const controllerMessenger = new Messenger({ + namespace: 'UiStateController', + parent: rootMessenger, +}); + +const uiStateController = new UiStateController({ + messenger: controllerMessenger, +}); +``` + +### Platform Integration + +Platform code calls `UiStateController:setUiOpen` when the UI is opened or closed: + +```text +onUiOpened() { + controllerMessenger.call('UiStateController:setUiOpen', true); +} + +onUiClosed() { + controllerMessenger.call('UiStateController:setUiOpen', false); +} +``` + +### Consumer controller and using with other lifecycle state (e.g. Keyring unlock/lock) + +Use `UiStateController:stateChange` only for behavior that **must** run when the UI is open or closed (e.g., pausing/resuming a critical background task). **Use the selector** when subscribing so the handler receives a single derived value (e.g. `isUiOpen`), and **prefer pause/resume** over stop/start for polling. + +UI open/close alone is usually not enough to decide when to start or stop work. Combine `UiStateController:stateChange` with other lifecycle events, such as **KeyringController:unlock** / **KeyringController:lock** (or any controller that expresses "ready for background work"). Only start subscriptions, polling, or network requests when **both** the UI is open and the keyring (or equivalent) is unlocked; stop or pause when the UI closes **or** the keyring locks. + +#### Important: Usage guidelines and warnings + +**Do not subscribe to updates for all kinds of data as soon as the client opens.** When MetaMask opens, the current screen may not need every type of data. Starting subscriptions, polling, or network requests for everything when `isUiOpen` becomes true can lead to unnecessary network traffic and battery use, requests before onboarding is complete (a recurring source of issues), and poor performance as more features are added. + +**Use this controller responsibly:** + +- Start only the subscriptions, polling, or requests that are **needed for the current screen or flow** +- Do **not** start network-dependent or heavy behavior solely because `UiStateController:stateChange` reported `isUiOpen: true` +- Consider **deferring** non-critical updates until the user has completed onboarding or reached a screen that needs that data +- Prefer starting and stopping per feature or per screen (e.g., when a component mounts that needs the data) rather than globally when the client opens +- **Combine with Keyring unlock/lock:** Only start work when it is appropriate for both UI open state and wallet state (e.g. client open **and** keyring unlocked) +- **Prefer pause/resume over stop/start for polling** so you can resume without full re-initialization. Use the selector when subscribing (see example below). + +```typescript +import { uiStateControllerSelectors } from '@metamask/ui-state-controller'; + +class SomeDataController extends BaseController { + #uiOpen = false; + #keyringUnlocked = false; + + constructor({ messenger }) { + super({ messenger, ... }); + + messenger.subscribe( + 'UiStateController:stateChange', + (isUiOpen) => { + this.#uiOpen = isUiOpen; + this.updateActive(); + }, + uiStateControllerSelectors.selectIsUiOpen, + ); + + messenger.subscribe('KeyringController:unlock', () => { + this.#keyringUnlocked = true; + this.updateActive(); + }); + + messenger.subscribe('KeyringController:lock', () => { + this.#keyringUnlocked = false; + this.updateActive(); + }); + } + + updateActive() { + const shouldRun = this.#uiOpen && this.#keyringUnlocked; + if (shouldRun) { + this.resume(); + } else { + this.pause(); + } + } +} +``` + +Note: `stateChange` emits `[state, patches]`; the selector receives the full payload and returns the value passed to the handler (here, `isUiOpen`). + +## API Reference + +### State + +| Property | Type | Description | +| ---------- | --------- | ------------------------------------------ | +| `isUiOpen` | `boolean` | Whether the client (UI) is currently open. | + +Note: State is not persisted. It always starts as `false`. + +### Actions + +| Action | Parameters | Description | +| ----------------------------- | --------------- | ---------------------------- | +| `UiStateController:getState` | none | Returns current state. | +| `UiStateController:setUiOpen` | `open: boolean` | Sets whether the UI is open. | + +### Events + +| Event | Payload | Description | +| ------------------------------- | ------------------ | ---------------------------- | +| `UiStateController:stateChange` | `[state, patches]` | Standard state change event. | + +### Selectors + +```typescript +import { uiStateControllerSelectors } from '@metamask/ui-state-controller'; + +const state = messenger.call('UiStateController:getState'); +const isOpen = uiStateControllerSelectors.selectIsUiOpen(state); +``` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/ui-state-controller/jest.config.js b/packages/ui-state-controller/jest.config.js new file mode 100644 index 00000000000..9efbc1e7d1f --- /dev/null +++ b/packages/ui-state-controller/jest.config.js @@ -0,0 +1,24 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + displayName, + coveragePathIgnorePatterns: [], + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/ui-state-controller/package.json b/packages/ui-state-controller/package.json new file mode 100644 index 00000000000..a24f38c9801 --- /dev/null +++ b/packages/ui-state-controller/package.json @@ -0,0 +1,72 @@ +{ + "name": "@metamask/ui-state-controller", + "version": "0.0.0", + "description": "Tracks and manages the lifecycle state of MetaMask as an application", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/ui-state-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/ui-state-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/ui-state-controller", + "publish:preview": "yarn npm publish --tag preview", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/base-controller": "^9.0.0", + "@metamask/messenger": "^0.3.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^27.5.2", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "ts-jest": "^27.1.5", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/ui-state-controller/src/UiStateController-method-action-types.ts b/packages/ui-state-controller/src/UiStateController-method-action-types.ts new file mode 100644 index 00000000000..410c4c09269 --- /dev/null +++ b/packages/ui-state-controller/src/UiStateController-method-action-types.ts @@ -0,0 +1,25 @@ +/** + * This file is auto generated by `scripts/generate-method-action-types.ts`. + * Do not edit manually. + */ + +import type { UiStateController } from './UiStateController'; + +/** + * Updates state with whether the MetaMask UI is open. + * + * This method should be called when the user has opened the first window or + * screen containing the MetaMask UI, or closed the last window or screen + * containing the MetaMask UI. + * + * @param open - Whether the MetaMask UI is open. + */ +export type UiStateControllerSetUiOpenAction = { + type: `UiStateController:setUiOpen`; + handler: UiStateController['setUiOpen']; +}; + +/** + * Union of all UiStateController action types. + */ +export type UiStateControllerMethodActions = UiStateControllerSetUiOpenAction; diff --git a/packages/ui-state-controller/src/UiStateController.test.ts b/packages/ui-state-controller/src/UiStateController.test.ts new file mode 100644 index 00000000000..25746e10437 --- /dev/null +++ b/packages/ui-state-controller/src/UiStateController.test.ts @@ -0,0 +1,189 @@ +import { Messenger } from '@metamask/messenger'; + +import { uiStateControllerSelectors } from './selectors'; +import type { + UiStateControllerActions, + UiStateControllerEvents, + UiStateControllerMessenger, +} from './UiStateController'; +import { + UiStateController, + controllerName, + getDefaultUiStateControllerState, +} from './UiStateController'; + +describe('UiStateController', () => { + type RootMessenger = Messenger< + 'Root', + UiStateControllerActions, + UiStateControllerEvents + >; + + /** + * Constructs the root messenger. + * + * @returns The root messenger. + */ + function getRootMessenger(): RootMessenger { + return new Messenger< + 'Root', + UiStateControllerActions, + UiStateControllerEvents + >({ namespace: 'Root' }); + } + + /** + * Constructs the messenger for the UiStateController. + * + * @param rootMessenger - The root messenger. + * @returns The controller-specific messenger. + */ + function getMessenger( + rootMessenger: RootMessenger, + ): UiStateControllerMessenger { + return new Messenger< + typeof controllerName, + UiStateControllerActions, + UiStateControllerEvents, + RootMessenger + >({ + namespace: controllerName, + parent: rootMessenger, + }); + } + + type WithControllerCallback = (payload: { + controller: UiStateController; + rootMessenger: RootMessenger; + messenger: UiStateControllerMessenger; + }) => Promise | ReturnValue; + + type WithControllerOptions = { + options: Partial[0]>; + }; + + /** + * Wraps tests for the controller by creating the controller and messengers, + * then calling the test function with them. + * + * @param args - Either a callback, or an options bag + a callback. The + * options bag contains arguments for the controller constructor. The + * callback is called with the new controller, root messenger, and + * controller messenger. + * @returns The return value of the callback. + */ + async function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] + ): Promise { + const [{ options = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + const rootMessenger = getRootMessenger(); + const messenger = getMessenger(rootMessenger); + const controller = new UiStateController({ + messenger, + ...options, + }); + return await testFunction({ controller, rootMessenger, messenger }); + } + + describe('constructor', () => { + it('initializes with default state (client closed)', async () => { + await withController(({ controller }) => { + expect(controller.state).toMatchInlineSnapshot(` + Object { + "isUiOpen": false, + } + `); + }); + }); + + it('allows initializing with partial state', async () => { + const givenState = { isUiOpen: true }; + await withController( + { options: { state: givenState } }, + ({ controller }) => { + expect(controller.state).toStrictEqual(givenState); + }, + ); + }); + + it('merges partial state with defaults', async () => { + await withController({ options: { state: {} } }, ({ controller }) => { + expect(controller.state).toMatchInlineSnapshot(` + Object { + "isUiOpen": false, + } + `); + }); + }); + }); + + describe('setUiOpen', () => { + it('updates isUiOpen in state to the given value', async () => { + await withController(({ controller }) => { + controller.setUiOpen(true); + + expect(controller.state).toMatchInlineSnapshot(` + Object { + "isUiOpen": true, + } + `); + + controller.setUiOpen(false); + + expect(controller.state).toMatchInlineSnapshot(` + Object { + "isUiOpen": false, + } + `); + }); + }); + }); + + describe('messenger actions', () => { + it('allows setting client open via messenger action', async () => { + await withController(({ controller, messenger }) => { + messenger.call(`${controllerName}:setUiOpen`, true); + expect(controller.state).toStrictEqual({ isUiOpen: true }); + }); + }); + + it('allows setting client closed via messenger action', async () => { + await withController(({ controller, messenger }) => { + controller.setUiOpen(true); + messenger.call(`${controllerName}:setUiOpen`, false); + expect(controller.state).toStrictEqual({ isUiOpen: false }); + }); + }); + }); + + describe('getDefaultUiStateControllerState', () => { + it('returns default state with client closed', () => { + const defaultState = getDefaultUiStateControllerState(); + + expect(defaultState.isUiOpen).toBe(false); + }); + }); + + describe('selectors', () => { + describe('selectIsUiOpen', () => { + it('returns true when client is open', () => { + expect( + uiStateControllerSelectors.selectIsUiOpen({ + isUiOpen: true, + }), + ).toBe(true); + }); + + it('returns false when client is closed', () => { + expect( + uiStateControllerSelectors.selectIsUiOpen({ + isUiOpen: false, + }), + ).toBe(false); + }); + }); + }); +}); diff --git a/packages/ui-state-controller/src/UiStateController.ts b/packages/ui-state-controller/src/UiStateController.ts new file mode 100644 index 00000000000..43e726b3ab5 --- /dev/null +++ b/packages/ui-state-controller/src/UiStateController.ts @@ -0,0 +1,214 @@ +import type { + StateMetadata, + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; + +import type { UiStateControllerMethodActions } from './UiStateController-method-action-types'; + +// === GENERAL === + +/** + * The name of the {@link UiStateController}. + */ +export const controllerName = 'UiStateController'; + +// === STATE === + +/** + * Describes the shape of the state object for {@link UiStateController}. + */ +export type UiStateControllerState = { + /** + * Whether the user has opened at least one window or screen + * containing the MetaMask UI. These windows or screens may or + * may not be in an inactive state. + */ + isUiOpen: boolean; +}; + +/** + * Constructs the default {@link UiStateController} state. + * + * @returns The default {@link UiStateController} state. + */ +export function getDefaultUiStateControllerState(): UiStateControllerState { + return { + isUiOpen: false, + }; +} + +/** + * The metadata for each property in {@link UiStateControllerState}. + */ +const controllerMetadata = { + isUiOpen: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: false, + }, +} satisfies StateMetadata; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = ['setUiOpen'] as const; + +/** + * Retrieves the state of the {@link UiStateController}. + */ +export type UiStateControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + UiStateControllerState +>; + +/** + * Actions that {@link UiStateController} exposes. + */ +export type UiStateControllerActions = + | UiStateControllerGetStateAction + | UiStateControllerMethodActions; + +/** + * Actions from other messengers that {@link UiStateController} calls. + */ +type AllowedActions = never; + +/** + * Published when the state of {@link UiStateController} changes. + */ +export type UiStateControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + UiStateControllerState +>; + +/** + * Events that {@link UiStateController} exposes. + */ +export type UiStateControllerEvents = UiStateControllerStateChangeEvent; + +/** + * Events from other messengers that {@link UiStateController} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger for {@link UiStateController}. + */ +export type UiStateControllerMessenger = Messenger< + typeof controllerName, + UiStateControllerActions | AllowedActions, + UiStateControllerEvents | AllowedEvents +>; + +// === CONTROLLER DEFINITION === + +/** + * The options for constructing a {@link UiStateController}. + */ +export type UiStateControllerOptions = { + /** + * The messenger suited for this controller. + */ + messenger: UiStateControllerMessenger; + /** + * The initial state to set on this controller. + */ + state?: Partial; +}; + +/** + * `UiStateController` manages the application lifecycle state. + * + * This controller tracks whether the MetaMask UI is open and publishes state + * change events that other controllers can subscribe to for adjusting their behavior. + * + * **Use cases:** + * - Polling controllers can pause when the UI closes, resume when it opens + * - WebSocket connections can disconnect when closed, reconnect when opened + * - Real-time subscriptions can pause when not visible + * + * **Platform Integration:** + * Platform code should call `UiStateController:setUiOpen` via messenger. + * + * @example + * ```typescript + * // In MetamaskController or platform code + * onUiOpened() { + * // ... + * this.controllerMessenger.call('UiStateController:setUiOpen', true); + * } + * + * onUiClosed() { + * // ... + * this.controllerMessenger.call('UiStateController:setUiOpen', false); + * } + * + * // Consumer controller subscribing to state changes + * class MyController extends BaseController { + * constructor({ messenger }) { + * super({ messenger, ... }); + * + * messenger.subscribe( + * 'UiStateController:stateChange', + * (isClientOpen) => { + * if (isClientOpen) { + * this.resumePolling(); + * } else { + * this.pausePolling(); + * } + * }, + * uiStateControllerSelectors.selectIsUiOpen, + * ); + * } + * } + * ``` + */ +export class UiStateController extends BaseController< + typeof controllerName, + UiStateControllerState, + UiStateControllerMessenger +> { + /** + * Constructs a new {@link UiStateController}. + * + * @param options - The constructor options. + * @param options.messenger - The messenger suited for this controller. + * @param options.state - The initial state to set on this controller. + */ + constructor({ messenger, state = {} }: UiStateControllerOptions) { + super({ + messenger, + metadata: controllerMetadata, + name: controllerName, + state: { + ...getDefaultUiStateControllerState(), + ...state, + }, + }); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Updates state with whether the MetaMask UI is open. + * + * This method should be called when the user has opened the first window or + * screen containing the MetaMask UI, or closed the last window or screen + * containing the MetaMask UI. + * + * @param open - Whether the MetaMask UI is open. + */ + setUiOpen(open: boolean): void { + if (this.state.isUiOpen !== open) { + this.update((state) => { + state.isUiOpen = open; + }); + } + } +} diff --git a/packages/ui-state-controller/src/index.ts b/packages/ui-state-controller/src/index.ts new file mode 100644 index 00000000000..47edf6739c0 --- /dev/null +++ b/packages/ui-state-controller/src/index.ts @@ -0,0 +1,19 @@ +export { + UiStateController, + getDefaultUiStateControllerState, +} from './UiStateController'; +export { uiStateControllerSelectors } from './selectors'; + +export type { + UiStateControllerState, + UiStateControllerOptions, + UiStateControllerGetStateAction, + UiStateControllerActions, + UiStateControllerStateChangeEvent, + UiStateControllerEvents, + UiStateControllerMessenger, +} from './UiStateController'; +export type { + UiStateControllerSetUiOpenAction, + UiStateControllerMethodActions, +} from './UiStateController-method-action-types'; diff --git a/packages/ui-state-controller/src/selectors.ts b/packages/ui-state-controller/src/selectors.ts new file mode 100644 index 00000000000..d23ce53d229 --- /dev/null +++ b/packages/ui-state-controller/src/selectors.ts @@ -0,0 +1,18 @@ +import type { UiStateControllerState } from './UiStateController'; + +/** + * Selects whether the UI is currently open. + * + * @param state - The UiStateController state. + * @returns True if the UI is open. + */ +const selectIsUiOpen = (state: UiStateControllerState): boolean => + state.isUiOpen; + +/** + * Selectors for the UiStateController state. + * These can be used with Redux or directly with controller state. + */ +export const uiStateControllerSelectors = { + selectIsUiOpen, +}; diff --git a/packages/ui-state-controller/tsconfig.build.json b/packages/ui-state-controller/tsconfig.build.json new file mode 100644 index 00000000000..931c4d6594b --- /dev/null +++ b/packages/ui-state-controller/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/ui-state-controller/tsconfig.json b/packages/ui-state-controller/tsconfig.json new file mode 100644 index 00000000000..3184a4bfde9 --- /dev/null +++ b/packages/ui-state-controller/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { "baseUrl": "." }, + "include": ["src"] +} diff --git a/packages/ui-state-controller/typedoc.json b/packages/ui-state-controller/typedoc.json new file mode 100644 index 00000000000..d02905868c6 --- /dev/null +++ b/packages/ui-state-controller/typedoc.json @@ -0,0 +1,8 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "plugin": ["typedoc-plugin-missing-exports"], + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index 9e0811957fe..f2d2d29773a 100644 --- a/teams.json +++ b/teams.json @@ -35,6 +35,7 @@ "metamask/multichain-api-middleware": "team-wallet-integrations", "metamask/selected-network-controller": "team-wallet-integrations", "metamask/eip-5792-middleware": "team-wallet-integrations", + "metamask/ui-state-controller": "team-core-platform", "metamask/base-controller": "team-core-platform", "metamask/build-utils": "team-core-platform", "metamask/composable-controller": "team-core-platform", diff --git a/tsconfig.build.json b/tsconfig.build.json index 8fa8236e1b1..22da88fba9d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -58,6 +58,9 @@ { "path": "./packages/claims-controller/tsconfig.build.json" }, + { + "path": "./packages/ui-state-controller/tsconfig.build.json" + }, { "path": "./packages/composable-controller/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 43acf48fbd2..904d3a13f9e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -59,6 +59,9 @@ { "path": "./packages/claims-controller" }, + { + "path": "./packages/ui-state-controller" + }, { "path": "./packages/composable-controller" }, diff --git a/yarn.lock b/yarn.lock index 30431d1148e..13b4a737ac2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5228,6 +5228,24 @@ __metadata: languageName: unknown linkType: soft +"@metamask/ui-state-controller@workspace:packages/ui-state-controller": + version: 0.0.0-use.local + resolution: "@metamask/ui-state-controller@workspace:packages/ui-state-controller" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^27.5.2" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + ts-jest: "npm:^27.1.5" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/user-operation-controller@workspace:packages/user-operation-controller": version: 0.0.0-use.local resolution: "@metamask/user-operation-controller@workspace:packages/user-operation-controller"