/* 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 Please see LICENSE files in the repository root for full details. */ import React from "react"; import ReactDOM from "react-dom"; import classNames from "classnames"; import { IDeferred, defer, sleep } from "matrix-js-sdk/src/utils"; import { TypedEventEmitter } from "matrix-js-sdk/src/matrix"; import { Glass } from "@vector-im/compound-web"; import dis, { defaultDispatcher } from "./dispatcher/dispatcher"; import AsyncWrapper from "./AsyncWrapper"; import { Defaultize } from "./@types/common"; import { ActionPayload } from "./dispatcher/payloads"; 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; // Generic type which returns the props of the Modal component with the onFinished being optional. export type ComponentProps = Defaultize< Omit, "onFinished">, C["defaultProps"] > & Partial, "onFinished">>; export interface IModal { elem: React.ReactNode; className?: string; beforeClosePromise?: Promise; closeReason?: ModalCloseReason; onBeforeClose?(reason?: ModalCloseReason): Promise; onFinished: ComponentProps["onFinished"]; close(...args: Parameters["onFinished"]>): void; hidden?: boolean; deferred?: IDeferred["onFinished"]>>; } export interface IHandle { finished: Promise["onFinished"]>>; close(...args: Parameters["onFinished"]>): void; } interface IOptions { onBeforeClose?: IModal["onBeforeClose"]; } export enum ModalManagerEvent { Opened = "opened", Closed = "closed", } type HandlerMap = { [ModalManagerEvent.Opened]: () => void; [ModalManagerEvent.Closed]: () => void; }; type ModalCloseReason = "backgroundClick"; 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 getOrCreateContainer(): HTMLElement { let container = document.getElementById(DIALOG_CONTAINER_ID); if (!container) { container = document.createElement("div"); container.id = DIALOG_CONTAINER_ID; document.body.appendChild(container); } return container; } private static getOrCreateStaticContainer(): HTMLElement { let container = document.getElementById(STATIC_DIALOG_CONTAINER_ID); if (!container) { container = document.createElement("div"); container.id = STATIC_DIALOG_CONTAINER_ID; document.body.appendChild(container); } return container; } 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; } public createDialog( Element: C, props?: ComponentProps, className?: string, isPriorityModal = false, isStaticModal = false, options: IOptions = {}, ): IHandle { return this.createDialogAsync( Promise.resolve(Element), props, className, isPriorityModal, isStaticModal, options, ); } public appendDialog( Element: C, props?: ComponentProps, className?: string, ): IHandle { return this.appendDialogAsync(Promise.resolve(Element), props, className); } /** * 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 { for (const modal of this.modals) { modal.deferred?.resolve([]); if (modal.onFinished) modal.onFinished.apply(null); this.emitClosed(); } this.modals = []; this.reRender(); } private buildModal( prom: Promise, props?: ComponentProps, className?: string, options?: IOptions, ): { modal: IModal; closeDialog: IHandle["close"]; onFinishedProm: IHandle["finished"]; } { const modal = { onFinished: props?.onFinished, 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; // never call this from onFinished() otherwise it will loop const [closeDialog, onFinishedProm] = this.getCloseFn(modal, props); // don't attempt to reuse the same AsyncWrapper for different dialogs, // otherwise we'll get confused. const modalCount = this.counter++; // FIXME: If a dialog uses getDefaultProps it clobbers the onFinished // property set here so you can't close the dialog from a button click! modal.elem = ; modal.close = closeDialog; return { modal, closeDialog, onFinishedProm }; } private getCloseFn( modal: IModal, props?: ComponentProps, ): [IHandle["close"], IHandle["finished"]] { modal.deferred = defer["onFinished"]>>(); return [ async (...args: Parameters["onFinished"]>): 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); if (props?.onFinished) props.onFinished.apply(null, 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 {Promise} prom a promise which resolves with a React component * which will be displayed as the modal view. * * @param {Object} props properties to pass to the displayed * component. (We will also pass an 'onFinished' property.) * * @param {String} className CSS class to apply to the modal wrapper * * @param {boolean} 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 {boolean} 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 {Object} options? extra options for the dialog * @param {onBeforeClose} options.onBeforeClose a callback to decide whether to close the dialog * @returns {object} Object with 'close' parameter being a function that will close the dialog */ public createDialogAsync( prom: Promise, props?: ComponentProps, className?: string, isPriorityModal = false, isStaticModal = false, options: IOptions = {}, ): IHandle { const beforeModal = this.getCurrentModal(); const { modal, closeDialog, onFinishedProm } = this.buildModal(prom, 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, }; } private appendDialogAsync( prom: Promise, props?: ComponentProps, className?: string, ): IHandle { const beforeModal = this.getCurrentModal(); const { modal, closeDialog, onFinishedProm } = this.buildModal(prom, 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 { // TODO: We should figure out how to remove this weird sleep. It also makes testing harder // // await next tick because sometimes ReactDOM can race with itself and cause the modal to wrongly stick around await sleep(0); 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 dis.dispatch({ action: "aria_unhide_main_app", }); ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateContainer()); ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateStaticContainer()); 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 dis.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}
); ReactDOM.render(staticDialog, ModalManager.getOrCreateStaticContainer()); } else { // This is safe to call repeatedly if we happen to do that ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateStaticContainer()); } 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}
); setTimeout(() => ReactDOM.render(dialog, ModalManager.getOrCreateContainer()), 0); } else { // This is safe to call repeatedly if we happen to do that ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateContainer()); } } } if (!window.singletonModalManager) { window.singletonModalManager = new ModalManager(); } export default window.singletonModalManager;