diff --git a/client/src/ui/modals.js b/client/src/ui/modals.js index c3800cb7..11bcd361 100644 --- a/client/src/ui/modals.js +++ b/client/src/ui/modals.js @@ -8,18 +8,22 @@ * LICENSE file in the root directory of this source tree. */ -import { Utils, FileUtils } from 'common'; +import { Utils, FileUtils, AsyncEventEmitter } from 'common'; import { Settings, Events, PluginManager, ThemeManager } from 'modules'; +import BaseModal from './components/common/Modal.vue'; import BasicModal from './components/bd/modals/BasicModal.vue'; import ConfirmModal from './components/bd/modals/ConfirmModal.vue'; import ErrorModal from './components/bd/modals/ErrorModal.vue'; import SettingsModal from './components/bd/modals/SettingsModal.vue'; import PermissionModal from './components/bd/modals/PermissionModal.vue'; -export default class { +class Modal extends AsyncEventEmitter { + constructor(_modal, component) { + super(); + Object.assign(this, _modal); - static add(modal, component) { - modal.component = modal.component || { + const modal = this; + this.component = this.component || { template: '', components: { 'custom-modal': component }, data() { return { modal }; }, @@ -28,49 +32,112 @@ export default class { modal.vue = this.$children[0]; } }; - modal.closing = false; - modal.close = force => this.close(modal, force); - modal.id = Date.now(); + + this.closing = false; + this.id = Date.now(); + this.vueInstance = undefined; + this.vue = undefined; + + this.closed = this.once('closed'); + } + + /** + * Closes the modal and removes it from the stack. + * @param {Boolean} force If not true throwing an error in the close hook will stop the modal being closed + * @return {Promise} + */ + close(force) { + return Modals.close(this, force); + } +} + +export default class Modals { + + /** + * Adds a modal to the open stack. + * @param {Object} modal A Modal object or options used to create a Modal object + * @param {Object} component A Vue component that will be used to render the modal (optional if modal is a Modal object or it contains a component property) + * @return {Modal} The Modal object that was passed or created using the passed options + */ + static add(_modal, component) { + const modal = _modal instanceof Modal ? _modal : new Modal(_modal, component); this.stack.push(modal); Events.emit('bd-refresh-modals'); return modal; } - static close(modal, force) { - return new Promise(async (resolve, reject) => { + /** + * Closes a modal and removes it from the stack. + * @param {Modal} modal The modal to close + * @param {Boolean} force If not true throwing an error in the close hook will stop the modal being closed + * @return {Promise} + */ + static async close(modal, force) { + try { if (modal.beforeClose) { - try { - const beforeCloseResult = await modal.beforeClose(force); - if (beforeCloseResult && !force) return reject(beforeCloseResult); - } catch (err) { - if (!force) return reject(err); - } + const beforeCloseResult = await modal.beforeClose(force); + if (beforeCloseResult) throw beforeCloseResult; } + await modal.emit('close', force); + } catch (err) { + Logger.err('Modals', ['Error thrown in modal close event:', err]); + if (!force) throw err; + } - modal.closing = true; - setTimeout(() => { - this._stack = this.stack.filter(m => m !== modal); - Events.emit('bd-refresh-modals'); - resolve(); - }, 200); - }); + modal.closing = true; + await new Promise(resolve => setTimeout(resolve, 200)); + + this._stack = this.stack.filter(m => m !== modal); + Events.emit('bd-refresh-modals'); + + try { + await modal.emit('closed', force); + } catch (err) { + Logger.err('Modals', ['Error thrown in modal closed event:', err]); + if (!force) throw err; + } } - static closeAll() { + /** + * Closes all open modals and removes them from the stack. + * @param {Boolean} force If not true throwing an error in the close hook will stop that modal and any modals higher in the stack from being closed + * @return {Promise} + */ + static closeAll(force) { + const promises = []; for (let modal of this.stack) - modal.close(); + promises.push(modal.close(force)); + return Promise.all(promises); } - static closeLast() { - if (!this.stack.length) return; - this.stack[this.stack.length - 1].close(); + /** + * Closes highest modal in the stack and removes it from the stack. + * @param {Boolean} force If not true throwing an error in the close hook will stop the modal being closed + * @return {Promise} + */ + static closeLast(force) { + if (!this.stack.length) return Promise.resolve(); + return this.stack[this.stack.length - 1].close(force); } + /** + * Creates a new basic modal and adds it to the open stack. + * @param {String} title A string that will be displayed in the modal header + * @param {String} text A string that will be displayed in the modal body + * @return {Modal} + */ static basic(title, text) { return this.add({ title, text }, BasicModal); } + /** + * Creates a new confirm modal and adds it to the open stack. + * The modal will have a promise property that will be set to a Promise object that is resolved or rejected if the user clicks the confirm button or closes the modal. + * @param {String} title A string that will be displayed in the modal header + * @param {String} text A string that will be displayed in the modal body + * @return {Modal} + */ static confirm(title, text) { const modal = { title, text }; modal.promise = new Promise((resolve, reject) => { @@ -81,6 +148,14 @@ export default class { return modal; } + /** + * Creates a new permissions modal and adds it to the open stack. + * The modal will have a promise property that will be set to a Promise object that is resolved or rejected if the user accepts the permissions or closes the modal. + * @param {String} title A string that will be displayed in the modal header + * @param {String} name The requesting plugin's name + * @param {Array} perms The permissions the plugin is requesting + * @return {Modal} + */ static permissions(title, name, perms) { const modal = { title, name, perms }; modal.promise = new Promise((resolve, reject) => { @@ -91,10 +166,20 @@ export default class { return modal; } + /** + * Creates a new error modal and adds it to the open stack. + * @param {Object} event An object containing details about the error[s] to display + * @return {Modal} + */ static error(event) { return this.add({ event }, ErrorModal); } + /** + * Creates a new error modal with errors from PluginManager and ThemeManager and adds it to the open stack. + * @param {Boolean} clear Whether to clear the errors array after opening the modal + * @return {Modal} + */ static showContentManagerErrors(clear = true) { // Get any errors from PluginManager and ThemeManager const errors = ([]).concat(PluginManager.errors).concat(ThemeManager.errors); @@ -122,6 +207,13 @@ export default class { } } + /** + * Creates a new settings modal and adds it to the open stack. + * @param {SettingsSet} settingsset The SettingsSet object to [clone and] display in the modal + * @param {String} headertext A string that will be displayed in the modal header + * @param {Object} options Additional options that will be passed to the modal + * @return {Modal} + */ static settings(settingsset, headertext, options) { return this.add(Object.assign({ headertext: headertext ? headertext : settingsset.headertext, @@ -130,18 +222,40 @@ export default class { }, options), SettingsModal); } - static internalSettings(set_id) { + /** + * Creates a new settings modal with one of BetterDiscord's settings sets and adds it to the open stack. + * @param {SettingsSet} set_id The ID of the SettingsSet object to [clone and] display in the modal + * @param {String} headertext A string that will be displayed in the modal header + * @return {Modal} + */ + static internalSettings(set_id, headertext) { const set = Settings.getSet(set_id); if (!set) return; - return this.settings(set, set.headertext); + return this.settings(set, headertext); } - static contentSettings(content) { - return this.settings(content.settings, content.name + ' Settings'); + /** + * Creates a new settings modal with a plugin/theme's settings set and adds it to the open stack. + * @param {SettingsSet} content The plugin/theme whose settings set is to be [cloned and] displayed in the modal + * @param {String} headertext A string that will be displayed in the modal header + * @return {Modal} + */ + static contentSettings(content, headertext) { + return this.settings(content.settings, headertext ? headertext : content.name + ' Settings'); } + /** + * An array of open modals. + */ static get stack() { return this._stack ? this._stack : (this._stack = []); } + /** + * A base Vue component for modals to use. + */ + static get baseComponent() { + return BaseModal; + } + } diff --git a/common/modules/async-eventemitter.js b/common/modules/async-eventemitter.js index ba801fcd..170f4dd9 100644 --- a/common/modules/async-eventemitter.js +++ b/common/modules/async-eventemitter.js @@ -10,32 +10,51 @@ import EventEmitter from 'events'; +/** + * Extends Node.js' EventEmitter to trigger event listeners asyncronously. + */ export default class AsyncEventEmitter extends EventEmitter { - emit(event, ...data) { - return new Promise(async (resolve, reject) => { - let listeners = this._events[event] || []; - listeners = Array.isArray(listeners) ? listeners : [listeners]; + /** + * Emits an event. + * @param {String} event The event to emit + * @param {Any} ...data Data to be passed to event listeners + * @return {Promise} + */ + async emit(event, ...data) { + let listeners = this._events[event] || []; + listeners = Array.isArray(listeners) ? listeners : [listeners]; - // Special treatment of internal newListener and removeListener events - if(event === 'newListener' || event === 'removeListener') { - data = [{ - event: data, - fn: err => { - if (err) throw err; - } - }]; - } - - for (let listener of listeners) { - try { - await listener.call(this, ...data); - } catch (err) { - return reject(err); + // Special treatment of internal newListener and removeListener events + if(event === 'newListener' || event === 'removeListener') { + data = [{ + event: data, + fn: err => { + if (err) throw err; } - } + }]; + } - resolve(); + for (let listener of listeners) { + await listener.apply(this, data); + } + } + + /** + * Adds an event listener that will be removed when it is called and therefore only be called once. + * If a callback is not specified a promise that is resolved once the event is triggered is returned. + */ + once(event, callback) { + if (callback) { + // If a callback was specified add this event as normal + return EventEmitter.prototype.once.apply(this, arguments); + } + + // Otherwise return a promise that is resolved once this event is triggered + return new Promise((resolve, reject) => { + EventEmitter.prototype.once.call(this, event, data => { + return resolve(data); + }); }); }