fix: keep quote markers, list markers and image alt text when switching a reply to plain text

This commit is contained in:
Linus Rath
2026-11-02 22:52:04 +01:00
parent 732175772c
commit dfb946541f
3 changed files with 24 additions and 5 deletions
@@ -10,6 +10,8 @@ import {
} from '../compose-format';
import { SIGNATURE_BLOCK_MARKER, SIGNATURE_RANGE_MARKER, containsEmbeddedSignature } from '../signature-block';
import { plainTextBodyHasSignature } from '@/lib/signature-utils';
import { extractUserAuthoredText } from '@/lib/email-composer-utils';
import { buildQuotedHtmlBlock } from '../quoted-html';
const identity = { htmlSignature: '<b>Alice</b><br>Acme', textSignature: 'Alice\nAcme' };
const count = (haystack: string, needle: string) => haystack.split(needle).length - 1;
@@ -46,10 +48,24 @@ describe('compose-format (#1022): HTML <-> plain text switch', () => {
it('does not invent a signature for a body that had none (below-quote reply)', () => {
const text = htmlComposeBodyToPlainText('<p>Thanks</p><blockquote>original</blockquote>', identity, { separator: true });
expect(text).toBe('Thanks\n\noriginal');
expect(text).toBe('Thanks\n\n> original');
expect(plainTextBodyHasSignature(text, identity)).toBe(false);
});
it('keeps the quoted original of a rich reply marked with "> "', () => {
const html = `<p>Thanks</p><p>On Monday Bob wrote:</p>${buildQuotedHtmlBlock('<p>Line one<br>Line two</p><p>Para two</p>')}`;
const text = htmlComposeBodyToPlainText(html, identity, { separator: true });
expect(text).toBe('Thanks\n\nOn Monday Bob wrote:\n\n> Line one\n> Line two\n>\n> Para two');
// The attachment reminder and plugin edits only see the user's text.
expect(extractUserAuthoredText(text, { plainTextMode: true })).not.toContain('Line one');
});
it('keeps list markers and image alt text', () => {
const html = '<ul><li>apples</li><li>pears</li></ul><ol><li>first</li><li>second</li></ol><p><img src="x.png" alt="a bike"></p>';
const text = htmlComposeBodyToPlainText(html, identity, { separator: true, imageAlt: (alt) => `[Image: ${alt}]` });
expect(text).toBe('- apples\n- pears\n\n1. first\n2. second\n\n[Image: a bike]');
});
it('honours the separator setting', () => {
const html = `<p>Hi</p>${buildEmbeddedSignatureHtml(identity, { embed: true, separator: false })}`;
expect(htmlComposeBodyToPlainText(html, identity, { separator: false })).toBe('Hi\n\nAlice\nAcme');
+5 -2
View File
@@ -202,14 +202,17 @@ export function replacePlainTextSignatureAboveQuote(
* Convert the rich-text editor's body to a plain-text body. Formatting is
* dropped (that is the point of plain text); an embedded signature comes back
* as the identity's plain-text signature so the body still "carries" it.
* The quote keeps its "> " lines and lists their markers, as in the text part
* the send path builds: the attachment reminder and plugin edits find the
* quote by them.
*/
export function htmlComposeBodyToPlainText(
html: string,
identity: SignatureIdentityLike,
options: { separator: boolean },
options: { separator: boolean; imageAlt?: (alt: string) => string },
): string {
const stripped = stripEmbeddedSignature(html);
const text = htmlToPlainText(stripped.html, { paragraphSpacing: true });
const text = htmlToPlainText(stripped.html, { paragraphSpacing: true, mailMarkup: true, imageAlt: options.imageAlt });
if (!stripped.hadSignature) return text;
return appendPlainTextSignature(text, identity, { separator: options.separator });
}
+2 -2
View File
@@ -1951,12 +1951,12 @@ export function EmailComposer({
const editor = editorRef.current;
const html = editor && !editor.isDestroyed ? serializeEditorContent(editor) : body;
editorRef.current = null;
setBody(htmlComposeBodyToPlainText(html, signatureIdentity, { separator }));
setBody(htmlComposeBodyToPlainText(html, signatureIdentity, { separator, imageAlt: imageAltText }));
setPlainTextMode(true);
// Keyed on the signature fields, not the identity object (see the
// signature effects above for the same rationale).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [plainTextMode, body, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled]);
}, [plainTextMode, body, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, imageAltText]);
// Move a chip from one recipient field to another. `toIndex`, when given,
// inserts at that position in the destination (drag-and-drop reordering,