From 0693bc403cfee638ccd93be73ca742660df34918 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 15 Jun 2022 11:10:41 +0100 Subject: [PATCH 1/7] Switch to using for modals --- src/Modal.tsx | 23 ++++--------------- src/components/structures/LoggedInView.tsx | 2 -- src/components/structures/MatrixChat.tsx | 15 ------------ src/components/views/dialogs/BaseDialog.tsx | 18 ++++++--------- .../views/dialogs/ScrollableBaseModal.tsx | 19 ++++++--------- 5 files changed, 19 insertions(+), 58 deletions(-) diff --git a/src/Modal.tsx b/src/Modal.tsx index af9ec22b699..f1348a73452 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -20,7 +20,6 @@ import ReactDOM from 'react-dom'; import classNames from 'classnames'; import { defer, sleep } from "matrix-js-sdk/src/utils"; -import dis from './dispatcher/dispatcher'; import AsyncWrapper from './AsyncWrapper'; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; @@ -300,31 +299,20 @@ export class ModalManager { 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', - }); - + const modal = this.getCurrentModal(); if (this.staticModal) { const classes = classNames("mx_Dialog_wrapper mx_Dialog_staticWrapper", this.staticModal.className); const staticDialog = (
-
+ { this.staticModal.elem } -
+
); @@ -335,7 +323,6 @@ export class ModalManager { 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, @@ -343,9 +330,9 @@ export class ModalManager { const dialog = (
-
+ { modal.elem } -
+
); diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index de60ca71fa1..ce28f6f2044 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -89,7 +89,6 @@ interface IProps { // Called with the credentials of a registered user (if they were a ROU that // transitioned to PWLU) onRegistered: (credentials: IMatrixClientCreds) => Promise; - hideToSRUsers: boolean; resizeNotifier: ResizeNotifier; // eslint-disable-next-line camelcase page_type?: string; @@ -668,7 +667,6 @@ class LoggedInView extends React.Component { onPaste={this.onPaste} onKeyDown={this.onReactKeyDown} className={wrapperClasses} - aria-hidden={this.props.hideToSRUsers} >
diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 072234b1ed0..a960cca2d06 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -192,9 +192,6 @@ interface IState { register_session_id?: string; // eslint-disable-next-line camelcase register_id_sid?: string; - // When showing Modal dialogs we need to set aria-hidden on the root app element - // and disable it when there are no dialogs - hideToSRUsers: boolean; syncError?: MatrixError; resizeNotifier: ResizeNotifier; serverConfig?: ValidatedServerConfig; @@ -240,8 +237,6 @@ export default class MatrixChat extends React.PureComponent { view: Views.LOADING, collapseLhs: false, - hideToSRUsers: false, - syncError: null, // If the current syncing status is ERROR, the error object, otherwise null. resizeNotifier: new ResizeNotifier(), ready: false, @@ -784,16 +779,6 @@ export default class MatrixChat extends React.PureComponent { case 'send_event': this.onSendEvent(payload.room_id, payload.event); break; - case 'aria_hide_main_app': - this.setState({ - hideToSRUsers: true, - }); - break; - case 'aria_unhide_main_app': - this.setState({ - hideToSRUsers: false, - }); - break; case Action.PseudonymousAnalyticsAccept: hideAnalyticsToast(); SettingsStore.setValue("pseudonymousAnalyticsOptIn", null, SettingLevel.ACCOUNT, true); diff --git a/src/components/views/dialogs/BaseDialog.tsx b/src/components/views/dialogs/BaseDialog.tsx index 5eb0c6ac82c..8a21adefa60 100644 --- a/src/components/views/dialogs/BaseDialog.tsx +++ b/src/components/views/dialogs/BaseDialog.tsx @@ -17,7 +17,6 @@ limitations under the License. */ import React from 'react'; -import FocusLock from 'react-focus-lock'; import classNames from 'classnames'; import { MatrixClient } from "matrix-js-sdk/src/client"; @@ -95,9 +94,7 @@ export default class BaseDialog extends React.Component { } private onKeyDown = (e: KeyboardEvent | React.KeyboardEvent): void => { - if (this.props.onKeyDown) { - this.props.onKeyDown(e); - } + this.props.onKeyDown?.(e); const action = getKeyBindingsManager().getAccessibilityAction(e); switch (action) { @@ -128,7 +125,7 @@ export default class BaseDialog extends React.Component { headerImage = ; } - const lockProps = { + const props = { "onKeyDown": this.onKeyDown, "role": "dialog", // This should point to a node describing the dialog. @@ -141,17 +138,16 @@ export default class BaseDialog extends React.Component { }; if (this.props["aria-label"]) { - lockProps["aria-label"] = this.props["aria-label"]; + props["aria-label"] = this.props["aria-label"]; } else { - lockProps["aria-labelledby"] = "mx_BaseDialog_title"; + props["aria-labelledby"] = "mx_BaseDialog_title"; } return ( - { { cancelButton }
{ this.props.children } - +
); } diff --git a/src/components/views/dialogs/ScrollableBaseModal.tsx b/src/components/views/dialogs/ScrollableBaseModal.tsx index 390178407f6..c9a1d339767 100644 --- a/src/components/views/dialogs/ScrollableBaseModal.tsx +++ b/src/components/views/dialogs/ScrollableBaseModal.tsx @@ -16,7 +16,6 @@ limitations under the License. import React, { FormEvent } from "react"; import { MatrixClient } from "matrix-js-sdk/src/client"; -import FocusLock from "react-focus-lock"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { IDialogProps } from "./IDialogProps"; @@ -74,16 +73,12 @@ export default abstract class ScrollableBaseModal -
@@ -114,7 +109,7 @@ export default abstract class ScrollableBaseModal
-
+ ); } From 148b38d0efdf08d4e5a715f8cc535dc2704a1ba3 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 15 Jun 2022 12:40:32 +0100 Subject: [PATCH 2/7] Refactor ModalManager to properly use modal dialogs --- res/css/_common.scss | 58 +++---- .../views/beacon/_BeaconViewDialog.scss | 1 - res/css/views/dialogs/_CompoundDialog.scss | 12 +- .../views/dialogs/_LocationViewDialog.scss | 1 - src/@types/global.d.ts | 6 +- src/AsyncWrapper.tsx | 3 +- src/Modal.tsx | 148 ++++++++---------- src/components/views/dialogs/BaseDialog.tsx | 18 --- src/components/views/dialogs/IDialogProps.ts | 1 + .../security/AccessSecretStorageDialog.tsx | 8 +- 10 files changed, 100 insertions(+), 156 deletions(-) diff --git a/res/css/_common.scss b/res/css/_common.scss index 867a04bf6cd..acaeff86c96 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -279,22 +279,32 @@ legend { */ .mx_Dialog_wrapper { - position: fixed; - z-index: 4000; - top: 0; + position: absolute; left: 0; - width: 100%; - height: 100%; + right: 0; + width: fit-content; + height: fit-content; + margin: auto; - display: flex; - align-items: center; - justify-content: center; + // override browser default decorations + border: none; + background: unset; + + &::backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + + background-color: $dialog-backdrop-color; + opacity: 0.8; + } } .mx_Dialog { background-color: $background; color: $light-fg-color; - z-index: 4012; font-size: $font-15px; position: relative; padding: 24px; @@ -376,33 +386,7 @@ legend { max-width: 704px; } -.mx_Dialog_staticWrapper .mx_Dialog { - z-index: 4010; - contain: content; -} - -.mx_Dialog_background { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: $dialog-backdrop-color; - opacity: 0.8; - z-index: 4011; -} - -.mx_Dialog_background.mx_Dialog_staticBackground { - z-index: 4009; -} - -.mx_Dialog_wrapperWithStaticUnder .mx_Dialog_background { - // Roughly half of what it would normally be - we don't want to black out - // the app, just make it clear that the dialogs are stacked. - opacity: 0.4; -} - -.mx_Dialog_lightbox .mx_Dialog_background { +.mx_Dialog_lightbox { opacity: $lightbox-background-bg-opacity; background-color: $lightbox-background-bg-color; animation-name: mx_Dialog_lightbox_background_keyframes; @@ -586,7 +570,7 @@ legend { } /* Spinner Dialog overide */ -.mx_Dialog_wrapper.mx_Dialog_spinner .mx_Dialog { +.mx_Dialog_spinner .mx_Dialog { width: auto; border-radius: 8px; padding: 8px; diff --git a/res/css/components/views/beacon/_BeaconViewDialog.scss b/res/css/components/views/beacon/_BeaconViewDialog.scss index 0fff7210533..fde4396016f 100644 --- a/res/css/components/views/beacon/_BeaconViewDialog.scss +++ b/res/css/components/views/beacon/_BeaconViewDialog.scss @@ -42,7 +42,6 @@ limitations under the License. } .mx_Dialog_cancelButton { - z-index: 4010; position: fixed; right: 5vw; top: 5vh; diff --git a/res/css/views/dialogs/_CompoundDialog.scss b/res/css/views/dialogs/_CompoundDialog.scss index 28e7388e0ea..6e9c626ccde 100644 --- a/res/css/views/dialogs/_CompoundDialog.scss +++ b/res/css/views/dialogs/_CompoundDialog.scss @@ -20,13 +20,13 @@ limitations under the License. // be in their respective stylesheets. // -------------------------------------------------------------------------------- -// Override legacy/default styles for dialogs -.mx_Dialog_wrapper.mx_CompoundDialog > .mx_Dialog { - padding: 0; // we'll manage it ourselves - color: $primary-content; -} - .mx_CompoundDialog { + // Override legacy/default styles for dialogs + > .mx_Dialog { + padding: 0; // we'll manage it ourselves + color: $primary-content; + } + .mx_CompoundDialog_header { padding: 32px 32px 16px 32px; diff --git a/res/css/views/dialogs/_LocationViewDialog.scss b/res/css/views/dialogs/_LocationViewDialog.scss index 600c3082657..9544d39b55e 100644 --- a/res/css/views/dialogs/_LocationViewDialog.scss +++ b/res/css/views/dialogs/_LocationViewDialog.scss @@ -39,7 +39,6 @@ limitations under the License. } .mx_Dialog_cancelButton { - z-index: 4010; position: absolute; right: 5vw; top: 5vh; diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index bbce7ea8759..0dfd7535fa2 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -174,7 +174,11 @@ declare global { } interface HTMLStyleElement { - disabled?: boolean; + disabled: boolean; + } + + interface HTMLDialogElement { + oncancel?(ev: Event); } // Add Chrome-specific `instant` ScrollBehaviour diff --git a/src/AsyncWrapper.tsx b/src/AsyncWrapper.tsx index 07a06f20d44..9beaba56fe9 100644 --- a/src/AsyncWrapper.tsx +++ b/src/AsyncWrapper.tsx @@ -82,7 +82,8 @@ export default class AsyncWrapper extends React.Component { } else if (this.state.error) { return { _t("Unable to load! Check your network connectivity and try again.") } - diff --git a/src/Modal.tsx b/src/Modal.tsx index f1348a73452..07456130de2 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -23,9 +23,9 @@ import { defer, sleep } from "matrix-js-sdk/src/utils"; import AsyncWrapper from './AsyncWrapper'; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; -const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer"; export interface IModal { + id: number; elem: React.ReactNode; className?: string; beforeClosePromise?: Promise; @@ -79,26 +79,8 @@ export class ModalManager { return container; } - private static getOrCreateStaticContainer() { - 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 toggleCurrentDialogVisibility() { - const modal = this.getCurrentModal(); - if (!modal) return; - modal.hidden = !modal.hidden; - } - - public hasDialogs() { - return this.priorityModal || this.staticModal || this.modals.length > 0; + public hasDialogs(): boolean { + return !!this.priorityModal || !!this.staticModal || this.modals.length > 0; } public createDialog( @@ -116,10 +98,8 @@ export class ModalManager { } public closeCurrentModal(reason: string) { - const modal = this.getCurrentModal(); - if (!modal) { - return; - } + const [modal] = this.getModals(); + if (!modal) return; modal.closeReason = reason; modal.close(); } @@ -131,6 +111,7 @@ export class ModalManager { options?: IOptions, ) { const modal: IModal = { + id: this.counter++, onFinished: props ? props.onFinished : null, onBeforeClose: options.onBeforeClose, beforeClosePromise: null, @@ -142,16 +123,25 @@ export class ModalManager { close: null, }; + const setModalHidden = () => { + modal.hidden = !modal.hidden; + this.reRender(); + }; + // 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.elem = ( + + ); modal.close = closeDialog; return { modal, closeDialog, onFinishedProm }; @@ -174,7 +164,7 @@ export class ModalManager { } } deferred.resolve(args); - if (props && props.onFinished) props.onFinished.apply(null, args); + props?.onFinished?.apply(null, args); const i = this.modals.indexOf(modal); if (i >= 0) { this.modals.splice(i, 1); @@ -207,7 +197,7 @@ export class ModalManager { /** * Open a modal view. * - * This can be used to display a react component which is loaded as an asynchronous + * This can be used to display a React component which is loaded as an asynchronous * webpack component. To do this, set 'loader' as: * * (cb) => { @@ -276,72 +266,58 @@ export class ModalManager { }; } - private onBackgroundClick = () => { - const modal = this.getCurrentModal(); - if (!modal) { - return; + private getModals(): IModal[] { + return [ + this.priorityModal, + ...this.modals, + this.staticModal, + ].filter(Boolean); + } + + private onActiveModalClick(this: IModal, ev: MouseEvent): void { + const target = ev.target as HTMLElement; + if (target.tagName === "DIALOG" && target.classList.contains("mx_Dialog_wrapper")) { + // 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 + this.closeReason = "backgroundClick"; + this.close(); + this.closeReason = null; } - // 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 = null; - }; + } - private getCurrentModal(): IModal { - return this.priorityModal ? this.priorityModal : (this.modals[0] || this.staticModal); + private onActiveModalRender(this: IModal, elem: HTMLDialogElement): void { + if (!elem || elem.open) return; + elem.oncancel = this.close; + elem.showModal(); } private async reRender() { // 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) { + const modals = this.getModals(); + if (modals.length === 0) { ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateContainer()); - ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateStaticContainer()); return; } - const modal = this.getCurrentModal(); - 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()); - } - - if (modal !== this.staticModal && !modal.hidden) { - const classes = classNames("mx_Dialog_wrapper", modal.className, { - mx_Dialog_wrapperWithStaticUnder: this.staticModal, - }); - - const dialog = ( -
- - { modal.elem } - -
-
- ); - - setImmediate(() => ReactDOM.render(dialog, ModalManager.getOrCreateContainer())); - } else { - // This is safe to call repeatedly if we happen to do that - ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateContainer()); - } + const firstVisible = modals.find(m => !m.hidden); + setImmediate(() => ReactDOM.render(<> + { modals.map(m => ( + +
+ { m.elem } +
+
+ )) } + , ModalManager.getOrCreateContainer())); } } diff --git a/src/components/views/dialogs/BaseDialog.tsx b/src/components/views/dialogs/BaseDialog.tsx index 8a21adefa60..20a3e8fae34 100644 --- a/src/components/views/dialogs/BaseDialog.tsx +++ b/src/components/views/dialogs/BaseDialog.tsx @@ -27,8 +27,6 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext"; import Heading from '../typography/Heading'; import { IDialogProps } from "./IDialogProps"; import { PosthogScreenTracker, ScreenName } from "../../../PosthogTrackers"; -import { getKeyBindingsManager } from "../../../KeyBindingsManager"; -import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; interface IProps extends IDialogProps { // Whether the dialog should have a 'close' button that will @@ -93,21 +91,6 @@ export default class BaseDialog extends React.Component { this.matrixClient = MatrixClientPeg.get(); } - private onKeyDown = (e: KeyboardEvent | React.KeyboardEvent): void => { - this.props.onKeyDown?.(e); - - const action = getKeyBindingsManager().getAccessibilityAction(e); - switch (action) { - case KeyBindingAction.Escape: - if (!this.props.hasCancel) break; - - e.stopPropagation(); - e.preventDefault(); - this.props.onFinished(false); - break; - } - }; - private onCancelClick = (e: ButtonEvent): void => { this.props.onFinished(false); }; @@ -126,7 +109,6 @@ export default class BaseDialog extends React.Component { } const props = { - "onKeyDown": this.onKeyDown, "role": "dialog", // This should point to a node describing the dialog. // If we were about to completely follow this recommendation we'd need to diff --git a/src/components/views/dialogs/IDialogProps.ts b/src/components/views/dialogs/IDialogProps.ts index b294fdafe19..3a3a980d0d6 100644 --- a/src/components/views/dialogs/IDialogProps.ts +++ b/src/components/views/dialogs/IDialogProps.ts @@ -16,4 +16,5 @@ limitations under the License. export interface IDialogProps { onFinished(...args: any): void; + setModalHidden?(hidden: boolean): void; } diff --git a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx index ee6d884b8cf..0650555815b 100644 --- a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx +++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx @@ -221,10 +221,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent Date: Fri, 1 Jul 2022 14:45:00 +0100 Subject: [PATCH 3/7] Iterate modal dialog approach --- src/@types/global.d.ts | 5 + src/Modal.tsx | 146 +- src/components/structures/MatrixChat.tsx | 2 + src/components/structures/ModalContainer.tsx | 73 + .../views/beacon/BeaconViewDialog.tsx | 92 +- .../dialogs/AddExistingSubspaceDialog.tsx | 27 +- .../dialogs/AddExistingToSpaceDialog.tsx | 56 +- src/components/views/dialogs/BaseDialog.tsx | 57 +- .../views/dialogs/CreateSubspaceDialog.tsx | 103 +- .../ManageRestrictedJoinRuleDialog.tsx | 127 +- .../views/dialogs/ScrollableBaseModal.tsx | 80 +- src/components/views/messages/MBeaconBody.tsx | 1 - .../views/beacon/BeaconViewDialog-test.tsx | 8 +- .../__snapshots__/ExportDialog-test.tsx.snap | 2216 +---------------- .../views/elements/PollCreateDialog-test.tsx | 15 +- .../PollCreateDialog-test.tsx.snap | 6 +- 16 files changed, 412 insertions(+), 2602 deletions(-) create mode 100644 src/components/structures/ModalContainer.tsx diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 0dfd7535fa2..af226c43051 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -178,7 +178,12 @@ declare global { } interface HTMLDialogElement { + readonly open: boolean; + readonly returnValue?: string; oncancel?(ev: Event); + show(): void; + showModal(): void; + close(returnValue?: string): void; } // Add Chrome-specific `instant` ScrollBehaviour diff --git a/src/Modal.tsx b/src/Modal.tsx index 07456130de2..3c626a7731e 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -16,13 +16,12 @@ limitations under the License. */ import React from 'react'; -import ReactDOM from 'react-dom'; -import classNames from 'classnames'; -import { defer, sleep } from "matrix-js-sdk/src/utils"; +import { defer } from "matrix-js-sdk/src/utils"; import AsyncWrapper from './AsyncWrapper'; - -const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; +import { AsyncStore } from "./stores/AsyncStore"; +import defaultDispatcher from "./dispatcher/dispatcher"; +import { ActionPayload } from "./dispatcher/payloads"; export interface IModal { id: number; @@ -53,34 +52,34 @@ interface IOptions { 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 +interface IState { + // 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. + priorityModal?: IModal; + // 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; + staticModal?: IModal; // 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[] = []; + modals?: IModal[]; +} - private static getOrCreateContainer() { - let container = document.getElementById(DIALOG_CONTAINER_ID); +export class ModalManager extends AsyncStore { + private counter = 0; - if (!container) { - container = document.createElement("div"); - container.id = DIALOG_CONTAINER_ID; - document.body.appendChild(container); - } + public constructor() { + super(defaultDispatcher, { + modals: [], + }); + } - return container; + protected onDispatch(payload: ActionPayload) { + // Nothing to do } public hasDialogs(): boolean { - return !!this.priorityModal || !!this.staticModal || this.modals.length > 0; + return !!this.state.priorityModal || !!this.state.staticModal || this.state.modals.length > 0; } public createDialog( @@ -118,14 +117,16 @@ export class ModalManager { closeReason: null, className, - // these will be set below but we need an object reference to pass to getCloseFn before we can do that + // these will be set below, but we need an object reference to pass to getCloseFn before we can do that elem: null, close: null, }; const setModalHidden = () => { modal.hidden = !modal.hidden; - this.reRender(); + this.updateState({ + modals: [...this.state.modals], + }); }; // never call this from onFinished() otherwise it will loop @@ -165,33 +166,37 @@ export class ModalManager { } deferred.resolve(args); props?.onFinished?.apply(null, args); - const i = this.modals.indexOf(modal); + + let { modals, priorityModal, staticModal } = this.state; + + const i = modals.indexOf(modal); if (i >= 0) { - this.modals.splice(i, 1); + modals = [...modals]; + modals.splice(i, 1); } - if (this.priorityModal === modal) { - this.priorityModal = null; + if (priorityModal === modal) { + priorityModal = null; // XXX: This is destructive - this.modals = []; + modals = []; } - if (this.staticModal === modal) { - this.staticModal = null; + if (staticModal === modal) { + staticModal = null; // XXX: This is destructive - this.modals = []; + modals = []; } - this.reRender(); + await this.updateState({ modals, staticModal, priorityModal }); }, deferred.promise]; } /** * @callback onBeforeClose * @param {string?} reason either "backgroundClick" or null - * @return {Promise} whether the dialog should close + * @return {Promise} whether the dialog should close */ /** @@ -234,17 +239,19 @@ export class ModalManager { options: IOptions = {}, ): IHandle { const { modal, closeDialog, onFinishedProm } = this.buildModal(prom, props, className, options); + if (isPriorityModal) { // XXX: This is destructive - this.priorityModal = modal; + this.updateState({ priorityModal: modal }); } else if (isStaticModal) { // This is intentionally destructive - this.staticModal = modal; + this.updateState({ staticModal: modal }); } else { - this.modals.unshift(modal); + this.updateState({ + modals: [modal, ...this.state.modals], + }); } - this.reRender(); return { close: closeDialog, finished: onFinishedProm, @@ -258,67 +265,22 @@ export class ModalManager { ): IHandle { const { modal, closeDialog, onFinishedProm } = this.buildModal(prom, props, className, {}); - this.modals.push(modal); - this.reRender(); + this.updateState({ + modals: [...this.state.modals, modal], + }); return { close: closeDialog, finished: onFinishedProm, }; } - private getModals(): IModal[] { + public getModals(): IModal[] { return [ - this.priorityModal, - ...this.modals, - this.staticModal, + this.state.priorityModal, + ...this.state.modals, + this.state.staticModal, ].filter(Boolean); } - - private onActiveModalClick(this: IModal, ev: MouseEvent): void { - const target = ev.target as HTMLElement; - if (target.tagName === "DIALOG" && target.classList.contains("mx_Dialog_wrapper")) { - // 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 - this.closeReason = "backgroundClick"; - this.close(); - this.closeReason = null; - } - } - - private onActiveModalRender(this: IModal, elem: HTMLDialogElement): void { - if (!elem || elem.open) return; - elem.oncancel = this.close; - elem.showModal(); - } - - private async reRender() { - // await next tick because sometimes ReactDOM can race with itself and cause the modal to wrongly stick around - await sleep(0); - - const modals = this.getModals(); - if (modals.length === 0) { - ReactDOM.unmountComponentAtNode(ModalManager.getOrCreateContainer()); - return; - } - - const firstVisible = modals.find(m => !m.hidden); - setImmediate(() => ReactDOM.render(<> - { modals.map(m => ( - -
- { m.elem } -
-
- )) } - , ModalManager.getOrCreateContainer())); - } } if (!window.singletonModalManager) { diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index a960cca2d06..87e3807f11a 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -130,6 +130,7 @@ import { SnakedObject } from "../../utils/SnakedObject"; import { leaveRoomBehaviour } from "../../utils/leave-behaviour"; import VideoChannelStore from "../../stores/VideoChannelStore"; import { IRoomStateEventsActionPayload } from "../../actions/MatrixActionCreators"; +import ModalContainer from './ModalContainer'; // legacy export export { default as Views } from "../../Views"; @@ -2036,6 +2037,7 @@ export default class MatrixChat extends React.PureComponent { return { view } + ; } } diff --git a/src/components/structures/ModalContainer.tsx b/src/components/structures/ModalContainer.tsx new file mode 100644 index 00000000000..15e6592142b --- /dev/null +++ b/src/components/structures/ModalContainer.tsx @@ -0,0 +1,73 @@ +/* +Copyright 2022 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. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import * as React from "react"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import classNames from "classnames"; + +import Modal, { IModal } from "../../Modal"; +import { useEventEmitterState } from "../../hooks/useEventEmitter"; +import { UPDATE_EVENT } from "../../stores/AsyncStore"; +import MatrixClientContext from "../../contexts/MatrixClientContext"; + +interface IProps { + matrixClient?: MatrixClient; +} + +function onActiveModalClick(this: IModal, ev: MouseEvent): void { + const target = ev.target as HTMLElement; + if (target.tagName === "DIALOG" && target.classList.contains("mx_Dialog_wrapper")) { + // 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 + this.closeReason = "backgroundClick"; + this.close(); + this.closeReason = null; + } +} + +function onActiveModalRender(this: IModal, onTop: boolean, elem: HTMLDialogElement): void { + if (!elem) return; + if (!onTop) { + elem.show(); + return; + } + + elem.oncancel = this.close; + elem.showModal(); +} + +const ModalContainer: React.FC = ({ matrixClient }) => { + const modals = useEventEmitterState(Modal, UPDATE_EVENT, () => Modal.getModals()); + const firstVisible = modals.find(m => !m.hidden); + return + { modals.map(m => ( + +
+ { m.elem } +
+
+ )) } +
; +}; + +export default ModalContainer; diff --git a/src/components/views/beacon/BeaconViewDialog.tsx b/src/components/views/beacon/BeaconViewDialog.tsx index f3e2fd12a17..acb3ad0f704 100644 --- a/src/components/views/beacon/BeaconViewDialog.tsx +++ b/src/components/views/beacon/BeaconViewDialog.tsx @@ -14,8 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useState, useRef } from 'react'; -import { MatrixClient } from 'matrix-js-sdk/src/client'; +import React, { useState, useRef, useContext } from 'react'; import { Beacon, Room, @@ -41,7 +40,6 @@ import MapFallback from '../location/MapFallback'; interface IProps extends IDialogProps { roomId: Room['roomId']; - matrixClient: MatrixClient; // open the map centered on this beacon's location focusBeacon?: Beacon; } @@ -74,9 +72,9 @@ const useInitialMapPosition = (liveBeacons: Beacon[], focusBeacon?: Beacon): { const BeaconViewDialog: React.FC = ({ focusBeacon, roomId, - matrixClient, onFinished, }) => { + const matrixClient = useContext(MatrixClientContext); const liveBeacons = useLiveBeacons(roomId, matrixClient); const [isSidebarOpen, setSidebarOpen] = useState(false); @@ -89,55 +87,53 @@ const BeaconViewDialog: React.FC = ({ onFinished={onFinished} fixedWidth={false} > - - { !!liveBeacons?.length ? - { - ({ map }: { map: maplibregl.Map}) => - <> - { liveBeacons.map(beacon => } - />) } - - - } - : - - { _t('No live locations') } - - { _t('Close') } - - + { !!liveBeacons?.length ? + { + ({ map }: { map: maplibregl.Map}) => + <> + { liveBeacons.map(beacon => } + />) } + + } - { isSidebarOpen ? - setSidebarOpen(false)} /> : + : + + { _t('No live locations') } setSidebarOpen(true)} - data-test-id='beacon-view-dialog-open-sidebar' - className='mx_BeaconViewDialog_viewListButton' + onClick={onFinished} + data-test-id='beacon-view-dialog-fallback-close' > -   - { _t('View list') } + { _t('Close') } - } - - + + } + { isSidebarOpen ? + setSidebarOpen(false)} /> : + setSidebarOpen(true)} + data-test-id='beacon-view-dialog-open-sidebar' + className='mx_BeaconViewDialog_viewListButton' + > +   + { _t('View list') } + + } + ); }; diff --git a/src/components/views/dialogs/AddExistingSubspaceDialog.tsx b/src/components/views/dialogs/AddExistingSubspaceDialog.tsx index 7fef2c2d9d7..8e57ea74684 100644 --- a/src/components/views/dialogs/AddExistingSubspaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingSubspaceDialog.tsx @@ -20,7 +20,6 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { _t } from '../../../languageHandler'; import BaseDialog from "./BaseDialog"; import AccessibleButton from "../elements/AccessibleButton"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { AddExistingToSpace, defaultSpacesRenderer, SubspaceSelector } from "./AddExistingToSpaceDialog"; interface IProps { @@ -46,20 +45,18 @@ const AddExistingSubspaceDialog: React.FC = ({ space, onCreateSubspaceCl onFinished={onFinished} fixedWidth={false} > - - -
{ _t("Want to add a new space instead?") }
- - { _t("Create a new space") } - - } - filterPlaceholder={_t("Search for spaces")} - spacesRenderer={defaultSpacesRenderer} - /> -
+ +
{ _t("Want to add a new space instead?") }
+ + { _t("Create a new space") } + + } + filterPlaceholder={_t("Search for spaces")} + spacesRenderer={defaultSpacesRenderer} + /> ; }; diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index 606f96553d7..ea237b0c2d1 100644 --- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -431,41 +431,39 @@ const AddExistingToSpaceDialog: React.FC = ({ space, onCreateRoomClick, onFinished={onFinished} fixedWidth={false} > - - -
{ _t("Want to add a new room instead?") }
+ +
{ _t("Want to add a new room instead?") }
+ { + onCreateRoomClick(ev); + onFinished(); + }} + > + { _t("Create a new room") } + + } + filterPlaceholder={_t("Search for rooms")} + roomsRenderer={defaultRoomsRenderer} + spacesRenderer={() => ( +
+

{ _t("Spaces") }

{ - onCreateRoomClick(ev); + onClick={() => { + onAddSubspaceClick(); onFinished(); }} > - { _t("Create a new room") } + { _t("Adding spaces has moved.") } - } - filterPlaceholder={_t("Search for rooms")} - roomsRenderer={defaultRoomsRenderer} - spacesRenderer={() => ( -
-

{ _t("Spaces") }

- { - onAddSubspaceClick(); - onFinished(); - }} - > - { _t("Adding spaces has moved.") } - -
- )} - dmsRenderer={defaultDmsRenderer} - /> - +
+ )} + dmsRenderer={defaultDmsRenderer} + /> ; }; diff --git a/src/components/views/dialogs/BaseDialog.tsx b/src/components/views/dialogs/BaseDialog.tsx index 20a3e8fae34..e63c0d2f21f 100644 --- a/src/components/views/dialogs/BaseDialog.tsx +++ b/src/components/views/dialogs/BaseDialog.tsx @@ -18,12 +18,9 @@ limitations under the License. import React from 'react'; import classNames from 'classnames'; -import { MatrixClient } from "matrix-js-sdk/src/client"; import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton'; -import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { _t } from "../../../languageHandler"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; import Heading from '../typography/Heading'; import { IDialogProps } from "./IDialogProps"; import { PosthogScreenTracker, ScreenName } from "../../../PosthogTrackers"; @@ -78,19 +75,11 @@ interface IProps extends IDialogProps { * dialog on escape. */ export default class BaseDialog extends React.Component { - private matrixClient: MatrixClient; - public static defaultProps = { hasCancel: true, fixedWidth: true, }; - constructor(props) { - super(props); - - this.matrixClient = MatrixClientPeg.get(); - } - private onCancelClick = (e: ButtonEvent): void => { this.props.onFinished(false); }; @@ -125,30 +114,28 @@ export default class BaseDialog extends React.Component { props["aria-labelledby"] = "mx_BaseDialog_title"; } - return ( - - -
-
- - { headerImage } - { this.props.title } - - { this.props.headerButton } - { cancelButton } -
- { this.props.children } + return <> + +
+
+ + { headerImage } + { this.props.title } + + { this.props.headerButton } + { cancelButton }
- - ); + { this.props.children } +
+ ; } } diff --git a/src/components/views/dialogs/CreateSubspaceDialog.tsx b/src/components/views/dialogs/CreateSubspaceDialog.tsx index a44d16dd40f..c9fc7bd375c 100644 --- a/src/components/views/dialogs/CreateSubspaceDialog.tsx +++ b/src/components/views/dialogs/CreateSubspaceDialog.tsx @@ -22,7 +22,6 @@ import { logger } from "matrix-js-sdk/src/logger"; import { _t } from '../../../languageHandler'; import BaseDialog from "./BaseDialog"; import AccessibleButton from "../elements/AccessibleButton"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { BetaPill } from "../beta/BetaCard"; import Field from "../elements/Field"; import RoomAliasField from "../elements/RoomAliasField"; @@ -125,62 +124,60 @@ const CreateSubspaceDialog: React.FC = ({ space, onAddExistingSpaceClick onFinished={onFinished} fixedWidth={false} > - -
-
- - { _t("Add a space to a space you manage.") } -
- - - - { joinRuleMicrocopy } - +
+
+ + { _t("Add a space to a space you manage.") }
-
-
-
{ _t("Want to add an existing space instead?") }
- { - onAddExistingSpaceClick(); - onFinished(); - }} - > - { _t("Add existing space") } - -
- - onFinished(false)}> - { _t("Cancel") } - - - { busy ? _t("Adding...") : _t("Add") } + + + { joinRuleMicrocopy } + +
+ +
+
+
{ _t("Want to add an existing space instead?") }
+ { + onAddExistingSpaceClick(); + onFinished(); + }} + > + { _t("Add existing space") }
- + + onFinished(false)}> + { _t("Cancel") } + + + { busy ? _t("Adding...") : _t("Add") } + +
; }; diff --git a/src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx b/src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx index b80f7741235..41d014a6a69 100644 --- a/src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx +++ b/src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx @@ -26,7 +26,6 @@ import RoomAvatar from "../avatars/RoomAvatar"; import AccessibleButton from "../elements/AccessibleButton"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import StyledCheckbox from "../elements/StyledCheckbox"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; interface IProps extends IDialogProps { room: Room; @@ -133,73 +132,71 @@ const ManageRestrictedJoinRuleDialog: React.FC = ({ room, selected = [], RoomName: () => { room.name }, }) }

- - - - { filteredSpacesContainingRoom.length > 0 ? ( -
-

- { room.isSpaceRoom() - ? _t("Spaces you know that contain this space") - : _t("Spaces you know that contain this room") } -

- { filteredSpacesContainingRoom.map(space => { - return { - onChange(checked, space); - }} - />; - }) } -
- ) : undefined } - - { filteredOtherEntries.length > 0 ? ( -
-

{ _t("Other spaces or rooms you might not know") }

-
-
{ _t("These are likely ones other room admins are a part of.") }
-
- { filteredOtherEntries.map(space => { - return { - onChange(checked, space); - }} - />; - }) } -
- ) : null } + + + { filteredSpacesContainingRoom.length > 0 ? ( +
+

+ { room.isSpaceRoom() + ? _t("Spaces you know that contain this space") + : _t("Spaces you know that contain this room") } +

+ { filteredSpacesContainingRoom.map(space => { + return { + onChange(checked, space); + }} + />; + }) } +
+ ) : undefined } - { filteredSpacesContainingRoom.length + filteredOtherEntries.length < 1 - ? - { _t("No results") } - - : undefined - } -
- -
- { inviteOnlyWarning } -
- onFinished()}> - { _t("Cancel") } - - onFinished(Array.from(newSelected))}> - { _t("Confirm") } - + { filteredOtherEntries.length > 0 ? ( +
+

{ _t("Other spaces or rooms you might not know") }

+
+
{ _t("These are likely ones other room admins are a part of.") }
+
+ { filteredOtherEntries.map(space => { + return { + onChange(checked, space); + }} + />; + }) }
+ ) : null } + + { filteredSpacesContainingRoom.length + filteredOtherEntries.length < 1 + ? + { _t("No results") } + + : undefined + } + + +
+ { inviteOnlyWarning } +
+ onFinished()}> + { _t("Cancel") } + + onFinished(Array.from(newSelected))}> + { _t("Confirm") } +
- +
; }; diff --git a/src/components/views/dialogs/ScrollableBaseModal.tsx b/src/components/views/dialogs/ScrollableBaseModal.tsx index c9a1d339767..defc1024251 100644 --- a/src/components/views/dialogs/ScrollableBaseModal.tsx +++ b/src/components/views/dialogs/ScrollableBaseModal.tsx @@ -17,13 +17,12 @@ limitations under the License. import React, { FormEvent } from "react"; import { MatrixClient } from "matrix-js-sdk/src/client"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { IDialogProps } from "./IDialogProps"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { _t } from "../../../languageHandler"; import AccessibleButton from "../elements/AccessibleButton"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; export interface IScrollableBaseState { canSubmit: boolean; @@ -36,12 +35,15 @@ export interface IScrollableBaseState { */ export default abstract class ScrollableBaseModal extends React.PureComponent { + public static contextType = MatrixClientContext; + public context!: React.ContextType; + protected constructor(props: TProps) { super(props); } protected get matrixClient(): MatrixClient { - return MatrixClientPeg.get(); + return this.context; } private onKeyDown = (e: KeyboardEvent | React.KeyboardEvent): void => { @@ -72,45 +74,43 @@ export default abstract class ScrollableBaseModal -
-
-

{ this.state.title }

+
+
+

{ this.state.title }

+ +
+
+
+ { this.renderContent() } +
+
+ + { _t("Cancel") } + + onClick={this.onSubmit} + kind="primary" + disabled={!this.state.canSubmit} + type="submit" + element="button" + className="mx_Dialog_nonDialogButton" + > + { this.state.actionLabel } +
- -
- { this.renderContent() } -
-
- - { _t("Cancel") } - - - { this.state.actionLabel } - -
-
-
- + +
); } } diff --git a/src/components/views/messages/MBeaconBody.tsx b/src/components/views/messages/MBeaconBody.tsx index ddb700c07a8..b26000e1212 100644 --- a/src/components/views/messages/MBeaconBody.tsx +++ b/src/components/views/messages/MBeaconBody.tsx @@ -110,7 +110,6 @@ const MBeaconBody: React.FC = React.forwardRef(({ mxEvent }, ref) => BeaconViewDialog, { roomId: mxEvent.getRoomId(), - matrixClient, focusBeacon: beacon, }, "mx_BeaconViewDialog_wrapper", diff --git a/test/components/views/beacon/BeaconViewDialog-test.tsx b/test/components/views/beacon/BeaconViewDialog-test.tsx index 12b40939392..70c7875e5b5 100644 --- a/test/components/views/beacon/BeaconViewDialog-test.tsx +++ b/test/components/views/beacon/BeaconViewDialog-test.tsx @@ -18,7 +18,6 @@ import React from 'react'; import { mount } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { - MatrixClient, MatrixEvent, Room, RoomMember, @@ -37,6 +36,7 @@ import { import { TILE_SERVER_WK_KEY } from '../../../../src/utils/WellKnownUtils'; import { OwnBeaconStore } from '../../../../src/stores/OwnBeaconStore'; import { BeaconDisplayStatus } from '../../../../src/components/views/beacon/displayStatus'; +import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; describe('', () => { // 14.03.2022 16:15 @@ -83,11 +83,13 @@ describe('', () => { const defaultProps = { onFinished: jest.fn(), roomId, - matrixClient: mockClient as MatrixClient, }; const getComponent = (props = {}) => - mount(); + mount(, { + wrappingComponent: MatrixClientContext.Provider, + wrappingComponentProps: { value: mockClient }, + }); beforeAll(() => { maplibregl.AttributionControl = jest.fn(); diff --git a/test/components/views/dialogs/__snapshots__/ExportDialog-test.tsx.snap b/test/components/views/dialogs/__snapshots__/ExportDialog-test.tsx.snap index b922d8b8ba3..a91ea090a44 100644 --- a/test/components/views/dialogs/__snapshots__/ExportDialog-test.tsx.snap +++ b/test/components/views/dialogs/__snapshots__/ExportDialog-test.tsx.snap @@ -11,2205 +11,10 @@ Array [ title="Export Chat" > - - -
- -
-

- Export Chat -

-
-
-

- Select from the options below to export chats from your timeline -

-
- - Format - -