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="application-name" content="waffle">
|
||||||
<meta name="description" content="Waffle - the free, open and focused chat application that runs on *your* server.">
|
<meta name="description" content="Waffle - the free, open and focused chat application that runs on *your* server.">
|
||||||
<meta name="referrer" content="same-origin">
|
<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="theme-color" content="#101414">
|
||||||
<meta name="color-scheme" content="dark">
|
<meta name="color-scheme" content="dark">
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { authWithToken, useAuthHandlers } from './auth';
|
||||||
import { initResponsiveHandlers } from './responsive';
|
import { initResponsiveHandlers } from './responsive';
|
||||||
import { useDebuggingApi } from './debuggingapi';
|
import { useDebuggingApi } from './debuggingapi';
|
||||||
import gateway, { GatewayEventType } from './gateway';
|
import gateway, { GatewayEventType } from './gateway';
|
||||||
|
import { pluginStore } from './stores';
|
||||||
|
|
||||||
import "@material-design-icons/font";
|
import "@material-design-icons/font";
|
||||||
|
|
||||||
|
@ -18,6 +19,19 @@ function handleGatewaySettlement() {
|
||||||
});
|
});
|
||||||
window.__waffle.app = app;
|
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.Ready, handleGatewaySettlement);
|
||||||
gateway.unsubscribe(GatewayEventType.Close, handleGatewaySettlement);
|
gateway.unsubscribe(GatewayEventType.Close, handleGatewaySettlement);
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,8 @@ const defaults = {
|
||||||
"ui:online:loadMessageHistory": true,
|
"ui:online:loadMessageHistory": true,
|
||||||
"ui:online:sendTypingUpdates": true,
|
"ui:online:sendTypingUpdates": true,
|
||||||
"ui:locale": "en-US",
|
"ui:locale": "en-US",
|
||||||
|
"app:javascript": [],
|
||||||
|
"app:stylesheets": [],
|
||||||
|
|
||||||
"ui:useragent:formFactor": () => {
|
"ui:useragent:formFactor": () => {
|
||||||
return (window.navigator && window.navigator.maxTouchPoints > 0) ? "touch" : "desktop";
|
return (window.navigator && window.navigator.maxTouchPoints > 0) ? "touch" : "desktop";
|
||||||
|
|
|
@ -12,6 +12,7 @@ class Store {
|
||||||
this._handlers = new Set();
|
this._handlers = new Set();
|
||||||
this.value = value;
|
this.value = value;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
|
this._isInBatch = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
log(...e) {
|
log(...e) {
|
||||||
|
@ -70,9 +71,21 @@ class Store {
|
||||||
storeLog(`[Flush] Flushed ${queueCount} callbacks from queue, took ${delta}ms`);
|
storeLog(`[Flush] Flushed ${queueCount} callbacks from queue, took ${delta}ms`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startBatch() {
|
||||||
|
this._isInBatch = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
endBatch() {
|
||||||
|
if (this._isInBatch) {
|
||||||
|
this._isInBatch = false;
|
||||||
|
this.updated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updated() {
|
updated() {
|
||||||
if (!this._handlers.size)
|
if (this._isInBatch) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
storeLog(`[Update] (${this.name}) Will queue ${this._handlers.size} handlers`, "value:", this.value, "handlers:", this._handlers);
|
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 selectedChannel = new Store({ id: -1, name: "none", creator_id: -1 }, "selectedChannel");
|
||||||
export const showSidebar = new Store(true, "showSidebar");
|
export const showSidebar = new Store(true, "showSidebar");
|
||||||
export const showPresenceSidebar = new Store(false, "showPresenceSidebar");
|
export const showPresenceSidebar = new Store(false, "showPresenceSidebar");
|
||||||
|
@ -579,6 +636,7 @@ export const overlayStore = new OverlayStore();
|
||||||
export const typingStore = new TypingStore();
|
export const typingStore = new TypingStore();
|
||||||
export const presenceStore = new PresenceStore();
|
export const presenceStore = new PresenceStore();
|
||||||
export const unreadStore = new UnreadStore();
|
export const unreadStore = new UnreadStore();
|
||||||
|
export const pluginStore = new PluginStore();
|
||||||
export const setMessageInputEvent = new Store(null, "event:setMessageInput");
|
export const setMessageInputEvent = new Store(null, "event:setMessageInput");
|
||||||
|
|
||||||
export const allStores = {
|
export const allStores = {
|
||||||
|
|
Loading…
Reference in a new issue