[READ-ONLY] Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
6

Configure Feed

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

Fix post composer autofocus

Grace Kind (Jul 18, 2026, 3:11 PM -0500) 9057bdf3 ebbb62cf

+101 -87
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.18.1", 3 + "version": "0.18.2", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+2 -4
src/js/components/new-chat-dialog.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { Component } from "/js/components/component.js"; 3 3 import { ScrollLock } from "/js/scrollLock.js"; 4 - import { enableDragToDismiss, debounce, isTouchDevice } from "/js/utils.js"; 4 + import { enableDragToDismiss, debounce } from "/js/utils.js"; 5 5 import { Signal, ReactiveStore, effect } from "/js/signals.js"; 6 6 import { getDisplayName, MISSING_HANDLE } from "/js/dataHelpers.js"; 7 7 import { avatarTemplate } from "/js/templates/avatar.template.js"; ··· 302 302 this.scrollLock.lock(); 303 303 const dialog = this.querySelector(".new-chat-dialog"); 304 304 dialog.showModal(); 305 - if (!isTouchDevice()) { 306 - this.querySelector(".new-chat-search-input")?.focus(); 307 - } 305 + this.querySelector(".new-chat-search-input")?.focus(); 308 306 enableDragToDismiss(dialog, { 309 307 onClose: () => this.close(), 310 308 scrollContainer: this.querySelector(".new-chat-results"),
+13 -19
src/js/components/post-composer.js
··· 208 208 this.setAttribute("data-dialog-wrapper", ""); 209 209 this.scrollLock = new ScrollLock(this); 210 210 this.innerHTML = ""; 211 - this.initialText = this.initialText ?? null; 212 - this.initialCursor = this.initialCursor ?? null; 213 211 this._unresolvedFacets = []; 214 212 this._quotedRecordUrl = null; 215 213 this._externalLinkUrl = null; ··· 359 357 this.handlePaste(e); 360 358 }} 361 359 placeholder="${promptText}" 360 + autofocus 362 361 ></rich-text-input> 363 362 </div> 364 363 </div> ··· 895 894 this.scrollLock.lock(); 896 895 const dialog = this.querySelector(".post-composer"); 897 896 dialog.showModal(); 898 - const richTextInput = this.querySelector("rich-text-input"); 899 - if (richTextInput && this.initialText !== null) { 900 - richTextInput.setText(this.initialText); 901 - } 902 - if (richTextInput && this.initialCursor !== null) { 903 - richTextInput.setCursor(this.initialCursor); 904 - } 905 897 906 898 // Setup mobile swipe-to-dismiss 907 899 enableDragToDismiss(dialog, { ··· 915 907 !!el.closest("[contenteditable]"), 916 908 }); 917 909 918 - // focus on the textarea 919 - requestAnimationFrame(() => { 920 - requestAnimationFrame(() => { 921 - const richTextInput = this.querySelector("rich-text-input"); 922 - if (richTextInput) { 923 - richTextInput.focus(); 924 - } 925 - }); 926 - }); 910 + resetScrollOnBlur(dialog, this.querySelector(".post-composer-scroll-area")); 911 + } 927 912 928 - resetScrollOnBlur(dialog, this.querySelector(".post-composer-scroll-area")); 913 + applyComposerInit({ text, cursor }) { 914 + if (this._isDirty) return; 915 + const richTextInput = this.querySelector("rich-text-input"); 916 + if (!richTextInput) return; 917 + if (text != null) { 918 + richTextInput.setText(text); 919 + } 920 + if (cursor != null) { 921 + richTextInput.setCursor(cursor); 922 + } 929 923 } 930 924 931 925 close() {
+5 -1
src/js/components/rich-text-input.js
··· 326 326 327 327 export class RichTextInput extends Component { 328 328 static get observedAttributes() { 329 - return ["disabled"]; 329 + return ["disabled", "autofocus"]; 330 330 } 331 331 332 332 connectedCallback() { ··· 362 362 if (!this.initialized) return; 363 363 if (name === "disabled") { 364 364 this.disabled = this.getAttribute("disabled") !== null; 365 + this.render(); 366 + } 367 + if (name === "autofocus") { 365 368 this.render(); 366 369 } 367 370 } ··· 419 422 class="rich-text-input" 420 423 data-testid="rich-text-input" 421 424 contenteditable=${this.disabled ? "false" : "true"} 425 + ?autofocus=${this.hasAttribute("autofocus")} 422 426 @input=${(e) => { 423 427 e.stopPropagation(); 424 428 this.handleInput(e);
+19 -10
src/js/postComposerService.js
··· 28 28 return; 29 29 } 30 30 hapticsImpactLight(); 31 - const composerInit = await this.pluginService.getPostComposerInit({ 32 - kind: replyTo ? "reply" : quotedPost ? "quote" : "post", 33 - replyTo, 34 - replyRoot, 35 - quotedPost, 36 - }); 37 31 return new Promise((resolve, reject) => { 38 32 this.currentPostComposer = document.createElement("post-composer"); 39 33 this.currentPostComposer.dataLayer = this.dataLayer; ··· 45 39 ? createEmbedFromPost(quotedPost) 46 40 : null; 47 41 this.currentPostComposer.currentUser = currentUser; 48 - if (composerInit) { 49 - this.currentPostComposer.initialText = composerInit.text; 50 - this.currentPostComposer.initialCursor = composerInit.cursor; 51 - } 52 42 this.currentPostComposer.addEventListener("send-post", async (e) => { 53 43 const { post, draft, successCallback, errorCallback } = e.detail; 54 44 try { ··· 85 75 } 86 76 }); 87 77 document.body.appendChild(this.currentPostComposer); 78 + // Open (and focus) synchronously: iOS Safari only honors programmatic 79 + // focus while still inside the user-gesture handler, so the composer 80 + // can't wait on plugin init before opening 88 81 this.currentPostComposer.open(); 82 + const composer = this.currentPostComposer; 83 + this.pluginService 84 + .getPostComposerInit({ 85 + kind: replyTo ? "reply" : quotedPost ? "quote" : "post", 86 + replyTo, 87 + replyRoot, 88 + quotedPost, 89 + }) 90 + .then((composerInit) => { 91 + if (composerInit && this.currentPostComposer === composer) { 92 + composer.applyComposerInit(composerInit); 93 + } 94 + }) 95 + .catch((error) => { 96 + console.error("Failed to get post composer init", error); 97 + }); 89 98 }); 90 99 } 91 100 }
+1 -22
tests/unit/specs/components/new-chat-dialog.test.js
··· 644 644 }); 645 645 646 646 describe("NewChatDialog - autofocus", () => { 647 - function setMaxTouchPoints(value) { 648 - Object.defineProperty(window.navigator, "maxTouchPoints", { 649 - value, 650 - configurable: true, 651 - }); 652 - } 653 - 654 - afterEach(() => setMaxTouchPoints(0)); 655 - 656 - it("should focus the search input on open on non-touch devices", () => { 657 - setMaxTouchPoints(0); 647 + it("should focus the search input on open", () => { 658 648 const { dataLayer } = createFakeDataLayer(); 659 649 const element = createDialog(dataLayer); 660 650 element.open(); ··· 662 652 '[data-testid="new-chat-search-input"]', 663 653 ); 664 654 assert.deepEqual(document.activeElement, input); 665 - }); 666 - 667 - it("should not focus the search input on open on touch devices", () => { 668 - setMaxTouchPoints(5); 669 - const { dataLayer } = createFakeDataLayer(); 670 - const element = createDialog(dataLayer); 671 - element.open(); 672 - const input = element.querySelector( 673 - '[data-testid="new-chat-search-input"]', 674 - ); 675 - assert(document.activeElement !== input); 676 655 }); 677 656 }); 678 657
+35 -30
tests/unit/specs/components/post-composer.test.js
··· 187 187 const dialog = element.querySelector(".post-composer"); 188 188 assert(dialog.open); 189 189 }); 190 + 191 + it("should render the rich text input with autofocus so showModal focuses it", () => { 192 + const element = createPostComposer(); 193 + connectElement(element); 194 + const richTextInput = element.querySelector("rich-text-input"); 195 + assert(richTextInput.hasAttribute("autofocus")); 196 + assert( 197 + element.querySelector(".rich-text-input").hasAttribute("autofocus"), 198 + ); 199 + }); 190 200 }); 191 201 192 202 describe("PostComposer - close method", () => { ··· 457 467 }); 458 468 }); 459 469 460 - describe("PostComposer - initial text/cursor", () => { 461 - it("defaults initialText and initialCursor to null when not set", () => { 462 - const element = createPostComposer(); 463 - connectElement(element); 464 - assert.deepEqual(element.initialText, null); 465 - assert.deepEqual(element.initialCursor, null); 466 - }); 467 - 468 - it("preserves initialText set before connectedCallback", () => { 469 - const element = createPostComposer(); 470 - element.initialText = "Pre-seeded"; 471 - element.initialCursor = 0; 472 - connectElement(element); 473 - assert.deepEqual(element.initialText, "Pre-seeded"); 474 - assert.deepEqual(element.initialCursor, 0); 475 - }); 476 - 477 - it("seeds the rich-text-input on open when initialText is set", () => { 470 + describe("PostComposer - applyComposerInit", () => { 471 + it("seeds the rich-text-input with text", () => { 478 472 const element = createPostComposer(); 479 - element.initialText = "Hello from a plugin"; 480 473 connectElement(element); 481 474 element.open(); 475 + element.applyComposerInit({ text: "Hello from a plugin", cursor: null }); 482 476 const richTextInput = element.querySelector("rich-text-input"); 483 477 assert.deepEqual(richTextInput.text, "Hello from a plugin"); 484 478 assert.deepEqual(element.state.$postText.get(), "Hello from a plugin"); 485 479 }); 486 480 487 - it("does not seed text when initialText is null", () => { 481 + it("does not seed text when text is null", () => { 488 482 const element = createPostComposer(); 489 483 connectElement(element); 490 484 element.open(); 485 + element.applyComposerInit({ text: null, cursor: null }); 491 486 const richTextInput = element.querySelector("rich-text-input"); 492 487 assert.deepEqual(richTextInput.text, ""); 493 488 assert.deepEqual(element.state.$postText.get(), ""); 494 489 }); 495 490 496 - it("calls setCursor on the rich-text-input when initialCursor is set", () => { 491 + it("calls setCursor on the rich-text-input when cursor is set", () => { 497 492 const element = createPostComposer(); 498 - element.initialText = "abcdef"; 499 - element.initialCursor = 3; 500 493 connectElement(element); 494 + element.open(); 501 495 502 496 const richTextInput = element.querySelector("rich-text-input"); 503 497 const calls = []; ··· 507 501 originalSetCursor(cursor); 508 502 }; 509 503 510 - element.open(); 504 + element.applyComposerInit({ text: "abcdef", cursor: 3 }); 511 505 assert.deepEqual(calls, [3]); 512 506 }); 513 507 514 - it("does not call setCursor when initialCursor is null", () => { 508 + it("does not call setCursor when cursor is null", () => { 515 509 const element = createPostComposer(); 516 - element.initialText = "abcdef"; 517 510 connectElement(element); 511 + element.open(); 518 512 519 513 const richTextInput = element.querySelector("rich-text-input"); 520 514 let cursorCalled = false; ··· 522 516 cursorCalled = true; 523 517 }; 524 518 525 - element.open(); 519 + element.applyComposerInit({ text: "abcdef", cursor: null }); 526 520 assert(!cursorCalled); 527 521 }); 528 522 529 - it("allows setting only initialCursor without initialText", () => { 523 + it("allows setting only cursor without text", () => { 530 524 const element = createPostComposer(); 531 - element.initialCursor = 0; 532 525 connectElement(element); 526 + element.open(); 533 527 534 528 const richTextInput = element.querySelector("rich-text-input"); 535 529 const calls = []; ··· 539 533 setTextCalled = true; 540 534 }; 541 535 542 - element.open(); 536 + element.applyComposerInit({ text: null, cursor: 0 }); 543 537 assert(!setTextCalled); 544 538 assert.deepEqual(calls, [0]); 539 + }); 540 + 541 + it("does not overwrite user edits made before the init arrives", () => { 542 + const element = createPostComposer(); 543 + connectElement(element); 544 + element.open(); 545 + 546 + const richTextInput = element.querySelector("rich-text-input"); 547 + richTextInput.setText("User typed this"); 548 + element.applyComposerInit({ text: "Plugin text", cursor: null }); 549 + assert.deepEqual(richTextInput.text, "User typed this"); 545 550 }); 546 551 }); 547 552
+25
tests/unit/specs/components/rich-text-input.test.js
··· 32 32 assert.deepEqual(element.text, ""); 33 33 }); 34 34 35 + it("should reflect autofocus onto the contenteditable div", () => { 36 + const element = document.createElement("rich-text-input"); 37 + element.setAttribute("autofocus", ""); 38 + document.body.appendChild(element); 39 + const input = element.querySelector(".rich-text-input"); 40 + assert(input.hasAttribute("autofocus")); 41 + }); 42 + 43 + it("should not set autofocus on the contenteditable div by default", () => { 44 + const element = document.createElement("rich-text-input"); 45 + document.body.appendChild(element); 46 + const input = element.querySelector(".rich-text-input"); 47 + assert(!input.hasAttribute("autofocus")); 48 + }); 49 + 50 + it("should update the contenteditable autofocus when the attribute changes", () => { 51 + const element = document.createElement("rich-text-input"); 52 + document.body.appendChild(element); 53 + element.setAttribute("autofocus", ""); 54 + const input = element.querySelector(".rich-text-input"); 55 + assert(input.hasAttribute("autofocus")); 56 + element.removeAttribute("autofocus"); 57 + assert(!input.hasAttribute("autofocus")); 58 + }); 59 + 35 60 it("should render placeholder", () => { 36 61 const element = document.createElement("rich-text-input"); 37 62 document.body.appendChild(element);