Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
5

Configure Feed

Select the types of activity you want to include in your feed.

Add basic plugin scaffolding

Grace Kind (May 4, 2026, 7:01 PM -0500) df2c0f84 a2fc5e1c

+995 -19
+3 -1
.gitignore
··· 4 4 .context 5 5 tests/e2e/.results 6 6 CLAUDE.md 7 - .claude 7 + .claude 8 + plugins-local/* 9 + !plugins-local/.gitkeep
+31
eleventy.config.js
··· 7 7 eleventyConfig.addPassthroughCopy("src/css"); 8 8 eleventyConfig.addPassthroughCopy("src/img"); 9 9 eleventyConfig.addPassthroughCopy("src/manifest.json"); 10 + eleventyConfig.addPassthroughCopy("plugins-local"); 11 + eleventyConfig.addWatchTarget("plugins-local"); 12 + 13 + // Prevent sandbox from being treated as a template 14 + eleventyConfig.ignores.add("src/js/plugins/sandbox.html"); 15 + 16 + // Local plugin index 17 + eleventyConfig.on("eleventy.before", () => { 18 + const localPluginsDir = "plugins-local"; 19 + if (!fs.existsSync(localPluginsDir)) return; 20 + const ids = []; 21 + for (const entry of fs.readdirSync(localPluginsDir, { 22 + withFileTypes: true, 23 + })) { 24 + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; 25 + if (entry.name.startsWith(".")) continue; 26 + const manifestPath = path.join( 27 + localPluginsDir, 28 + entry.name, 29 + "manifest.json", 30 + ); 31 + const mainPath = path.join(localPluginsDir, entry.name, "main.js"); 32 + if (!fs.existsSync(manifestPath) || !fs.existsSync(mainPath)) continue; 33 + ids.push(entry.name); 34 + } 35 + fs.mkdirSync("build/plugins-local", { recursive: true }); 36 + fs.writeFileSync( 37 + "build/plugins-local/index.json", 38 + JSON.stringify({ ids }, null, 2), 39 + ); 40 + }); 10 41 11 42 // Send index for SPA 12 43 eleventyConfig.setServerOptions({
+2 -2
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.11.50", 3 + "version": "0.11.51", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf build && NODE_ENV=development eleventy --serve", 7 7 "start:tunnel": "node scripts/dev-tunnel.js", 8 8 "build": "rm -rf build && NODE_ENV=production eleventy", 9 - "ios": "npm run build && cap sync && NODE_ENV=development cap run ios", 10 9 "bundle:capacitor": "esbuild ./clientLibs/capacitor.js --bundle --format=esm --outfile=src/js/lib/capacitor.js", 10 + "ios": "npm run build && cap sync && NODE_ENV=development cap run ios", 11 11 "bundle:lit-html": "esbuild ./clientLibs/litHtml.js --bundle --format=esm --outfile=src/js/lib/lit-html.js", 12 12 "format": "pretty-quick --staged", 13 13 "test": "npm run test:unit",
+192
pluginWorker.js
··· 1 + export class SimpleUUID { 2 + constructor() { 3 + this._id = 0; 4 + } 5 + create() { 6 + return this._id++; 7 + } 8 + } 9 + 10 + const uuid = new SimpleUUID(); 11 + 12 + const handlers = new Map(); 13 + 14 + let registered = false; 15 + 16 + export class Plugin { 17 + constructor() {} 18 + 19 + // registerMutedWordMatcher(fn) { 20 + // const handlerId = getHandlerId(); 21 + // handlers.set(handlerId, fn); 22 + // self.postMessage({ 23 + // type: "register", 24 + // target: "mutedWordMatcher", 25 + // handlerId, 26 + // }); 27 + // } 28 + 29 + addSidebarIcon(icon, title, callback) { 30 + const handlerId = uuid.create(); 31 + handlers.set(handlerId, callback); 32 + self.postMessage({ 33 + type: "register", 34 + target: "sidebarIcon", 35 + icon, 36 + title, 37 + handlerId, 38 + }); 39 + } 40 + 41 + onload() {} 42 + onunload() {} 43 + 44 + static register() { 45 + if (registered) return; 46 + registered = true; 47 + const instance = new this(); 48 + Promise.resolve() 49 + .then(() => instance.onload()) 50 + .then( 51 + () => self.postMessage({ type: "ready" }), 52 + (error) => 53 + self.postMessage({ 54 + type: "ready", 55 + error: error?.message ?? String(error), 56 + }), 57 + ); 58 + } 59 + } 60 + 61 + const openModals = new Map(); 62 + 63 + export class Modal { 64 + constructor() { 65 + this._modalId = uuid.create(); 66 + this.contentEl = new VirtualEl("div"); 67 + this.titleEl = new VirtualEl("div"); 68 + } 69 + 70 + open() { 71 + if (openModals.has(this._modalId)) return; 72 + openModals.set(this._modalId, this); 73 + this.onOpen(); 74 + self.postMessage({ 75 + type: "hostCall", 76 + method: "openModal", 77 + args: [ 78 + { 79 + modalId: this._modalId, 80 + title: this.titleEl._serialize(), 81 + content: this.contentEl._serialize(), 82 + }, 83 + ], 84 + }); 85 + } 86 + 87 + close() { 88 + if (!openModals.has(this._modalId)) return; 89 + openModals.delete(this._modalId); 90 + self.postMessage({ 91 + type: "hostCall", 92 + method: "closeModal", 93 + args: [{ modalId: this._modalId }], 94 + }); 95 + this.onClose(); 96 + } 97 + 98 + onOpen() {} 99 + onClose() {} 100 + } 101 + 102 + class VirtualEl { 103 + constructor(tag) { 104 + this.tag = tag; 105 + this.attrs = {}; 106 + this.text = null; 107 + this.children = []; 108 + } 109 + 110 + setText(text) { 111 + this.text = text; 112 + this.children = []; 113 + return this; 114 + } 115 + 116 + empty() { 117 + this.text = null; 118 + this.children = []; 119 + return this; 120 + } 121 + 122 + addClass(cls) { 123 + this.attrs.class = this.attrs.class ? `${this.attrs.class} ${cls}` : cls; 124 + return this; 125 + } 126 + 127 + setAttr(name, value) { 128 + this.attrs[name] = value; 129 + return this; 130 + } 131 + 132 + createEl(tag, options = {}) { 133 + const child = new VirtualEl(tag); 134 + if (options.text != null) child.text = options.text; 135 + if (options.cls) child.attrs.class = options.cls; 136 + if (options.attr) Object.assign(child.attrs, options.attr); 137 + this.children.push(child); 138 + return child; 139 + } 140 + 141 + createDiv(options = {}) { 142 + return this.createEl("div", options); 143 + } 144 + 145 + _serialize() { 146 + return { 147 + tag: this.tag, 148 + attrs: this.attrs, 149 + text: this.text, 150 + children: this.children.map((child) => child._serialize()), 151 + }; 152 + } 153 + } 154 + 155 + self.addEventListener("message", async (event) => { 156 + const message = event.data; 157 + if (!message || typeof message !== "object") return; 158 + 159 + // RPC 160 + if (message.type === "call") { 161 + const fn = handlers.get(message.handlerId); 162 + if (!fn) { 163 + self.postMessage({ 164 + type: "result", 165 + callId: message.callId, 166 + error: `unknown handler ${message.handlerId}`, 167 + }); 168 + return; 169 + } 170 + try { 171 + const value = await fn(...message.args); 172 + self.postMessage({ type: "result", callId: message.callId, value }); 173 + } catch (error) { 174 + self.postMessage({ 175 + type: "result", 176 + callId: message.callId, 177 + error: error.message ?? String(error), 178 + }); 179 + } 180 + return; 181 + } 182 + 183 + // Events 184 + if (message.type === "modalDismissed") { 185 + const modal = openModals.get(message.modalId); 186 + if (modal) { 187 + openModals.delete(message.modalId); 188 + modal.onClose(); 189 + } 190 + return; 191 + } 192 + });
plugins-local/.gitkeep

This is a binary file and will not be displayed.

+6
src/css/style.css
··· 1206 1206 } 1207 1207 } 1208 1208 1209 + button.sidebar-plugin-nav-item { 1210 + background: transparent; 1211 + border: none; 1212 + text-align: left; 1213 + } 1214 + 1209 1215 .sidebar-spacer { 1210 1216 flex: 1; 1211 1217 }
+9
src/index.html
··· 109 109 getAppViewConfig, 110 110 handleAppViewResetQueryParam, 111 111 } from "/js/appViewConfig.js"; 112 + import { pluginHost } from "/js/plugins/pluginHost.js"; 113 + import { setupPluginModals } from "/js/plugins/pluginModals.js"; 112 114 113 115 // Body class for styling 114 116 if (isNative()) { ··· 154 156 window.showGlobalErrorState(); 155 157 throw retryError; 156 158 } 159 + } 160 + 161 + try { 162 + setupPluginModals(); 163 + await pluginHost.loadEnabledPlugins(); 164 + } catch (error) { 165 + console.error("Error loading plugins", error); 157 166 } 158 167 159 168 if (notificationService) {
+1 -11
src/js/dataLayer/patchStore.js
··· 1 - import { deepClone } from "/js/utils.js"; 2 - 3 - class SimpleUUID { 4 - constructor() { 5 - this._id = 0; 6 - } 7 - 8 - create() { 9 - return this._id++; 10 - } 11 - } 1 + import { deepClone, SimpleUUID } from "/js/utils.js"; 12 2 13 3 // The store saves patch data for optimistic updates. 14 4 export class PatchStore {
+17
src/js/eventEmitter.js
··· 35 35 } 36 36 } 37 37 } 38 + 39 + export class EventTarget { 40 + constructor() { 41 + this.ee = new EventEmitter(); 42 + } 43 + addEventListener(event, listener) { 44 + this.ee.on(event, listener); 45 + } 46 + 47 + removeEventListener(event, listener) { 48 + this.ee.off(event, listener); 49 + } 50 + 51 + dispatchEvent(event) { 52 + this.ee.emit(event.type, event); 53 + } 54 + }
+405
src/js/plugins/pluginHost.js
··· 1 + import { EventTarget } from "/js/eventEmitter.js"; 2 + import { SimpleUUID, isDev } from "/js/utils.js"; 3 + 4 + const LOCAL_PLUGINS_INDEX_URL = "/plugins-local/index.json"; 5 + const REQUIRED_MANIFEST_FIELDS = ["id", "name", "version"]; 6 + const PLUGIN_READY_TIMEOUT_MS = 5000; 7 + const WORKER_PREFIX = ` 8 + delete self.BroadcastChannel; 9 + delete self.SharedWorker; 10 + `; 11 + 12 + const SANDBOX_URL = "/js/plugins/sandbox.html"; 13 + const SANDBOX_READY_TIMEOUT_MS = 5000; 14 + 15 + function parsePluginManifest(pluginId, manifest) { 16 + for (const field of REQUIRED_MANIFEST_FIELDS) { 17 + if (typeof manifest[field] !== "string") { 18 + throw new Error(`missing required field "${field}"`); 19 + } 20 + } 21 + if (manifest.id !== pluginId) { 22 + throw new Error( 23 + `manifest id "${manifest.id}" does not match directory name`, 24 + ); 25 + } 26 + return manifest; 27 + } 28 + 29 + class Logger { 30 + static LEVELS = { info: 10, warn: 20, error: 30, silent: 40 }; 31 + 32 + constructor(prefix, logLevel = "warn") { 33 + this.prefix = prefix; 34 + this.logLevel = logLevel; 35 + } 36 + _enabled(level) { 37 + return Logger.LEVELS[level] >= Logger.LEVELS[this.logLevel]; 38 + } 39 + info(...args) { 40 + if (this._enabled("info")) console.info(this.prefix, ...args); 41 + } 42 + warn(...args) { 43 + if (this._enabled("warn")) console.warn(this.prefix, ...args); 44 + } 45 + error(...args) { 46 + if (this._enabled("error")) console.error(this.prefix, ...args); 47 + } 48 + } 49 + 50 + class WorkerSandbox { 51 + constructor(id) { 52 + this.id = id; 53 + this.frame = this._createSandboxFrame(); 54 + this.worker = null; 55 + } 56 + 57 + _createSandboxFrame() { 58 + const frame = document.createElement("iframe"); 59 + frame.setAttribute("sandbox", "allow-scripts"); 60 + frame.setAttribute("aria-hidden", "true"); 61 + frame.setAttribute("title", `worker sandbox: ${this.id}`); 62 + frame.style.display = "none"; 63 + frame.src = SANDBOX_URL; 64 + return frame; 65 + } 66 + 67 + _destroy() { 68 + this.frame.remove(); 69 + } 70 + 71 + async load(source, timeoutMs = SANDBOX_READY_TIMEOUT_MS) { 72 + const ready = new Promise((resolve, reject) => { 73 + const onMessage = (event) => { 74 + if (event.source !== this.frame.contentWindow) return; 75 + if (event.data?.type !== "ready") return; 76 + clearTimeout(timeout); 77 + window.removeEventListener("message", onMessage); 78 + if (event.data.error) reject(new Error(event.data.error)); 79 + else resolve(); 80 + }; 81 + const timeout = setTimeout(() => { 82 + window.removeEventListener("message", onMessage); 83 + reject(new Error("sandbox iframe did not become ready")); 84 + }, timeoutMs); 85 + window.addEventListener("message", onMessage); 86 + }); 87 + this.frame.addEventListener("load", () => { 88 + this.frame.contentWindow.postMessage({ type: "init", source }, "*"); 89 + }); 90 + document.body.appendChild(this.frame); 91 + await ready; 92 + 93 + this.worker = new WorkerInterface(this.frame.contentWindow); 94 + this.worker.addEventListener("terminate", () => this._destroy()); 95 + return this.worker; 96 + } 97 + } 98 + 99 + class PluginInstance { 100 + constructor(pluginId, worker) { 101 + this.pluginId = pluginId; 102 + this.worker = worker; 103 + this.disposers = []; 104 + } 105 + 106 + async waitForReady(timeoutMs = PLUGIN_READY_TIMEOUT_MS) { 107 + const ready = new Promise((resolve, reject) => { 108 + const cleanup = () => { 109 + this.worker.removeEventListener("message", messageListener); 110 + this.worker.removeEventListener("error", errorListener); 111 + }; 112 + const messageListener = (event) => { 113 + if (event.data?.type !== "ready") return; 114 + cleanup(); 115 + if (event.data.error) reject(new Error(event.data.error)); 116 + else resolve(); 117 + }; 118 + const errorListener = (event) => { 119 + cleanup(); 120 + reject(new Error(event.message)); 121 + }; 122 + this.worker.addEventListener("message", messageListener); 123 + this.worker.addEventListener("error", errorListener); 124 + }); 125 + const timeout = new Promise((_, reject) => 126 + setTimeout( 127 + () => reject(new Error(`did not finish loading within ${timeoutMs}ms`)), 128 + timeoutMs, 129 + ), 130 + ); 131 + return Promise.race([ready, timeout]); 132 + } 133 + } 134 + 135 + class PluginHost { 136 + constructor({ verbose = false } = {}) { 137 + this.registries = { 138 + mutedWordMatchers: new Set(), 139 + sidebarIcons: new Set(), 140 + }; 141 + 142 + this.logger = new Logger("[plugins]"); 143 + this._loadedPlugins = new Map(); // id -> PluginInstance 144 + 145 + this._hostCallHandlers = new Map(); 146 + this._pendingPluginCalls = new Map(); 147 + 148 + this.uuid = new SimpleUUID(); 149 + } 150 + 151 + async loadEnabledPlugins({ enabledIds } = {}) { 152 + const availablePlugins = await this._fetchPluginIndex(); 153 + this.logger.info( 154 + `discovered ${availablePlugins.length} plugin(s):`, 155 + availablePlugins, 156 + ); 157 + const toLoad = enabledIds 158 + ? availablePlugins.filter((id) => enabledIds.includes(id)) 159 + : availablePlugins; 160 + if (enabledIds) { 161 + const skipped = availablePlugins.filter((id) => !enabledIds.includes(id)); 162 + if (skipped.length) 163 + this.logger.info("skipping disabled plugin(s):", skipped); 164 + } 165 + await Promise.all(toLoad.map((id) => this._loadPlugin(id))); 166 + } 167 + 168 + registerHostCall(method, handler) { 169 + this._hostCallHandlers.set(method, handler); 170 + return () => this._hostCallHandlers.delete(method); 171 + } 172 + 173 + async _fetchPluginIndex() { 174 + try { 175 + const response = await fetch(LOCAL_PLUGINS_INDEX_URL); 176 + if (!response.ok) return []; 177 + const body = await response.json(); 178 + return Array.isArray(body.ids) ? body.ids : []; 179 + } catch { 180 + return []; 181 + } 182 + } 183 + 184 + async _loadPlugin(pluginId) { 185 + if (this._loadedPlugins.has(pluginId)) return; 186 + 187 + const manifest = await this._fetchManifest(pluginId); 188 + if (!manifest) { 189 + this.logger.error( 190 + `failed to load "${pluginId}": invalid or missing manifest`, 191 + ); 192 + return; 193 + } 194 + 195 + const source = await this._fetchSource(pluginId); 196 + if (!source) { 197 + this.logger.error(`failed to load "${pluginId}": could not fetch source`); 198 + return; 199 + } 200 + 201 + let worker; 202 + try { 203 + const sandbox = new WorkerSandbox(); 204 + worker = await sandbox.load(source); 205 + } catch (error) { 206 + this.logger.error( 207 + `failed to load "${pluginId}": could not spawn worker`, 208 + error, 209 + ); 210 + return; 211 + } 212 + const pluginInstance = new PluginInstance(pluginId, worker); 213 + 214 + worker.addEventListener("message", (event) => 215 + this._handleWorkerMessage(pluginInstance, event.data), 216 + ); 217 + worker.addEventListener("error", (event) => 218 + this.logger.error(`"${pluginId}" worker error:`, event.message), 219 + ); 220 + 221 + this._loadedPlugins.set(pluginId, pluginInstance); 222 + try { 223 + await pluginInstance.waitForReady(); 224 + this.logger.info(`loaded "${pluginId}" v${manifest.version}`); 225 + } catch (error) { 226 + this.logger.error(`"${pluginId}" failed during onload:`, error.message); 227 + } 228 + } 229 + 230 + async _fetchSource(id) { 231 + try { 232 + const response = await fetch(`/plugins-local/${id}/main.js`); 233 + if (!response.ok) throw new Error(`HTTP ${response.status}`); 234 + return await response.text(); 235 + } catch (error) { 236 + this.logger.error( 237 + `failed to load "${id}": could not fetch main.js`, 238 + error, 239 + ); 240 + return null; 241 + } 242 + } 243 + 244 + async _fetchManifest(id) { 245 + try { 246 + const response = await fetch(`/plugins-local/${id}/manifest.json`); 247 + if (!response.ok) return null; 248 + return parsePluginManifest(id, await response.json()); 249 + } catch (error) { 250 + this.logger.warn(`"${id}" invalid manifest:`, error.message); 251 + return null; 252 + } 253 + } 254 + 255 + _handleWorkerMessage(pluginInstance, message) { 256 + if (!message || typeof message !== "object") return; 257 + switch (message.type) { 258 + case "register": { 259 + const dispose = this._registerFromPlugin(pluginInstance, message); 260 + if (dispose) pluginInstance.disposers.push(dispose); 261 + return; 262 + } 263 + case "result": { 264 + this._handleCallResult(message); 265 + return; 266 + } 267 + case "hostCall": { 268 + this._handleHostCall(pluginInstance.pluginId, message); 269 + return; 270 + } 271 + default: 272 + return; 273 + } 274 + } 275 + 276 + _handleHostCall(pluginId, message) { 277 + const handler = this._hostCallHandlers.get(message.method); 278 + if (!handler) { 279 + this.logger.warn( 280 + `"${pluginId}" called unknown host method "${message.method}"`, 281 + ); 282 + return; 283 + } 284 + try { 285 + handler({ pluginId, args: message.args ?? [] }); 286 + } catch (error) { 287 + this.logger.error( 288 + `"${pluginId}" host method "${message.method}" threw:`, 289 + error, 290 + ); 291 + } 292 + } 293 + 294 + notifyModalDismissed(pluginId, modalId) { 295 + const instance = this._loadedPlugins.get(pluginId); 296 + if (!instance) return; 297 + instance.worker.postMessage({ type: "modalDismissed", modalId }); 298 + } 299 + 300 + getSidebarIcons() { 301 + return [...this.registries.sidebarIcons]; 302 + } 303 + 304 + _registerFromPlugin(pluginInstance, message) { 305 + switch (message.target) { 306 + // case "mutedWordMatcher": { 307 + // const entry = { 308 + // pluginId, 309 + // match: (text, value) => 310 + // this._callWorker(worker, "mutedWordMatcher", message.handlerId, [ 311 + // text, 312 + // value, 313 + // ]), 314 + // }; 315 + // this.registries.mutedWordMatchers.add(entry); 316 + // return () => this.registries.mutedWordMatchers.delete(entry); 317 + // } 318 + case "sidebarIcon": { 319 + const entry = { 320 + pluginId: pluginInstance.pluginId, 321 + icon: message.icon, 322 + title: message.title, 323 + invoke: () => 324 + this._callPlugin( 325 + pluginInstance, 326 + "sidebarIcon", 327 + message.handlerId, 328 + [], 329 + ), 330 + }; 331 + this.registries.sidebarIcons.add(entry); 332 + return () => this.registries.sidebarIcons.delete(entry); 333 + } 334 + default: 335 + this.logger.warn( 336 + `"${pluginInstance.pluginId}" attempted to register unknown target "${message.target}"`, 337 + ); 338 + return null; 339 + } 340 + } 341 + 342 + _callPlugin(pluginInstance, target, handlerId, args) { 343 + const callId = this.uuid.create(); 344 + return new Promise((resolve, reject) => { 345 + this._pendingPluginCalls.set(callId, { resolve, reject }); 346 + pluginInstance.worker.postMessage({ 347 + type: "call", 348 + callId, 349 + target, 350 + handlerId, 351 + args, 352 + }); 353 + }); 354 + } 355 + 356 + _handleCallResult(message) { 357 + const pending = this._pendingPluginCalls.get(message.callId); 358 + if (!pending) return; 359 + this._pendingPluginCalls.delete(message.callId); 360 + if (message.error) pending.reject(new Error(message.error)); 361 + else pending.resolve(message.value); 362 + } 363 + 364 + unloadPlugin(pluginId) { 365 + const instance = this._loadedPlugins.get(pluginId); 366 + if (!instance) return; 367 + instance.disposers.forEach((dispose) => dispose()); 368 + instance.worker.terminate(); 369 + this._loadedPlugins.delete(pluginId); 370 + } 371 + } 372 + 373 + class WorkerInterface extends EventTarget { 374 + constructor(messageTarget) { 375 + super(); 376 + this._messageTarget = messageTarget; 377 + this._handleWindowMessage = this._handleWindowMessage.bind(this); 378 + window.addEventListener("message", this._handleWindowMessage); 379 + } 380 + 381 + postMessage(payload) { 382 + this._messageTarget.postMessage({ type: "send", payload }, "*"); 383 + } 384 + 385 + terminate() { 386 + window.removeEventListener("message", this._handleWindowMessage); 387 + this.dispatchEvent({ type: "terminate" }); 388 + } 389 + 390 + _handleWindowMessage(event) { 391 + if (event.source !== this._messageTarget) return; 392 + const message = event.data; 393 + if (!message || typeof message !== "object") return; 394 + switch (message.type) { 395 + case "fromWorker": 396 + this.dispatchEvent({ type: "message", data: message.payload }); 397 + return; 398 + case "workerError": 399 + this.dispatchEvent({ type: "error", message: message.error }); 400 + return; 401 + } 402 + } 403 + } 404 + 405 + export const pluginHost = new PluginHost({ verbose: isDev() });
+73
src/js/plugins/pluginModals.js
··· 1 + import { pluginHost } from "/js/plugins/pluginHost.js"; 2 + import { renderNode, isEmptyNode } from "./pluginRendering.js"; 3 + 4 + // `${pluginId}:${modalId}` -> { dialog, content, isOpen, dismiss } 5 + const dialogs = new Map(); 6 + 7 + function dialogKey(pluginId, modalId) { 8 + return `${pluginId}:${modalId}`; 9 + } 10 + 11 + export function setupPluginModals() { 12 + pluginHost.registerHostCall("openModal", ({ pluginId, args }) => { 13 + const [options] = args; 14 + if (!options || options.modalId == null) return; 15 + const key = dialogKey(pluginId, options.modalId); 16 + let entry = dialogs.get(key); 17 + if (entry?.isOpen) return; 18 + 19 + if (!entry) { 20 + const dialog = document.createElement("dialog"); 21 + dialog.classList.add("modal-dialog", "plugin-modal"); 22 + dialog.dataset.pluginId = pluginId; 23 + 24 + const content = document.createElement("div"); 25 + content.classList.add("modal-dialog-content"); 26 + dialog.appendChild(content); 27 + 28 + entry = { dialog, content, isOpen: false, dismiss: null }; 29 + 30 + const dismiss = ({ notify = true } = {}) => { 31 + if (!entry.isOpen) return; 32 + entry.isOpen = false; 33 + dialog.close(); 34 + if (notify) pluginHost.notifyModalDismissed(pluginId, options.modalId); 35 + }; 36 + entry.dismiss = dismiss; 37 + 38 + dialog.addEventListener("click", (event) => { 39 + if (event.target.tagName === "DIALOG") dismiss(); 40 + }); 41 + dialog.addEventListener("cancel", (event) => { 42 + event.preventDefault(); 43 + dismiss(); 44 + }); 45 + 46 + dialogs.set(key, entry); 47 + document.body.appendChild(dialog); 48 + } 49 + 50 + entry.content.replaceChildren(); 51 + if (!isEmptyNode(options.title)) { 52 + const title = renderNode(options.title, pluginId); 53 + title.classList.add("modal-dialog-title"); 54 + entry.content.appendChild(title); 55 + } 56 + if (!isEmptyNode(options.content)) { 57 + const body = renderNode(options.content, pluginId); 58 + body.classList.add("modal-dialog-message"); 59 + entry.content.appendChild(body); 60 + } 61 + 62 + entry.isOpen = true; 63 + entry.dialog.showModal(); 64 + }); 65 + 66 + pluginHost.registerHostCall("closeModal", ({ pluginId, args }) => { 67 + const [options] = args; 68 + if (!options || options.modalId == null) return; 69 + const entry = dialogs.get(dialogKey(pluginId, options.modalId)); 70 + // Plugin-initiated, so don't notify 71 + if (entry) entry.dismiss({ notify: false }); 72 + }); 73 + }
+89
src/js/plugins/pluginRendering.js
··· 1 + import { lightningBoltIconTemplate } from "/js/templates/icons/lightningBoltIcon.template.js"; 2 + 3 + const ALLOWED_TAGS = [ 4 + "div", 5 + "span", 6 + "p", 7 + "h1", 8 + "h2", 9 + "h3", 10 + "h4", 11 + "ul", 12 + "ol", 13 + "li", 14 + "strong", 15 + "em", 16 + "b", 17 + "i", 18 + "code", 19 + "pre", 20 + "br", 21 + "hr", 22 + ]; 23 + 24 + function isAllowedTag(tag) { 25 + return ALLOWED_TAGS.includes(tag); 26 + } 27 + 28 + const ALLOWED_ATTRS = ["class", "title", "role", "lang", "dir"]; 29 + 30 + function isAllowedAttr(name) { 31 + return ( 32 + ALLOWED_ATTRS.includes(name) || 33 + name.startsWith("data-") || 34 + name.startsWith("aria-") 35 + ); 36 + } 37 + 38 + // Render a serialized VirtualEl node ({ tag, attrs, text, children }) into a 39 + // real element. Text and children are mutually exclusive on 40 + // the worker side (setText() clears children). 41 + export function renderNode(node, pluginId) { 42 + let tag = typeof node.tag === "string" ? node.tag.toLowerCase() : "div"; 43 + if (!isAllowedTag(tag)) { 44 + console.warn( 45 + `[plugins] "${pluginId}" tried to render disallowed tag <${tag}>`, 46 + ); 47 + tag = "span"; 48 + } 49 + const element = document.createElement(tag); 50 + if (node.attrs) { 51 + for (const [name, value] of Object.entries(node.attrs)) { 52 + if (!isAllowedAttr(name)) { 53 + console.warn( 54 + `[plugins] "${pluginId}" tried to set disallowed attribute "${name}" on <${tag}>`, 55 + ); 56 + continue; 57 + } 58 + element.setAttribute(name, String(value)); 59 + } 60 + } 61 + if (node.text != null) { 62 + element.textContent = node.text; 63 + } else if (Array.isArray(node.children)) { 64 + for (const child of node.children) { 65 + element.appendChild(renderNode(child, pluginId)); 66 + } 67 + } 68 + return element; 69 + } 70 + 71 + export function isEmptyNode(node) { 72 + if (!node) return true; 73 + if (node.text != null && node.text !== "") return false; 74 + if (Array.isArray(node.children) && node.children.length > 0) return false; 75 + return true; 76 + } 77 + 78 + const PLUGIN_ICON_TEMPLATES = { 79 + "lightning-bolt": lightningBoltIconTemplate, 80 + }; 81 + 82 + export function getPluginIconTemplate(icon) { 83 + const template = PLUGIN_ICON_TEMPLATES[icon]; 84 + if (!template) { 85 + console.warn(`[plugins] requested unknown icon "${icon}"`); 86 + return null; 87 + } 88 + return template; 89 + }
+64
src/js/plugins/sandbox.html
··· 1 + <!doctype html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <title>Plugin sandbox</title> 6 + <meta 7 + http-equiv="Content-Security-Policy" 8 + content="default-src 'none'; script-src 'unsafe-inline' blob:; worker-src blob:; connect-src 'none'" 9 + /> 10 + </head> 11 + <body> 12 + <script> 13 + (() => { 14 + let worker = null; 15 + 16 + // Handle messages from parent 17 + window.addEventListener("message", (event) => { 18 + if (event.source !== window.parent) return; 19 + const message = event.data; 20 + if (!message || typeof message !== "object") return; 21 + if (message.type === "init") init(message.source); 22 + else if (message.type === "send" && worker) 23 + worker.postMessage(message.payload); 24 + else if (message.type === "terminate" && worker) worker.terminate(); 25 + }); 26 + 27 + function init(source) { 28 + if (worker) return; 29 + try { 30 + const blob = new Blob([source], { type: "text/javascript" }); 31 + const url = URL.createObjectURL(blob); 32 + worker = new Worker(url, { type: "module" }); 33 + URL.revokeObjectURL(url); 34 + } catch (error) { 35 + window.parent.postMessage( 36 + { 37 + type: "ready", 38 + error: (error && error.message) || String(error), 39 + }, 40 + "*", 41 + ); 42 + return; 43 + } 44 + 45 + // Handle messages from worker 46 + worker.addEventListener("message", (event) => { 47 + window.parent.postMessage( 48 + { type: "fromWorker", payload: event.data }, 49 + "*", 50 + ); 51 + }); 52 + worker.addEventListener("error", (event) => { 53 + window.parent.postMessage( 54 + { type: "workerError", error: event.message || "worker error" }, 55 + "*", 56 + ); 57 + }); 58 + 59 + window.parent.postMessage({ type: "ready" }, "*"); 60 + } 61 + })(); 62 + </script> 63 + </body> 64 + </html>
+40
src/js/templates/icons/lightningBoltIcon.template.js
··· 1 + import { html } from "/js/lib/lit-html.js"; 2 + import { classnames } from "/js/utils.js"; 3 + 4 + // Source: https://github.com/halfmage/majesticons/blob/main/line/lightning-bolt-line.svg 5 + export function lightningBoltIconTemplate({ filled = false } = {}) { 6 + return html`<div 7 + class=${classnames("icon lightning-bolt-icon", { 8 + filled, 9 + })} 10 + > 11 + ${filled 12 + ? html`<svg 13 + xmlns="http://www.w3.org/2000/svg" 14 + viewBox="0 0 24 24" 15 + fill="none" 16 + > 17 + <path 18 + fill="currentColor" 19 + stroke="currentColor" 20 + stroke-linecap="round" 21 + stroke-linejoin="round" 22 + stroke-width="2" 23 + d="M4 14 14 3v7h6L10 21v-7H4z" 24 + /> 25 + </svg>` 26 + : html`<svg 27 + xmlns="http://www.w3.org/2000/svg" 28 + viewBox="0 0 24 24" 29 + fill="none" 30 + > 31 + <path 32 + stroke="currentColor" 33 + stroke-linecap="round" 34 + stroke-linejoin="round" 35 + stroke-width="2" 36 + d="M4 14 14 3v7h6L10 21v-7H4z" 37 + /> 38 + </svg>`} 39 + </div>`; 40 + }
+2
src/js/templates/mainLayout.template.js
··· 2 2 import { sidebarTemplate } from "/js/templates/sidebar.template.js"; 3 3 import { footerTemplate } from "/js/templates/footer.template.js"; 4 4 import { editIconTemplate } from "/js/templates/icons/editIcon.template.js"; 5 + import { pluginHost } from "/js/plugins/pluginHost.js"; 5 6 import "/js/components/animated-sidebar.js"; 6 7 7 8 function defaultOnClickComposeButton() { ··· 37 38 numChatNotifications, 38 39 onClickActiveItem: onClickActiveNavItem, 39 40 onClickComposeButton, 41 + pluginSidebarIcons: pluginHost.getSidebarIcons(), 40 42 }) 41 43 : ""} 42 44 </div>
+51 -5
src/js/templates/sidebar.template.js
··· 22 22 } from "/js/navigation.js"; 23 23 import "/js/components/animated-sidebar.js"; 24 24 import { showInfoModal } from "/js/modals.js"; 25 + import { getPluginIconTemplate } from "/js/plugins/pluginRendering.js"; 26 + 27 + function pluginSidebarIconTemplate({ entry }) { 28 + const iconTemplate = getPluginIconTemplate(entry.icon); 29 + return html` 30 + <button 31 + class="sidebar-nav-item sidebar-plugin-nav-item" 32 + title=${entry.title} 33 + @click=${(event) => { 34 + const sidebar = event.currentTarget.closest("animated-sidebar"); 35 + if (sidebar) sidebar.close(); 36 + entry.invoke(); 37 + }} 38 + > 39 + <span class="sidebar-nav-icon" 40 + >${iconTemplate ? iconTemplate() : ""}</span 41 + > 42 + <span class="sidebar-nav-label">${entry.title}</span> 43 + </button> 44 + `; 45 + } 25 46 26 47 function showAboutModal() { 27 48 showInfoModal({ ··· 38 59 }); 39 60 } 40 61 41 - function sidebarNavTemplate({ menuItems, activeNavItem, onClickActiveItem }) { 62 + function sidebarNavTemplate({ 63 + menuItems, 64 + activeNavItem, 65 + onClickActiveItem, 66 + pluginSidebarIcons = [], 67 + }) { 42 68 return html` 43 69 <nav class="sidebar-nav" data-testid="sidebar-nav"> 44 70 ${menuItems.map( ··· 77 103 </a> 78 104 `, 79 105 )} 106 + ${pluginSidebarIcons.map((entry) => pluginSidebarIconTemplate({ entry }))} 80 107 </nav> 81 108 `; 82 109 } 83 110 84 - function loggedOutSidebarTemplate({ activeNavItem, onClickActiveItem }) { 111 + function loggedOutSidebarTemplate({ 112 + activeNavItem, 113 + onClickActiveItem, 114 + pluginSidebarIcons, 115 + }) { 85 116 const menuItems = [ 86 117 { 87 118 id: "home", ··· 104 135 <div class="sidebar-header"> 105 136 <a href="/" class="sidebar-title"><h1>IMPRO</h1></a> 106 137 </div> 107 - ${sidebarNavTemplate({ menuItems, activeNavItem, onClickActiveItem })} 138 + ${sidebarNavTemplate({ 139 + menuItems, 140 + activeNavItem, 141 + onClickActiveItem, 142 + pluginSidebarIcons, 143 + })} 108 144 <a 109 145 href=${linkToLogin()} 110 146 class="square-button primary-button login-button" ··· 141 177 numChatNotifications = 0, 142 178 onClickActiveItem, 143 179 onClickComposeButton, 180 + pluginSidebarIcons = [], 144 181 }) { 145 182 if (!isAuthenticated) { 146 - return loggedOutSidebarTemplate({ activeNavItem, onClickActiveItem }); 183 + return loggedOutSidebarTemplate({ 184 + activeNavItem, 185 + onClickActiveItem, 186 + pluginSidebarIcons, 187 + }); 147 188 } 148 189 149 190 const menuItems = [ ··· 266 307 </div> 267 308 </div> 268 309 <div class="sidebar-divider"></div> 269 - ${sidebarNavTemplate({ menuItems, activeNavItem, onClickActiveItem })} 310 + ${sidebarNavTemplate({ 311 + menuItems, 312 + activeNavItem, 313 + onClickActiveItem, 314 + pluginSidebarIcons, 315 + })} 270 316 ${onClickComposeButton 271 317 ? html`<button 272 318 class="sidebar-compose-button"
+10
src/js/utils.js
··· 457 457 this._loading.clear(); 458 458 } 459 459 } 460 + 461 + export class SimpleUUID { 462 + constructor() { 463 + this._id = 0; 464 + } 465 + 466 + create() { 467 + return this._id++; 468 + } 469 + }