From b868617ba3a7dc736f28bc55cfb246aecd69d7e1 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Sun, 12 Jul 2020 21:13:28 +0100 Subject: [PATCH] Convert Modal to TypeScript Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/@types/global.d.ts | 2 + src/{Modal.js => Modal.tsx} | 251 ++++++++++++++++++++++-------------- 2 files changed, 157 insertions(+), 96 deletions(-) rename src/{Modal.js => Modal.tsx} (52%) diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 3f970ea8c3..63c2c54138 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -22,6 +22,7 @@ import DeviceListener from "../DeviceListener"; import { RoomListStore2 } from "../stores/room-list/RoomListStore2"; import { PlatformPeg } from "../PlatformPeg"; import RoomListLayoutStore from "../stores/room-list/RoomListLayoutStore"; +import {ModalManager} from "../Modal"; declare global { interface Window { @@ -37,6 +38,7 @@ declare global { mx_RoomListStore2: RoomListStore2; mx_RoomListLayoutStore: RoomListLayoutStore; mxPlatformPeg: PlatformPeg; + singletonModalManager: ModalManager; // TODO: Remove flag before launch: https://github.com/vector-im/riot-web/issues/14231 mx_QuietRoomListLogging: boolean; diff --git a/src/Modal.js b/src/Modal.tsx similarity index 52% rename from src/Modal.js rename to src/Modal.tsx index 9b9f190d58..3243867555 100644 --- a/src/Modal.js +++ b/src/Modal.tsx @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,6 +18,8 @@ limitations under the License. import React from 'react'; import ReactDOM from 'react-dom'; +import classNames from 'classnames'; + import Analytics from './Analytics'; import dis from './dispatcher/dispatcher'; import {defer} from './utils/promise'; @@ -25,36 +28,52 @@ import AsyncWrapper from './AsyncWrapper'; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer"; -class ModalManager { - constructor() { - this._counter = 0; +interface IModal { + elem: React.ReactNode; + className?: string; + beforeClosePromise?: Promise; + closeReason?: string; + onBeforeClose?(reason?: string): Promise; + onFinished(...args: T): void; + close(...args: T): void; +} - // 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. - this._priorityModal = 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. - this._staticModal = 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. - this._modals = [ - /* { - elem: React component for this dialog - onFinished: caller-supplied onFinished callback - className: CSS class for the dialog wrapper div - } */ - ]; +interface IHandle { + finished: Promise; + close(...args: T): void; +} - this.onBackgroundClick = this.onBackgroundClick.bind(this); - } +interface IProps { + onFinished(...args: T): void; +} - hasDialogs() { - return this._priorityModal || this._staticModal || this._modals.length > 0; - } +interface IOptions { + onBeforeClose?: IModal["onBeforeClose"]; +} - getOrCreateContainer() { +type ParametersWithoutFirst any> = T extends (a: any, ...args: infer P) => any ? P : never; + +export class ModalManager { + 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; + // 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; + // 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[] = [ + /* { + elem: React component for this dialog + onFinished: caller-supplied onFinished callback + className: CSS class for the dialog wrapper div + } */ + ]; + + private static getOrCreateContainer() { let container = document.getElementById(DIALOG_CONTAINER_ID); if (!container) { @@ -66,7 +85,7 @@ class ModalManager { return container; } - getOrCreateStaticContainer() { + private static getOrCreateStaticContainer() { let container = document.getElementById(STATIC_DIALOG_CONTAINER_ID); if (!container) { @@ -78,63 +97,93 @@ class ModalManager { return container; } - createTrackedDialog(analyticsAction, analyticsInfo, ...rest) { + public hasDialogs() { + return this.priorityModal || this.staticModal || this.modals.length > 0; + } + + public createTrackedDialog( + analyticsAction: string, + analyticsInfo: string, + ...rest: Parameters + ) { Analytics.trackEvent('Modal', analyticsAction, analyticsInfo); return this.createDialog(...rest); } - appendTrackedDialog(analyticsAction, analyticsInfo, ...rest) { + public appendTrackedDialog( + analyticsAction: string, + analyticsInfo: string, + ...rest: Parameters + ) { Analytics.trackEvent('Modal', analyticsAction, analyticsInfo); return this.appendDialog(...rest); } - createDialog(Element, ...rest) { + public createDialog(Element: React.ComponentType, ...rest: ParametersWithoutFirst) { return this.createDialogAsync(Promise.resolve(Element), ...rest); } - appendDialog(Element, ...rest) { + public appendDialog(Element: React.ComponentType, ...rest: ParametersWithoutFirst) { return this.appendDialogAsync(Promise.resolve(Element), ...rest); } - createTrackedDialogAsync(analyticsAction, analyticsInfo, ...rest) { + public createTrackedDialogAsync( + analyticsAction: string, + analyticsInfo: string, + ...rest: Parameters + ) { Analytics.trackEvent('Modal', analyticsAction, analyticsInfo); return this.createDialogAsync(...rest); } - appendTrackedDialogAsync(analyticsAction, analyticsInfo, ...rest) { + public appendTrackedDialogAsync( + analyticsAction: string, + analyticsInfo: string, + ...rest: Parameters + ) { Analytics.trackEvent('Modal', analyticsAction, analyticsInfo); return this.appendDialogAsync(...rest); } - _buildModal(prom, props, className, options) { - const modal = {}; + private buildModal( + prom: Promise, + props?: IProps, + className?: string, + options?: IOptions + ) { + const modal: IModal = { + onFinished: props ? props.onFinished : null, + onBeforeClose: options.onBeforeClose, + beforeClosePromise: null, + closeReason: null, + className, + + // these will be set below but we need an object reference to pass to getCloseFn before we can do that + elem: null, + close: null, + }; // never call this from onFinished() otherwise it will loop - const [closeDialog, onFinishedProm] = this._getCloseFn(modal, props); + 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++; + 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.onFinished = props ? props.onFinished : null; - modal.className = className; - modal.onBeforeClose = options.onBeforeClose; - modal.beforeClosePromise = null; + modal.elem = ; modal.close = closeDialog; - modal.closeReason = null; return {modal, closeDialog, onFinishedProm}; } - _getCloseFn(modal, props) { - const deferred = defer(); - return [async (...args) => { + private getCloseFn( + modal: IModal, + props: IProps + ): [IHandle["close"], IHandle["finished"]] { + const deferred = defer(); + return [async (...args: T) => { if (modal.beforeClosePromise) { await modal.beforeClosePromise; } else if (modal.onBeforeClose) { @@ -147,26 +196,26 @@ class ModalManager { } deferred.resolve(args); if (props && props.onFinished) props.onFinished.apply(null, args); - const i = this._modals.indexOf(modal); + const i = this.modals.indexOf(modal); if (i >= 0) { - this._modals.splice(i, 1); + this.modals.splice(i, 1); } - if (this._priorityModal === modal) { - this._priorityModal = null; + if (this.priorityModal === modal) { + this.priorityModal = null; // XXX: This is destructive - this._modals = []; + this.modals = []; } - if (this._staticModal === modal) { - this._staticModal = null; + if (this.staticModal === modal) { + this.staticModal = null; // XXX: This is destructive - this._modals = []; + this.modals = []; } - this._reRender(); + this.reRender(); }, deferred.promise]; } @@ -207,38 +256,49 @@ class ModalManager { * @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 */ - createDialogAsync(prom, props, className, isPriorityModal, isStaticModal, options = {}) { - const {modal, closeDialog, onFinishedProm} = this._buildModal(prom, props, className, options); + private createDialogAsync( + prom: Promise, + props?: IProps & React.ComponentProps, + className?: string, + isPriorityModal = false, + isStaticModal = false, + options: IOptions = {} + ): IHandle { + const {modal, closeDialog, onFinishedProm} = this.buildModal(prom, props, className, options); if (isPriorityModal) { // XXX: This is destructive - this._priorityModal = modal; + this.priorityModal = modal; } else if (isStaticModal) { // This is intentionally destructive - this._staticModal = modal; + this.staticModal = modal; } else { - this._modals.unshift(modal); + this.modals.unshift(modal); } - this._reRender(); + this.reRender(); return { close: closeDialog, finished: onFinishedProm, }; } - appendDialogAsync(prom, props, className) { - const {modal, closeDialog, onFinishedProm} = this._buildModal(prom, props, className, {}); + private appendDialogAsync( + prom: Promise, + props?: IProps, + className?: string + ): IHandle { + const {modal, closeDialog, onFinishedProm} = this.buildModal(prom, props, className, {}); - this._modals.push(modal); - this._reRender(); + this.modals.push(modal); + this.reRender(); return { close: closeDialog, finished: onFinishedProm, }; } - onBackgroundClick() { - const modal = this._getCurrentModal(); + private onBackgroundClick = () => { + const modal = this.getCurrentModal(); if (!modal) { return; } @@ -249,21 +309,21 @@ class ModalManager { modal.closeReason = "backgroundClick"; modal.close(); modal.closeReason = null; + }; + + private getCurrentModal(): IModal { + return this.priorityModal ? this.priorityModal : (this.modals[0] || this.staticModal); } - _getCurrentModal() { - return this._priorityModal ? this._priorityModal : (this._modals[0] || this._staticModal); - } - - _reRender() { - if (this._modals.length === 0 && !this._priorityModal && !this._staticModal) { + private reRender() { + if (this.modals.length === 0 && !this.priorityModal && !this.staticModal) { // If there is no modal to render, make all of Riot available // to screen reader users again dis.dispatch({ action: 'aria_unhide_main_app', }); - ReactDOM.unmountComponentAtNode(this.getOrCreateContainer()); - ReactDOM.unmountComponentAtNode(this.getOrCreateStaticContainer()); + ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateContainer()); + ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateStaticContainer()); return; } @@ -274,49 +334,48 @@ class ModalManager { action: 'aria_hide_main_app', }); - if (this._staticModal) { - const classes = "mx_Dialog_wrapper mx_Dialog_staticWrapper " - + (this._staticModal.className ? this._staticModal.className : ''); + if (this.staticModal) { + const classes = classNames("mx_Dialog_wrapper mx_Dialog_staticWrapper", this.staticModal.className); const staticDialog = (
- { this._staticModal.elem } + { this.staticModal.elem }
-
+
); - ReactDOM.render(staticDialog, this.getOrCreateStaticContainer()); + ReactDOM.render(staticDialog, ModalManager.getOrCreateStaticContainer()); } else { // This is safe to call repeatedly if we happen to do that - ReactDOM.unmountComponentAtNode(this.getOrCreateStaticContainer()); + ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateStaticContainer()); } - const modal = this._getCurrentModal(); - if (modal !== this._staticModal) { - const classes = "mx_Dialog_wrapper " - + (this._staticModal ? "mx_Dialog_wrapperWithStaticUnder " : '') - + (modal.className ? modal.className : ''); + const modal = this.getCurrentModal(); + if (modal !== this.staticModal) { + const classes = classNames("mx_Dialog_wrapper", modal.className, { + mx_Dialog_wrapperWithStaticUnder: this.staticModal, + }); const dialog = (
{modal.elem}
-
+
); - ReactDOM.render(dialog, this.getOrCreateContainer()); + ReactDOM.render(dialog, ModalManager.getOrCreateContainer()); } else { // This is safe to call repeatedly if we happen to do that - ReactDOM.unmountComponentAtNode(this.getOrCreateContainer()); + ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateContainer()); } } } -if (!global.singletonModalManager) { - global.singletonModalManager = new ModalManager(); +if (!window.singletonModalManager) { + window.singletonModalManager = new ModalManager(); } -export default global.singletonModalManager; +export default window.singletonModalManager;