add plugin and custom style system
This commit is contained in:
parent
52cc76f676
commit
bd16ec07fe
5 changed files with 171 additions and 2 deletions
95
doc/plugins.md
Normal file
95
doc/plugins.md
Normal 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
|
||||
})();
|
||||
```
|
|
@ -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">
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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 = {
|
||||
|
|
Loading…
Reference in a new issue