369 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
			
		
		
	
	
			369 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
| /*
 | |
| 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.
 | |
| */
 | |
| 
 | |
| 
 | |
| 'use strict';
 | |
| 
 | |
| const React = require('react');
 | |
| const ReactDOM = require('react-dom');
 | |
| import PropTypes from 'prop-types';
 | |
| import Analytics from './Analytics';
 | |
| import sdk from './index';
 | |
| import dis from './dispatcher';
 | |
| import { _t } from './languageHandler';
 | |
| 
 | |
| 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 = React.createClass({
 | |
|     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 <Component {...this.props} />;
 | |
|         } else if (this.state.error) {
 | |
|             const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
 | |
|             const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
 | |
|             return <BaseDialog onFinished={this.props.onFinished}
 | |
|                 title={_t("Error")}
 | |
|             >
 | |
|                 {_t("Unable to load! Check your network connectivity and try again.")}
 | |
|                 <DialogButtons primaryButton={_t("Dismiss")}
 | |
|                     onPrimaryButtonClick={this._onWrapperCancelClick}
 | |
|                     hasCancel={false}
 | |
|                 />
 | |
|             </BaseDialog>;
 | |
|         } else {
 | |
|             // show a spinner until the component is loaded.
 | |
|             const Spinner = sdk.getComponent("elements.Spinner");
 | |
|             return <Spinner />;
 | |
|         }
 | |
|     },
 | |
| });
 | |
| 
 | |
| 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 = 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 = (
 | |
|             <AsyncWrapper key={modalCount} prom={prom} {...props}
 | |
|                           onFinished={closeDialog} />
 | |
|         );
 | |
|         modal.onFinished = props ? props.onFinished : null;
 | |
|         modal.className = className;
 | |
| 
 | |
|         return {modal, closeDialog};
 | |
|     }
 | |
| 
 | |
|     _getCloseFn(modal, props) {
 | |
|         return (...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();
 | |
|         };
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * 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(['<module>'], 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} = 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};
 | |
|     }
 | |
| 
 | |
|     appendDialogAsync(prom, props, className) {
 | |
|         const {modal, closeDialog} = this._buildModal(prom, props, className);
 | |
| 
 | |
|         this._modals.push(modal);
 | |
|         this._reRender();
 | |
|         return {close: closeDialog};
 | |
|     }
 | |
| 
 | |
|     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 = (
 | |
|                 <div className={classes}>
 | |
|                     <div className="mx_Dialog">
 | |
|                         { this._staticModal.elem }
 | |
|                     </div>
 | |
|                     <div className="mx_Dialog_background mx_Dialog_staticBackground" onClick={this.closeAll}></div>
 | |
|                 </div>
 | |
|             );
 | |
| 
 | |
|             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 = (
 | |
|                 <div className={classes}>
 | |
|                     <div className="mx_Dialog">
 | |
|                         {modal.elem}
 | |
|                     </div>
 | |
|                     <div className="mx_Dialog_background" onClick={this.closeAll}></div>
 | |
|                 </div>
 | |
|             );
 | |
| 
 | |
|             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;
 |