Something went wrong. Try again.
Mirror of https://github.com/improsocial/improAn extensible Bluesky client for web
Something went wrong. Try again.
22 kB · 900 lines
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901export 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(); } 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); return { title: item.title, icon: item.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). Requires no // extra permission, but does a network round-trip if not already cached. 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 }); }}
// Moderation actions on behalf of the signed-in user. Each method requires// the corresponding scope ("mute", "block", "feedback") to be declared in// the plugin manifest's `permissions.moderation` array, which the user must// grant at install time.class PluginModeration { muteActor(did, mute = true) { return hostCall("muteActor", { did, mute }); } blockActor(did, block = true) { return hostCall("blockActor", { did, block }); } // feedContext/feedProxyUrl are optional and let the signal be attributed // back to the specific feed generator that served the post, matching // app.bsky.feed.sendInteractions semantics; omit them to just send a // general requestLess signal. sendShowLessInteraction( postUri, { feedContext = null, feedProxyUrl = null } = {}, ) { return hostCall("sendShowLessInteraction", { postUri, feedContext, feedProxyUrl, }); }}
class App { constructor() { this.currentUser = null; this.data = new PluginData(); this.moderation = new PluginModeration(); } on(event, listener) { addEventListener(event, listener); }
refreshFeedFilters(feedURI = null) { return hostCall("refreshFeedFilters", feedURI); }}
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); }}
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, 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, }); }
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 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 VirtualEl { constructor(tag) { this.tag = tag; this.attrs = {}; this.text = null; this.children = []; this.events = {}; }
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.text = text; this.children = []; return this; }
empty() { this.text = null; this.children = []; return this; }
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.text = 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; }
_serialize() { return { tag: this.tag, attrs: this.attrs, text: this.text, children: this.children.map((child) => child._serialize()), events: this.events, }; }}
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; }};