Merge pull request #158 from samuelthomas2774/array-merging-dynamic-settingssets-categories-fixes-and-comments

Better array merging, changeable settings sets and categories, fixes and comments
This commit is contained in:
Alexei Stukov 2018-03-07 06:42:33 +02:00 committed by GitHub
commit f263cfac24
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1168 additions and 460 deletions

View File

@ -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);
}
}

View File

@ -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

View File

@ -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()) }
}

View File

@ -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);

View File

@ -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
});
}
}

View File

@ -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;
}

View File

@ -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) {

View File

@ -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);
}
}

View File

@ -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);
}
/**

View File

@ -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

View File

@ -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.

View File

@ -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]);

View File

@ -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;
}
}
}

View File

@ -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';

View File

@ -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 {
}

View File

@ -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 {

View File

@ -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;
}
}

View File

@ -12,10 +12,10 @@
<Card :item="plugin">
<SettingSwitch v-if="plugin.type === 'plugin'" slot="toggle" :checked="plugin.enabled" :change="togglePlugin" />
<ButtonGroup slot="controls">
<Button v-tooltip="'Settings'" v-if="plugin.hasSettings" :onClick="() => showSettings(plugin)"><MiSettings size="18" /></Button>
<Button v-tooltip="'Settings (shift + click to open settings without cloning the set)'" v-if="plugin.hasSettings" :onClick="e => showSettings(e.shiftKey)"><MiSettings size="18" /></Button>
<Button v-tooltip="'Reload'" :onClick="reloadPlugin"><MiRefresh size="18" /></Button>
<Button v-tooltip="'Edit'" :onClick="editPlugin"><MiPencil size="18" /></Button>
<Button v-tooltip="'Uninstall (shift + click to unload)'" :onClick="deletePlugin" type="err"><MiDelete size="18" /></Button>
<Button v-tooltip="'Uninstall (shift + click to unload)'" :onClick="e => deletePlugin(e.shiftKey)" type="err"><MiDelete size="18" /></Button>
</ButtonGroup>
</Card>
</template>

View File

@ -23,7 +23,7 @@
<div class="bd-flex bd-flex-col bd-pluginsview">
<div v-if="local" class="bd-flex bd-flex-grow bd-flex-col bd-plugins-container bd-local-plugins">
<PluginCard v-for="plugin in localPlugins" :plugin="plugin" :key="plugin.id" :togglePlugin="() => togglePlugin(plugin)" :reloadPlugin="() => reloadPlugin(plugin)" :deletePlugin="e => deletePlugin(plugin, e.shiftKey)" :showSettings="() => showSettings(plugin)" />
<PluginCard v-for="plugin in localPlugins" :plugin="plugin" :key="plugin.id" :togglePlugin="() => togglePlugin(plugin)" :reloadPlugin="() => reloadPlugin(plugin)" :deletePlugin="unload => deletePlugin(plugin, unload)" :showSettings="dont_clone => showSettings(plugin, dont_clone)" />
</div>
<div v-if="!local" class="bd-online-ph">
<h3>Coming Soon</h3>
@ -93,8 +93,10 @@
console.error(err);
}
},
showSettings(plugin) {
return Modals.contentSettings(plugin);
showSettings(plugin, dont_clone) {
return Modals.contentSettings(plugin, null, {
dont_clone
});
}
}
}

View File

@ -12,10 +12,10 @@
<Card :item="theme">
<SettingSwitch slot="toggle" :checked="theme.enabled" :change="toggleTheme" />
<ButtonGroup slot="controls">
<Button v-tooltip="'Settings'" v-if="theme.hasSettings" :onClick="showSettings"><MiSettings size="18" /></Button>
<Button v-tooltip="'Recompile (shift + click to reload)'" :onClick="reloadTheme"><MiRefresh size="18" /></Button>
<Button v-tooltip="'Settings (shift + click to open settings without cloning the set)'" v-if="theme.hasSettings" :onClick="e => showSettings(e.shiftKey)"><MiSettings size="18" /></Button>
<Button v-tooltip="'Recompile (shift + click to reload)'" :onClick="e => reloadTheme(e.shiftKey)"><MiRefresh size="18" /></Button>
<Button v-tooltip="'Edit'" :onClick="editTheme"><MiPencil size="18" /></Button>
<Button v-tooltip="'Uninstall (shift + click to unload)'" :onClick="deleteTheme" type="err"><MiDelete size="18" /></Button>
<Button v-tooltip="'Uninstall (shift + click to unload)'" :onClick="e => deleteTheme(e.shiftKey)" type="err"><MiDelete size="18" /></Button>
</ButtonGroup>
</Card>
</template>

View File

@ -23,7 +23,7 @@
<div class="bd-flex bd-flex-col bd-themesview">
<div v-if="local" class="bd-flex bd-flex-grow bd-flex-col bd-themes-container bd-local-themes">
<ThemeCard v-for="theme in localThemes" :theme="theme" :key="theme.id" :toggleTheme="() => toggleTheme(theme)" :reloadTheme="e => reloadTheme(theme, e.shiftKey)" :showSettings="() => showSettings(theme)" :deleteTheme="e => deleteTheme(theme, e.shiftKey)" />
<ThemeCard v-for="theme in localThemes" :theme="theme" :key="theme.id" :toggleTheme="() => toggleTheme(theme)" :reloadTheme="reload => reloadTheme(theme, reload)" :showSettings="dont_clone => showSettings(theme, dont_clone)" :deleteTheme="unload => deleteTheme(theme, unload)" />
</div>
<div v-if="!local" class="bd-online-ph">
<h3>Coming Soon</h3>
@ -94,27 +94,11 @@
console.error(err);
}
},
showSettings(theme) {
return Modals.contentSettings(theme);
showSettings(theme, dont_clone) {
return Modals.contentSettings(theme, null, {
dont_clone
});
}
}
}
</script>
<style>
.bd-online-ph {
display: flex;
flex-direction: column;
}
.bd-online-ph h3 {
color: #FFF;
font-weight: 700;
font-size: 20px;
text-align: center;
padding: 20px;
}
.bd-online-ph a {
padding: 20px;
text-align: center;
}
</style>

View File

@ -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}`);

View File

@ -16,18 +16,22 @@
</div>
<div class="bd-hint">{{ setting.hint }}</div>
</div>
<div class="bd-form-textarea-wrap">
<div contenteditable="true" @keyup.stop @input="input">{{ setting.value }}</div>
</div>
<textarea class="bd-textarea" ref="textarea" @keyup.stop v-model="setting.value" :disabled="setting.disabled"></textarea>
</div>
</template>
<script>
export default {
props: ['setting', 'change'],
props: ['setting'],
methods: {
input(e) {
this.change(e.target.textContent);
recalculateHeight() {
const { textarea } = this.$refs;
textarea.style.height = '1px';
textarea.style.height = textarea.scrollHeight + 2 + 'px';
}
},
mounted() {
this.$watch('setting.value', this.recalculateHeight);
this.recalculateHeight();
}
}
</script>

View File

@ -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: '<custom-modal :modal="modal" />',
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;
}
}

View File

@ -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);
});
});
}

View File

@ -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"
}
]
}
]
}
]
}
]
}
]
}

View File

@ -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');
}
};

View File

@ -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() {