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