diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs b/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs index 6c7055c20d8..106b0369341 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs @@ -10,9 +10,10 @@ import { buildHighlightPatterns, createMentionCaretSettlement, findHighlightMatches, - insertPosForMentionTextInput, + insertionForMentionTextInput, MentionHighlightExtension, - mentionTextInputInsertPos, + mentionHighlightKey, + mentionTextInputInsertion, positionAfterArrowLeftThroughMentionSpace, selectionAfterMentionTrailingSpace, shouldAdvanceMentionCaret, @@ -252,35 +253,79 @@ test("createMentionCaretSettlement keeps two editors independent", () => { assert.equal(composerB.peek(), 12); }); -test("insertPosForMentionTextInput redirects a caret at the chip edge", () => { +test("insertionForMentionTextInput redirects a caret at the chip edge", () => { const doc = document(paragraph(text("@quinn "))); const spacePos = 1 + "@quinn".length; + assert.deepEqual(insertionForMentionTextInput(doc, spacePos, spacePos, "x"), { + insertAt: spacePos + 1, + text: "x", + }); assert.equal( - insertPosForMentionTextInput(doc, spacePos, spacePos), - spacePos + 1, - ); - assert.equal( - insertPosForMentionTextInput(doc, spacePos + 1, spacePos + 1), + insertionForMentionTextInput(doc, spacePos + 1, spacePos + 1, "x"), null, ); }); -test("insertPosForMentionTextInput keeps a selected trailing space", () => { +test("insertionForMentionTextInput keeps a selected trailing space", () => { const doc = document(paragraph(text("@quinn "))); const spacePos = 1 + "@quinn".length; + assert.deepEqual( + insertionForMentionTextInput(doc, spacePos, spacePos + 1, "x"), + { insertAt: spacePos + 1, text: "x" }, + ); +}); + +test("insertionForMentionTextInput keeps the draft space in a whitespace-run rewrite", () => { + // Chromium can rewrite the whole space run when typing between the + // mention's trailing space and a pre-existing draft space, emitting + // replace(" " -> " a") — usually with a non-breaking space, and anchored + // at either edge of the run. The draft's space must survive every shape. + const doc = document(paragraph(text("hello @bob world"))); + const spacePos = 1 + "hello @bob".length; + const kept = { insertAt: spacePos + 1, text: "a" }; + // Anchored at the chip edge, replacing the whole run. + assert.deepEqual( + insertionForMentionTextInput(doc, spacePos, spacePos + 2, " a"), + kept, + ); + assert.deepEqual( + insertionForMentionTextInput(doc, spacePos, spacePos + 2, "\u00A0a"), + kept, + ); + // Anchored past the trailing space, rewriting only the draft's own space. + // This shape used to fall through to the destructive default and produce + // "hello @bob abcworld" — the failure CI caught. + assert.deepEqual( + insertionForMentionTextInput(doc, spacePos + 1, spacePos + 2, "\u00A0a"), + kept, + ); + // Trailing whitespace is the run being re-emitted on the other side. + assert.deepEqual( + insertionForMentionTextInput(doc, spacePos, spacePos + 2, "a\u00A0"), + kept, + ); + // Whatever the shape, replaced whitespace is never dropped while settling. + assert.deepEqual( + insertionForMentionTextInput(doc, spacePos, spacePos + 2, "x"), + { insertAt: spacePos + 1, text: "x" }, + ); + // Replacing something other than whitespace is a real edit — leave it. assert.equal( - insertPosForMentionTextInput(doc, spacePos, spacePos + 1), - spacePos + 1, + insertionForMentionTextInput(doc, spacePos, spacePos + 3, " a"), + null, ); }); -test("mentionTextInputInsertPos honors a deliberate caret after settlement", () => { +test("mentionTextInputInsertion honors a deliberate caret after settlement", () => { const doc = document(paragraph(text("@bob "))); const spacePos = 1 + "@bob".length; - assert.equal(mentionTextInputInsertPos(doc, spacePos, spacePos, false), null); assert.equal( - mentionTextInputInsertPos(doc, spacePos, spacePos, true), - spacePos + 1, + mentionTextInputInsertion(doc, spacePos, spacePos, "x", false), + null, + ); + assert.deepEqual( + mentionTextInputInsertion(doc, spacePos, spacePos, "x", true), + { insertAt: spacePos + 1, text: "x" }, ); }); @@ -378,3 +423,85 @@ test("typing after a completed mention keeps the separator intact", () => { const typed = typeAt(state, 1 + "@quinn world".length, "!"); assert.equal(typed.doc.textContent, "@quinn world!"); }); + +// ── the browser branch: Chromium's whitespace-run rewrite ───────────── +// +// The helper tests above model the payload. These drive the plugin's real +// `handleTextInput` prop and fall back to ProseMirror's default insertion +// when it declines, exactly as the browser does with the return value — so +// the assertion is sensitive to the production branch itself rather than to +// winning a timing race in a headless browser. + +/** Apply an autocomplete pick: replace the typed token and settle the caret. */ +function pickMentionAt(state, tokenFrom, tokenTo, inserted) { + const tr = state.tr.insertText(inserted, tokenFrom, tokenTo); + tr.setSelection(TextSelection.create(tr.doc, tokenFrom + inserted.length)); + tr.setMeta(mentionHighlightKey, true); + return state.apply(tr); +} + +/** Route a text-input event through the plugin, then the default handling. */ +function textInput(state, from, to, text) { + let current = state; + const view = { + get state() { + return current; + }, + dispatch(tr) { + current = current.apply(tr); + }, + domAtPos: () => ({ node: {}, offset: 0 }), + root: undefined, + }; + const handled = current.plugins.some( + (plugin) => plugin.props?.handleTextInput?.(view, from, to, text) === true, + ); + return handled + ? current + : current.apply(current.tr.insertText(text, from, to)); +} + +test("a whitespace-run rewrite after a mention pick keeps the draft space", () => { + // The CI failure: with "hello world" drafted, the caret placed after + // "hello", " @bo" typed and the "bob" suggestion picked, the document is + // "hello @bob world" — the mention's trailing space followed by the + // draft's own space. The next keystroke arrives as one of these shapes + // depending on how Chromium reconciles that whitespace run, and all of + // them have to keep both spaces. + const tokenFrom = 1 + "hello ".length; + const spacePos = 1 + "hello @bob".length; + const shapes = [ + { name: "caret at the chip edge", from: spacePos, to: spacePos, text: "a" }, + { + name: "caret past the trailing space", + from: spacePos + 1, + to: spacePos + 1, + text: "a", + }, + { + name: "run rewritten from the chip edge", + from: spacePos, + to: spacePos + 2, + text: "\u00A0a", + }, + { + name: "draft space rewritten on its own", + from: spacePos + 1, + to: spacePos + 2, + text: "\u00A0a", + }, + ]; + + for (const shape of shapes) { + // A fresh editor per shape: settlement is per-plugin closure state. + const picked = pickMentionAt( + editorStateWithMentionHighlight("hello @bo world", ["bob"]), + tokenFrom, + tokenFrom + "@bo".length, + "@bob ", + ); + assert.equal(picked.doc.textContent, "hello @bob world", shape.name); + const typed = textInput(picked, shape.from, shape.to, shape.text); + assert.equal(typed.doc.textContent, "hello @bob a world", shape.name); + } +}); diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.ts b/desktop/src/features/messages/lib/mentionHighlightExtension.ts index 3d1d5d6ef71..e55c79c9a38 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.ts +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.ts @@ -58,25 +58,71 @@ export function shouldAdvanceMentionCaret({ return next !== from && settling; } +export type MentionTextInsertion = { + insertAt: number; + text: string; +}; + +const SPACE_RUN = /^[ \u00A0]+$/; +const OUTER_SPACES = /^[ \u00A0]+|[ \u00A0]+$/g; + /** - * Where to insert typed text when the caret (or a one-character selection) - * sits on the trailing space after an `@name` / `#channel` token. - * A selected trailing space would otherwise be replaced, producing - * `@bobhello`. + * Position just after the trailing space of the mention token that `pos` is + * adjacent to, or `null` when `pos` is nowhere near one. + * + * `pos` may sit at the token end (before the space) or already past the + * space: when Chromium rewrites the whitespace run around the caret it + * anchors the replacement at either edge, and both mean the same boundary. */ -export function insertPosForMentionTextInput( +function mentionTrailingSpaceBoundary( + doc: ProseMirrorNode, + pos: number, +): number | null { + const afterSpace = selectionAfterMentionTrailingSpace(doc, pos); + if (afterSpace !== pos) return afterSpace; + if (pos > 0 && selectionAfterMentionTrailingSpace(doc, pos - 1) === pos) { + return pos; + } + return null; +} + +/** + * Where (and what) to insert when typed text arrives at the trailing space + * after an `@name` / `#channel` token. + * + * - Caret on the space: insert after it, so the next keystroke lands after + * the token (`@bobhello` fix). + * - Whitespace replaced next to that space: keep every space the document + * already has and insert only the typed characters after the token's + * trailing space. + * + * The second rule matters because typing between the mention's trailing + * space and a pre-existing draft space makes Chromium re-emit the whole + * whitespace run — `replace(" " -> " a")`, usually with a non-breaking + * space, and anchored at either edge of the run. Applying any of those + * verbatim deletes the draft's space (`hello @bob abcworld`). + * + * Only whitespace is ever redirected, and only while autocomplete is + * settling — a window in which the user cannot have selected anything, + * because a selection cancels settlement. So a replacement arriving here + * is the browser normalizing whitespace, never an intentional delete, and + * preserving the document's spaces is the whole invariant. Recognizing one + * specific rewrite shape instead is what left the draft space exposed. + */ +export function insertionForMentionTextInput( doc: ProseMirrorNode, from: number, to: number, -): number | null { - const next = selectionAfterMentionTrailingSpace(doc, from); + text: string, +): MentionTextInsertion | null { if (from === to) { - return next === from ? null : next; + const next = selectionAfterMentionTrailingSpace(doc, from); + return next === from ? null : { insertAt: next, text }; } - if (to === next && next === from + 1) { - return next; - } - return null; + const boundary = mentionTrailingSpaceBoundary(doc, from); + if (boundary === null) return null; + if (!SPACE_RUN.test(doc.textBetween(from, to, "\n", "\0"))) return null; + return { insertAt: boundary, text: text.replace(OUTER_SPACES, "") }; } /** @@ -84,14 +130,15 @@ export function insertPosForMentionTextInput( * deliberate ArrowLeft or chip click, honor the caret so `x` lands in the * token (`@bobx`) instead of after the space (`@bob x`). */ -export function mentionTextInputInsertPos( +export function mentionTextInputInsertion( doc: ProseMirrorNode, from: number, to: number, + text: string, settling: boolean, -): number | null { +): MentionTextInsertion | null { if (!settling) return null; - return insertPosForMentionTextInput(doc, from, to); + return insertionForMentionTextInput(doc, from, to, text); } /** Caret just after a mention trailing space: ArrowLeft lands on the token end. */ @@ -396,18 +443,22 @@ export const MentionHighlightExtension = Extension.create({ return this.getState(state) ?? DecorationSet.empty; }, handleTextInput(view, from, to, text) { - const insertAt = mentionTextInputInsertPos( + const insertion = mentionTextInputInsertion( view.state.doc, from, to, + text, settlement.peek() !== null, ); - if (insertAt == null) { + if (insertion == null) { settlement.cancel(); return false; } - const tr = view.state.tr.insertText(text, insertAt); - const caret = tr.mapping.map(insertAt, 1); + const tr = view.state.tr.insertText( + insertion.text, + insertion.insertAt, + ); + const caret = tr.mapping.map(insertion.insertAt, 1); tr.setSelection(TextSelection.create(tr.doc, caret)); view.dispatch(tr); settlement.cancel();