add plugin and custom style system

This commit is contained in:
hippoz 2022-09-27 16:22:00 +03:00
parent 52cc76f676
commit bd16ec07fe
No known key found for this signature in database
GPG key ID: 7C52899193467641
5 changed files with 171 additions and 2 deletions

95
doc/plugins.md Normal file
View file

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

View file

@ -7,7 +7,7 @@
<meta name="application-name" content="waffle">
<meta name="description" content="Waffle - the free, open and focused chat application that runs on *your* server.">
<meta name="referrer" content="same-origin">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:;">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:; script-src 'self' 'unsafe-eval';">
<meta name="theme-color" content="#101414">
<meta name="color-scheme" content="dark">

View file

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

View file

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

View file

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