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 plugin post composer init

Grace Kind (Jun 9, 2026, 1:43 AM -0500) d4a39545 abac837c

+653 -21
+88 -12
impro-plugin/main.js
··· 22 22 } 23 23 24 24 const eventListeners = new Map(); 25 + const registeredEvents = new Set(); 26 + 27 + async function invokeListeners(listeners, event, args) { 28 + for (const listener of listeners) { 29 + try { 30 + await listener(...args); 31 + } catch (error) { 32 + console.error(`"${event}" listener threw:`, error); 33 + } 34 + } 35 + } 36 + 37 + async function dispatchEvent(event, args) { 38 + const listeners = eventListeners.get(event) ?? new Set(); 39 + switch (event) { 40 + case "post-context-menu": 41 + case "profile-context-menu": { 42 + const menu = new Menu(); 43 + await invokeListeners(listeners, event, [menu, ...args]); 44 + return menu._serialize(); 45 + } 46 + case "post-composer-open": { 47 + const composer = new Composer(); 48 + await invokeListeners(listeners, event, [composer, ...args]); 49 + return composer._serialize(); 50 + } 51 + default: 52 + console.warn(`No dispatch case for plugin event "${event}".`); 53 + return null; 54 + } 55 + } 25 56 26 57 function addEventListener(event, listener) { 27 58 let listeners = eventListeners.get(event); 28 59 if (!listeners) { 29 60 listeners = new Set(); 30 61 eventListeners.set(event, listeners); 62 + } 63 + listeners.add(listener); 64 + // Register handler 65 + if (!registeredEvents.has(event)) { 66 + registeredEvents.add(event); 31 67 const handlerId = uuid.create(); 32 - callHandlers.set(handlerId, async (...args) => { 33 - const menu = new Menu(); 34 - for (const eventListener of listeners) { 35 - try { 36 - await eventListener(menu, ...args); 37 - } catch (error) { 38 - console.error(`"${event}" listener threw:`, error); 39 - } 40 - } 41 - return menu._serialize(); 42 - }); 68 + callHandlers.set(handlerId, (...args) => dispatchEvent(event, args)); 43 69 self.postMessage({ 44 70 type: "register", 45 71 target: "eventListener", ··· 47 73 handlerId, 48 74 }); 49 75 } 50 - listeners.add(listener); 51 76 } 52 77 53 78 export class MenuItem { ··· 86 111 callHandlers.set(handlerId, item._callback); 87 112 return { title: item.title, icon: item.icon, handlerId }; 88 113 }); 114 + } 115 + } 116 + 117 + export class Composer { 118 + constructor() { 119 + this._ops = []; 120 + this._cursor = null; 121 + } 122 + setText(text) { 123 + this._ops.push({ op: "set", text: String(text) }); 124 + return this; 125 + } 126 + appendText(text) { 127 + this._ops.push({ op: "append", text: String(text) }); 128 + return this; 129 + } 130 + prependText(text) { 131 + this._ops.push({ op: "prepend", text: String(text) }); 132 + return this; 133 + } 134 + setCursor(index) { 135 + this._cursor = index; 136 + return this; 137 + } 138 + _serialize() { 139 + return { ops: this._ops, cursor: this._cursor }; 89 140 } 90 141 } 91 142 ··· 382 433 callback(component); 383 434 return this; 384 435 } 436 + addTextArea(callback) { 437 + const component = new TextAreaComponent(this.controlEl); 438 + callback(component); 439 + return this; 440 + } 385 441 addToggle(callback) { 386 442 const component = new ToggleComponent(this.controlEl); 387 443 callback(component); ··· 408 464 } 409 465 setValue(value) { 410 466 this.el.setAttr("value", value == null ? "" : String(value)); 467 + return this; 468 + } 469 + setPlaceholder(value) { 470 + this.el.setAttr("placeholder", value); 471 + return this; 472 + } 473 + onChange(callback) { 474 + this.el.onChange((event) => callback(event.target.value)); 475 + return this; 476 + } 477 + } 478 + 479 + class TextAreaComponent { 480 + constructor(containerEl) { 481 + this.el = containerEl.createEl("textarea", { 482 + cls: "setting-item-textarea", 483 + }); 484 + } 485 + setValue(value) { 486 + this.el.setText(value == null ? "" : String(value)); 411 487 return this; 412 488 } 413 489 setPlaceholder(value) {
+1 -1
impro-plugin/package.json
··· 1 1 { 2 2 "name": "@impro.social/impro-plugin", 3 - "version": "0.0.7", 3 + "version": "0.0.8", 4 4 "type": "module", 5 5 "main": "main.js", 6 6 "license": "0BSD",
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.16.6", 3 + "version": "0.16.7", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+1 -1
src/index.html
··· 165 165 ? new ChatNotificationService(api) 166 166 : null; 167 167 const postComposerService = session 168 - ? new PostComposerService(dataLayer, identityResolver) 168 + ? new PostComposerService(dataLayer, identityResolver, pluginService) 169 169 : null; 170 170 const reportService = session ? new ReportService(dataLayer) : null; 171 171 const interactionHandlers = new InteractionHandlers({
+9
src/js/components/post-composer.js
··· 190 190 this.scrollLock = new ScrollLock(this); 191 191 this.innerHTML = ""; 192 192 this._postText = ""; 193 + this.initialText = this.initialText ?? null; 194 + this.initialCursor = this.initialCursor ?? null; 193 195 this._isSending = false; 194 196 this._unresolvedFacets = []; 195 197 this._quotedPostUrl = null; ··· 739 741 this.scrollLock.lock(); 740 742 const dialog = this.querySelector(".post-composer"); 741 743 dialog.showModal(); 744 + const richTextInput = this.querySelector("rich-text-input"); 745 + if (richTextInput && this.initialText !== null) { 746 + richTextInput.setText(this.initialText); 747 + } 748 + if (richTextInput && this.initialCursor !== null) { 749 + richTextInput.setCursor(this.initialCursor); 750 + } 742 751 743 752 // Setup mobile swipe-to-dismiss 744 753 enableDragToDismiss(dialog, {
+21
src/js/components/rich-text-input.js
··· 286 286 } 287 287 } 288 288 289 + setText(text) { 290 + this.text = text; 291 + const unresolvedFacets = getUnresolvedFacetsFromText(this.text); 292 + this.facets = this.partiallyResolveFacets(unresolvedFacets); 293 + this.render(); 294 + this.updateFacets(); 295 + this.saveHistory(); 296 + this.dispatchEvent( 297 + new CustomEvent("input", { 298 + detail: { text: this.text, facets: this.facets }, 299 + }), 300 + ); 301 + } 302 + 303 + setCursor(cursor) { 304 + const input = this.querySelector(".rich-text-input"); 305 + if (!input) return; 306 + const position = Math.max(0, Math.min(this.text.length, cursor)); 307 + setCursorPosition(input, position); 308 + } 309 + 289 310 render() { 290 311 render( 291 312 html`
+43 -3
src/js/plugins/pluginService.js
··· 672 672 return this._collectContextMenuItems("profile-context-menu", profile); 673 673 } 674 674 675 - async _collectContextMenuItems(event, arg) { 675 + async getPostComposerInit({ kind, replyTo, replyRoot, quotedPost }) { 676 + const listeners = this.registries.eventListeners.get("post-composer-open"); 677 + if (!listeners || listeners.size === 0) return null; 678 + const context = { kind, replyTo, replyRoot, quotedPost }; 679 + const results = await Promise.all( 680 + [...listeners].map(async ([pluginId, handler]) => { 681 + try { 682 + return await handler(context); 683 + } catch (error) { 684 + console.error( 685 + `Plugin ${pluginId} post-composer-open handler failed:`, 686 + error, 687 + ); 688 + return null; 689 + } 690 + }), 691 + ); 692 + let text = ""; 693 + let cursor = null; 694 + let touched = false; 695 + for (const result of results) { 696 + if (!result) continue; 697 + for (const op of result.ops ?? []) { 698 + if (op.op === "set") text = op.text; 699 + else if (op.op === "append") text = text + op.text; 700 + else if (op.op === "prepend") text = op.text + text; 701 + else continue; 702 + touched = true; 703 + } 704 + if (result.cursor != null) { 705 + cursor = result.cursor; 706 + touched = true; 707 + } 708 + } 709 + if (!touched) return null; 710 + return { text, cursor }; 711 + } 712 + 713 + async _collectContextMenuItems(event, target) { 676 714 const listeners = this.registries.eventListeners.get(event); 677 715 if (!listeners || listeners.size === 0) return []; 678 716 const results = await Promise.all( 679 717 [...listeners].map(async ([pluginId, handler]) => { 680 718 try { 681 - const items = await handler(arg); 719 + const items = await handler(target); 682 720 return (items ?? []).map((item) => ({ 683 721 pluginId, 684 722 icon: item.icon, 685 723 title: item.title, 686 724 invoke: () => 687 - this.pluginBridge.getInstance(pluginId).call(item.handlerId, arg), 725 + this.pluginBridge 726 + .getInstance(pluginId) 727 + .call(item.handlerId, target), 688 728 })); 689 729 } catch (error) { 690 730 console.error(`Plugin ${pluginId} ${event} handler failed:`, error);
+12 -1
src/js/postComposerService.js
··· 5 5 import { hapticsImpactLight } from "/js/haptics.js"; 6 6 7 7 export class PostComposerService { 8 - constructor(dataLayer, identityResolver) { 8 + constructor(dataLayer, identityResolver, pluginService) { 9 9 this.dataLayer = dataLayer; 10 10 this.identityResolver = identityResolver; 11 + this.pluginService = pluginService; 11 12 this.currentPostComposer = null; 12 13 } 13 14 ··· 26 27 return; 27 28 } 28 29 hapticsImpactLight(); 30 + const composerInit = await this.pluginService.getPostComposerInit({ 31 + kind: replyTo ? "reply" : quotedPost ? "quote" : "post", 32 + replyTo, 33 + replyRoot, 34 + quotedPost, 35 + }); 29 36 return new Promise((resolve, reject) => { 30 37 this.currentPostComposer = document.createElement("post-composer"); 31 38 this.currentPostComposer.dataLayer = this.dataLayer; ··· 34 41 this.currentPostComposer.replyRoot = replyRoot; 35 42 this.currentPostComposer.quotedPost = quotedPost; 36 43 this.currentPostComposer.currentUser = currentUser; 44 + if (composerInit) { 45 + this.currentPostComposer.initialText = composerInit.text; 46 + this.currentPostComposer.initialCursor = composerInit.cursor; 47 + } 37 48 this.currentPostComposer.addEventListener("send-post", async (e) => { 38 49 const { 39 50 postText,
+114
tests/e2e/specs/concerns/composerInit.test.js
··· 1 + import { test, expect } from "../../base.js"; 2 + import { login } from "../../helpers.js"; 3 + import { userProfile } from "../../fixtures.js"; 4 + import { MockServer } from "../../mockServer.js"; 5 + import { createPost } from "../../factories.js"; 6 + import { 7 + TEST_PLUGIN_MANIFEST, 8 + getPostComposerInitPluginSource, 9 + } from "../../testPlugin.js"; 10 + 11 + function installComposerInitPlugin(mockServer) { 12 + mockServer.installedPlugins = [{ ...TEST_PLUGIN_MANIFEST, enabled: true }]; 13 + mockServer.localPluginSource = getPostComposerInitPluginSource(); 14 + } 15 + 16 + test.describe("Composer init plugin flow", () => { 17 + test("plugin seeds the composer with text when opening a new post", async ({ 18 + page, 19 + }) => { 20 + const mockServer = new MockServer(); 21 + installComposerInitPlugin(mockServer); 22 + await mockServer.setup(page); 23 + 24 + await login(page); 25 + await page.goto("/"); 26 + 27 + await expect(page.locator("#home-view")).toBeVisible({ timeout: 10000 }); 28 + await page.locator('[data-testid="sidebar-compose-button"]').click(); 29 + 30 + const composer = page.locator("post-composer .post-composer"); 31 + await expect(composer).toBeVisible({ timeout: 10000 }); 32 + 33 + const richTextInput = composer.locator(".rich-text-input"); 34 + await expect(richTextInput).toContainText("— from test plugin (post)", { 35 + timeout: 10000, 36 + }); 37 + }); 38 + 39 + test("plugin sees kind=reply when opening a reply composer", async ({ 40 + page, 41 + }) => { 42 + const mockServer = new MockServer(); 43 + installComposerInitPlugin(mockServer); 44 + const rootPost = createPost({ 45 + uri: "at://did:plc:author1/app.bsky.feed.post/post1", 46 + text: "Original post", 47 + authorHandle: "author1.bsky.social", 48 + authorDisplayName: "Author One", 49 + }); 50 + mockServer.addPosts([rootPost]); 51 + mockServer.setPostThread(rootPost.uri, { 52 + $type: "app.bsky.feed.defs#threadViewPost", 53 + post: rootPost, 54 + parent: null, 55 + replies: [], 56 + }); 57 + await mockServer.setup(page); 58 + 59 + await login(page); 60 + await page.goto(`/profile/author1.bsky.social/post/post1`); 61 + 62 + const view = page.locator("#post-detail-view"); 63 + await expect(view.locator('[data-testid="large-post"]')).toBeVisible({ 64 + timeout: 10000, 65 + }); 66 + await view.locator(".post-thread-reply-prompt").click(); 67 + 68 + const composer = page.locator("post-composer .post-composer"); 69 + await expect(composer).toBeVisible({ timeout: 10000 }); 70 + 71 + const richTextInput = composer.locator(".rich-text-input"); 72 + await expect(richTextInput).toContainText("— from test plugin (reply)", { 73 + timeout: 10000, 74 + }); 75 + }); 76 + 77 + test("seeded text is sent with the post", async ({ page }) => { 78 + const mockServer = new MockServer(); 79 + installComposerInitPlugin(mockServer); 80 + await mockServer.setup(page); 81 + 82 + await login(page); 83 + await page.goto("/"); 84 + 85 + await expect(page.locator("#home-view")).toBeVisible({ timeout: 10000 }); 86 + await page.locator('[data-testid="sidebar-compose-button"]').click(); 87 + 88 + const composer = page.locator("post-composer .post-composer"); 89 + await expect(composer).toBeVisible({ timeout: 10000 }); 90 + 91 + const richTextInput = composer.locator(".rich-text-input"); 92 + await expect(richTextInput).toContainText("— from test plugin (post)", { 93 + timeout: 10000, 94 + }); 95 + 96 + await richTextInput.click(); 97 + await page.keyboard.type("Hello world"); 98 + 99 + await composer 100 + .locator(".rounded-button-primary", { hasText: "Post" }) 101 + .click(); 102 + 103 + await expect(composer).not.toBeVisible({ timeout: 10000 }); 104 + 105 + await page.goto(`/profile/${userProfile.did}`); 106 + const profileView = page.locator("#profile-view"); 107 + await expect(profileView.locator('[data-testid="feed-item"]')).toHaveCount( 108 + 1, 109 + { timeout: 10000 }, 110 + ); 111 + await expect(profileView).toContainText("Hello world"); 112 + await expect(profileView).toContainText("— from test plugin (post)"); 113 + }); 114 + });
+19
tests/e2e/testPlugin.js
··· 146 146 TestPlugin.register(); 147 147 `; 148 148 149 + // A plugin that seeds the composer with a signature string on every open 150 + // (post and reply). Used by composer-init e2e tests. 151 + const POST_COMPOSER_INIT_PLUGIN_BODY = /* js */ ` 152 + class TestPlugin extends Plugin { 153 + async onload() { 154 + this.app.on("post-composer-open", (composer, context) => { 155 + composer.appendText("\\n\\n— from test plugin (" + context.kind + ")"); 156 + composer.setCursor(0); 157 + }); 158 + } 159 + } 160 + 161 + TestPlugin.register(); 162 + `; 163 + 149 164 let cachedWorkerSource = null; 150 165 151 166 function getWorkerSource() { ··· 168 183 export function getNoSettingsPluginSource() { 169 184 return getWorkerSource() + "\n" + NO_SETTINGS_PLUGIN_BODY; 170 185 } 186 + 187 + export function getPostComposerInitPluginSource() { 188 + return getWorkerSource() + "\n" + POST_COMPOSER_INIT_PLUGIN_BODY; 189 + }
+88
tests/unit/specs/components/post-composer.test.js
··· 416 416 }); 417 417 }); 418 418 419 + t.describe("PostComposer - initial text/cursor", (it) => { 420 + it("defaults initialText and initialCursor to null when not set", () => { 421 + const element = createPostComposer(); 422 + connectElement(element); 423 + assertEquals(element.initialText, null); 424 + assertEquals(element.initialCursor, null); 425 + }); 426 + 427 + it("preserves initialText set before connectedCallback", () => { 428 + const element = createPostComposer(); 429 + element.initialText = "Pre-seeded"; 430 + element.initialCursor = 0; 431 + connectElement(element); 432 + assertEquals(element.initialText, "Pre-seeded"); 433 + assertEquals(element.initialCursor, 0); 434 + }); 435 + 436 + it("seeds the rich-text-input on open when initialText is set", () => { 437 + const element = createPostComposer(); 438 + element.initialText = "Hello from a plugin"; 439 + connectElement(element); 440 + element.open(); 441 + const richTextInput = element.querySelector("rich-text-input"); 442 + assertEquals(richTextInput.text, "Hello from a plugin"); 443 + assertEquals(element._postText, "Hello from a plugin"); 444 + }); 445 + 446 + it("does not seed text when initialText is null", () => { 447 + const element = createPostComposer(); 448 + connectElement(element); 449 + element.open(); 450 + const richTextInput = element.querySelector("rich-text-input"); 451 + assertEquals(richTextInput.text, ""); 452 + assertEquals(element._postText, ""); 453 + }); 454 + 455 + it("calls setCursor on the rich-text-input when initialCursor is set", () => { 456 + const element = createPostComposer(); 457 + element.initialText = "abcdef"; 458 + element.initialCursor = 3; 459 + connectElement(element); 460 + 461 + const richTextInput = element.querySelector("rich-text-input"); 462 + const calls = []; 463 + const originalSetCursor = richTextInput.setCursor.bind(richTextInput); 464 + richTextInput.setCursor = (cursor) => { 465 + calls.push(cursor); 466 + originalSetCursor(cursor); 467 + }; 468 + 469 + element.open(); 470 + assertEquals(calls, [3]); 471 + }); 472 + 473 + it("does not call setCursor when initialCursor is null", () => { 474 + const element = createPostComposer(); 475 + element.initialText = "abcdef"; 476 + connectElement(element); 477 + 478 + const richTextInput = element.querySelector("rich-text-input"); 479 + let cursorCalled = false; 480 + richTextInput.setCursor = () => { 481 + cursorCalled = true; 482 + }; 483 + 484 + element.open(); 485 + assert(!cursorCalled); 486 + }); 487 + 488 + it("allows setting only initialCursor without initialText", () => { 489 + const element = createPostComposer(); 490 + element.initialCursor = 0; 491 + connectElement(element); 492 + 493 + const richTextInput = element.querySelector("rich-text-input"); 494 + const calls = []; 495 + richTextInput.setCursor = (cursor) => calls.push(cursor); 496 + let setTextCalled = false; 497 + richTextInput.setText = () => { 498 + setTextCalled = true; 499 + }; 500 + 501 + element.open(); 502 + assert(!setTextCalled); 503 + assertEquals(calls, [0]); 504 + }); 505 + }); 506 + 419 507 await t.run();
+117
tests/unit/specs/components/rich-text-input.test.js
··· 391 391 }); 392 392 }); 393 393 394 + t.describe("RichTextInput - setText", (it) => { 395 + it("updates text and renders it into the contenteditable", () => { 396 + const element = document.createElement("rich-text-input"); 397 + document.body.appendChild(element); 398 + element.setText("Hello world"); 399 + assertEquals(element.text, "Hello world"); 400 + const input = element.querySelector(".rich-text-input"); 401 + assertEquals(input.textContent, "Hello world"); 402 + }); 403 + 404 + it("hides the placeholder after setting non-empty text", () => { 405 + const element = document.createElement("rich-text-input"); 406 + document.body.appendChild(element); 407 + element.setText("anything"); 408 + const placeholder = element.querySelector(".rich-text-input-placeholder"); 409 + assert(placeholder.classList.contains("hidden")); 410 + }); 411 + 412 + it("recomputes facets for the new text", () => { 413 + const element = document.createElement("rich-text-input"); 414 + document.body.appendChild(element); 415 + element.setText("check out #news today"); 416 + assert( 417 + element.facets.some( 418 + (facet) => 419 + facet.features[0].$type === "app.bsky.richtext.facet#tag" && 420 + facet.features[0].tag === "news", 421 + ), 422 + "should detect a #news tag facet", 423 + ); 424 + }); 425 + 426 + it("dispatches an input event with the new text and facets", () => { 427 + const element = document.createElement("rich-text-input"); 428 + document.body.appendChild(element); 429 + let detail = null; 430 + element.addEventListener("input", (event) => { 431 + detail = event.detail; 432 + }); 433 + element.setText("hi"); 434 + assert(detail !== null, "input event should fire"); 435 + assertEquals(detail.text, "hi"); 436 + assertEquals(detail.facets, element.facets); 437 + }); 438 + 439 + it("schedules a history save", () => { 440 + const element = document.createElement("rich-text-input"); 441 + document.body.appendChild(element); 442 + assertEquals(element.historyDebounceTimer, null); 443 + element.setText("first"); 444 + assert(element.historyDebounceTimer !== null); 445 + }); 446 + }); 447 + 448 + t.describe("RichTextInput - setCursor", (it) => { 449 + // JSDOM doesn't track selection state inside contenteditable, so verify the 450 + // resolved offset by spying on the Range passed to selection.addRange(). 451 + function withSelectionSpy(fn) { 452 + const captured = []; 453 + const stub = { 454 + rangeCount: 0, 455 + removeAllRanges: () => {}, 456 + addRange: (range) => { 457 + captured.push({ 458 + startContainer: range.startContainer, 459 + startOffset: range.startOffset, 460 + }); 461 + }, 462 + }; 463 + const original = window.getSelection; 464 + window.getSelection = () => stub; 465 + try { 466 + fn(); 467 + } finally { 468 + window.getSelection = original; 469 + } 470 + return captured; 471 + } 472 + 473 + // For plain text rendered through richTextTemplate, content is wrapped as 474 + // <div class="rich-text"><div>TEXT</div></div>; the walker lands on the 475 + // inner text node and the offset equals the resolved index. 476 + function lastCursorOffset(element, cursor) { 477 + const captured = withSelectionSpy(() => element.setCursor(cursor)); 478 + if (captured.length === 0) return null; 479 + return captured.at(-1).startOffset; 480 + } 481 + 482 + it("places the cursor at index 0 for setCursor(0)", () => { 483 + const element = document.createElement("rich-text-input"); 484 + document.body.appendChild(element); 485 + element.setText("abcdef"); 486 + assertEquals(lastCursorOffset(element, 0), 0); 487 + }); 488 + 489 + it("places the cursor at a positive index", () => { 490 + const element = document.createElement("rich-text-input"); 491 + document.body.appendChild(element); 492 + element.setText("abcdef"); 493 + assertEquals(lastCursorOffset(element, 3), 3); 494 + }); 495 + 496 + it("clamps positive indexes past the end to the text length", () => { 497 + const element = document.createElement("rich-text-input"); 498 + document.body.appendChild(element); 499 + element.setText("abc"); 500 + assertEquals(lastCursorOffset(element, 99), 3); 501 + }); 502 + 503 + it("clamps negative indexes to 0", () => { 504 + const element = document.createElement("rich-text-input"); 505 + document.body.appendChild(element); 506 + element.setText("abc"); 507 + assertEquals(lastCursorOffset(element, -5), 0); 508 + }); 509 + }); 510 + 394 511 await t.run();
+103
tests/unit/specs/plugins/pluginService.test.js
··· 1265 1265 }); 1266 1266 }); 1267 1267 1268 + t.describe("getPostComposerInit", (it) => { 1269 + function addListener(service, pluginId, handler) { 1270 + let listeners = service.registries.eventListeners.get("post-composer-open"); 1271 + if (!listeners) { 1272 + listeners = new Map(); 1273 + service.registries.eventListeners.set("post-composer-open", listeners); 1274 + } 1275 + listeners.set(pluginId, handler); 1276 + } 1277 + 1278 + it("returns null when no listeners are registered", async () => { 1279 + const { service } = makeService(); 1280 + const result = await service.getPostComposerInit({ kind: "post" }); 1281 + assertEquals(result, null); 1282 + }); 1283 + 1284 + it("returns null when listeners contribute no ops and no cursor", async () => { 1285 + const { service } = makeService(); 1286 + addListener(service, "noop", async () => ({ ops: [], cursor: null })); 1287 + addListener(service, "alsoNoop", async () => null); 1288 + const result = await service.getPostComposerInit({ kind: "post" }); 1289 + assertEquals(result, null); 1290 + }); 1291 + 1292 + it("appends text from a single listener", async () => { 1293 + const { service } = makeService(); 1294 + addListener(service, "sig", async () => ({ 1295 + ops: [{ op: "append", text: "\n\n— signed" }], 1296 + cursor: null, 1297 + })); 1298 + const result = await service.getPostComposerInit({ kind: "post" }); 1299 + assertEquals(result, { text: "\n\n— signed", cursor: null }); 1300 + }); 1301 + 1302 + it("composes set/append/prepend across multiple listeners in order", async () => { 1303 + const { service } = makeService(); 1304 + addListener(service, "alpha", async () => ({ 1305 + ops: [{ op: "set", text: "middle" }], 1306 + cursor: null, 1307 + })); 1308 + addListener(service, "beta", async () => ({ 1309 + ops: [{ op: "append", text: " end" }], 1310 + cursor: null, 1311 + })); 1312 + addListener(service, "gamma", async () => ({ 1313 + ops: [{ op: "prepend", text: "start " }], 1314 + cursor: null, 1315 + })); 1316 + const result = await service.getPostComposerInit({ kind: "post" }); 1317 + assertEquals(result.text, "start middle end"); 1318 + }); 1319 + 1320 + it("last setCursor wins; nulls do not clobber prior cursor", async () => { 1321 + const { service } = makeService(); 1322 + addListener(service, "alpha", async () => ({ 1323 + ops: [{ op: "append", text: "a" }], 1324 + cursor: 0, 1325 + })); 1326 + addListener(service, "beta", async () => ({ 1327 + ops: [{ op: "append", text: "b" }], 1328 + cursor: null, 1329 + })); 1330 + addListener(service, "gamma", async () => ({ 1331 + ops: [{ op: "append", text: "c" }], 1332 + cursor: -1, 1333 + })); 1334 + const result = await service.getPostComposerInit({ kind: "post" }); 1335 + assertEquals(result, { text: "abc", cursor: -1 }); 1336 + }); 1337 + 1338 + it("ignores listeners that throw", async () => { 1339 + const { service } = makeService(); 1340 + addListener(service, "alpha", async () => { 1341 + throw new Error("boom"); 1342 + }); 1343 + addListener(service, "beta", async () => ({ 1344 + ops: [{ op: "append", text: "ok" }], 1345 + cursor: null, 1346 + })); 1347 + const originalError = console.error; 1348 + console.error = () => {}; 1349 + let result; 1350 + try { 1351 + result = await service.getPostComposerInit({ kind: "post" }); 1352 + } finally { 1353 + console.error = originalError; 1354 + } 1355 + assertEquals(result, { text: "ok", cursor: null }); 1356 + }); 1357 + 1358 + it("passes context through to each listener", async () => { 1359 + const { service } = makeService(); 1360 + let captured = null; 1361 + addListener(service, "alpha", async (context) => { 1362 + captured = context; 1363 + return { ops: [], cursor: null }; 1364 + }); 1365 + const context = { kind: "reply", replyTo: { uri: "at://x" } }; 1366 + await service.getPostComposerInit(context); 1367 + assertEquals(captured, context); 1368 + }); 1369 + }); 1370 + 1268 1371 await t.run();
+36 -2
tests/unit/specs/plugins/pluginWorker.test.js
··· 580 580 it("registers an eventListener target and returns serialized menu items", async () => { 581 581 clearMessages(); 582 582 const plugin = new Plugin(); 583 - plugin.app.on("post:menu", (menu, post) => { 583 + plugin.app.on("post-context-menu", (menu, post) => { 584 584 menu.addItem((item) => 585 585 item.setTitle(`Open ${post.id}`).onClick(() => {}), 586 586 ); ··· 590 590 message.type === "register" && message.target === "eventListener", 591 591 ); 592 592 assert(register, "an eventListener register message should be posted"); 593 - assertEquals(register.event, "post:menu"); 593 + assertEquals(register.event, "post-context-menu"); 594 594 595 595 clearMessages(); 596 596 await dispatch({ ··· 602 602 const result = postedMessages.find((message) => message.type === "result"); 603 603 assertEquals(result.value.length, 1); 604 604 assertEquals(result.value[0].title, "Open 42"); 605 + }); 606 + 607 + it("warns when an event with no dispatch case is invoked", async () => { 608 + clearMessages(); 609 + const plugin = new Plugin(); 610 + plugin.app.on("totally-unknown-event", () => {}); 611 + const register = postedMessages.find( 612 + (message) => 613 + message.type === "register" && message.target === "eventListener", 614 + ); 615 + assert(register, "registration should still happen for unknown events"); 616 + 617 + clearMessages(); 618 + const originalWarn = console.warn; 619 + let warned = null; 620 + console.warn = (...args) => { 621 + warned = args.join(" "); 622 + }; 623 + try { 624 + await dispatch({ 625 + type: "call", 626 + handlerId: register.handlerId, 627 + callId: 1, 628 + args: [], 629 + }); 630 + } finally { 631 + console.warn = originalWarn; 632 + } 633 + assert( 634 + warned && warned.includes("totally-unknown-event"), 635 + "should warn at dispatch time", 636 + ); 637 + const result = postedMessages.find((message) => message.type === "result"); 638 + assertEquals(result.value, null); 605 639 }); 606 640 }); 607 641