Something went wrong. Try again.
Mirror of https://github.com/improsocial/improAn extensible Bluesky client for web
Something went wrong. Try again.
4.5 kB · 141 lines
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142import { Component } from "/js/components/component.js";import { effect } from "/js/signals.js";
const CONTEXT_PREFIX = "context-";
function kebabToCamel(name) { return name.replace(/-([a-z])/g, (_, char) => char.toUpperCase());}
class PluginSlot extends Component { connectedCallback() { if (this.initialized) return; this.initialized = true; if (!this.pluginService) { throw new Error("pluginService is required"); } // Only surfaces like plugin-posts-feed rendering actually read // interactionHandlers (see pluginRendering.js); slots with no post/ // profile interaction surface (e.g. the persistent overlay slot in // mainLayout.js) have no need to supply one. this.interactionHandlers ??= {}; this._pluginRoots = new Map(); this._currentRequest = null; this._subscribe(); }
_subscribe() { this._disposeEffect?.(); const slotName = this.getAttribute("name"); if (!slotName) return; this._disposeEffect = effect(() => { this.pluginService.$slots.get(slotName); this._reconcile(); }, `plugin-slot[${slotName}]`); }
disconnectedCallback() { if (!this.initialized) return; this._disposeEffect?.(); this._disposeEffect = null; this._currentRequest = null; this._pluginRoots.clear(); }
// TODO - automatic? static get observedAttributes() { return ["name", "context-uri", "context-page", "context-notifications"]; }
attributeChangedCallback(name, oldValue, newValue) { if (!this.initialized || oldValue === newValue) return; if (name === "name") { this._subscribe(); } else { this._reconcile(); } }
_getContext() { const context = {}; for (const attr of this.attributes) { if (!attr.name.startsWith(CONTEXT_PREFIX)) continue; const key = kebabToCamel(attr.name.slice(CONTEXT_PREFIX.length)); context[key] = attr.value; } return context; }
async _reconcile() { const slotName = this.getAttribute("name"); if (!slotName) return; const context = this._getContext(); const entries = this.pluginService.getSlotEntries(slotName);
const requestToken = Symbol(); this._currentRequest = requestToken;
// Drop cached roots for plugins no longer registered for this slot. const currentIds = new Set(entries.map((entry) => entry.pluginId)); for (const pluginId of [...this._pluginRoots.keys()]) { if (!currentIds.has(pluginId)) this._pluginRoots.delete(pluginId); }
if (entries.length === 0) { this.replaceChildren(); return; }
const results = await Promise.all( entries.map(async (entry) => { try { const node = await entry.invoke(context); return { entry, node }; } catch (error) { console.error( `Plugin "${entry.pluginId}" slot "${slotName}" failed:`, error, ); return { entry, node: null }; } }), );
if (this._currentRequest !== requestToken) return;
const nextChildren = []; for (const { entry, node } of results) { if (!node) continue; let state = this._pluginRoots.get(entry.pluginId); if (!state) { const renderer = this.pluginService.getRenderer(entry.pluginId); state = { root: renderer.createRoot(), }; this._pluginRoots.set(entry.pluginId, state); } const element = state.root.render(node); nextChildren.push(element); }
// Skip the DOM write when nothing actually changed. replaceChildren() // unconditionally detaches and reattaches every child per the DOM // spec's "replace all" algorithm, even when passed the exact same node // references already in place — which cancels any running CSS // animation/transition on them. Since state.root.render() patches // same-shape trees in place (see PluginRenderer._patch), nextChildren // is often referentially identical to the current children, and this // guard is what makes continuous animations (e.g. a plugin moving an // element via CSS transitions) actually stay continuous across // frequent reconciles (context changes, refreshSlot, etc.). const currentChildren = Array.from(this.childNodes); const changed = currentChildren.length !== nextChildren.length || currentChildren.some((child, i) => child !== nextChildren[i]); if (changed) { this.replaceChildren(...nextChildren); } }}
PluginSlot.register();