diff --git a/client/src/modules/pluginapi.js b/client/src/modules/pluginapi.js index 387b7515..ac5bfae7 100644 --- a/client/src/modules/pluginapi.js +++ b/client/src/modules/pluginapi.js @@ -12,6 +12,7 @@ import { EmoteModule } from 'builtin'; import { SettingsSet, SettingsCategory, Setting, SettingsScheme } from 'structs'; import { BdMenu, Modals, DOM, DOMObserver, VueInjector, Toasts, Notifications, BdContextMenu, DiscordContextMenu } from 'ui'; import * as CommonComponents from 'commoncomponents'; +import { default as Components } from '../ui/components/generic'; import { Utils, Filters, ClientLogger as Logger, ClientIPC, AsyncEventEmitter } from 'common'; import Settings from './settings'; import ExtModuleManager from './extmodulemanager'; @@ -24,6 +25,9 @@ import DiscordApi from './discordapi'; import { ReactComponents, ReactHelpers } from './reactcomponents'; import { Patcher, MonkeyPatch } from './patcher'; import GlobalAc from '../ui/autocomplete'; +import Vue from 'vue'; +import path from 'path'; +import Globals from './globals'; export default class PluginApi { @@ -61,6 +65,7 @@ export default class PluginApi { get EventsWrapper() { return EventsWrapper } get CommonComponents() { return CommonComponents } + get Components() { return Components } get Filters() { return Filters } get Discord() { return DiscordApi } get DiscordApi() { return DiscordApi } @@ -105,7 +110,9 @@ export default class PluginApi { removeFromArray: (...args) => Utils.removeFromArray.apply(Utils, args), defineSoftGetter: (...args) => Utils.defineSoftGetter.apply(Utils, args), wait: (...args) => Utils.wait.apply(Utils, args), - until: (...args) => Utils.until.apply(Utils, args) + until: (...args) => Utils.until.apply(Utils, args), + findInTree: (...args) => Utils.findInTree.apply(Utils, args), + findInReactTree: (...args) => Utils.findInReactTree.apply(Utils, args) }; } @@ -605,6 +612,10 @@ export default class PluginApi { }); } + Vuewrap(id, component, props) { + return VueInjector.createReactElement(Vue.component(id, component), props); + } + } // Stop plugins from modifying the plugin API for all plugins diff --git a/client/src/modules/pluginmanager.js b/client/src/modules/pluginmanager.js index 079a9ae1..e1c0d04b 100644 --- a/client/src/modules/pluginmanager.js +++ b/client/src/modules/pluginmanager.js @@ -130,6 +130,12 @@ export default class extends ContentManager { static unloadContentHook(content, reload) { delete Globals.require.cache[Globals.require.resolve(content.paths.mainPath)]; + const uncache = []; + for (const required in Globals.require.cache) { + if (!required.includes(content.paths.contentPath)) continue; + uncache.push(Globals.require.resolve(required)); + } + for (const u of uncache) delete Globals.require.cache[u]; } /** diff --git a/client/src/ui/components/generic/Button.vue b/client/src/ui/components/generic/Button.vue new file mode 100644 index 00000000..190d5507 --- /dev/null +++ b/client/src/ui/components/generic/Button.vue @@ -0,0 +1,21 @@ +/** + * BetterDiscord Generic Button Component + * 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. +*/ + + + + diff --git a/client/src/ui/components/generic/ButtonGroup.vue b/client/src/ui/components/generic/ButtonGroup.vue new file mode 100644 index 00000000..d57011cd --- /dev/null +++ b/client/src/ui/components/generic/ButtonGroup.vue @@ -0,0 +1,23 @@ +/** + * BetterDiscord Generic Button Group Component + * 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. +*/ + + + + diff --git a/client/src/ui/components/generic/index.js b/client/src/ui/components/generic/index.js new file mode 100644 index 00000000..e89b4651 --- /dev/null +++ b/client/src/ui/components/generic/index.js @@ -0,0 +1,29 @@ +import VrWrapper from '../../vrwrapper'; + +import ButtonGroupComponent from './ButtonGroup.vue'; +class ButtonGroupWrapper extends VrWrapper { + get component() { return ButtonGroupComponent } + constructor(props) { + super(); + this.props = props; + } +} + +import ButtonComponent from './Button.vue'; +class ButtonWrapper extends VrWrapper { + get component() { return ButtonComponent } + constructor(props) { + super(); + this.props = props; + } +} + +export default class { + static Button(props) { + return new ButtonWrapper(props); + } + + static ButtonGroup(props) { + return new ButtonGroupWrapper(props); + } +} diff --git a/tests/ext/plugins/Render Example/components/reactcomponent.js b/tests/ext/plugins/Render Example/components/reactcomponent.js new file mode 100644 index 00000000..36222d14 --- /dev/null +++ b/tests/ext/plugins/Render Example/components/reactcomponent.js @@ -0,0 +1,7 @@ +module.exports = (React, props) => { + return React.createElement( + 'button', + { className: 'exampleCustomElement', onClick: props.onClick }, + 'r' + ); +} diff --git a/tests/ext/plugins/Render Example/components/vuecomponent.js b/tests/ext/plugins/Render Example/components/vuecomponent.js new file mode 100644 index 00000000..197f72cc --- /dev/null +++ b/tests/ext/plugins/Render Example/components/vuecomponent.js @@ -0,0 +1,13 @@ +module.exports = (VueWrap, props) => { + return VueWrap('somecomponent', { + render: function (createElement) { + return createElement('button', { + class: 'exampleCustomElement', + on: { + click: this.onClick + } + }, 'v'); + }, + props: ['onClick'] + }, props); +} diff --git a/tests/ext/plugins/Render Example/config.json b/tests/ext/plugins/Render Example/config.json new file mode 100644 index 00000000..ea3abb01 --- /dev/null +++ b/tests/ext/plugins/Render Example/config.json @@ -0,0 +1,17 @@ +{ + "info": { + "id": "render-example", + "name": "Render Example", + "authors": [ + { + "name": "Jiiks", + "discord_id": "81388395867156480", + "github_username": "Jiiks", + "twitter_username": "Jiiksi" + } + ], + "version": 1.0, + "description": "Example for rendering stuff" + }, + "main": "index.js" +} diff --git a/tests/ext/plugins/Render Example/index.js b/tests/ext/plugins/Render Example/index.js new file mode 100644 index 00000000..60297e5d --- /dev/null +++ b/tests/ext/plugins/Render Example/index.js @@ -0,0 +1,128 @@ +/** + * This is an example of how you should add custom elements instead of manipulating the DOM directly + */ + +// Import custom components +const customVueComponent = require('./components/vuecomponent'); +const customReactComponent = require('./components/reactcomponent'); + +module.exports = (Plugin, Api, Vendor) => { + + // Destructure some apis + const { Logger, ReactComponents, Patcher, monkeyPatch, Reflection, Utils, CssUtils, VueInjector, Vuewrap, requireUncached } = Api; + const { Vue } = Vendor; + const { React } = Reflection.modules; // This should be in vendor + + return class extends Plugin { + + async onStart() { + this.injectStyle(); + this.patchGuildTextChannel(); + this.patchMessages(); + return true; + } + + async onStop() { + // The automatic unpatcher is not there yet + Patcher.unpatchAll(); + CssUtils.deleteAllStyles(); + + // Force update elements to remove our changes + const GuildTextChannel = await ReactComponents.getComponent('GuildTextChannel'); + GuildTextChannel.forceUpdateAll(); + const MessageContent = await ReactComponents.getComponent('MessageContent', { selector: Reflection.resolve('container', 'containerCozy', 'containerCompact', 'edited').selector }); + MessageContent.forceUpdateAll(); + return true; + } + + /* Inject some style for our custom element */ + async injectStyle() { + const css = ` + .exampleCustomElement { + background: #7a7d82; + color: #FFF; + border-radius: 5px; + font-size: 12px; + font-weight: 600; + opacity: .5; + &:hover { + opacity: 1; + } + } + .exampleBtnGroup { + .bd-button { + font-size: 14px; + padding: 5px; + } + } + `; + await CssUtils.injectSass(css); + } + + async patchGuildTextChannel() { + // Get the GuildTextChannel component and patch it's render function + const GuildTextChannel = await ReactComponents.getComponent('GuildTextChannel'); + monkeyPatch(GuildTextChannel.component.prototype).after('render', this.injectCustomElements.bind(this)); + // Force update to see our changes immediatly + GuildTextChannel.forceUpdateAll(); + } + + async patchMessages() { + // Get Message component and patch it's render function + const MessageContent = await ReactComponents.getComponent('MessageContent', { selector: Reflection.resolve('container', 'containerCozy', 'containerCompact', 'edited').selector }); + monkeyPatch(MessageContent.component.prototype).after('render', this.injectGenericComponents.bind(this)); + // Force update to see our changes immediatly + MessageContent.forceUpdateAll(); + } + + /* + * Injecting a custom React element using React.createElement + * https://reactjs.org/docs/react-api.html#createelement + * Injecting a custom Vue element using Vue.component + * https://vuejs.org/v2/guide/render-function.html + **/ + injectCustomElements(that, args, returnValue) { + // Get the child we want using a treewalker since we know the child we want has a channel property and children. + const child = Utils.findInReactTree(returnValue, filter => filter.hasOwnProperty('channel') && filter.children); + if (!child) return; + // If children is not an array make it into one + if (!child.children instanceof Array) child.children = [child.children]; + + // Add our custom components to children + child.children.push(customReactComponent(React, { onClick: e => this.handleClick(e, child.channel) })); + child.children.push(customVueComponent(Vuewrap, { onClick: e => this.handleClick(e, child.channel) })); + } + + /** + * Inject generic components provided by BD + */ + injectGenericComponents(that, args, returnValue) { + // If children is not an array make it into one + if (!returnValue.props.children instanceof Array) returnValue.props.children = [returnValue.props.children]; + // Add a generic Button component provided by BD + returnValue.props.children.push(Api.Components.ButtonGroup({ + classes: [ 'exampleBtnGroup' ], // Additional classes for button group + buttons: [ + { + classes: ['exampleBtn'], // Additional classes for button + text: 'Hello World!', // Text for button + onClick: e => Logger.log('Hello World!') // Button click handler + }, + { + classes: ['exampleBtn'], + text: 'Button', + onClick: e => Logger.log('Button!') + } + ] + }).render()); // Render will return the wrapped component that can then be displayed + } + + /** + * Will log the channel object + */ + handleClick(e, channel) { + Logger.log('Clicked!', channel); + } + } + +};