mirror of
https://github.com/revoltchat/revite.git
synced 2024-11-25 08:30:58 -05:00
feat(experiment): basic plugin API
This commit is contained in:
parent
f9f0d5c55a
commit
b3be822568
3 changed files with 263 additions and 3 deletions
|
@ -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<unknown>][] = [];
|
||||
private disabled: Set<string> = 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
235
src/mobx/stores/Plugins.ts
Normal file
235
src/mobx/stores/Plugins.ts
Normal file
|
@ -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<string, Plugin>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles loading and persisting plugins.
|
||||
*/
|
||||
export default class Plugins implements Store, Persistent<Data> {
|
||||
private state: State;
|
||||
|
||||
private plugins: ObservableMap<string, Plugin>;
|
||||
private instances: Map<string, Instance>;
|
||||
|
||||
/**
|
||||
* 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<Plugin, "namespace" | "id">) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue