From 354d77f239c5beefdd74733e5ceee60e409bf373 Mon Sep 17 00:00:00 2001 From: Nameless 7778777 <7778777@7778777.online> Date: Thu, 6 Aug 2026 17:34:58 +0200 Subject: [PATCH 1/3] Resolve link embeds immediately on IME-driven paste too The immediate-on-paste link/quote-embed resolution only fired from a real native paste ClipboardEvent. Some Android keyboards insert clipboard content as regular input events instead (e.g. Gboard's clipboard suggestion chip, as opposed to the OS text-selection "Paste" menu), so that path never saw it and it fell through to the "wait for a trailing space" typing behavior instead - correct for actually-typed text, but a paste should still resolve immediately. InputEvent.inputType is spec'd to report "insertFromPaste" for this kind of bulk insertion even without a ClipboardEvent, and rich-text-input.js was already forwarding it unused. Treat it as an equal trigger to the existing "unchanged facets + trailing space" check in handleInput. Not yet verified on a real Android keyboard - deploying to the shared test build for that before opening a PR. --- src/js/components/post-composer.js | 12 +++++++--- .../specs/components/post-composer.test.js | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/js/components/post-composer.js b/src/js/components/post-composer.js index da41317b..26508625 100644 --- a/src/js/components/post-composer.js +++ b/src/js/components/post-composer.js @@ -1147,10 +1147,16 @@ class PostComposer extends Component { const previousFacets = postState.unresolvedFacets; const unresolvedFacets = e.detail.facets; this._updatePost(postId, { text: e.detail.text, unresolvedFacets }); - // If the facets *haven't* changed, and the latest change was a space or newline, check for possible link embeds + // If the facets *haven't* changed, and the latest change was a space or + // newline, check for possible link embeds. Also check immediately if + // this input reports itself as a paste: some Android keyboards insert + // clipboard content as regular input events rather than firing a native + // paste event, so the dedicated paste handler below never sees it - this + // is the fallback for that case. if ( - JSON.stringify(previousFacets) === JSON.stringify(unresolvedFacets) && - (e.detail.text.endsWith(" ") || e.detail.text.endsWith("\n")) + (JSON.stringify(previousFacets) === JSON.stringify(unresolvedFacets) && + (e.detail.text.endsWith(" ") || e.detail.text.endsWith("\n"))) || + e.detail.inputType === "insertFromPaste" ) { for (const facet of unresolvedFacets) { // Only handle one feature for now diff --git a/tests/unit/specs/components/post-composer.test.js b/tests/unit/specs/components/post-composer.test.js index 0a4114a7..197681af 100644 --- a/tests/unit/specs/components/post-composer.test.js +++ b/tests/unit/specs/components/post-composer.test.js @@ -985,6 +985,29 @@ describe("post-composer", () => { ); }); + it("attaches an external link embed immediately when handleInput reports a paste (no trailing space needed)", () => { + // Some Android keyboards insert clipboard content as a regular input + // event instead of firing a native paste event, so this exercises the + // handleInput fallback rather than handlePaste directly. previousFacets + // ([]) differs from the event's facets, so the pre-existing "unchanged + // facets + trailing space" branch can't be what triggers this - only + // the inputType check can. + const element = createPostComposer(); + connectElement(element); + patchFirstPost(element, { unresolvedFacets: [] }); + element.handleInput(getFirstPost(element).id, { + detail: { + text: "check this out https://example.com/article", + facets: [makeLinkFacet("https://example.com/article")], + inputType: "insertFromPaste", + }, + }); + assert.deepEqual( + getFirstPost(element).externalLinkUrl, + "https://example.com/article", + ); + }); + it("does not attach an external link embed for a rejected URL", async () => { const element = createPostComposer(); connectElement(element); -- 2.51.2 From 464f9815476562083bf637d599377f3485ceec9f Mon Sep 17 00:00:00 2001 From: Nameless 7778777 <7778777@7778777.online> Date: Thu, 6 Aug 2026 17:48:40 +0200 Subject: [PATCH 2/3] Detect bulk-inserted links by text length, not just inputType Confirmed on a real device: Samsung Keyboard's IME-driven clipboard insert doesn't set inputType to "insertFromPaste" either, so the previous fix's fallback never fired for it - no keyboard metadata to key off exists here that actually works across keyboards. Fall back further to a purely content-based signal: a jump in text length within a single input event bigger than any real keystroke (including IME composition bursts) can plausibly produce, comfortably below the shortest realistic pasted URL. This works regardless of what the keyboard reports, at the cost of also firing for very fast bulk text entry generally (e.g. swipe-typing a long word) - harmless since the resolution loop only acts on facets that are actually links. Still unverified on-device - deploying for that before opening a PR. --- src/js/components/post-composer.js | 25 +++++++++--- .../specs/components/post-composer.test.js | 39 +++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/js/components/post-composer.js b/src/js/components/post-composer.js index 26508625..17631bc9 100644 --- a/src/js/components/post-composer.js +++ b/src/js/components/post-composer.js @@ -51,6 +51,12 @@ import "/js/components/emoji-picker-dialog.js"; import "/js/components/drafts-dialog.js"; const MAX_DRAFT_GRAPHEME_LENGTH = 1000; +// A real keystroke (including a composed IME character) inserts a handful +// of characters at most; anything longer in one input event is a paste or +// autocomplete-style bulk insert, whatever the keyboard reports for +// inputType. Comfortably above IME composition bursts, comfortably below +// the shortest realistic pasted URL (e.g. "http://a.io" is 11 characters). +const BULK_TEXT_INSERT_THRESHOLD = 8; function isDraftTextSavable(text) { return graphemeCount(text) <= MAX_DRAFT_GRAPHEME_LENGTH; @@ -1144,19 +1150,28 @@ class PostComposer extends Component { const postState = this._getPost(postId); if (!postState) return; this._isDirty = true; + const previousText = postState.text; const previousFacets = postState.unresolvedFacets; const unresolvedFacets = e.detail.facets; this._updatePost(postId, { text: e.detail.text, unresolvedFacets }); // If the facets *haven't* changed, and the latest change was a space or // newline, check for possible link embeds. Also check immediately if - // this input reports itself as a paste: some Android keyboards insert - // clipboard content as regular input events rather than firing a native - // paste event, so the dedicated paste handler below never sees it - this - // is the fallback for that case. + // this input reports itself as a paste (e.detail.inputType), or if a + // large chunk of text just appeared in one event regardless of what the + // keyboard reports: some Android keyboards insert clipboard content as + // regular input events rather than firing a native paste event, and + // don't reliably set inputType to "insertFromPaste" either - the + // dedicated paste handler below never sees these, so a real single + // keystroke's length is the only keyboard-agnostic signal left to catch + // them by. + const insertedLength = e.detail.text.length - previousText.length; + const looksLikeBulkInsert = + e.detail.inputType === "insertFromPaste" || + insertedLength > BULK_TEXT_INSERT_THRESHOLD; if ( (JSON.stringify(previousFacets) === JSON.stringify(unresolvedFacets) && (e.detail.text.endsWith(" ") || e.detail.text.endsWith("\n"))) || - e.detail.inputType === "insertFromPaste" + looksLikeBulkInsert ) { for (const facet of unresolvedFacets) { // Only handle one feature for now diff --git a/tests/unit/specs/components/post-composer.test.js b/tests/unit/specs/components/post-composer.test.js index 197681af..bd069ff1 100644 --- a/tests/unit/specs/components/post-composer.test.js +++ b/tests/unit/specs/components/post-composer.test.js @@ -1008,6 +1008,45 @@ describe("post-composer", () => { ); }); + it("attaches an external link embed immediately when a large chunk of text appears in one input event, even without an inputType hint", () => { + // Some keyboards (e.g. Samsung Keyboard) don't reliably set + // inputType: "insertFromPaste" for IME-driven clipboard inserts + // either, so this is the keyboard-agnostic fallback: a big jump in + // text length within a single event, regardless of what (if + // anything) the keyboard reports. + const element = createPostComposer(); + connectElement(element); + patchFirstPost(element, { text: "", unresolvedFacets: [] }); + element.handleInput(getFirstPost(element).id, { + detail: { + text: "check this out https://example.com/article", + facets: [makeLinkFacet("https://example.com/article")], + inputType: null, + }, + }); + assert.deepEqual( + getFirstPost(element).externalLinkUrl, + "https://example.com/article", + ); + }); + + it("still waits for a trailing space when a link facet completes via a single ordinary keystroke", () => { + const element = createPostComposer(); + connectElement(element); + patchFirstPost(element, { + text: "https://example.co", + unresolvedFacets: [], + }); + element.handleInput(getFirstPost(element).id, { + detail: { + text: "https://example.com", + facets: [makeLinkFacet("https://example.com")], + inputType: null, + }, + }); + assert.deepEqual(getFirstPost(element).externalLinkUrl, null); + }); + it("does not attach an external link embed for a rejected URL", async () => { const element = createPostComposer(); connectElement(element); -- 2.51.2 From a09b5668c81abff60f1f6c5cc742c198524bb25e Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Mon, 10 Aug 2026 10:47:37 -0400 Subject: [PATCH 3/3] Shorten comments and remove old paste logic --- src/js/components/post-composer.js | 57 +++---------- .../specs/components/post-composer.test.js | 80 ++++++++++++------- 2 files changed, 62 insertions(+), 75 deletions(-) diff --git a/src/js/components/post-composer.js b/src/js/components/post-composer.js index 17631bc9..294557a3 100644 --- a/src/js/components/post-composer.js +++ b/src/js/components/post-composer.js @@ -51,11 +51,7 @@ import "/js/components/emoji-picker-dialog.js"; import "/js/components/drafts-dialog.js"; const MAX_DRAFT_GRAPHEME_LENGTH = 1000; -// A real keystroke (including a composed IME character) inserts a handful -// of characters at most; anything longer in one input event is a paste or -// autocomplete-style bulk insert, whatever the keyboard reports for -// inputType. Comfortably above IME composition bursts, comfortably below -// the shortest realistic pasted URL (e.g. "http://a.io" is 11 characters). +// Threshold for detecting pasted text, since InputEvent.inputType is unreliable const BULK_TEXT_INSERT_THRESHOLD = 8; function isDraftTextSavable(text) { @@ -1155,24 +1151,19 @@ class PostComposer extends Component { const unresolvedFacets = e.detail.facets; this._updatePost(postId, { text: e.detail.text, unresolvedFacets }); // If the facets *haven't* changed, and the latest change was a space or - // newline, check for possible link embeds. Also check immediately if - // this input reports itself as a paste (e.detail.inputType), or if a - // large chunk of text just appeared in one event regardless of what the - // keyboard reports: some Android keyboards insert clipboard content as - // regular input events rather than firing a native paste event, and - // don't reliably set inputType to "insertFromPaste" either - the - // dedicated paste handler below never sees these, so a real single - // keystroke's length is the only keyboard-agnostic signal left to catch - // them by. - const insertedLength = e.detail.text.length - previousText.length; - const looksLikeBulkInsert = + // newline, check for possible link embeds. Also check for embeds + // immediately on bulk inserts (paste). + const facetsChanged = + JSON.stringify(previousFacets) !== JSON.stringify(unresolvedFacets); + const isCommit = + e.detail.text.endsWith(" ") || e.detail.text.endsWith("\n"); + const insertedLength = + graphemeCount(e.detail.text) - graphemeCount(previousText); + const isBulkInsert = e.detail.inputType === "insertFromPaste" || + e.detail.inputType === "insertFromDrop" || insertedLength > BULK_TEXT_INSERT_THRESHOLD; - if ( - (JSON.stringify(previousFacets) === JSON.stringify(unresolvedFacets) && - (e.detail.text.endsWith(" ") || e.detail.text.endsWith("\n"))) || - looksLikeBulkInsert - ) { + if ((!facetsChanged && isCommit) || isBulkInsert) { for (const facet of unresolvedFacets) { // Only handle one feature for now const feature = facet.features[0]; @@ -1199,7 +1190,7 @@ class PostComposer extends Component { } // If the facets have changed, check to see if links have been removed. // This will allow links to be re-added after being rejected. - if (JSON.stringify(previousFacets) !== JSON.stringify(unresolvedFacets)) { + if (facetsChanged) { const linkFacetUrls = unresolvedFacets .filter( (facet) => facet.features[0].$type === "app.bsky.richtext.facet#link", @@ -1220,28 +1211,6 @@ class PostComposer extends Component { this.addMediaFiles(postId, pastedFiles); return; } - // Attach link embeds immediately if a link is pasted - // Wait a tick so handleInput runs first - requestAnimationFrame(() => { - const postState = this._getPost(postId); - if (!postState) return; - for (const facet of postState.unresolvedFacets) { - const feature = facet.features[0]; - if (feature.$type === "app.bsky.richtext.facet#link") { - const url = feature.uri; - if (postState.rejectedLinkEmbeds.has(url)) continue; - if (parseRecordLink(url)) { - if (!postState.quotedRecord && !postState.quotedRecordUrl) { - this._updatePost(postId, { quotedRecordUrl: url }); - this.loadQuotedRecordFromLink(postId); - } - } else if (!postState.externalLinkUrl) { - this._updatePost(postId, { externalLinkUrl: url }); - this.loadExternalLinkEmbedPreview(postId); - } - } - } - }); } async loadExternalLinkEmbedPreview(postId) { diff --git a/tests/unit/specs/components/post-composer.test.js b/tests/unit/specs/components/post-composer.test.js index bd069ff1..fbbf7db7 100644 --- a/tests/unit/specs/components/post-composer.test.js +++ b/tests/unit/specs/components/post-composer.test.js @@ -967,13 +967,16 @@ describe("post-composer", () => { delete globalThis.fetch; }); + function pasteLink(element, url, inputType = "insertFromPaste") { + element.handleInput(getFirstPost(element).id, { + detail: { text: url, facets: [makeLinkFacet(url)], inputType }, + }); + } + it("attaches an external link embed immediately when a link is pasted", async () => { const element = createPostComposer(); connectElement(element); - patchFirstPost(element, { - unresolvedFacets: [makeLinkFacet("https://example.com/article")], - }); - element.handlePaste(getFirstPost(element).id, makePasteEvent([])); + pasteLink(element, "https://example.com/article"); await new Promise((resolve) => requestAnimationFrame(resolve)); assert.deepEqual( getFirstPost(element).externalLinkUrl, @@ -985,6 +988,16 @@ describe("post-composer", () => { ); }); + it("attaches an external link embed immediately when a link is dropped", () => { + const element = createPostComposer(); + connectElement(element); + pasteLink(element, "https://example.com/article", "insertFromDrop"); + assert.deepEqual( + getFirstPost(element).externalLinkUrl, + "https://example.com/article", + ); + }); + it("attaches an external link embed immediately when handleInput reports a paste (no trailing space needed)", () => { // Some Android keyboards insert clipboard content as a regular input // event instead of firing a native paste event, so this exercises the @@ -1030,6 +1043,27 @@ describe("post-composer", () => { ); }); + it("attaches an external link embed when a trailing space follows a typed link", () => { + const element = createPostComposer(); + connectElement(element); + const facet = makeLinkFacet("https://example.com"); + patchFirstPost(element, { + text: "https://example.com", + unresolvedFacets: [facet], + }); + element.handleInput(getFirstPost(element).id, { + detail: { + text: "https://example.com ", + facets: [facet], + inputType: "insertText", + }, + }); + assert.deepEqual( + getFirstPost(element).externalLinkUrl, + "https://example.com", + ); + }); + it("still waits for a trailing space when a link facet completes via a single ordinary keystroke", () => { const element = createPostComposer(); connectElement(element); @@ -1053,10 +1087,7 @@ describe("post-composer", () => { getFirstPost(element).rejectedLinkEmbeds.add( "https://example.com/article", ); - patchFirstPost(element, { - unresolvedFacets: [makeLinkFacet("https://example.com/article")], - }); - element.handlePaste(getFirstPost(element).id, makePasteEvent([])); + pasteLink(element, "https://example.com/article"); await new Promise((resolve) => requestAnimationFrame(resolve)); assert.deepEqual(getFirstPost(element).externalLinkUrl, null); assert.deepEqual(getFirstPost(element).external, null); @@ -1066,10 +1097,7 @@ describe("post-composer", () => { const element = createPostComposer(); connectElement(element); patchFirstPost(element, { externalLinkUrl: "https://existing.com/page" }); - patchFirstPost(element, { - unresolvedFacets: [makeLinkFacet("https://example.com/article")], - }); - element.handlePaste(getFirstPost(element).id, makePasteEvent([])); + pasteLink(element, "https://example.com/article"); await new Promise((resolve) => requestAnimationFrame(resolve)); assert.deepEqual( getFirstPost(element).externalLinkUrl, @@ -1084,12 +1112,7 @@ describe("post-composer", () => { element.loadQuotedRecordFromLink = () => { loadedQuoteUrl = getFirstPost(element).quotedRecordUrl; }; - patchFirstPost(element, { - unresolvedFacets: [ - makeLinkFacet("https://bsky.app/profile/alice.test/post/3abc"), - ], - }); - element.handlePaste(getFirstPost(element).id, makePasteEvent([])); + pasteLink(element, "https://bsky.app/profile/alice.test/post/3abc"); await new Promise((resolve) => requestAnimationFrame(resolve)); assert.deepEqual( loadedQuoteUrl, @@ -1108,12 +1131,7 @@ describe("post-composer", () => { patchFirstPost(element, { quotedRecordUrl: "https://bsky.app/profile/bob.test/post/3xyz", }); - patchFirstPost(element, { - unresolvedFacets: [ - makeLinkFacet("https://bsky.app/profile/alice.test/post/3abc"), - ], - }); - element.handlePaste(getFirstPost(element).id, makePasteEvent([])); + pasteLink(element, "https://bsky.app/profile/alice.test/post/3abc"); await new Promise((resolve) => requestAnimationFrame(resolve)); assert(!loadCalled); assert.deepEqual( @@ -1379,14 +1397,14 @@ describe("post-composer", () => { element.loadQuotedRecordFromLink = () => { loadedRecordUrl = getFirstPost(element).quotedRecordUrl; }; - patchFirstPost(element, { - unresolvedFacets: [ - makeLinkFacet( - "https://bsky.app/profile/creator1.test/feed/cool-feed", - ), - ], + const url = "https://bsky.app/profile/creator1.test/feed/cool-feed"; + element.handleInput(getFirstPost(element).id, { + detail: { + text: url, + facets: [makeLinkFacet(url)], + inputType: "insertFromPaste", + }, }); - element.handlePaste(getFirstPost(element).id, makePasteEvent([])); await new Promise((resolve) => requestAnimationFrame(resolve)); assert.deepEqual( loadedRecordUrl, -- 2.51.2