/* Copyright 2024 New Vector Ltd. Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2015, 2016 OpenMarket Ltd SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ import React, { StrictMode } from "react"; import { createRoot, type Root } from "react-dom/client"; import classNames from "classnames"; import { TypedEventEmitter } from "matrix-js-sdk/src/matrix"; import { Glass, TooltipProvider } from "@vector-im/compound-web"; import defaultDispatcher from "./dispatcher/dispatcher"; import AsyncWrapper from "./AsyncWrapper"; import { type Defaultize } from "./@types/common"; import { type ActionPayload } from "./dispatcher/payloads"; import { filterBoolean } from "./utils/arrays.ts"; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer"; // Type which accepts a React Component which looks like a Modal (accepts an onFinished prop) export type ComponentType = | React.ComponentType<{ onFinished(...args: any): void; }> | React.ComponentType; /** * The parameter types of the `onFinished` callback property exposed by the component which forms the * body of the dialog. * * @typeParam C - The type of the React component which forms the body of the dialog. */ type OnFinishedParams = Parameters["onFinished"]>; /** * The properties exposed by the `props` argument to {@link Modal.createDialog}: the same as * those exposed by the underlying component, with the exception of `onFinished`, which is provided by * `createDialog`. * * @typeParam C - The type of the React component which forms the body of the dialog. */ export type ComponentProps = Defaultize< Omit, "onFinished">, C["defaultProps"] >; export interface IModal { elem: React.ReactNode; className?: string; beforeClosePromise?: Promise; closeReason?: ModalCloseReason; onBeforeClose?(reason?: ModalCloseReason): Promise; /** * Run the {@link deferred} with the given arguments, and close this modal. * * This method is passed as the `onFinished` callback to the underlying component, * as well as being returned by {@link Modal.createDialog} to the caller. */ close(...args: OnFinishedParams | []): void; hidden?: boolean; /** A deferred to resolve when the dialog closes, with the results as provided by * the call to {@link close} (normally from the `onFinished` callback). */ deferred?: PromiseWithResolvers | []>; } /** The result of {@link Modal.createDialog}. * * @typeParam C - The type of the React component which forms the body of the dialog. */ export interface IHandle { /** * A promise which will resolve when the dialog closes. * * If the dialog body component calls the `onFinished` property, or the caller calls {@link close}, * the promise resolves with an array holding the arguments to that call. * * If the dialog is closed by clicking in the background, the promise resolves with an empty array. */ finished: Promise | []>; /** * A function which, if called, will close the dialog. * * @param args - Arguments to return to {@link finished}. */ close(...args: OnFinishedParams): void; } interface IOptions { onBeforeClose?: IModal["onBeforeClose"]; } export enum ModalManagerEvent { Opened = "opened", Closed = "closed", } type HandlerMap = { [ModalManagerEvent.Opened]: () => void; [ModalManagerEvent.Closed]: () => void; }; type ModalCloseReason = "backgroundClick"; function getOrCreateContainer(id: string): HTMLDivElement { let container = document.getElementById(id) as HTMLDivElement | null; if (!container) { container = document.createElement("div"); container.id = id; document.body.appendChild(container); } return container; } export class ModalManager extends TypedEventEmitter { private counter = 0; // The modal to prioritise over all others. If this is set, only show // this modal. Remove all other modals from the stack when this modal // is closed. private priorityModal: IModal | null = null; // The modal to keep open underneath other modals if possible. Useful // for cases like Settings where the modal should remain open while the // user is prompted for more information/errors. private staticModal: IModal | null = null; // A list of the modals we have stacked up, with the most recent at [0] // Neither the static nor priority modal will be in this list. private modals: IModal[] = []; private static root?: Root; private static getOrCreateRoot(): Root { if (!ModalManager.root) { const container = getOrCreateContainer(DIALOG_CONTAINER_ID); ModalManager.root = createRoot(container); } return ModalManager.root; } private static staticRoot?: Root; private static getOrCreateStaticRoot(): Root { if (!ModalManager.staticRoot) { const container = getOrCreateContainer(STATIC_DIALOG_CONTAINER_ID); ModalManager.staticRoot = createRoot(container); } return ModalManager.staticRoot; } public constructor() { super(); // We never unregister this, but the Modal class is a singleton so there would // never be an opportunity to do so anyway, except in the entirely theoretical // scenario of instantiating a non-singleton instance of the Modal class. defaultDispatcher.register(this.onAction); } private onAction = (payload: ActionPayload): void => { if (payload.action === "logout") { this.forceCloseAllModals(); } }; public toggleCurrentDialogVisibility(): void { const modal = this.getCurrentModal(); if (!modal) return; modal.hidden = !modal.hidden; } public hasDialogs(): boolean { return !!this.priorityModal || !!this.staticModal || this.modals.length > 0; } /** * DEPRECATED. * This is used only for tests. They should be using forceCloseAllModals but that * caused a chunk of tests to fail, so for now they continue to use this. * * @param reason either "backgroundClick" or undefined * @return whether a modal was closed */ public closeCurrentModal(reason?: ModalCloseReason): boolean { const modal = this.getCurrentModal(); if (!modal) { return false; } modal.closeReason = reason; modal.close(); return true; } /** * Forces closes all open modals. The modals onBeforeClose function will not be * run and the modal will not have a chance to prevent closing. Intended for * situations like the user logging out of the app. */ public forceCloseAllModals(): void { const modals = filterBoolean([...this.modals, this.staticModal, this.priorityModal]); for (const modal of modals) { modal.deferred?.resolve([]); this.emitClosed(); } this.modals = []; this.staticModal = null; this.priorityModal = null; this.reRender(); } /** * @typeParam C - the component type */ private buildModal( Component: C, props?: ComponentProps, className?: string, options?: IOptions, ): { modal: IModal; closeDialog: IHandle["close"]; onFinishedProm: IHandle["finished"]; } { const modal = { onBeforeClose: options?.onBeforeClose, className, // these will be set below but we need an object reference to pass to getCloseFn before we can do that elem: null, } as IModal; const [closeDialog, onFinishedProm] = this.getCloseFn(modal); // don't attempt to reuse the same AsyncWrapper for different dialogs, // otherwise we'll get confused. const modalCount = this.counter++; // Typescript doesn't like us passing props as any here, but we know that they are well typed due to the rigorous generics. modal.elem = ( ); modal.close = closeDialog; return { modal, closeDialog, onFinishedProm }; } private getCloseFn(modal: IModal): [IHandle["close"], IHandle["finished"]] { modal.deferred = Promise.withResolvers | []>(); return [ async (...args: OnFinishedParams): Promise => { if (modal.beforeClosePromise) { await modal.beforeClosePromise; } else if (modal.onBeforeClose) { modal.beforeClosePromise = modal.onBeforeClose(modal.closeReason); const shouldClose = await modal.beforeClosePromise; modal.beforeClosePromise = undefined; if (!shouldClose) { return; } } modal.deferred?.resolve(args); const i = this.modals.indexOf(modal); if (i >= 0) { this.modals.splice(i, 1); } if (this.priorityModal === modal) { this.priorityModal = null; // XXX: This is destructive this.modals = []; } if (this.staticModal === modal) { this.staticModal = null; // XXX: This is destructive this.modals = []; } this.reRender(); this.emitClosed(); }, modal.deferred.promise, ]; } /** * @callback onBeforeClose * @param {string?} reason either "backgroundClick" or null * @return {Promise} whether the dialog should close */ /** * Open a modal view. * * This can be used to display a react component which is loaded as an asynchronous * webpack component. To do this, set 'loader' as: * * (cb) => { * require([''], cb); * } * * @param component The component to render as a dialog. This component must accept an `onFinished` prop function as * per the type {@link ComponentType}. If loading a component with esoteric dependencies consider * using React.lazy to async load the component. * e.g. `lazy(() => import('./MyComponent'))` * * @param props properties to pass to the displayed component. (We will also pass an `onFinished` property; when * called, that property will close the dialog and return the results to the caller via {@link IHandle.finished}.) * * @param className CSS class to apply to the modal wrapper * * @param isPriorityModal if true, this modal will be displayed regardless * of other modals that are currently in the stack. * Also, when closed, all modals will be removed * from the stack. * @param isStaticModal if true, this modal will be displayed under other * modals in the stack. When closed, all modals will * also be removed from the stack. This is not compatible * with being a priority modal. Only one modal can be * static at a time. * @param options? extra options for the dialog * @param options.onBeforeClose a callback to decide whether to close the dialog * @returns {@link IHandle} object. */ public createDialog( component: C, props?: ComponentProps, className?: string, isPriorityModal = false, isStaticModal = false, options: IOptions = {}, ): IHandle { const beforeModal = this.getCurrentModal(); const { modal, closeDialog, onFinishedProm } = this.buildModal(component, props, className, options); if (isPriorityModal) { // XXX: This is destructive this.priorityModal = modal; } else if (isStaticModal) { // This is intentionally destructive this.staticModal = modal; } else { this.modals.unshift(modal); } this.reRender(); this.emitIfChanged(beforeModal); return { close: closeDialog, finished: onFinishedProm, }; } public appendDialog( component: C, props?: ComponentProps, className?: string, ): IHandle { const beforeModal = this.getCurrentModal(); const { modal, closeDialog, onFinishedProm } = this.buildModal(component, props, className, {}); this.modals.push(modal); this.reRender(); this.emitIfChanged(beforeModal); return { close: closeDialog, finished: onFinishedProm, }; } private emitIfChanged(beforeModal?: IModal): void { if (beforeModal !== this.getCurrentModal()) { this.emit(ModalManagerEvent.Opened); } } /** * Emit the closed event * @private */ private emitClosed(): void { this.emit(ModalManagerEvent.Closed); } private onBackgroundClick = (): void => { const modal = this.getCurrentModal(); if (!modal) { return; } // we want to pass a reason to the onBeforeClose // callback, but close is currently defined to // pass all number of arguments to the onFinished callback // so, pass the reason to close through a member variable modal.closeReason = "backgroundClick"; modal.close(); modal.closeReason = undefined; }; private getCurrentModal(): IModal { return this.priorityModal ? this.priorityModal : this.modals[0] || this.staticModal; } private async reRender(): Promise { if (this.modals.length === 0 && !this.priorityModal && !this.staticModal) { // If there is no modal to render, make all of Element available // to screen reader users again defaultDispatcher.dispatch({ action: "aria_unhide_main_app", }); ModalManager.getOrCreateRoot().render(<>); ModalManager.getOrCreateStaticRoot().render(<>); return; } // Hide the content outside the modal to screen reader users // so they won't be able to navigate into it and act on it using // screen reader specific features defaultDispatcher.dispatch({ action: "aria_hide_main_app", }); if (this.staticModal) { const classes = classNames("mx_Dialog_wrapper mx_Dialog_staticWrapper", this.staticModal.className); const staticDialog = (
{this.staticModal.elem}
); ModalManager.getOrCreateStaticRoot().render(staticDialog); } else { // This is safe to call repeatedly if we happen to do that ModalManager.getOrCreateStaticRoot().render(<>); } const modal = this.getCurrentModal(); if (modal !== this.staticModal && !modal.hidden) { const classes = classNames("mx_Dialog_wrapper", modal.className, { mx_Dialog_wrapperWithStaticUnder: this.staticModal, }); const dialog = (
{modal.elem}
); ModalManager.getOrCreateRoot().render(dialog); } else { // This is safe to call repeatedly if we happen to do that ModalManager.getOrCreateRoot().render(<>); } } } if (!window.singletonModalManager) { window.singletonModalManager = new ModalManager(); } export default window.singletonModalManager;