Something went wrong. Try again.
Mirror of https://github.com/improsocial/improAn extensible Bluesky client for web
Something went wrong. Try again.
30 kB · 1119 lines
JavaScript
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120export class SimpleUUID { constructor() { this._id = 0; } create() { return this._id++; }}
const uuid = new SimpleUUID();
const callHandlers = new Map();
const pendingHostCalls = new Map();
function hostCall(method, ...args) { const hostCallId = uuid.create(); return new Promise((resolve, reject) => { pendingHostCalls.set(hostCallId, { resolve, reject }); self.postMessage({ type: "hostCall", method, hostCallId, args }); });}
const eventListeners = new Map();const registeredEvents = new Set();
async function invokeListeners(listeners, event, args) { for (const listener of listeners) { try { await listener(...args); } catch (error) { console.error(`"${event}" listener threw:`, error); } }}
async function dispatchEvent(event, args) { const listeners = eventListeners.get(event) ?? new Set(); switch (event) { case "post-context-menu": case "profile-context-menu": { const menu = new Menu(); await invokeListeners(listeners, event, [menu, ...args]); return menu._serialize(); } case "post-composer-open": { const composer = new Composer(); await invokeListeners(listeners, event, [composer, ...args]); return composer._serialize(); } // One-way notifications: the host just tells listeners something // happened, no return value is serialized back. case "post-liked": case "post-unliked": case "post-reposted": case "post-unreposted": case "profile-followed": case "profile-unfollowed": case "post-created": case "feed-refreshed": { await invokeListeners(listeners, event, args); return null; } default: console.warn(`No dispatch case for plugin event "${event}".`); return null; }}
function addEventListener(event, listener) { let listeners = eventListeners.get(event); if (!listeners) { listeners = new Set(); eventListeners.set(event, listeners); } listeners.add(listener); // Register handler if (!registeredEvents.has(event)) { registeredEvents.add(event); const handlerId = uuid.create(); callHandlers.set(handlerId, (...args) => dispatchEvent(event, args)); self.postMessage({ type: "register", target: "eventListener", event, handlerId, }); }}
export class MenuItem { constructor() { this.title = ""; this.icon = null; this._callback = () => {}; } setTitle(title) { this.title = title; return this; } setIcon(icon) { this.icon = icon; return this; } onClick(callback) { this._callback = callback; return this; }}
export class Menu { constructor() { this.items = []; } addItem(builder) { const item = new MenuItem(); builder(item); this.items.push(item); return this; } _serialize() { return this.items.map((item) => { const handlerId = uuid.create(); callHandlers.set(handlerId, item._callback); const icon = item.icon instanceof VirtualEl ? item.icon._serialize() : item.icon; return { title: item.title, icon, handlerId }; }); }}
export class Composer { constructor() { this._ops = []; this._cursor = null; } setText(text) { this._ops.push({ op: "set", text: String(text) }); return this; } appendText(text) { this._ops.push({ op: "append", text: String(text) }); return this; } prependText(text) { this._ops.push({ op: "prepend", text: String(text) }); return this; } setCursor(index) { this._cursor = index; return this; } _serialize() { return { ops: this._ops, cursor: this._cursor }; }}
class PluginData { getPost(uri) { return hostCall("getPost", { uri }); } getProfile(did) { return hostCall("getProfile", { did }); } // Like getProfile, but includes viewer relationship details not present // on the basic profile view: viewer.following, viewer.followedBy, and // viewer.knownFollowers (a summary of mutual followers). getDetailedProfile(did) { return hostCall("getDetailedProfile", { did }); } // The full known-followers list for did (the summary on // getDetailedProfile's viewer.knownFollowers is capped to a handful). getKnownFollowers(did) { return hostCall("getKnownFollowers", { did }); } getRecord(repo, collection, rkey) { return hostCall("getRecord", { repo, collection, rkey }); } // A one-time snapshot of real UI chrome geometry (sidebar, footer nav, // compose button, notifications icon) — { viewport: {width, height}, // landmarks: { [name]: {x, y, width, height} | null } }. A landmark is // null when it isn't currently present/visible (e.g. no compose button // on this view, or the mobile sidebar drawer is closed). Not push-based; // call again to get fresh values after layout may have changed. getLandmarkRects() { return hostCall("getLandmarkRects"); } // Plugin code runs in a real Worker with no window/matchMedia of its own // (see getLandmarkRects' comment on the host side for why), so checking // the user's OS-level reduced-motion preference has to go through the // host. Not push-based — call again if you need to notice a live change. prefersReducedMotion() { return hostCall("prefersReducedMotion"); }}
// Lets a plugin store spritesheet images a user picks at runtime (via a// real <input type="file"> the plugin renders — see// TextComponent-style .onChange usage below), complementing// manifest.json's `images` array (bundled into the plugin's own repo at// publish time). Requires permissions.images: ["upload"] in the manifest.// `fileToken` is the opaque string a file input's onChange handler// receives as `event.target.value` — the host reads the actual File the// moment it's picked and hands the plugin only this token, never the raw// bytes (see pluginFileStaging.js on the host side).class CustomImages { register({ name, frameWidth, frameHeight, frameCount, fileToken }) { return hostCall("registerCustomImage", { name, frameWidth, frameHeight, frameCount, fileToken, }); } list() { return hostCall("listCustomImages"); } delete(name) { return hostCall("deleteCustomImage", { name }); }}
// Lets a plugin write text to the system clipboard — e.g. a drafted reply// the user can paste wherever they like. Requires// permissions.clipboard: ["write"] in the manifest. There is no read// counterpart: a plugin can only hand the user text to paste, never read// what's already on their clipboard.class Clipboard { write(text) { return hostCall("copyToClipboard", { text: String(text) }); }}
class App { constructor() { this.currentUser = null; this.data = new PluginData(); this.configuredEndpoint = new ConfiguredEndpoint(); this.customImages = new CustomImages(); this.clipboard = new Clipboard(); } on(event, listener) { addEventListener(event, listener); }
refreshFeedFilters(feedURI = null) { return hostCall("refreshFeedFilters", feedURI); }
// Actions on behalf of the signed-in user. Each method requires the // corresponding scope ("mute", "block", "feedFeedback") to be declared in // the plugin manifest's `permissions.actions` array, which the user must // grant at install time. muteActor(did) { return hostCall("muteActor", { did, mute: true }); } unmuteActor(did) { return hostCall("muteActor", { did, mute: false }); } blockActor(did) { return hostCall("blockActor", { did, block: true }); } unblockActor(did) { return hostCall("blockActor", { did, block: false }); } // Acts like the user clicking "Show less like this": sends the requestLess // feedback signal to the feed that served the post and collapses the post // behind a feedback message in feeds. showLessLikeThis(postUri, feedUri) { return hostCall("showLessLikeThis", { postUri, feedUri }); } showMoreLikeThis(postUri, feedUri) { return hostCall("showMoreLikeThis", { postUri, feedUri }); } likePost(uri) { return hostCall("likePost", { uri, like: true }); } unlikePost(uri) { return hostCall("likePost", { uri, like: false }); } repostPost(uri) { return hostCall("repostPost", { uri, repost: true }); } unrepostPost(uri) { return hostCall("repostPost", { uri, repost: false }); } followActor(did) { return hostCall("followActor", { did, follow: true }); } unfollowActor(did) { return hostCall("followActor", { did, follow: false }); } bookmarkPost(uri) { return hostCall("bookmarkPost", { uri, bookmark: true }); } unbookmarkPost(uri) { return hostCall("bookmarkPost", { uri, bookmark: false }); }}
export async function fetch(url, init = {}) { const result = await hostCall("fetch", { url, init: serializeFetchInit(init), }); return new PluginResponse(result);}
function serializeFetchInit(init) { const serialized = {}; if (init.method != null) serialized.method = String(init.method); if (init.headers != null) { const headers = {}; if (typeof init.headers.forEach === "function") { // Headers, Map, and similar iterables expose forEach(value, name) init.headers.forEach((value, name) => { headers[name] = value; }); } else if (typeof init.headers[Symbol.iterator] === "function") { for (const [name, value] of init.headers) headers[name] = value; } else { Object.assign(headers, init.headers); } serialized.headers = headers; } if (init.body != null) serialized.body = init.body; return serialized;}
class PluginResponse { constructor({ status, ok, headers, body }) { this.status = status; this.ok = ok; this.headers = new Map(Object.entries(headers ?? {})); this._body = body; } async text() { return this._body; } async json() { return JSON.parse(this._body); }}
// Lets a plugin send requests to a single network address a human has// personally typed into that plugin's own settings and approved — not a// manifest-declared allowlist like the ordinary top-level fetch() export,// and not "any host": requestUrl() always re-prompts the user with the// exact address before it takes effect, and fetch() only ever succeeds// against that one approved origin. Requires// permissions.network: ["configuredEndpoint"] in the manifest. Unlike// ordinary fetch(), an Authorization header is allowed through, since it's// the plugin's own credential for a URL the user explicitly approved (e.g.// an API key for a self-hosted or cloud LLM endpoint), not anything ambient.class ConfiguredEndpoint { // The currently-approved URL, or null if none has been approved yet (or // it was cleared, e.g. by uninstalling and reinstalling the plugin). getUrl() { return hostCall("getConfiguredEndpointUrl"); } // Prompts the user to approve `url` as this plugin's one allowed network // address. Resolves to { accepted, url }: `url` is the *current* approved // address (which may be unchanged from before if the user declined), and // `accepted` reflects whether this particular request was granted. requestUrl(url) { return hostCall("requestConfiguredEndpointUrl", { url: String(url) }); } async fetch(url, init = {}) { const result = await hostCall("configuredFetch", { url, init: serializeFetchInit(init), }); return new PluginResponse(result); }}
export class Notice { constructor(message, timeout = 0) { this._toastId = uuid.create(); this._timeout = timeout; this._hidden = false; this.noticeEl = new VirtualEl("div"); this.noticeEl.addClass("toast"); this.noticeEl.setText(message); queueMicrotask(() => { if (this._hidden) return; hostCall("showToast", { toastId: this._toastId, element: this.noticeEl._serialize(), timeout: this._timeout, }); }); } setMessage(message) { this.noticeEl.setText(message); return this; } hide() { if (this._hidden) return; this._hidden = true; hostCall("hideToast", { toastId: this._toastId }); }}
export class StyleSnippet { constructor(cssText) { this._snippetId = uuid.create(); this._removed = false; this.ready = new Promise((resolve, reject) => { queueMicrotask(() => { if (this._removed) return resolve(); hostCall("applyStyleSnippet", { snippetId: this._snippetId, cssText, }).then(resolve, reject); }); }); } remove() { if (this._removed) return; this._removed = true; hostCall("removeStyleSnippet", { snippetId: this._snippetId }); }}
let registered = false;
export class Plugin { constructor() { this.app = new App(); }
addSidebarItem(icon, title, callback = () => {}) { const handlerId = uuid.create(); callHandlers.set(handlerId, callback); self.postMessage({ type: "register", target: "sidebarItem", icon: icon instanceof VirtualEl ? icon._serialize() : icon, title, handlerId, }); }
async loadData() { return hostCall("loadData"); }
async saveData(data) { await hostCall("saveData", { data }); }
addSettingTab(tab) { tab.plugin = this; const displayHandlerId = uuid.create(); callHandlers.set(displayHandlerId, () => { tab.containerEl = new VirtualEl("div"); tab.display(); return tab.containerEl._serialize(); }); self.postMessage({ type: "register", target: "settingTab", name: tab.name ?? null, displayHandlerId, }); this._settingTab = tab; }
addFeedFilter(callback = () => {}) { const handlerId = uuid.create(); callHandlers.set(handlerId, callback); self.postMessage({ type: "register", target: "feedFilter", handlerId, }); }
// callback(tokens, context) receives the rich-text token stream for one // post and returns a new token array (or the input unchanged). The host // batches all posts of a render into one call per plugin. // // options.handlesFacetTypes: array of facet feature $type strings this // transform owns, to prevent render flash of fallback text registerRichTextTransform(callback = (tokens) => tokens, options = {}) { const handlerId = uuid.create(); callHandlers.set(handlerId, async (batch) => { const results = []; for (const { tokens, context } of batch) { try { const value = await callback(tokens, context); results.push({ value: serializeTransformTokens(value) }); } catch (error) { results.push({ error: error?.message ?? String(error) }); } } return results; }); const handlesFacetTypes = Array.isArray(options.handlesFacetTypes) ? options.handlesFacetTypes.filter((type) => typeof type === "string") : []; self.postMessage({ type: "register", target: "richTextTransform", handlerId, handlesFacetTypes, }); }
registerSlot(name, callback = () => null) { const handlerId = uuid.create(); callHandlers.set(handlerId, async (context) => { const result = await callback(context); if (result == null) return null; if (!(result instanceof VirtualEl)) { const description = result?.constructor?.name ?? typeof result; throw new Error( `Slot "${name}" must return a VirtualEl (or null), got ${description}`, ); } return result._serialize(); }); self.postMessage({ type: "register", target: "slot", name, handlerId, }); }
// Forces every plugin currently registered for slot `name` (not just this // one) to re-invoke its callback, e.g. after internal state driving what // that callback renders has changed. Complements registerSlot's own // re-invocation on registration-set changes. refreshSlot(name) { return hostCall("refreshSlot", { name }); }
onload() {} onunload() {}
static register() { if (registered) return; registered = true; const instance = new this(); hostCall("getCurrentUser") .then((user) => { instance.app.currentUser = user; return instance.onload(); }) .then( () => self.postMessage({ type: "ready" }), (error) => self.postMessage({ type: "ready", error: error?.message ?? String(error), }), ); }}
function serializeTransformTokens(tokens) { if (!Array.isArray(tokens)) return tokens; return tokens.map((token) => { if ( (token?.type === "inline" || token?.type === "block") && token.node instanceof VirtualEl ) { return { ...token, node: token.node._serialize() }; } return token; });}
// Concatenates the renderable text of a token stream into a string with a position map,// so a transform can pattern-match across token boundaries and map matches back to tokens.export class FlattenedTokens { constructor(tokens) { this._segments = []; let text = ""; for (const token of tokens) { let value = null; if (token.type === "text") value = token.value; else if (token.type === "facet") value = token.text; const start = text.length; if (value != null) text += value; this._segments.push({ token, start, end: text.length }); } this.text = text; }
// emit an inert range (no facets) textFor(start, end) { return this.text.slice(start, end); }
// emit a live range, demoting partial facet tokens to text tokens tokensFor(start, end) { const out = []; for (const segment of this._segments) { if (segment.start === segment.end) { if (segment.start >= start && segment.start < end) { out.push(segment.token); } continue; } if (segment.end <= start || segment.start >= end) continue; const from = Math.max(start, segment.start); const to = Math.min(end, segment.end); const isWholeToken = from === segment.start && to === segment.end; if (isWholeToken) { out.push(segment.token); } else { out.push({ type: "text", value: this.text.slice(from, to) }); } } return out; }}
export function flattenForScan(tokens) { return new FlattenedTokens(tokens);}
const openModals = new Map();
export class Modal { constructor() { this._modalId = uuid.create(); this.contentEl = new VirtualEl("div"); this.titleEl = new VirtualEl("h2"); }
open() { if (openModals.has(this._modalId)) return; openModals.set(this._modalId, this); this.onOpen(); self.postMessage({ type: "hostCall", method: "openModal", args: [ { modalId: this._modalId, title: this.titleEl._serialize(), content: this.contentEl._serialize(), }, ], }); }
close() { if (!openModals.has(this._modalId)) return; openModals.delete(this._modalId); self.postMessage({ type: "hostCall", method: "closeModal", args: [{ modalId: this._modalId }], }); this.onClose(); }
onOpen() {} onClose() {}}
export class PluginSettingTab { constructor() { this.containerEl = new VirtualEl("div"); this.name = null; } setName(name) { this.name = name; return this; } display() {} refresh({ reset = false } = {}) { return hostCall("refreshSettingTab", { reset }); }}
export class Setting { constructor(containerEl) { this.settingEl = containerEl.createDiv({ cls: "setting-item" }); this.infoEl = this.settingEl.createDiv({ cls: "setting-item-info" }); this.nameEl = this.infoEl.createEl("h2", { cls: "setting-item-name" }); this.descEl = this.infoEl.createEl("p", { cls: "setting-item-desc" }); this.controlEl = this.settingEl.createDiv({ cls: "setting-item-control", }); } setName(text) { this.nameEl.setText(text); return this; } setDesc(text) { this.descEl.setText(text); return this; } addText(callback) { const component = new TextComponent(this.controlEl); callback(component); return this; } addTextArea(callback) { const component = new TextAreaComponent(this.controlEl); callback(component); return this; } addToggle(callback) { const component = new ToggleComponent(this.controlEl); callback(component); return this; } addDropdown(callback) { const component = new DropdownComponent(this.controlEl); callback(component); return this; } addButton(callback) { const component = new ButtonComponent(this.controlEl); callback(component); return this; }}
class TextComponent { constructor(containerEl) { this.el = containerEl.createEl("input", { attr: { type: "text" }, cls: "setting-item-text-input", }); } setValue(value) { this.el.setAttr("value", value == null ? "" : String(value)); return this; } setPlaceholder(value) { this.el.setAttr("placeholder", value); return this; } onChange(callback) { this.el.onChange((event) => callback(event.target.value)); return this; }}
class TextAreaComponent { constructor(containerEl) { this.el = containerEl.createEl("textarea", { cls: "setting-item-textarea", }); } setValue(value) { this.el.setText(value == null ? "" : String(value)); return this; } setPlaceholder(value) { this.el.setAttr("placeholder", value); return this; } onChange(callback) { this.el.onChange((event) => callback(event.target.value)); return this; }}
class ToggleComponent { constructor(containerEl) { this.el = containerEl.createEl("toggle-switch", { cls: "setting-item-toggle", }); } setValue(value) { if (value) this.el.setAttr("checked", ""); else delete this.el.attrs.checked; return this; } onChange(callback) { this.el.onChange((event) => callback(event.target.checked)); return this; }}
class DropdownComponent { constructor(containerEl) { this.el = containerEl.createEl("select", { cls: "setting-item-dropdown", }); } addOption(value, label) { this.el.createEl("option", { text: label, attr: { value } }); return this; } addOptions(map) { for (const [value, label] of Object.entries(map)) { this.addOption(value, label); } return this; } setValue(value) { for (const child of this.el.children) { if (child.attrs?.value === value) { child.attrs.selected = ""; } else if (child.attrs) { delete child.attrs.selected; } } return this; } onChange(callback) { this.el.onChange((event) => callback(event.target.value)); return this; }}
class ButtonComponent { constructor(containerEl) { this.el = containerEl.createEl("button", { cls: "rounded-button", }); } setButtonText(text) { this.el.setText(text); return this; } setCta() { this.el.addClass("rounded-button-primary"); return this; } onClick(callback) { this.el.onClick(callback); return this; }}
class IconComponent { constructor(containerEl) { this.el = containerEl.createEl("plugin-icon"); } setIcon(name) { this.el.setAttr("icon", name); return this; }}
class BlobImageComponent { constructor(containerEl) { this.el = containerEl.createEl("plugin-blob-image"); } setDid(did) { this.el.setAttr("did", did); return this; } setCid(cid) { this.el.setAttr("cid", cid); return this; } setAlt(alt) { this.el.setAttr("alt", alt); return this; } setCdnPrefix(prefix) { this.el.setAttr("cdn-prefix", prefix); return this; }}
class SpriteComponent { constructor(containerEl) { this.el = containerEl.createEl("plugin-sprite"); } // `name` is the "name" key of an entry in this plugin's manifest.json // `images` array. Frame geometry (frameWidth/frameHeight/frameCount) is // declared there too, not here, and is resolved host-side — this mirrors // plugin-blob-image's did/cid trust model rather than letting the plugin // hand the host a raw URL. setImage(name) { this.el.setAttr("image", String(name)); return this; }}
class ProfilesListComponent { constructor(containerEl) { this.el = containerEl.createEl("plugin-profiles-list"); } setDids(dids) { const value = Array.isArray(dids) ? dids.join(",") : String(dids ?? ""); this.el.setAttr("dids", value); return this; } setEmptyMessage(message) { this.el.setAttr("empty-message", message); return this; }}
class PostsFeedComponent { constructor(containerEl) { this.el = containerEl.createEl("plugin-posts-feed"); } setUris(uris) { const value = Array.isArray(uris) ? uris.join(",") : String(uris ?? ""); this.el.setAttr("uris", value); return this; } setEmptyMessage(message) { this.el.setAttr("empty-message", message); return this; }}
export class VirtualText { constructor(value) { this.value = value == null ? "" : String(value); }
_serialize() { return { type: "text", value: this.value }; }}
export class VirtualEl { constructor(tag) { this.tag = tag; this.attrs = {}; this.styles = {}; this.children = []; this.events = {}; }
setStyle(name, value) { this.styles[String(name)] = value == null ? "" : String(value); return this; }
onClick(fn) { const handlerId = uuid.create(); callHandlers.set(handlerId, fn); this.events.click = handlerId; return this; }
onChange(fn) { const handlerId = uuid.create(); callHandlers.set(handlerId, fn); this.events.change = handlerId; return this; }
onInput(fn) { const handlerId = uuid.create(); callHandlers.set(handlerId, fn); this.events.input = handlerId; return this; }
setText(text) { this.children = []; if (text != null && text !== "") this.children.push(new VirtualText(text)); return this; }
empty() { this.children = []; return this; }
appendChild(child) { if (!(child instanceof VirtualEl) && !(child instanceof VirtualText)) { throw new TypeError( "appendChild expects a VirtualEl or VirtualText instance", ); } this.children.push(child); return this; }
appendText(value) { this.children.push(new VirtualText(value)); return this; }
createText(value) { const node = new VirtualText(value); this.children.push(node); return node; }
addClass(cls) { this.attrs.class = this.attrs.class ? `${this.attrs.class} ${cls}` : cls; return this; }
setAttr(name, value) { this.attrs[name] = value === undefined ? "" : value; return this; }
createEl(tag, options = {}, callback) { const child = new VirtualEl(tag); if (options.text != null) child.setText(options.text); if (options.cls) { child.attrs.class = Array.isArray(options.cls) ? options.cls.join(" ") : options.cls; } if (options.attr) Object.assign(child.attrs, options.attr); this.children.push(child); if (typeof callback === "function") callback(child); return child; }
createDiv(options = {}, callback) { return this.createEl("div", options, callback); }
createSpan(options = {}, callback) { return this.createEl("span", options, callback); }
createProfilesList(callback) { const component = new ProfilesListComponent(this); if (typeof callback === "function") callback(component); return component; }
createPostsFeed(callback) { const component = new PostsFeedComponent(this); if (typeof callback === "function") callback(component); return component; }
createIcon(callback) { const component = new IconComponent(this); if (typeof callback === "function") callback(component); return component; }
createBlobImage(callback) { const component = new BlobImageComponent(this); if (typeof callback === "function") callback(component); return component; }
createSprite(callback) { const component = new SpriteComponent(this); if (typeof callback === "function") callback(component); return component; }
_serialize() { const serialized = { type: "element", tag: this.tag, attrs: this.attrs, events: this.events, children: this.children.map((child) => child._serialize()), }; if (Object.keys(this.styles).length > 0) serialized.styles = this.styles; return serialized; }}
self.onmessage = async (event) => { const message = event.data; if (!message || typeof message !== "object") return;
// RPC calls if (message.type === "call") { const fn = callHandlers.get(message.handlerId); if (!fn) { self.postMessage({ type: "result", callId: message.callId, error: `unknown handler ${message.handlerId}`, }); return; } try { const value = await fn(...message.args); self.postMessage({ type: "result", callId: message.callId, value }); } catch (error) { self.postMessage({ type: "result", callId: message.callId, error: error.message ?? String(error), }); } return; }
// Host call results if (message.type === "hostResult") { const pending = pendingHostCalls.get(message.hostCallId); if (!pending) return; pendingHostCalls.delete(message.hostCallId); if (message.error) pending.reject(new Error(message.error)); else pending.resolve(message.value); return; }
// Events if (message.type === "event") { switch (message.event) { case "modalDismissed": { const modal = openModals.get(message.data.modalId); if (modal) { openModals.delete(message.data.modalId); modal.onClose(); } return; } } return; }};