diff --git a/client/src/modules/content.js b/client/src/modules/content.js new file mode 100644 index 00000000..cba21ed6 --- /dev/null +++ b/client/src/modules/content.js @@ -0,0 +1,162 @@ +/** + * BetterDiscord Content Base + * Copyright (c) 2015-present Jiiks/JsSucks - https://github.com/Jiiks / https://github.com/JsSucks + * All rights reserved. + * https://betterdiscord.net + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. +*/ + +import { Utils, FileUtils, ClientLogger as Logger, AsyncEventEmitter } from 'common'; +import { Modals } from 'ui'; + +export default class Content { + + constructor(internals) { + this.__internals = internals; + + this.settings.on('setting-updated', event => this.events.emit('setting-updated', event)); + this.settings.on('settings-updated', event => this.events.emit('settings-updated', event)); + this.settings.on('settings-updated', event => this.__settingsUpdated(event)); + + // Add hooks + if (this.onstart) this.on('start', event => this.onstart(event)); + if (this.onStart) this.on('start', event => this.onStart(event)); + if (this.onstop) this.on('stop', event => this.onstop(event)); + if (this.onStop) this.on('stop', event => this.onStop(event)); + if (this.onunload) this.on('unload', event => this.onunload(event)); + if (this.onUnload) this.on('unload', event => this.onUnload(event)); + if (this.settingUpdated) this.on('setting-updated', event => this.settingUpdated(event)); + if (this.settingsUpdated) this.on('settings-updated', event => this.settingsUpdated(event)); + } + + get type() { return undefined } + get configs() { return this.__internals.configs } + get info() { return this.__internals.info } + get paths() { return this.__internals.paths } + get main() { return this.__internals.main } + get defaultConfig() { return this.configs.defaultConfig } + get userConfig() { return this.configs.userConfig } + get configSchemes() { return this.configs.schemes } + get id() { return this.info.id || this.name.toLowerCase().replace(/[^a-zA-Z0-9-]/g, '-').replace(/--/g, '-') } + get name() { return this.info.name } + get icon() { return this.info.icon } + get description() { return this.info.description } + get authors() { return this.info.authors } + get version() { return this.info.version } + get contentPath() { return this.paths.contentPath } + get dirName() { return this.paths.dirName } + get enabled() { return this.userConfig.enabled } + get settings() { return this.userConfig.config } + get config() { return this.settings.categories } + get data() { return this.userConfig.data || (this.userConfig.data = {}) } + get events() { return this.EventEmitter || (this.EventEmitter = new AsyncEventEmitter()) } + + /** + * Opens a settings modal for this content. + */ + showSettingsModal() { + return Modals.contentSettings(this); + } + + /** + * Whether this content has any settings. + */ + get hasSettings() { + return !!this.settings.findSetting(() => true); + } + + /** + * Saves the content's current configuration. + */ + async saveConfiguration() { + try { + await FileUtils.writeFile(`${this.contentPath}/user.config.json`, JSON.stringify({ + enabled: this.enabled, + config: this.settings.strip().settings, + data: this.data + })); + + this.settings.setSaved(); + } catch (err) { + Logger.err(this.name, ['Failed to save configuration', err]); + throw err; + } + } + + /** + * Called when settings are updated. + * This can be overridden by other content types. + */ + __settingsUpdated(event) { + return this.saveConfiguration(); + } + + /** + * Enables the content. + * @param {Boolean} save Whether to save the new enabled state + * @return {Promise} + */ + async enable(save = true) { + if (this.enabled) return; + await this.emit('enable'); + await this.emit('start'); + + this.userConfig.enabled = true; + if (save) await this.saveConfiguration(); + } + + /** + * Disables the content. + * @param {Boolean} save Whether to save the new enabled state + * @return {Promise} + */ + async disable(save = true) { + if (!this.enabled) return; + await this.emit('stop'); + await this.emit('disable'); + + this.userConfig.enabled = false; + if (save) await this.saveConfiguration(); + } + + /** + * Adds an event listener. + * @param {String} event The event to add the listener to + * @param {Function} callback The function to call when the event is emitted + */ + on(...args) { + return this.events.on(...args); + } + + /** + * Removes an event listener. + * @param {String} event The event to remove the listener from + * @param {Function} callback The bound callback (optional) + */ + off(...args) { + return this.events.removeListener(...args); + } + + /** + * Adds an event listener that removes itself when called, therefore only being called once. + * @param {String} event The event to add the listener to + * @param {Function} callback The function to call when the event is emitted + * @return {Promise|undefined} + */ + once(...args) { + return this.events.once(...args); + } + + /** + * Emits an event. + * @param {String} event The event to emit + * @param {Any} data Data to be passed to listeners + * @return {Promise|undefined} + */ + emit(...args) { + return this.events.emit(...args); + } + +} diff --git a/client/src/modules/contentmanager.js b/client/src/modules/contentmanager.js index b5620b87..789a7975 100644 --- a/client/src/modules/contentmanager.js +++ b/client/src/modules/contentmanager.js @@ -8,6 +8,7 @@ * LICENSE file in the root directory of this source tree. */ +import Content from './content'; import Globals from './globals'; import { Utils, FileUtils, ClientLogger as Logger } from 'common'; import path from 'path'; @@ -245,17 +246,19 @@ export default class { if (!content) throw {message: `Could not find a ${this.contentType} from ${content}.`}; try { - if (content.enabled && content.disable) content.disable(false); - if (content.enabled && content.stop) content.stop(false); - if (content.onunload) content.onunload(reload); - if (content.onUnload) content.onUnload(reload); + await content.disable(false); + await content.emit('unload', reload); + const index = this.getContentIndex(content); delete window.require.cache[window.require.resolve(content.paths.mainPath)]; if (reload) { const newcontent = await this.preloadContent(content.dirName, true, index); - if (newcontent.enabled && newcontent.start) newcontent.start(false); + if (newcontent.enabled) { + newcontent.userConfig.enabled = false; + newcontent.start(false); + } return newcontent; } else this.localContent.splice(index, 1); } catch (err) { @@ -268,7 +271,7 @@ export default class { * Reload content * @param {any} content Content to reload */ - static async reloadContent(content) { + static reloadContent(content) { return this.unloadContent(content, true); } @@ -295,12 +298,20 @@ export default class { * @param {any} content Object to check */ static isThisContent(content) { - return false; + return content instanceof Content; + } + + /** + * Returns the first content where calling {function} returns true. + * @param {Function} function A function to call to filter content + */ + static find(f) { + return this.localContent.find(f); } /** * Wildcard content finder - * @param {any} wild Content name | id | path | dirname + * @param {any} wild Content ID / directory name / path / name * @param {bool} nonunique Allow searching attributes that may not be unique */ static findContent(wild, nonunique) { @@ -313,10 +324,10 @@ export default class { } static getContentIndex(content) { return this.localContent.findIndex(c => c === content) } - static getContentByName(name) { return this.localContent.find(c => c.name === name) } static getContentById(id) { return this.localContent.find(c => c.id === id) } - static getContentByPath(path) { return this.localContent.find(c => c.contentPath === path) } static getContentByDirName(dirName) { return this.localContent.find(c => c.dirName === dirName) } + static getContentByPath(path) { return this.localContent.find(c => c.contentPath === path) } + static getContentByName(name) { return this.localContent.find(c => c.name === name) } /** * Wait for content to load diff --git a/client/src/modules/extmodule.js b/client/src/modules/extmodule.js index 43f37811..4b8fb0db 100644 --- a/client/src/modules/extmodule.js +++ b/client/src/modules/extmodule.js @@ -8,37 +8,15 @@ * LICENSE file in the root directory of this source tree. */ -import { AsyncEventEmitter } from 'common'; -import { EventEmitter } from 'events'; +import Content from './content'; -export default class ExtModule { +export default class ExtModule extends Content { - constructor(pluginInternals) { - this.__pluginInternals = pluginInternals; + constructor(internals) { + super(internals); this.__require = window.require(this.paths.mainPath); - this.hasSettings = false; } get type() { return 'module' } - get configs() { return this.__pluginInternals.configs } - get info() { return this.__pluginInternals.info } - get icon() { return this.info.icon } - get paths() { return this.__pluginInternals.paths } - get main() { return this.__pluginInternals.main } - get defaultConfig() { return this.configs.defaultConfig } - get userConfig() { return this.configs.userConfig } - get configSchemes() { return this.configs.schemes } - get id() { return this.info.id || this.name.toLowerCase().replace(/[^a-zA-Z0-9-]/g, '-').replace(/--/g, '-') } - get name() { return this.info.name } - get description() { return this.info.description } - get authors() { return this.info.authors } - get version() { return this.info.version } - get contentPath() { return this.paths.contentPath } - get modulePath() { return this.paths.contentPath } - get dirName() { return this.paths.dirName } - get enabled() { return true } - get config() { return this.userConfig.config || [] } - get data() { return this.userConfig.data || (this.userConfig.data = {}) } - get events() { return this.EventEmitter ? this.EventEmitter : (this.EventEmitter = new AsyncEventEmitter()) } } diff --git a/client/src/modules/plugin.js b/client/src/modules/plugin.js index 889e2b4d..7b786eff 100644 --- a/client/src/modules/plugin.js +++ b/client/src/modules/plugin.js @@ -8,93 +8,19 @@ * LICENSE file in the root directory of this source tree. */ -import { Utils, FileUtils, AsyncEventEmitter } from 'common'; -import { Modals } from 'ui'; -import { EventEmitter } from 'events'; import PluginManager from './pluginmanager'; -import { SettingUpdatedEvent, SettingsUpdatedEvent } from 'structs'; +import Content from './content'; -export default class Plugin { - - constructor(pluginInternals) { - this.__pluginInternals = pluginInternals; - this.saveConfiguration = this.saveConfiguration.bind(this); - this.hasSettings = this.config && this.config.length > 0; - this.start = this.start.bind(this); - this.stop = this.stop.bind(this); - - this.settings.on('setting-updated', event => this.events.emit('setting-updated', event)); - this.settings.on('settings-updated', event => this.events.emit('settings-updated', event)); - this.settings.on('settings-updated', event => this.saveConfiguration()); - } +export default class Plugin extends Content { get type() { return 'plugin' } - get configs() { return this.__pluginInternals.configs } - get info() { return this.__pluginInternals.info } - get icon() { return this.info.icon } - get paths() { return this.__pluginInternals.paths } - get main() { return this.__pluginInternals.main } - get defaultConfig() { return this.configs.defaultConfig } - get userConfig() { return this.configs.userConfig } - get configSchemes() { return this.configs.schemes } - get id() { return this.info.id || this.name.toLowerCase().replace(/[^a-zA-Z0-9-]/g, '-').replace(/--/g, '-') } - get name() { return this.info.name } - get description() { return this.info.description } - get authors() { return this.info.authors } - get version() { return this.info.version } - get contentPath() { return this.paths.contentPath } - get pluginPath() { return this.paths.contentPath } - get dirName() { return this.paths.dirName } - get enabled() { return this.userConfig.enabled } - get settings() { return this.userConfig.config } - get config() { return this.settings.settings } + + // Don't use - these will eventually be removed! + get pluginPath() { return this.contentPath } get pluginConfig() { return this.config } - get data() { return this.userConfig.data || (this.userConfig.data = {}) } - get exports() { return this._exports ? this._exports : (this._exports = this.getExports()) } - get events() { return this.EventEmitter ? this.EventEmitter : (this.EventEmitter = new AsyncEventEmitter()) } - showSettingsModal() { - return Modals.contentSettings(this); - } - - async saveConfiguration() { - try { - await FileUtils.writeFile(`${this.pluginPath}/user.config.json`, JSON.stringify({ - enabled: this.enabled, - config: this.settings.strip().settings, - data: this.data - })); - - this.settings.setSaved(); - } catch (err) { - console.error(`Plugin ${this.id} configuration failed to save`, err); - throw err; - } - } - - start(save = true) { - if (this.onstart && !this.onstart()) return false; - if (this.onStart && !this.onStart()) return false; - - if (!this.enabled) { - this.userConfig.enabled = true; - if (save) this.saveConfiguration(); - } - - return true; - } - - stop(save = true) { - if (this.onstop && !this.onstop()) return false; - if (this.onStop && !this.onStop()) return false; - - if (this.enabled) { - this.userConfig.enabled = false; - if (save) this.saveConfiguration(); - } - - return true; - } + get start() { return this.enable } + get stop() { return this.disable } unload() { PluginManager.unloadPlugin(this); diff --git a/client/src/modules/pluginapi.js b/client/src/modules/pluginapi.js index 7b628259..dd07cf35 100644 --- a/client/src/modules/pluginapi.js +++ b/client/src/modules/pluginapi.js @@ -14,6 +14,7 @@ import ExtModuleManager from './extmodulemanager'; import PluginManager from './pluginmanager'; import ThemeManager from './thememanager'; import Events from './events'; +import WebpackModules from './webpackmodules'; import { SettingsSet, SettingsCategory, Setting, SettingsScheme } from 'structs'; import { Modals, DOM } from 'ui'; import SettingsModal from '../ui/components/bd/modals/SettingsModal.vue'; @@ -63,6 +64,24 @@ export default class PluginApi { return PluginManager.getPluginById(this.pluginInfo.id || this.pluginInfo.name.toLowerCase().replace(/[^a-zA-Z0-9-]/g, '-').replace(/--/g, '-')); } + async bridge(plugin_id) { + const plugin = await PluginManager.waitForPlugin(plugin_id); + return plugin.bridge; + } + + get require() { return this.import } + import(m) { + const module = ExtModuleManager.findModule(m); + if (module && module.__require) return module.__require; + return null; + } + + get Api() { return this } + + /** + * Logger + */ + loggerLog(...message) { Logger.log(this.pluginInfo.name, message) } loggerErr(...message) { Logger.err(this.pluginInfo.name, message) } loggerWarn(...message) { Logger.warn(this.pluginInfo.name, message) } @@ -78,6 +97,10 @@ export default class PluginApi { }; } + /** + * Utils + */ + get Utils() { return { overload: () => Utils.overload.apply(Utils, arguments), @@ -92,8 +115,12 @@ export default class PluginApi { }; } + /** + * Settings + */ + createSettingsSet(args, ...merge) { - return new SettingsSet(args, ...merge); + return new SettingsSet(args || {}, ...merge); } createSettingsCategory(args, ...merge) { return new SettingsCategory(args, ...merge); @@ -106,13 +133,17 @@ export default class PluginApi { } get Settings() { return { - createSet: this.createSet.bind(this), + createSet: this.createSettingsSet.bind(this), createCategory: this.createSettingsCategory.bind(this), createSetting: this.createSetting.bind(this), createScheme: this.createSettingsScheme.bind(this) }; } + /** + * InternalSettings + */ + getInternalSetting(set, category, setting) { return Settings.get(set, category, setting); } @@ -122,6 +153,10 @@ export default class PluginApi { }; } + /** + * CssUtils + */ + get injectedStyles() { return this._injectedStyles || (this._injectedStyles = []); } @@ -169,36 +204,48 @@ export default class PluginApi { }; } + /** + * Modals + */ + get modalStack() { return this._modalStack || (this._modalStack = []); } + get baseModalComponent() { + return Modals.baseComponent; + } addModal(_modal, component) { const modal = Modals.add(_modal, component); modal.close = force => this.closeModal(modal, force); + modal.on('close', () => { + let index; + while ((index = this.modalStack.findIndex(m => m === modal)) > -1) + this.modalStack.splice(index, 1); + }); this.modalStack.push(modal); return modal; } - async closeModal(modal, force) { - await Modals.close(modal, force); - this._modalStack = this.modalStack.filter(m => m !== modal); + closeModal(modal, force) { + return Modals.close(modal, force); } - closeAllModals() { + closeAllModals(force) { + const promises = []; for (let modal of this.modalStack) - modal.close(); + promises.push(modal.close(force)); + return Promise.all(promises); } - closeLastModal() { + closeLastModal(force) { if (!this.modalStack.length) return; - this.modalStack[this.modalStack.length - 1].close(); + return this.modalStack[this.modalStack.length - 1].close(force); + } + basicModal(title, text) { + return this.addModal(Modals.basic(title, text)); } settingsModal(settingsset, headertext, options) { - return this.addModal(Object.assign({ - headertext: headertext ? headertext : settingsset.headertext, - settings: settingsset, - schemes: settingsset.schemes - }, options), SettingsModal); + return this.addModal(Modals.settings(settingsset, headertext, options)); } get Modals() { - return Object.defineProperty({ + return Object.defineProperty(Object.defineProperty({ add: this.addModal.bind(this), close: this.closeModal.bind(this), closeAll: this.closeAllModals.bind(this), @@ -206,14 +253,20 @@ export default class PluginApi { settings: this.settingsModal.bind(this) }, 'stack', { get: () => this.modalStack + }), 'baseComponent', { + get: () => this.baseModalComponent }); } + /** + * Plugins + */ + async getPlugin(plugin_id) { // This should require extra permissions return await PluginManager.waitForPlugin(plugin_id); } - listPlugins(plugin_id) { + listPlugins() { return PluginManager.localContent.map(plugin => plugin.id); } get Plugins() { @@ -223,30 +276,75 @@ export default class PluginApi { }; } + /** + * Themes + */ + async getTheme(theme_id) { // This should require extra permissions return await ThemeManager.waitForContent(theme_id); } - listThemes(plugin_id) { + listThemes() { return ThemeManager.localContent.map(theme => theme.id); } get Themes() { return { getTheme: this.getTheme.bind(this), - getThemes: this.listThemes.bind(this) + listThemes: this.listThemes.bind(this) }; } - async bridge(plugin_id) { - const plugin = await PluginManager.waitForPlugin(plugin_id); - return plugin.bridge; + /** + * ExtModules + */ + + async getModule(module_id) { + // This should require extra permissions + return await ExtModuleManager.waitForContent(module_id); + } + listModules() { + return ExtModuleManager.localContent.map(module => module.id); + } + get ExtModules() { + return { + getModule: this.getModule.bind(this), + listModules: this.listModules.bind(this) + }; } - get require() { return this.import } - import(m) { - const module = ExtModuleManager.findModule(m); - if (module && module.__require) return module.__require; - return null; + /** + * WebpackModules + */ + + get webpackRequire() { + return WebpackModules.require; + } + getWebpackModule(filter, first = true) { + return WebpackModules.getModule(filter, first); + } + getWebpackModuleByName(name, fallback) { + return WebpackModules.getModuleByName(name, fallback); + } + getWebpackModuleByRegex(regex, first = true) { + return WebpackModules.getModuleByRegex(regex, first); + } + getWebpackModuleByProperties(props, first = true) { + return WebpackModules.getModuleByProps(props, first); + } + getWebpackModuleByPrototypeFields(props, first = true) { + return WebpackModules.getModuleByPrototypes(props, first); + } + get WebpackModules() { + return Object.defineProperty({ + getModule: this.getWebpackModule.bind(this), + getModuleByName: this.getWebpackModuleByName.bind(this), + getModuleByDisplayName: this.getWebpackModuleByName.bind(this), + getModuleByRegex: this.getWebpackModuleByRegex.bind(this), + getModuleByProperties: this.getWebpackModuleByProperties.bind(this), + getModuleByPrototypeFields: this.getWebpackModuleByPrototypeFields.bind(this) + }, 'require', { + get: () => this.webpackRequire + }); } } diff --git a/client/src/modules/pluginmanager.js b/client/src/modules/pluginmanager.js index d042f915..6f8a3b12 100644 --- a/client/src/modules/pluginmanager.js +++ b/client/src/modules/pluginmanager.js @@ -41,11 +41,13 @@ export default class extends ContentManager { const loadAll = await this.loadAllContent(true); this.loaded = true; for (let plugin of this.localPlugins) { + if (!plugin.enabled) continue; + plugin.userConfig.enabled = false; + try { - if (plugin.enabled) plugin.start(); + plugin.start(false); } catch (err) { // Disable the plugin but don't save it - the next time BetterDiscord is started the plugin will attempt to start again - plugin.userConfig.enabled = false; this.errors.push(new ErrorEvent({ module: this.moduleName, message: `Failed to start ${plugin.name}`, @@ -72,7 +74,6 @@ export default class extends ContentManager { static get loadContent() { return this.loadPlugin } static async loadPlugin(paths, configs, info, main, dependencies, permissions) { - if (permissions && permissions.length > 0) { for (let perm of permissions) { console.log(`Permission: ${Permissions.permissionText(perm).HEADER} - ${Permissions.permissionText(perm).BODY}`); @@ -107,7 +108,10 @@ export default class extends ContentManager { } }); - if (instance.enabled && this.loaded) instance.start(); + if (instance.enabled && this.loaded) { + instance.userConfig.enabled = false; + instance.start(false); + } return instance; } diff --git a/client/src/modules/theme.js b/client/src/modules/theme.js index da96e5ae..bd68cef9 100644 --- a/client/src/modules/theme.js +++ b/client/src/modules/theme.js @@ -8,26 +8,17 @@ * LICENSE file in the root directory of this source tree. */ +import Content from './content'; import Settings from './settings'; import ThemeManager from './thememanager'; -import { EventEmitter } from 'events'; -import { SettingUpdatedEvent, SettingsUpdatedEvent } from 'structs'; -import { DOM, Modals } from 'ui'; -import { Utils, FileUtils, ClientIPC, ClientLogger as Logger, AsyncEventEmitter } from 'common'; +import { DOM } from 'ui'; +import { FileUtils, ClientIPC, ClientLogger as Logger } from 'common'; import filewatcher from 'filewatcher'; -export default class Theme { +export default class Theme extends Content { - constructor(themeInternals) { - this.__themeInternals = themeInternals; - this.hasSettings = this.config && this.config.length > 0; - this.saveConfiguration = this.saveConfiguration.bind(this); - this.enable = this.enable.bind(this); - this.disable = this.disable.bind(this); - - this.settings.on('setting-updated', event => this.events.emit('setting-updated', event)); - this.settings.on('settings-updated', event => this.events.emit('settings-updated', event)); - this.settings.on('settings-updated', event => this.recompile()); + constructor(internals) { + super(internals); const watchfiles = Settings.getSetting('css', 'default', 'watch-files'); if (watchfiles.value) this.watchfiles = this.files; @@ -37,63 +28,39 @@ export default class Theme { }); } - get configs() { return this.__themeInternals.configs } - get info() { return this.__themeInternals.info } - get icon() { return this.info.icon } - get paths() { return this.__themeInternals.paths } - get main() { return this.__themeInternals.main } - get loaded() { return this.__themeInternals.loaded } - get defaultConfig() { return this.configs.defaultConfig } - get userConfig() { return this.configs.userConfig } - get configSchemes() { return this.configs.schemes } - get id() { return this.info.id || this.name.toLowerCase().replace(/[^a-zA-Z0-9-]/g, '-').replace(/\s+/g, '-') } - get name() { return this.info.name } - get description() { return this.info.description } - get authors() { return this.info.authors } - get version() { return this.info.version } - get contentPath() { return this.paths.contentPath } - get themePath() { return this.paths.contentPath } - get dirName() { return this.paths.dirName } - get enabled() { return this.userConfig.enabled } - get settings() { return this.userConfig.config } - get config() { return this.settings.settings } - get themeConfig() { return this.config } - get data() { return this.userConfig.data || (this.userConfig.data = {}) } + get type() { return 'theme' } get css() { return this.data.css } - get events() { return this.EventEmitter ? this.EventEmitter : (this.EventEmitter = new AsyncEventEmitter()) } - showSettingsModal() { - return Modals.contentSettings(this); + // Don't use - these will eventually be removed! + get themePath() { return this.contentPath } + get themeConfig() { return this.config } + + /** + * Called when settings are updated. + * This can be overridden by other content types. + */ + __settingsUpdated(event) { + return this.recompile(); } - async saveConfiguration() { - try { - await FileUtils.writeFile(`${this.themePath}/user.config.json`, JSON.stringify({ - enabled: this.enabled, - config: this.settings.strip().settings, - data: this.data - })); - - this.settings.setSaved(); - } catch (err) { - throw err; - } - } - - enable(save = true) { - if (!this.enabled) { - this.userConfig.enabled = true; - if (save) this.saveConfiguration(); - } + /** + * This is called when the theme is enabled. + */ + onstart() { DOM.injectTheme(this.css, this.id); } - disable(save = true) { - this.userConfig.enabled = false; - if (save) this.saveConfiguration(); + /** + * This is called when the theme is disabled. + */ + onstop() { DOM.deleteTheme(this.id); } + /** + * Compiles the theme and returns an object containing the CSS and an array of files that were included. + * @return {Promise} + */ async compile() { console.log('Compiling CSS'); @@ -117,11 +84,15 @@ export default class Theme { }; } else { return { - css: FileUtils.readFile(this.paths.mainPath) + css: await FileUtils.readFile(this.paths.mainPath) }; } } + /** + * Compiles the theme and updates and saves the CSS and the list of include files. + * @return {Promise} + */ async recompile() { const data = await this.compile(); this.data.css = data.css; @@ -136,7 +107,7 @@ export default class Theme { } /** - * An array of files that are imported in custom CSS. + * An array of files that are imported in the theme's SCSS. * @return {Array} Files being watched */ get files() { @@ -144,7 +115,7 @@ export default class Theme { } /** - * Sets all files that are imported in custom CSS. + * Sets all files that are imported in the theme's SCSS. * @param {Array} files Files to watch */ set files(files) { diff --git a/client/src/modules/webpackmodules.js b/client/src/modules/webpackmodules.js index 8416f810..1f173c4d 100644 --- a/client/src/modules/webpackmodules.js +++ b/client/src/modules/webpackmodules.js @@ -86,7 +86,6 @@ const KnownModules = { UserActivityStore: Filters.byProperties(['getActivity']), UserNameResolver: Filters.byProperties(['getName']), - /* Emoji Store and Utils */ EmojiInfo: Filters.byProperties(['isEmojiDisabled']), EmojiUtils: Filters.byProperties(['diversitySurrogate']), @@ -97,7 +96,6 @@ const KnownModules = { InviteResolver: Filters.byProperties(['findInvite']), InviteActions: Filters.byProperties(['acceptInvite']), - /* Discord Objects & Utils */ DiscordConstants: Filters.byProperties(["Permissions", "ActivityTypes", "StatusTypes"]), Permissions: Filters.byProperties(['getHighestRole']), @@ -122,7 +120,6 @@ const KnownModules = { ExperimentsManager: Filters.byProperties(['isDeveloper']), CurrentExperiment: Filters.byProperties(['getExperimentId']), - /* Images, Avatars and Utils */ ImageResolver: Filters.byProperties(["getUserAvatarURL"]), ImageUtils: Filters.byProperties(['getSizedImageSrc']), @@ -176,7 +173,6 @@ const KnownModules = { URLParser: Filters.byProperties(['Url', 'parse']), ExtraURLs: Filters.byProperties(['getArticleURL']), - /* DOM/React Components */ /* ==================== */ UserSettingsWindow: Filters.byProperties(['open', 'updateAccount']), @@ -201,60 +197,114 @@ const KnownModules = { ExternalLink: Filters.byCode(/\.trusted\b/) }; -export default class { - /* Synchronous */ +export default class WebpackModules { + + /** + * Finds a module using a filter function. + * @param {Function} filter A function to use to filter modules + * @param {Boolean} first Whether to return only the first matching module + * @return {Any} + */ + static getModule(filter, first = true) { + const modules = this.getAllModules(); + const rm = []; + for (let index in modules) { + if (!modules.hasOwnProperty(index)) continue; + const module = modules[index]; + const { exports } = module; + let foundModule = null; + + if (!exports) continue; + if (exports.__esModule && exports.default && filter(exports.default)) foundModule = exports.default; + if (filter(exports)) foundModule = exports; + if (!foundModule) continue; + if (first) return foundModule; + rm.push(foundModule); + } + return first || rm.length == 0 ? undefined : rm; + } + + /** + * Finds a module by it's name. + * @param {String} name The name of the module + * @param {Function} fallback A function to use to filter modules if not finding a known module + * @return {Any} + */ static getModuleByName(name, fallback) { if (Cache.hasOwnProperty(name)) return Cache[name]; if (KnownModules.hasOwnProperty(name)) fallback = KnownModules[name]; - if (!fallback) return null; - return Cache[name] = this.getModule(fallback, true); + if (!fallback) return undefined; + const module = this.getModule(fallback, true); + return module ? Cache[name] = module : undefined; } + /** + * Finds a module by it's display name. + * @param {String} name The display name of the module + * @return {Any} + */ static getModuleByDisplayName(name) { return this.getModule(Filters.byDisplayName(name), true); } + /** + * Finds a module using it's code. + * @param {RegEx} regex A regular expression to use to filter modules + * @param {Boolean} first Whether to return the only the first matching module + * @return {Any} + */ static getModuleByRegex(regex, first = true) { return this.getModule(Filters.byCode(regex), first); } + /** + * Finds a module using properties on it's prototype. + * @param {Array} props Properties to use to filter modules + * @param {Boolean} first Whether to return only the first matching module + * @return {Any} + */ static getModuleByPrototypes(prototypes, first = true) { return this.getModule(Filters.byPrototypeFields(prototypes), first); } + /** + * Finds a module using it's own properties. + * @param {Array} props Properties to use to filter modules + * @param {Boolean} first Whether to return only the first matching module + * @return {Any} + */ static getModuleByProps(props, first = true) { return this.getModule(Filters.byProperties(props), first); } - static getModule(filter, first = true) { - const modules = this.getAllModules(); - const rm = []; - for (let index in modules) { - if (!modules.hasOwnProperty(index)) continue; - const module = modules[index]; - const { exports } = module; - let foundModule = null; - - if (!exports) continue; - if (exports.__esModule && exports.default && filter(exports.default)) foundModule = exports.default; - if (filter(exports)) foundModule = exports; - if (!foundModule) continue; - if (first) return foundModule; - rm.push(foundModule); - } - return first || rm.length == 0 ? null : rm; - } - - static getAllModules() { + /** + * Discord's __webpack_require__ function. + */ + static get require() { + if (this._require) return this._require; const id = 'bd-webpackmodules'; - const __webpack_require__ = window['webpackJsonp']( - [], - { - [id]: (module, exports, __webpack_require__) => exports.default = __webpack_require__ - }, - [id]).default; + const __webpack_require__ = window['webpackJsonp']([], { + [id]: (module, exports, __webpack_require__) => exports.default = __webpack_require__ + }, [id]).default; delete __webpack_require__.m[id]; delete __webpack_require__.c[id]; - return __webpack_require__.c; + return this._require = __webpack_require__; } + + /** + * Returns all loaded modules. + * @return {Array} + */ + static getAllModules() { + return this.require.c; + } + + /** + * Returns an array of known modules. + * @return {Array} + */ + static listKnownModules() { + return Object.keys(KnownModules); + } + } diff --git a/client/src/structs/settings/settingscategory.js b/client/src/structs/settings/settingscategory.js index 4ac07453..e9ecb7fa 100644 --- a/client/src/structs/settings/settingscategory.js +++ b/client/src/structs/settings/settingscategory.js @@ -9,6 +9,7 @@ */ import Setting from './setting'; +import BaseSetting from './types/basesetting'; import { ClientLogger as Logger, AsyncEventEmitter } from 'common'; import { SettingUpdatedEvent, SettingsUpdatedEvent } from 'structs'; @@ -24,17 +25,12 @@ export default class SettingsCategory { this._merge(newCategory); } + this.__settingUpdated = this.__settingUpdated.bind(this); + this.__settingsUpdated = this.__settingsUpdated.bind(this); + for (let setting of this.settings) { - setting.on('setting-updated', ({ value, old_value }) => this.emit('setting-updated', new SettingUpdatedEvent({ - category: this, category_id: this.id, - setting, setting_id: setting.id, - value, old_value - }))); - setting.on('settings-updated', ({ updatedSettings }) => this.emit('settings-updated', new SettingsUpdatedEvent({ - updatedSettings: updatedSettings.map(updatedSetting => new SettingUpdatedEvent(Object.assign({ - category: this, category_id: this.id - }, updatedSetting))) - }))); + setting.on('setting-updated', this.__settingUpdated); + setting.on('settings-updated', this.__settingsUpdated); } } @@ -53,7 +49,7 @@ export default class SettingsCategory { * Category name */ get name() { - return this.args.category_name; + return this.args.name || this.args.category_name; } get category_name() { @@ -83,6 +79,82 @@ export default class SettingsCategory { return false; } + /** + * Setting event listeners. + * This only exists for use by the constructor and settingscategory.addSetting. + */ + __settingUpdated({ setting, value, old_value }) { + return this.emit('setting-updated', new SettingUpdatedEvent({ + category: this, category_id: this.id, + setting, setting_id: setting.id, + value, old_value + })); + } + + __settingsUpdated({ updatedSettings }) { + return this.emit('settings-updated', new SettingsUpdatedEvent({ + updatedSettings: updatedSettings.map(updatedSetting => new SettingUpdatedEvent(Object.assign({ + category: this, category_id: this.id + }, updatedSetting))) + })); + } + + /** + * Dynamically adds a setting to this category. + * @param {Setting} category The setting to add to this category + * @param {Number} index The index to add the setting at (optional) + * @return {Promise} + */ + async addSetting(setting, index) { + if (this.settings.find(s => s === setting)) return; + + if (!(setting instanceof BaseSetting)) + setting = new Setting(setting); + + if (this.getSetting(setting.id)) + throw {message: 'A setting with this ID already exists.'}; + + setting.on('setting-updated', this.__settingUpdated); + setting.on('settings-updated', this.__settingsUpdated); + + if (index === undefined) index = this.settings.length; + this.settings.splice(index, 0, setting); + + const event = { + category: this, category_id: this.id, + setting, setting_id: setting.id, + at_index: index + }; + + await setting.emit('added-to', event); + await this.emit('added-setting', event); + return setting; + } + + /** + * Dynamically removes a setting from this category. + * @param {Setting} setting The setting to remove from this category + * @return {Promise} + */ + async removeSetting(setting) { + setting.off('setting-updated', this.__settingUpdated); + setting.off('settings-updated', this.__settingsUpdated); + + let index; + while ((index = this.settings.findIndex(s => s === setting)) > -1) { + this.settings.splice(index, 0); + } + + const event = { + set: this, set_id: this.id, + category: this, category_id: this.id, + from_index: index + }; + + await setting.emit('removed-from', event); + await this.emit('removed-category', event); + } + /** * Returns the first setting where calling {function} returns true. * @param {Function} function A function to call to filter settings @@ -107,7 +179,7 @@ export default class SettingsCategory { * @return {Setting} */ getSetting(id) { - return this.findSetting(setting => setting.id === id); + return this.find(setting => setting.id === id); } /** diff --git a/client/src/structs/settings/settingsset.js b/client/src/structs/settings/settingsset.js index e488f6ef..5b324a69 100644 --- a/client/src/structs/settings/settingsset.js +++ b/client/src/structs/settings/settingsset.js @@ -27,18 +27,16 @@ export default class SettingsSet { this._merge(newSet); } + this.__settingUpdated = this.__settingUpdated.bind(this); + this.__settingsUpdated = this.__settingsUpdated.bind(this); + this.__addedSetting = this.__addedSetting.bind(this); + this.__removedSetting = this.__removedSetting.bind(this); + for (let category of this.categories) { - category.on('setting-updated', ({ setting, value, old_value }) => this.emit('setting-updated', new SettingUpdatedEvent({ - set: this, set_id: this.id, - category, category_id: category.id, - setting, setting_id: setting.id, - value, old_value - }))); - category.on('settings-updated', ({ updatedSettings }) => this.emit('settings-updated', new SettingsUpdatedEvent({ - updatedSettings: updatedSettings.map(updatedSetting => new SettingUpdatedEvent(Object.assign({ - set: this, set_id: this.id - }, updatedSetting))) - }))); + category.on('setting-updated', this.__settingUpdated); + category.on('settings-updated', this.__settingsUpdated); + category.on('added-setting', this.__addedSetting); + category.on('removed-setting', this.__removedSetting); } } @@ -101,6 +99,149 @@ export default class SettingsSet { return false; } + /** + * Category event listeners. + * These only exists for use by the constructor and settingsset.addCategory. + */ + __settingUpdated({ category, setting, value, old_value }) { + return this.emit('setting-updated', new SettingUpdatedEvent({ + set: this, set_id: this.id, + category, category_id: category.id, + setting, setting_id: setting.id, + value, old_value + })); + } + + __settingsUpdated({ updatedSettings }) { + return this.emit('settings-updated', new SettingsUpdatedEvent({ + updatedSettings: updatedSettings.map(updatedSetting => new SettingUpdatedEvent(Object.assign({ + set: this, set_id: this.id + }, updatedSetting))) + })); + } + + __addedSetting({ category, setting, at_index }) { + return this.emit('added-setting', { + set: this, set_id: this.id, + category, category_id: category.id, + setting, setting_id: setting.id, + at_index + }); + } + + __removedSetting({ category, setting, from_index }) { + return this.emit('removed-setting', { + set: this, set_id: this.id, + category, category_id: category.id, + setting, setting_id: setting.id, + from_index + }); + } + + /** + * Dynamically adds a category to this set. + * @param {SettingsCategory} category The category to add to this set + * @param {Number} index The index to add the category at (optional) + * @return {Promise} + */ + async addCategory(category, index) { + if (this.categories.find(c => c === category)) return; + + if (!(category instanceof SettingsCategory)) + category = new SettingsCategory(category); + + if (this.getCategory(category.id)) + throw {message: 'A category with this ID already exists.'}; + + category.on('setting-updated', this.__settingUpdated); + category.on('settings-updated', this.__settingsUpdated); + category.on('added-setting', this.__addedSetting); + category.on('removed-setting', this.__removedSetting); + + if (index === undefined) index = this.categories.length; + this.categories.splice(index, 0, category); + + const event = { + set: this, set_id: this.id, + category, category_id: category.id, + at_index: index + }; + + await category.emit('added-to', event); + await this.emit('added-category', event); + return category; + } + + /** + * Dynamically removes a category from this set. + * @param {SettingsCategory} category The category to remove from this set + * @return {Promise} + */ + async removeCategory(category) { + category.off('setting-updated', this.__settingUpdated); + category.off('settings-updated', this.__settingsUpdated); + category.off('added-setting', this.__addedSetting); + category.off('removed-setting', this.__removedSetting); + + let index; + while ((index = this.categories.findIndex(c => c === category)) > -1) { + this.categories.splice(index, 0); + } + + const event = { + set: this, set_id: this.id, + category, category_id: category.id, + from_index: index + }; + + await category.emit('removed-from', event); + await this.emit('removed-category', event); + } + + /** + * Dynamically adds a scheme to this set. + * @param {SettingsScheme} scheme The scheme to add to this set + * @param {Number} index The index to add the scheme at (optional) + * @return {Promise} + */ + async addScheme(scheme, index) { + if (this.schemes.find(c => c === scheme)) return; + + if (!(scheme instanceof SettingsScheme)) + scheme = new SettingsScheme(scheme); + + if (this.schemes.find(s => s.id === scheme.id)) + throw {message: 'A scheme with this ID already exists.'}; + + if (index === undefined) index = this.schemes.length; + this.schemes.splice(index, 0, scheme); + + await this.emit('added-scheme', { + set: this, set_id: this.id, + scheme, scheme_id: scheme.id, + at_index: index + }); + return scheme; + } + + /** + * Dynamically removes a scheme from this set. + * @param {SettingsScheme} scheme The scheme to remove from this set + * @return {Promise} + */ + async removeScheme(scheme) { + let index; + while ((index = this.schemes.findIndex(s => s === scheme)) > -1) { + this.schemes.splice(index, 0); + } + + await this.emit('removed-scheme', { + set: this, set_id: this.id, + scheme, scheme_id: scheme.id, + from_index: index + }); + } + /** * Returns the first category where calling {function} returns true. * @param {Function} function A function to call to filter categories diff --git a/client/src/structs/settings/types/array.js b/client/src/structs/settings/types/array.js index e06ae06f..6b128864 100644 --- a/client/src/structs/settings/types/array.js +++ b/client/src/structs/settings/types/array.js @@ -14,6 +14,7 @@ import Setting from './basesetting'; import SettingsSet from '../settingsset'; import SettingsCategory from '../settingscategory'; import SettingsScheme from '../settingsscheme'; +import { SettingsUpdatedEvent } from 'structs'; export default class ArraySetting extends Setting { @@ -108,21 +109,27 @@ export default class ArraySetting extends Setting { * @param {SettingsSet} item Values to merge into the new set (optional) * @return {SettingsSet} The new set */ - addItem(item) { - const newItem = this.createItem(item); - this.args.items.push(newItem); - this.updateValue(); - return newItem; + async addItem(_item) { + const item = this.createItem(_item); + this.args.items.push(item); + await this.updateValue(); + + await this.emit('item-added', { item }); + + return item; } /** * Removes a set from this array setting. * This ignores the minimum value. * @param {SettingsSet} item The set to remove + * @return {Promise} */ - removeItem(item) { + async removeItem(item) { this.args.items = this.items.filter(i => i !== item); - this.updateValue(); + await this.updateValue(); + + await this.emit('item-removed', { item }); } /** @@ -135,24 +142,84 @@ export default class ArraySetting extends Setting { return item; const set = new SettingsSet({ + id: item ? item.args ? item.args.id : item.id : Math.random(), settings: Utils.deepclone(this.settings), schemes: this.schemes }, item ? item.args || item : undefined); set.setSaved(); - set.on('settings-updated', () => this.updateValue()); + set.on('settings-updated', async event => { + await this.emit('item-updated', { item: set, event, updatedSettings: event.updatedSettings }); + if (event.args.updating_array !== this) await this.updateValue(); + }); return set; } + /** + * Function to be called after the value changes. + * This can be overridden by other settings types. + * This function is used when the value needs to be updated synchronously (basically just in the constructor - so there won't be any events to emit anyway). + * @param {SettingUpdatedEvent} updatedSetting + */ + setValueHookSync(updatedSetting) { + this.args.items = updatedSetting.value ? updatedSetting.value.map(item => this.createItem(item)) : []; + } + /** * Function to be called after the value changes. * This can be overridden by other settings types. * @param {SettingUpdatedEvent} updatedSetting */ - setValueHook(updatedSetting) { - this.args.items = updatedSetting.value ? updatedSetting.value.map(item => this.createItem(item)) : []; + async setValueHook(updatedSetting) { + const newItems = []; + let error; + + for (let newItem of updatedSetting.value) { + try { + const item = this.items.find(i => i.id && i.id === newItem.id); + + if (item) { + // Merge the new item into the original item + newItems.push(item); + const updatedSettings = await item.merge(newItem, false); + if (!updatedSettings.length) continue; + + const event = new SettingsUpdatedEvent({ + updatedSettings, + updating_array: this + }); + + await item.emit('settings-updated', event); + // await this.emit('item-updated', { item, event, updatedSettings }); + } else { + // Add a new item + const item = this.createItem(newItem); + newItems.push(item); + await this.emit('item-added', { item }); + } + } catch (e) { error = e; } + } + + for (let item of this.items) { + if (newItems.includes(item)) continue; + + try { + // Item removed + await this.emit('item-removed', { item }); + } catch (e) { error = e; } + } + + this.args.items = newItems; + + // We can't throw anything before the items array is updated, otherwise the array setting would be in an inconsistent state where the values in this.items wouldn't match the values in this.value + if (error) throw error; } + // emit(...args) { + // console.log('Emitting event', args[0], 'with data', args[1]); + // return this.emitter.emit(...args); + // } + /** * Updates the value of this array setting. * This only exists for use by array settings. diff --git a/client/src/structs/settings/types/basesetting.js b/client/src/structs/settings/types/basesetting.js index 19a74a10..b174bcea 100644 --- a/client/src/structs/settings/types/basesetting.js +++ b/client/src/structs/settings/types/basesetting.js @@ -105,9 +105,9 @@ export default class Setting { * Merges a setting into this setting without emitting events (and therefore synchronously). * This only exists for use by the constructor and SettingsCategory. */ - _merge(newSetting) { + _merge(newSetting, hook = true) { const value = newSetting.args ? newSetting.args.value : newSetting.value; - return this._setValue(value); + return this._setValue(value, hook); } /** @@ -116,12 +116,13 @@ export default class Setting { * @return {Promise} */ async merge(newSetting, emit_multi = true, emit = true) { - const updatedSettings = this._merge(newSetting); + const updatedSettings = this._merge(newSetting, false); if (!updatedSettings.length) return []; - const updatedSetting = updatedSettings[0]; + + await this.setValueHook(updatedSettings[0]); if (emit) - await this.emit('setting-updated', updatedSetting); + await this.emit('setting-updated', updatedSettings[0]); if (emit_multi) await this.emit('settings-updated', new SettingsUpdatedEvent({ @@ -135,7 +136,7 @@ export default class Setting { * Sets the value of this setting. * This only exists for use by the constructor and SettingsCategory. */ - _setValue(value) { + _setValue(value, hook = true) { const old_value = this.args.value; if (Utils.compare(value, old_value)) return []; this.args.value = value; @@ -146,7 +147,8 @@ export default class Setting { value, old_value }); - this.setValueHook(updatedSetting); + if (hook) + this.setValueHookSync(updatedSetting); return [updatedSetting]; } @@ -156,7 +158,8 @@ export default class Setting { * This can be overridden by other settings types. * @param {SettingUpdatedEvent} updatedSetting */ - setValueHook(updatedSetting) {} + async setValueHook(updatedSetting) {} + setValueHookSync(updatedSetting) {} /** * Sets the value of this setting. @@ -164,9 +167,11 @@ export default class Setting { * @return {Promise} */ async setValue(value, emit_multi = true, emit = true) { - const updatedSettings = this._setValue(value); + const updatedSettings = this._setValue(value, false); if (!updatedSettings.length) return []; + await this.setValueHook(updatedSettings[0]); + if (emit) await this.emit('setting-updated', updatedSettings[0]); diff --git a/client/src/styles/partials/bdsettings/contentview.scss b/client/src/styles/partials/bdsettings/contentview.scss new file mode 100644 index 00000000..090c9966 --- /dev/null +++ b/client/src/styles/partials/bdsettings/contentview.scss @@ -0,0 +1,20 @@ +.bd-pluginsview, +.bd-themesview { + .bd-online-ph { + display: flex; + flex-direction: column; + + h3 { + color: #fff; + font-weight: 700; + font-size: 20px; + text-align: center; + padding: 20px; + } + + a { + padding: 20px; + text-align: center; + } + } +} diff --git a/client/src/styles/partials/bdsettings/index.scss b/client/src/styles/partials/bdsettings/index.scss index 258a9019..ad0fe545 100644 --- a/client/src/styles/partials/bdsettings/index.scss +++ b/client/src/styles/partials/bdsettings/index.scss @@ -1,6 +1,6 @@ @import './button.scss'; @import './sidebarview.scss'; -@import './plugins.scss'; +@import './contentview.scss'; @import './card.scss'; @import './tooltips.scss'; @import './settings-schemes.scss'; diff --git a/client/src/styles/partials/bdsettings/plugins.scss b/client/src/styles/partials/bdsettings/plugins.scss deleted file mode 100644 index 7294c03e..00000000 --- a/client/src/styles/partials/bdsettings/plugins.scss +++ /dev/null @@ -1,55 +0,0 @@ -/*.bd-pluginsView { - .bd-button { - text-align: center; - background: transparent; - display: flex; - border-bottom: 2px solid #2b2d31; - align-items: center; - - h3 { - -webkit-user-select: none; - user-select: none; - display: block; - font-size: 1.17em; - margin-top: 1em; - margin-bottom: 1em; - margin-left: 0; - margin-right: 0; - font-weight: bold; - flex-grow: 1; - } - - .material-design-icon { - display: flex; - align-items: center; - fill: #fff; - } - - &:hover, - &.bd-active { - color: #fff; - background: transparent; - border-bottom: 2px solid #3e82e5; - } - } - - .bd-spinner-container { - display: flex; - flex-grow: 1; - align-items: center; - align-content: center; - justify-content: center; - - .bd-spinner-2 { - width: 200px; - height: 200px; - } - } -} - */ - -.bd-pluginsView { - - - -} \ No newline at end of file diff --git a/client/src/styles/partials/generic/drawers.scss b/client/src/styles/partials/generic/drawers.scss index 5b7dc9ba..cfe4fb94 100644 --- a/client/src/styles/partials/generic/drawers.scss +++ b/client/src/styles/partials/generic/drawers.scss @@ -50,11 +50,13 @@ } } - &.bd-drawer-open { + &.bd-animating { > .bd-drawer-contents-wrap { - overflow: visible; + overflow: hidden; } + } + &.bd-drawer-open { > .bd-drawer-header .bd-drawer-open-button { .bd-chevron-1 { svg { diff --git a/client/src/styles/partials/generic/forms/text.scss b/client/src/styles/partials/generic/forms/text.scss index 7709b624..738af627 100644 --- a/client/src/styles/partials/generic/forms/text.scss +++ b/client/src/styles/partials/generic/forms/text.scss @@ -54,7 +54,8 @@ } .bd-form-textarea { - .bd-form-textarea-wrap { + .bd-form-textarea-wrap, + textarea.bd-textarea { margin-top: 15px; background: rgba(0, 0, 0, 0.1); border: 1px solid rgba(0, 0, 0, 0.3); @@ -62,6 +63,7 @@ color: #b9bbbe; overflow-y: scroll; max-height: 140px; + transition: border-color .2s ease, color .2s ease; &:focus { color: #fff; @@ -71,9 +73,24 @@ @include scrollbar; } - div[contenteditable] { + div[contenteditable], + textarea { padding: 11px; cursor: text; + min-height: 45px; + } + + textarea { + background: transparent; + border: none; + resize: none; + outline: none; + width: 100%; + color: inherit; + font-size: inherit; + box-sizing: border-box; + overflow-y: visible; + max-height: 140px; } } diff --git a/client/src/ui/components/bd/PluginCard.vue b/client/src/ui/components/bd/PluginCard.vue index 901f3b30..a35cf18e 100644 --- a/client/src/ui/components/bd/PluginCard.vue +++ b/client/src/ui/components/bd/PluginCard.vue @@ -12,10 +12,10 @@ - + - + diff --git a/client/src/ui/components/bd/PluginsView.vue b/client/src/ui/components/bd/PluginsView.vue index 2a1684ec..322c7272 100644 --- a/client/src/ui/components/bd/PluginsView.vue +++ b/client/src/ui/components/bd/PluginsView.vue @@ -23,7 +23,7 @@
- +

Coming Soon

@@ -93,8 +93,10 @@ console.error(err); } }, - showSettings(plugin) { - return Modals.contentSettings(plugin); + showSettings(plugin, dont_clone) { + return Modals.contentSettings(plugin, null, { + dont_clone + }); } } } diff --git a/client/src/ui/components/bd/ThemeCard.vue b/client/src/ui/components/bd/ThemeCard.vue index 5776f4a0..2cb29f9d 100644 --- a/client/src/ui/components/bd/ThemeCard.vue +++ b/client/src/ui/components/bd/ThemeCard.vue @@ -12,10 +12,10 @@ - - + + - + diff --git a/client/src/ui/components/bd/ThemesView.vue b/client/src/ui/components/bd/ThemesView.vue index 2a7b2f46..8f0b6023 100644 --- a/client/src/ui/components/bd/ThemesView.vue +++ b/client/src/ui/components/bd/ThemesView.vue @@ -23,7 +23,7 @@
- +

Coming Soon

@@ -94,27 +94,11 @@ console.error(err); } }, - showSettings(theme) { - return Modals.contentSettings(theme); + showSettings(theme, dont_clone) { + return Modals.contentSettings(theme, null, { + dont_clone + }); } } } - - diff --git a/client/src/ui/components/bd/setting/Array.vue b/client/src/ui/components/bd/setting/Array.vue index d4f2c80f..3386f203 100644 --- a/client/src/ui/components/bd/setting/Array.vue +++ b/client/src/ui/components/bd/setting/Array.vue @@ -48,26 +48,14 @@ MiSettings, MiOpenInNew, MiMinus }, methods: { - addItem(openModal) { + async addItem(openModal) { if (this.setting.disabled || this.setting.max && this.setting.items.length >= this.setting.max) return; - const item = this.setting.addItem(); - if (openModal) this.showModal(item, this.setting.items.length); + const item = await this.setting.addItem(); + if (openModal) this.showModal(item, this.setting.items.length - 1); }, - removeItem(item) { + async removeItem(item) { if (this.setting.disabled || this.setting.min && this.setting.items.length <= this.setting.min) return; - this.setting.removeItem(item); - }, - changeInItem(item, category_id, setting_id, value) { - console.log('Setting', item, category_id, setting_id, 'to', value); - - const category = item.settings.find(c => c.category === category_id); - if (!category) return; - - const setting = category.settings.find(s => s.id === setting_id); - if (!setting || Utils.compare(setting.value, value)) return; - - setting.value = value; - setting.changed = !Utils.compare(setting.value, setting.old_value); + await this.setting.removeItem(item); }, showModal(item, index) { Modals.settings(item, this.setting.headertext ? this.setting.headertext.replace(/%n/, index + 1) : this.setting.text + ` #${index + 1}`); diff --git a/client/src/ui/components/bd/setting/Multiline.vue b/client/src/ui/components/bd/setting/Multiline.vue index a103e61a..567a3ff5 100644 --- a/client/src/ui/components/bd/setting/Multiline.vue +++ b/client/src/ui/components/bd/setting/Multiline.vue @@ -16,18 +16,22 @@
{{ setting.hint }}
-
-
{{ setting.value }}
-
+
diff --git a/client/src/ui/modals.js b/client/src/ui/modals.js index c3800cb7..6ebbaa0c 100644 --- a/client/src/ui/modals.js +++ b/client/src/ui/modals.js @@ -8,69 +8,142 @@ * LICENSE file in the root directory of this source tree. */ -import { Utils, FileUtils } from 'common'; +import { Utils, FileUtils, ClientLogger as Logger, 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(); - static add(modal, component) { - modal.component = modal.component || { + for (let key in _modal) + this[key] = _modal[key]; + + const modal = this; + this.component = this.component || { template: '', components: { 'custom-modal': component }, data() { return { modal }; }, - created() { + mounted() { modal.vueInstance = this; 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.close = this.close.bind(this); + 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)); + + let index; + while ((index = this.stack.findIndex(m => m === modal)) > -1) + this.stack.splice(index, 1); + + 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 +154,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 +172,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 +213,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 +228,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, options) { + return this.settings(content.settings, headertext ? headertext : content.name + ' Settings', options); } + /** + * An array of open modals. + */ static get stack() { - return this._stack ? this._stack : (this._stack = []); + return 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); + }); }); } diff --git a/tests/plugins/Example 4/config.json b/tests/plugins/Example 4/config.json new file mode 100644 index 00000000..c38da573 --- /dev/null +++ b/tests/plugins/Example 4/config.json @@ -0,0 +1,84 @@ +{ + "info": { + "id": "example-plugin-4", + "name": "Example Plugin 4", + "authors": [ + "Samuel Elliott" + ], + "version": 1.0, + "description": "Plugin for testing array setting events as the first example plugin has a lot of stuff in it now." + }, + "main": "index.js", + "type": "plugin", + "defaultConfig": [ + { + "category": "default", + "settings": [ + { + "id": "array-1", + "type": "array", + "text": "Test settings array", + "settings": [ + { + "category": "default", + "settings": [ + { + "id": "default-0", + "type": "bool", + "value": false, + "text": "Bool Test Setting 3", + "hint": "Bool Test Setting Hint 3" + }, + { + "id": "default-1", + "type": "text", + "value": "defaultValue", + "text": "Text Test Setting", + "hint": "Text Test Setting Hint" + } + ] + } + ], + "schemes": [ + { + "id": "scheme-1", + "name": "Test scheme", + "icon_url": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/Cow_female_black_white.jpg/220px-Cow_female_black_white.jpg", + "settings": [ + { + "category": "default", + "settings": [ + { + "id": "default-0", + "value": true + } + ] + } + ] + }, + { + "id": "scheme-2", + "name": "Another test scheme", + "icon_url": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/Cow_female_black_white.jpg/220px-Cow_female_black_white.jpg", + "settings": [ + { + "category": "default", + "settings": [ + { + "id": "default-0", + "value": false + }, + { + "id": "default-1", + "value": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/Cow_female_black_white.jpg/220px-Cow_female_black_white.jpg" + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/tests/plugins/Example 4/index.js b/tests/plugins/Example 4/index.js new file mode 100644 index 00000000..a42c60ed --- /dev/null +++ b/tests/plugins/Example 4/index.js @@ -0,0 +1,38 @@ +module.exports = (Plugin, { Logger, Settings }) => class extends Plugin { + async onstart() { + // Some array event examples + const arraySetting = this.settings.getSetting('default', 'array-1'); + Logger.log('Array setting', arraySetting); + arraySetting.on('item-added', event => Logger.log('Item', event.item, 'was added to the array setting')); + arraySetting.on('item-updated', event => Logger.log('Item', event.item, 'of the array setting was updated', event)); + arraySetting.on('item-removed', event => Logger.log('Item', event.item, 'removed from the array setting')); + + // Create a new settings set and show it in a modal + const set = Settings.createSet({}); + const category = await set.addCategory({ id: 'default' }); + + const setting = await category.addSetting({ + id: 'test', + type: 'text', + text: 'Enter some text', + multiline: true // Works better now + }); + + setting.on('setting-updated', event => Logger.log('Setting was changed to', event.value)); + + const scheme = await set.addScheme({ + id: 'scheme-1', + name: 'Test scheme', + icon_url: 'https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/Cow_female_black_white.jpg/220px-Cow_female_black_white.jpg', + settings: [{ category: 'default', settings: [{ id: 'test', value: 'Some\npresent\n\nmultiline\n\ntext' }] }] + }); + + set.on('settings-updated', async updatedSettings => { + Logger.log('Updated settings', updatedSettings); + await new Promise(resolve => setTimeout(resolve, 500)); + set.setSaved(); + }) + + set.showModal('Custom settings panel'); + } +}; diff --git a/tests/plugins/Example/index.js b/tests/plugins/Example/index.js index d94f12f3..38583eba 100644 --- a/tests/plugins/Example/index.js +++ b/tests/plugins/Example/index.js @@ -14,7 +14,7 @@ module.exports = (Plugin, Api, Vendor, Dependencies) => { Events.subscribe('TEST_EVENT', this.eventTest); Logger.log('onStart'); - Logger.log(`Plugin setting "default-0" value: ${this.getSetting('default-0')}`); + Logger.log(`Plugin setting "default-0" value: ${this.settings.get('default-0')}`); this.events.on('setting-updated', event => { console.log('Received plugin setting update:', event); }); @@ -92,14 +92,14 @@ module.exports = (Plugin, Api, Vendor, Dependencies) => { test1() { return 'It works!'; } test2() { return 'This works too!'; } - settingChanged(category, setting_id, value) { + settingChanged(event) { if (!this.enabled) return; - Logger.log(`${category}/${setting_id} changed to ${value}`); + Logger.log(`${event.category_id}/${event.setting_id} changed to ${event.value}`); } - settingsChanged(settings) { + settingsChanged(event) { if (!this.enabled) return; - Logger.log([ 'Settings updated', settings ]); + Logger.log([ 'Settings updated', event.updatedSettings ]); } get settingscomponent() {