diff --git a/desktop/src/features/messages/lib/markdownTrailingWhitespace.test.mjs b/desktop/src/features/messages/lib/markdownTrailingWhitespace.test.mjs new file mode 100644 index 0000000000..f5c93b5820 --- /dev/null +++ b/desktop/src/features/messages/lib/markdownTrailingWhitespace.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { markdownTrailingWhitespace } from "./markdownTrailingWhitespace.ts"; + +// The markdown parse behind `setContent` drops end-of-line whitespace, which +// silently breaks mention chips (`@Name ` → `@Name`, next keystroke → +// `@Nameabc`, recipient dropped). This decides what gets put back. + +test("captures the single trailing space after a mention", () => { + assert.equal(markdownTrailingWhitespace("@Morgarita "), " "); +}); + +test("captures the trailing space after multiple mentions", () => { + assert.equal(markdownTrailingWhitespace("@Vogue @Morgarita "), " "); +}); + +test("returns null when there is no trailing whitespace", () => { + assert.equal(markdownTrailingWhitespace("@Morgarita"), null); + assert.equal(markdownTrailingWhitespace(""), null); +}); + +test("returns null for whitespace-only input", () => { + // Nothing to trail. Restoring here would leave a composer that reads as + // non-empty — Send enabled, placeholder suppressed — on an empty draft. + assert.equal(markdownTrailingWhitespace(" "), null); + assert.equal(markdownTrailingWhitespace(" \t"), null); +}); + +test("captures a mixed run of spaces and tabs", () => { + assert.equal(markdownTrailingWhitespace("@Morgarita \t "), " \t "); +}); + +test("captures whitespace trailing the last line only", () => { + assert.equal(markdownTrailingWhitespace("first line\n@Morgarita "), " "); +}); + +test("returns null when the string ends on a newline", () => { + // The caret lands on the empty final line, so nothing needs restoring. + assert.equal(markdownTrailingWhitespace("@Morgarita \n"), null); +}); + +test("ignores interior whitespace", () => { + assert.equal(markdownTrailingWhitespace("@Morgarita hello"), null); +}); diff --git a/desktop/src/features/messages/lib/markdownTrailingWhitespace.ts b/desktop/src/features/messages/lib/markdownTrailingWhitespace.ts new file mode 100644 index 0000000000..31d7318599 --- /dev/null +++ b/desktop/src/features/messages/lib/markdownTrailingWhitespace.ts @@ -0,0 +1,58 @@ +import type { Editor } from "@tiptap/core"; +import { Selection } from "@tiptap/pm/state"; + +/** + * The run of spaces/tabs at the very end of `markdown`, or `null` when there + * is none. + * + * A whitespace-only string yields `null` on purpose: there is no content for + * the whitespace to trail, and restoring it would leave a "blank" composer + * that reads as non-empty (enabling Send, suppressing the placeholder). + */ +export function markdownTrailingWhitespace(markdown: string): string | null { + return /[^ \t]([ \t]+)$/.exec(markdown)?.[1] ?? null; +} + +/** + * Re-append trailing spaces/tabs that a markdown parse dropped. + * + * `setContent` runs its argument through `tiptap-markdown`, and markdown-it + * discards end-of-line whitespace — `"@Name "` parses to `
@Name
`. That + * lone character is load-bearing: mention chips are inline decorations over + * plain text (see `mentionHighlightExtension`), matched only when a boundary + * follows the name. Restore the caret flush against `@Name` and the next + * keystroke produces `@Nameabc`, which stops matching — the chip disappears + * and `extractMentionPubkeys` no longer resolves the name, so the recipient is + * silently dropped from the outgoing event. + * + * The whitespace survives *serialization* (drafts on disk keep it), so it is + * recoverable here, at the single parse boundary, rather than at each of the + * call sites that reload composer content: post-send refill, draft restore on + * channel/thread switch, loading a message to edit, cancelling an edit, and + * restoring after a failed send. + * + * Uses the `preventUpdate` meta so the repair is invisible to the user-edit + * observers — the same mechanism `setContent`'s `emitUpdate: false` uses. + * Without it the insert looks like a keystroke and re-opens the mention + * autocomplete on every channel switch. + */ +export function restoreMarkdownTrailingWhitespace( + editor: Editor, + markdown: string, +): void { + const trailing = markdownTrailingWhitespace(markdown); + if (!trailing) return; + + const end = Selection.atEnd(editor.state.doc).from; + // Idempotence guard: if the parser ever starts preserving the whitespace, + // this must not double it. + const existing = editor.state.doc.textBetween( + Math.max(0, end - trailing.length), + end, + ); + if (existing === trailing) return; + + editor.view.dispatch( + editor.state.tr.insertText(trailing, end).setMeta("preventUpdate", true), + ); +} diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 4fee2db46f..ac5846977b 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -26,6 +26,7 @@ import { mentionHighlightKey, } from "./mentionHighlightExtension"; import { CUSTOM_EMOJI_NODE_NAME } from "./customEmojiNode"; +import { restoreMarkdownTrailingWhitespace } from "./markdownTrailingWhitespace"; import { useComposerCustomEmoji } from "./useComposerCustomEmoji"; import { buildPlainTextProjection } from "./plainTextProjection"; import { createLinkInteractionExtension } from "./linkInteractionExtension"; @@ -681,6 +682,7 @@ export function useRichTextEditor({ (markdown: string) => { if (!editor) return; editor.commands.setContent(markdown); + restoreMarkdownTrailingWhitespace(editor, markdown); }, [editor], ); @@ -689,13 +691,12 @@ export function useRichTextEditor({ (markdown: string) => { if (!editor) return; // The caller already synchronizes composer state. Keep this programmatic - // restoration out of user-edit observers (autocomplete/reconciliation), - // then move selection in the same command chain. - editor - .chain() - .setContent(markdown, { emitUpdate: false }) - .focus("end") - .run(); + // restoration out of user-edit observers (autocomplete/reconciliation). + editor.commands.setContent(markdown, { emitUpdate: false }); + // Repair before focusing: the caret must land *after* any trailing + // whitespace the parse dropped, not flush against the last character. + restoreMarkdownTrailingWhitespace(editor, markdown); + editor.commands.focus("end"); }, [editor], ); diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index ae424b4e5c..6cd0bebc1a 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -66,6 +66,25 @@ async function emitRootMessage( return event; } +/** + * Assert the composer's *exact* text, trailing whitespace included. + * + * `toHaveText` normalizes whitespace, so `"@Morgarita"` and `"@Morgarita "` + * both satisfy it — which is how a dropped trailing space shipped past an + * assertion written to guard that very space. Mention chips are decorations + * over plain text and only match when a boundary follows the name, so the + * space is the difference between the next keystroke starting a message and + * it corrupting the mention. + */ +async function expectExactComposerText( + input: ReturnType