From bd16ec07fe68d198876c62c4383d0af31eb3af34 Mon Sep 17 00:00:00 2001 From: hippoz <10706925-hippoz@users.noreply.gitlab.com> Date: Tue, 27 Sep 2022 16:22:00 +0300 Subject: [PATCH] add plugin and custom style system --- doc/plugins.md | 95 ++++++++++++++++++++++++++++++++++++++ frontend/public/index.html | 2 +- frontend/src/main.js | 14 ++++++ frontend/src/storage.js | 2 + frontend/src/stores.js | 60 +++++++++++++++++++++++- 5 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 doc/plugins.md diff --git a/doc/plugins.md b/doc/plugins.md new file mode 100644 index 0000000..0db5abd --- /dev/null +++ b/doc/plugins.md @@ -0,0 +1,95 @@ +# Frontend Plugins + +If you want to change the functionality of Waffle's frontend, you can use plugins. + +## Overview + +Plugins are pieces of Javascript that run on the web frontend. They get access to most of the functionality of the app. + +Waffle also supports loading CSS using the `app:stylesheets` localstorage item (array of strings which contain css code). + +Here's some general rules of thumb: + - There is no dedicated plugin API yet. Plugins simply get access to internal objects. This is because it is very difficult to create a good plugin API which covers most usecases. This also means plugins are liable to break with updates. + - The less "intrusive" the change, the less likely it is to break with an update: if your plugin uses `Proxy` on a bunch of internal functions and relies on weird behavior, it is very likely to break with an update. + - Never use `window.__waffle` in your plugin. It is designed for easy access into internal objects from the browser devtools. Plugins get the same access through the objects passed in `plugin.main`. The difference is that the plugin context can have certain differences in implementation and structure to ensure compatibility and safety. + +## Loading + +The client supports 2 ways of loading plugins: +1. Automatic loading: the client iterates over all of the strings in the `app:javascript` localstorage array and executes them as Javascript. +2. Manual loading: done using a script that runs before the frontend's javascript, such as a userscript, a script injected in the HTML, or a WebExtension. +3. Pasting in the console: pasting the code of a plugin in the console. Useful for development, or when you don't have access to localstorage or userscripts. + +All plugins that are correctly implemented will be compatible with both methods. localstorage has limited space on most browsers, so you may not be able to store big plugins, or a bunch of them. + +For automatic loading, you generally want to minify the script before appending it to the `app:javascript` localstorage array. + +Adding a script to the `app:javascript` localstorage array is done as follows (you can paste this code in the browser devtools console): +```javascript +(() => { + const code = `YOUR_CODE_GOES_HERE`; + window.__waffle.storage.setItem("app:javascript", [...window.__waffle.storage.getItem("app:javascript"), code]); +})(); +``` +Most browsers also support editing localstorage directly from the devtools. + +Likewise, adding a style to `app:stylesheets` is done as follows: +```javascript +(() => { + const code = `YOUR_STYLE_GOES_HERE`; + window.__waffle.storage.setItem("app:stylesheets", [...window.__waffle.storage.getItem("app:stylesheets"), code]); +})(); +``` + +## Examples + +Plugin that prints the name of the currently selected channel each time it changes: +```javascript +// We wrap our plugin in a function that runs immediately so that we don't pollute the scope. +(() => { + let unsubscribers = []; + + // Called when the app is loaded. + // This does not guarantee the gateway connection being alive, so you should probably use the `gatewayStatus` store to determine when you can use it. + const pluginMain = (context) => { + // the context variable contains all objects and methods required to interact with the app. + // here, you could add `console.log(context)` to inspect the context object and see what you can do. + const { stores } = context; + + // Here, we subscribe to the selectedChannel store. + // Stores are mutable containers that have a subscribe() method. + // When the value of the store changes, it notifies all of the subscribers. + // The subscribe() method returns a function which you must call when you're done with using the store. + // Here, we're putting that value in an array which we will iterate over in the pluginDispose() function. + unsubscribers.push(stores.selectedChannel.subscribe(channel => { + if (channel && channel.name) { + console.log(channel.name); + } + })); + }; + + // Called when the plugin should unload and reverse all of its effects. + const pluginDispose = (context) => { + // Call all cleanup functions/unsubscribers. + // See above for explanation on why this needs to be done. + unsubscribers.forEach(e => e()); + unsubscribers.length = 0; + }; + + // Plugins are objects that follow this format. + const plugin = { + name: "selectedChannelPrint", + description: "Prints the selected channel to the console each time it changes", + main: pluginMain, + dispose: pluginDispose + }; + + // __wafflePluginLoaders is an array present on the window object or in the current scope of execution. + // It is an array of functions which, when called, return a plugin. + // While __wafflePluginLoaders is usually an array, you should not treat it as such. + // Certain methods of plugin loading may actually set __wafflePluginLoaders to an empty object with a push() function. + // Thus, the only function you should assume to exist on __wafflePluginLoaders should be push(). + const pluginLoaders = window.__wafflePluginLoaders || __wafflePluginLoaders || []; + pluginLoaders.push(() => plugin); // window.__wafflePluginLoaders is an array of functions which return a plugin +})(); +``` diff --git a/frontend/public/index.html b/frontend/public/index.html index 62c650b..5f4873e 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -7,7 +7,7 @@ - + diff --git a/frontend/src/main.js b/frontend/src/main.js index a79b13e..4ffbccb 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -4,6 +4,7 @@ import { authWithToken, useAuthHandlers } from './auth'; import { initResponsiveHandlers } from './responsive'; import { useDebuggingApi } from './debuggingapi'; import gateway, { GatewayEventType } from './gateway'; +import { pluginStore } from './stores'; import "@material-design-icons/font"; @@ -18,6 +19,19 @@ function handleGatewaySettlement() { }); window.__waffle.app = app; + pluginStore.consumePluginLoaders(); + const scripts = getItem("app:javascript"); + scripts.forEach((script) => { + const func = new Function(script); + func(); + }); + const styles = getItem("app:stylesheets"); + styles.forEach((styleText) => { + const style = document.createElement("style"); + style.textContent = styleText; + document.head.appendChild(style); + }); + gateway.unsubscribe(GatewayEventType.Ready, handleGatewaySettlement); gateway.unsubscribe(GatewayEventType.Close, handleGatewaySettlement); } diff --git a/frontend/src/storage.js b/frontend/src/storage.js index 2de785b..6ca2973 100644 --- a/frontend/src/storage.js +++ b/frontend/src/storage.js @@ -16,6 +16,8 @@ const defaults = { "ui:online:loadMessageHistory": true, "ui:online:sendTypingUpdates": true, "ui:locale": "en-US", + "app:javascript": [], + "app:stylesheets": [], "ui:useragent:formFactor": () => { return (window.navigator && window.navigator.maxTouchPoints > 0) ? "touch" : "desktop"; diff --git a/frontend/src/stores.js b/frontend/src/stores.js index 51c6fd4..8376896 100644 --- a/frontend/src/stores.js +++ b/frontend/src/stores.js @@ -12,6 +12,7 @@ class Store { this._handlers = new Set(); this.value = value; this.name = name; + this._isInBatch = false; } log(...e) { @@ -70,9 +71,21 @@ class Store { storeLog(`[Flush] Flushed ${queueCount} callbacks from queue, took ${delta}ms`); } + startBatch() { + this._isInBatch = true; + } + + endBatch() { + if (this._isInBatch) { + this._isInBatch = false; + this.updated(); + } + } + updated() { - if (!this._handlers.size) + if (this._isInBatch) { return; + } storeLog(`[Update] (${this.name}) Will queue ${this._handlers.size} handlers`, "value:", this.value, "handlers:", this._handlers); @@ -564,6 +577,50 @@ class UnreadStore extends Store { } } +class PluginStore extends Store { + constructor() { + super([], "PluginStore"); + } + + getPluginContext(_plugin) { + return window.__waffle; + } + + loadPlugin(plugin) { + plugin.id = Math.random(); + plugin.main(this.getPluginContext(plugin)); + + this.value.push(plugin); + this.updated(); + return plugin.id; + } + + unloadPlugin(id) { + const index = this.value.findIndex(e => e.id === id); + if (index !== -1) { + this.value[index].dispose(); + this.value.splice(index, 1); + return true; + } + return false; + } + + consumePluginLoaders() { + window.__wafflePluginLoaders = window.__wafflePluginLoaders || []; + const loaders = window.__wafflePluginLoaders; + + this.startBatch(); + loaders.forEach(async (load) => this.loadPlugin(await load())); + loaders.length = 0; + loaders.push = async (load) => { + this.loadPlugin(await load()); + }; + this.endBatch(); + + return true; + } +} + export const selectedChannel = new Store({ id: -1, name: "none", creator_id: -1 }, "selectedChannel"); export const showSidebar = new Store(true, "showSidebar"); export const showPresenceSidebar = new Store(false, "showPresenceSidebar"); @@ -579,6 +636,7 @@ export const overlayStore = new OverlayStore(); export const typingStore = new TypingStore(); export const presenceStore = new PresenceStore(); export const unreadStore = new UnreadStore(); +export const pluginStore = new PluginStore(); export const setMessageInputEvent = new Store(null, "event:setMessageInput"); export const allStores = {