diff --git a/src/mobx/State.ts b/src/mobx/State.ts index 94f2315f..8f1c5720 100644 --- a/src/mobx/State.ts +++ b/src/mobx/State.ts @@ -17,6 +17,7 @@ import Layout from "./stores/Layout"; import LocaleOptions from "./stores/LocaleOptions"; import MessageQueue from "./stores/MessageQueue"; import NotificationOptions from "./stores/NotificationOptions"; +import Plugins from "./stores/Plugins"; import ServerConfig from "./stores/ServerConfig"; import Settings from "./stores/Settings"; import Sync, { Data as DataSync, SyncKeys } from "./stores/Sync"; @@ -39,10 +40,13 @@ export default class State { queue: MessageQueue; settings: Settings; sync: Sync; + plugins: Plugins; private persistent: [string, Persistent][] = []; private disabled: Set = new Set(); + client?: Client; + /** * Construct new State. */ @@ -57,6 +61,7 @@ export default class State { this.queue = new MessageQueue(); this.settings = new Settings(); this.sync = new Sync(this); + this.plugins = new Plugins(this); makeAutoObservable(this); this.register(); @@ -118,6 +123,11 @@ export default class State { * @returns Function to dispose of listeners */ registerListeners(client?: Client) { + if (client) { + this.client = client; + this.plugins.onClient(client); + } + const listeners = this.persistent.map(([id, store]) => { return reaction( () => stringify(store.toJSON()), @@ -191,7 +201,10 @@ export default class State { ); }); - return () => listeners.forEach((x) => x()); + return () => { + delete this.client; + listeners.forEach((x) => x()); + }; } /** @@ -228,6 +241,9 @@ export default class State { // Dump stores back to disk. await this.save(); + + // Post-hydration, init plugins. + this.plugins.init(); } } diff --git a/src/mobx/stores/Experiments.ts b/src/mobx/stores/Experiments.ts index 6efe2725..2d3f29f4 100644 --- a/src/mobx/stores/Experiments.ts +++ b/src/mobx/stores/Experiments.ts @@ -10,12 +10,16 @@ import Store from "../interfaces/Store"; /** * Union type of available experiments. */ -export type Experiment = "dummy" | "offline_users"; +export type Experiment = "dummy" | "offline_users" | "plugins"; /** * Currently active experiments. */ -export const AVAILABLE_EXPERIMENTS: Experiment[] = ["dummy", "offline_users"]; +export const AVAILABLE_EXPERIMENTS: Experiment[] = [ + "dummy", + "offline_users", + "plugins", +]; /** * Definitions for experiments listed by {@link Experiment}. @@ -32,6 +36,11 @@ export const EXPERIMENTS: { description: "If you can take the performance hit (for example, you're on desktop), you can re-enable offline users for big servers such as Revolt Lounge.", }, + plugins: { + title: "Experimental Plugin API", + description: + "This will enable the experimental plugin API. Only touch this if you know what you're doing.", + }, }; export interface Data { diff --git a/src/mobx/stores/Plugins.ts b/src/mobx/stores/Plugins.ts new file mode 100644 index 00000000..10b6c01f --- /dev/null +++ b/src/mobx/stores/Plugins.ts @@ -0,0 +1,235 @@ +import localforage from "localforage"; +import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; +import { Client } from "revolt.js"; + +import { mapToRecord } from "../../lib/conversion"; + +import State from "../State"; +import Persistent from "../interfaces/Persistent"; +import Store from "../interfaces/Store"; + +type Plugin = { + /** + * Plugin Format Revision + */ + format: 1; + + /** + * Semver Version String + */ + version: string; + + /** + * Plugin Namespace + * + * This will usually be the author's name. + */ + namespace: string; + + /** + * Plugin Id + * + * This should be a valid URL slug, i.e. cool-plugin. + */ + id: string; + + /** + * Entrypoint + * + * Valid Javascript code, must be function which returns object. + * + * ```typescript + * function (state: State) { + * return { + * onClient: (client: Client) => {}, + * onUnload: () => {} + * } + * } + * ``` + */ + entrypoint: string; + + /** + * Whether this plugin is enabled + * + * @default true + */ + enabled?: boolean; +}; + +type Instance = { + format: 1; + onClient?: (client: Client) => {}; + onUnload?: () => void; +}; + +// Example plugin: +// state.plugins.add({ format: 1, version: "0.0.1", namespace: "insert", id: "my-plugin", entrypoint: "(state) => { console.log('[my-plugin] Plugin init!'); return { onClient: c => console.log('[my-plugin] Acquired Client:', c, '\\nHello', c.user.username + '!'), onUnload: () => console.log('[my-plugin] bye!') } }" }) + +export interface Data { + "revite:plugins": Record; +} + +/** + * Handles loading and persisting plugins. + */ +export default class Plugins implements Store, Persistent { + private state: State; + + private plugins: ObservableMap; + private instances: Map; + + /** + * Construct new Draft store. + */ + constructor(state: State) { + this.plugins = new ObservableMap(); + this.instances = new Map(); + makeAutoObservable(this); + + this.state = state; + } + + get id() { + return "revite:plugins"; + } + + toJSON() { + return { + "revite:plugins": mapToRecord(this.plugins), + }; + } + + @action hydrate(data: Data) { + Object.keys(data["revite:plugins"]).forEach((key) => + this.plugins.set(key, data["revite:plugins"][key]), + ); + } + + /** + * Get plugin by id + * @param namespace Namespace + * @param id Plugin Id + */ + @computed get(namespace: string, id: string) { + return this.plugins.get(namespace + "/" + id); + } + + /** + * Get an existing instance of a plugin + * @param plugin Plugin Information + * @returns Plugin Instance + */ + private getInstance(plugin: Pick) { + return this.instances.get(plugin.namespace + "/" + plugin.id); + } + + /** + * Initialise all plugins + */ + init() { + if (!this.state.experiments.isEnabled("plugins")) return; + this.plugins.forEach( + ({ namespace, id, enabled }) => enabled && this.load(namespace, id), + ); + } + + /** + * Add a plugin + * @param plugin Plugin Manifest + */ + add(plugin: Plugin) { + if (!this.state.experiments.isEnabled("plugins")) + return console.error("Enable plugins in experiments!"); + + let loaded = this.getInstance(plugin); + if (loaded) { + this.unload(plugin.namespace, plugin.id); + } + + this.plugins.set(plugin.namespace + "/" + plugin.id, plugin); + + if (typeof plugin.enabled === "undefined" || plugin) { + this.load(plugin.namespace, plugin.id); + } + } + + /** + * Remove a plugin + * @param namespace Plugin Namespace + * @param id Plugin Id + */ + remove(namespace: string, id: string) { + this.unload(namespace, id); + this.plugins.delete(namespace + "/" + id); + } + + /** + * Load a plugin + * @param namespace Plugin Namespace + * @param id Plugin Id + */ + load(namespace: string, id: string) { + let plugin = this.get(namespace, id); + if (!plugin) throw "Unknown plugin!"; + + try { + let ns = plugin.namespace + "/" + plugin.id; + + let instance: Instance = eval(plugin.entrypoint)(); + this.instances.set(ns, { + ...instance, + format: plugin.format, + }); + + this.plugins.set(ns, { + ...plugin, + enabled: true, + }); + + if (this.state.client) { + instance.onClient?.(this.state.client); + } + } catch (error) { + console.error(`Failed to load ${namespace}/${id}!`); + console.error(error); + } + } + + /** + * Unload a plugin + * @param namespace Plugin Namespace + * @param id Plugin Id + */ + unload(namespace: string, id: string) { + let plugin = this.get(namespace, id); + if (!plugin) throw "Unknown plugin!"; + + let ns = plugin.namespace + "/" + plugin.id; + let loaded = this.getInstance(plugin); + if (loaded) { + loaded.onUnload?.(); + this.plugins.set(ns, { + ...plugin, + enabled: true, + }); + } + } + + /** + * Reset everything + */ + reset() { + localforage.removeItem("revite:plugins"); + window.location.reload(); + } + + /** + * Push client through to plugins + */ + onClient(client: Client) { + for (const instance of this.instances.values()) { + instance.onClient?.(client); + } + } +}