/* Copyright 2015, 2016 OpenMarket Ltd 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 React from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; import Analytics from './Analytics'; import * as sdk from './index'; import dis from './dispatcher'; import { _t } from './languageHandler'; import {defer} from "./utils/promise"; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer"; /** * Wrap an asynchronous loader function with a react component which shows a * spinner until the real component loads. */ const AsyncWrapper = createReactClass({ propTypes: { /** A promise which resolves with the real component */ prom: PropTypes.object.isRequired, }, getInitialState: function() { return { component: null, error: null, }; }, componentWillMount: function() { this._unmounted = false; // XXX: temporary logging to try to diagnose // https://github.com/vector-im/riot-web/issues/3148 console.log('Starting load of AsyncWrapper for modal'); this.props.prom.then((result) => { if (this._unmounted) { return; } // Take the 'default' member if it's there, then we support // passing in just an import()ed module, since ES6 async import // always returns a module *namespace*. const component = result.default ? result.default : result; this.setState({component}); }).catch((e) => { console.warn('AsyncWrapper promise failed', e); this.setState({error: e}); }); }, componentWillUnmount: function() { this._unmounted = true; }, _onWrapperCancelClick: function() { this.props.onFinished(false); }, render: function() { if (this.state.component) { const Component = this.state.component; return ; } else if (this.state.error) { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return {_t("Unable to load! Check your network connectivity and try again.")} ; } else { // show a spinner until the component is loaded. const Spinner = sdk.getComponent("elements.Spinner"); return ; } }, }); class ModalManager { constructor() { this._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. 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 } */ ]; this.closeAll = this.closeAll.bind(this); } hasDialogs() { return this._priorityModal || this._staticModal || this._modals.length > 0; } getOrCreateContainer() { let container = document.getElementById(DIALOG_CONTAINER_ID); if (!container) { container = document.createElement("div"); container.id = DIALOG_CONTAINER_ID; document.body.appendChild(container); } return container; } 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; } createTrackedDialog(analyticsAction, analyticsInfo, ...rest) { Analytics.trackEvent('Modal', analyticsAction, analyticsInfo); return this.createDialog(...rest); } appendTrackedDialog(analyticsAction, analyticsInfo, ...rest) { Analytics.trackEvent('Modal', analyticsAction, analyticsInfo); return this.appendDialog(...rest); } createDialog(Element, ...rest) { return this.createDialogAsync(Promise.resolve(Element), ...rest); } appendDialog(Element, ...rest) { return this.appendDialogAsync(Promise.resolve(Element), ...rest); } createTrackedDialogAsync(analyticsAction, analyticsInfo, ...rest) { Analytics.trackEvent('Modal', analyticsAction, analyticsInfo); return this.createDialogAsync(...rest); } appendTrackedDialogAsync(analyticsAction, analyticsInfo, ...rest) { Analytics.trackEvent('Modal', analyticsAction, analyticsInfo); return this.appendDialogAsync(...rest); } _buildModal(prom, props, className) { const modal = {}; // 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.onFinished = props ? props.onFinished : null; modal.className = className; return {modal, closeDialog, onFinishedProm}; } _getCloseFn(modal, props) { const deferred = defer(); return [(...args) => { deferred.resolve(args); if (props && 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(); }, deferred.promise]; } /** * 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. * @returns {object} Object with 'close' parameter being a function that will close the dialog */ createDialogAsync(prom, props, className, isPriorityModal, isStaticModal) { const {modal, closeDialog, onFinishedProm} = this._buildModal(prom, props, className); 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(); return { close: closeDialog, finished: onFinishedProm, }; } appendDialogAsync(prom, props, className) { const {modal, closeDialog, onFinishedProm} = this._buildModal(prom, props, className); this._modals.push(modal); this._reRender(); return { close: closeDialog, finished: onFinishedProm, }; } closeAll() { const modalsToClose = [...this._modals, this._priorityModal]; this._modals = []; this._priorityModal = null; if (this._staticModal && modalsToClose.length === 0) { modalsToClose.push(this._staticModal); this._staticModal = null; } for (let i = 0; i < modalsToClose.length; i++) { const m = modalsToClose[i]; if (m && m.onFinished) { m.onFinished(false); } } this._reRender(); } _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()); 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 = "mx_Dialog_wrapper mx_Dialog_staticWrapper " + (this._staticModal.className ? this._staticModal.className : ''); const staticDialog = (
{ this._staticModal.elem }
); ReactDOM.render(staticDialog, this.getOrCreateStaticContainer()); } else { // This is safe to call repeatedly if we happen to do that ReactDOM.unmountComponentAtNode(this.getOrCreateStaticContainer()); } const modal = this._priorityModal ? this._priorityModal : this._modals[0]; if (modal) { const classes = "mx_Dialog_wrapper " + (this._staticModal ? "mx_Dialog_wrapperWithStaticUnder " : '') + (modal.className ? modal.className : ''); const dialog = (
{modal.elem}
); ReactDOM.render(dialog, this.getOrCreateContainer()); } else { // This is safe to call repeatedly if we happen to do that ReactDOM.unmountComponentAtNode(this.getOrCreateContainer()); } } } if (!global.singletonModalManager) { global.singletonModalManager = new ModalManager(); } export default global.singletonModalManager;