Offer the message's own format when replying (#407) (#408)

A reply opened in the format the settings ask for, whatever the message
being answered was written in, and the per-draft switch was buried in
the composer's ⋮ menu. Replying in plain text to a rich text message
throws away the formatting; replying in rich text to a plain-text one
overrides what the sender chose to write in.

When the two disagree the composer now says so above the editor -- "This
message is rich text", with a Switch button and a dismiss -- and the
draft still opens in the format the settings ask for. Switching converts
that draft only and leaves the setting alone; switching from the ⋮ menu
answers the offer too. Forwards get it as well, where the formatting
being passed on is somebody else's.

What counts as rich text is hasHtmlAlternative(), which reads the body
part's own type: `htmlBody` is derived (RFC 8621 4.1.4), so a plain-text
message has one too and its presence proves nothing.

The mock said otherwise -- it returned an empty `htmlBody` for a
plain-text message, where Stalwart 0.16.21 returns the text/plain part
in both lists. Both builders now answer as the server does, so the path
this feature depends on is exercised in development rather than only
against a real mailbox.

Two new strings, translated in all nine catalogs; the buttons reuse the
menu's existing "Switch to plain text" / "Switch to rich text". The
count falling back to English stays at 16 in every language.

Fixes #407
This commit is contained in:
jcoffey
2026-09-19 14:43:38 -07:00
committed by GitHub
parent 07b39eb9b6
commit d992442b81
17 changed files with 240 additions and 6 deletions
+7
View File
@@ -441,6 +441,13 @@ minimizable and maximizable; full-screen on mobile.
code block, links (`Ctrl+K`), inline images, an emoji picker, and remove code block, links (`Ctrl+K`), inline images, an emoji picker, and remove
formatting. Tab and Shift+Tab indent inside the body. formatting. Tab and Shift+Tab indent inside the body.
- **Plain text** as a per-message or default format. - **Plain text** as a per-message or default format.
- **Answering in the format the message was written in.** Replying in plain
text to a rich text message, or the reverse, loses either the formatting or
the plain text somebody chose to write in. The composer opens in the default
format and offers the other one for that message, above the editor; the
offer is dismissible and changes no setting. Forwards too. What counts as
rich text is the body part's own type, not the presence of `htmlBody`, which
RFC 8621 derives for plain-text mail as well.
- **Recipient chips** with autocomplete from contacts, shared address books you - **Recipient chips** with autocomplete from contacts, shared address books you
have added, the server directory and recent recipients; your own cards win a have added, the server directory and recent recipients; your own cards win a
tie against a colleague's copy of the same person. Free-form addresses parse tie against a colleague's copy of the same person. Free-form addresses parse
+7 -2
View File
@@ -122,7 +122,11 @@ export function addSignedEmail(o: { which: keyof typeof SIGNED_MESSAGES; from: [
hasAttachment: false, hasAttachment: false,
preview: body.slice(0, 120), preview: body.slice(0, 120),
textBody: [{ partId: "1", blobId: textBlob, size: body.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }], textBody: [{ partId: "1", blobId: textBlob, size: body.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
htmlBody: [], // `htmlBody` is derived (RFC 8621 4.1.4): a message with no HTML
// alternative still gets one, holding the text/plain part. Checked against
// Stalwart 0.16.21 on 2026-09-10 -- see hasHtmlAlternative() in the client,
// which reads the part's type rather than trusting this list to be empty.
htmlBody: [{ partId: "1", blobId: textBlob, size: body.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
attachments: [], attachments: [],
bodyValues: { "1": { value: body, isEncodingProblem: false, isTruncated: false } }, bodyValues: { "1": { value: body, isEncodingProblem: false, isTruncated: false } },
bodyStructure: { bodyStructure: {
@@ -192,7 +196,8 @@ export function addEmail(o: { from: [string, string]; to?: string; subject: stri
from: [{ name: o.from[0], email: o.from[1] }], to: [{ name: "Demo User", email: o.to ?? USER }], cc: null, bcc: null, replyTo: null, sender: null, from: [{ name: o.from[0], email: o.from[1] }], to: [{ name: "Demo User", email: o.to ?? USER }], cc: null, bcc: null, replyTo: null, sender: null,
subject: o.subject, hasAttachment: Boolean(o.attach), preview: text.slice(0, 120).replace(/\n/g, " "), subject: o.subject, hasAttachment: Boolean(o.attach), preview: text.slice(0, 120).replace(/\n/g, " "),
textBody: [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }], textBody: [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
htmlBody: o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }] : [], // No HTML alternative means `htmlBody` names the text part, not nothing. See addSignedEmail.
htmlBody: o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }] : [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
attachments, attachments,
bodyValues: { "1": { value: text, isEncodingProblem: false, isTruncated: false }, ...(o.html ? { "2": { value: o.styled ? STYLED_MARKETING_HTML : html, isEncodingProblem: false, isTruncated: false } } : {}) }, bodyValues: { "1": { value: text, isEncodingProblem: false, isTruncated: false }, ...(o.html ? { "2": { value: o.styled ? STYLED_MARKETING_HTML : html, isEncodingProblem: false, isTruncated: false } } : {}) },
bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] }, bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] },
+2
View File
@@ -1392,6 +1392,8 @@ export const catalog: Catalog = {
"Collapse all": "Alle einklappen", "Collapse all": "Alle einklappen",
"Expand all": "Alle ausklappen", "Expand all": "Alle ausklappen",
"Send now instead": "Stattdessen jetzt senden", "Send now instead": "Stattdessen jetzt senden",
"This message is rich text": "Diese Nachricht ist formatierter Text",
"This message is plain text": "Diese Nachricht ist Nur-Text",
"Switch to plain text": "Zu Nur-Text wechseln", "Switch to plain text": "Zu Nur-Text wechseln",
"Switch to rich text": "Zu formatiertem Text wechseln", "Switch to rich text": "Zu formatiertem Text wechseln",
"{used} of {total} used": "{used} von {total} belegt", "{used} of {total} used": "{used} von {total} belegt",
+2
View File
@@ -1365,6 +1365,8 @@ export const catalog: Catalog = {
"Collapse all": "Contraer todo", "Collapse all": "Contraer todo",
"Expand all": "Expandir todo", "Expand all": "Expandir todo",
"Send now instead": "Enviar ahora, sin programar", "Send now instead": "Enviar ahora, sin programar",
"This message is rich text": "Este mensaje es texto enriquecido",
"This message is plain text": "Este mensaje es texto sin formato",
"Switch to plain text": "Cambiar a texto sin formato", "Switch to plain text": "Cambiar a texto sin formato",
"Switch to rich text": "Cambiar a texto enriquecido", "Switch to rich text": "Cambiar a texto enriquecido",
"{used} of {total} used": "{used} de {total} usados", "{used} of {total} used": "{used} de {total} usados",
+2
View File
@@ -1370,6 +1370,8 @@ export const catalog: Catalog = {
"Collapse all": "Tout réduire", "Collapse all": "Tout réduire",
"Expand all": "Tout développer", "Expand all": "Tout développer",
"Send now instead": "Envoyer tout de suite", "Send now instead": "Envoyer tout de suite",
"This message is rich text": "Ce message est en texte enrichi",
"This message is plain text": "Ce message est en texte brut",
"Switch to plain text": "Passer en texte brut", "Switch to plain text": "Passer en texte brut",
"Switch to rich text": "Passer en texte enrichi", "Switch to rich text": "Passer en texte enrichi",
"{used} of {total} used": "{used} sur {total} utilisés", "{used} of {total} used": "{used} sur {total} utilisés",
+2
View File
@@ -1373,6 +1373,8 @@ export const catalog: Catalog = {
"Collapse all": "すべて折りたたむ", "Collapse all": "すべて折りたたむ",
"Expand all": "すべて展開", "Expand all": "すべて展開",
"Send now instead": "予約をやめて今すぐ送信", "Send now instead": "予約をやめて今すぐ送信",
"This message is rich text": "このメールはリッチテキストです",
"This message is plain text": "このメールはプレーンテキストです",
"Switch to plain text": "プレーンテキストに切り替え", "Switch to plain text": "プレーンテキストに切り替え",
"Switch to rich text": "リッチテキストに切り替え", "Switch to rich text": "リッチテキストに切り替え",
"{used} of {total} used": "{total} 中 {used} を使用", "{used} of {total} used": "{total} 中 {used} を使用",
+2
View File
@@ -1362,6 +1362,8 @@ export const catalog: Catalog = {
"Collapse all": "Alles samenvouwen", "Collapse all": "Alles samenvouwen",
"Expand all": "Alles uitvouwen", "Expand all": "Alles uitvouwen",
"Send now instead": "Toch nu verzenden", "Send now instead": "Toch nu verzenden",
"This message is rich text": "Dit bericht is opgemaakte tekst",
"This message is plain text": "Dit bericht is platte tekst",
"Switch to plain text": "Overschakelen naar platte tekst", "Switch to plain text": "Overschakelen naar platte tekst",
"Switch to rich text": "Overschakelen naar opgemaakte tekst", "Switch to rich text": "Overschakelen naar opgemaakte tekst",
"{used} of {total} used": "{used} van {total} gebruikt", "{used} of {total} used": "{used} van {total} gebruikt",
+2
View File
@@ -1368,6 +1368,8 @@ export const catalog: Catalog = {
"Collapse all": "Recolher tudo", "Collapse all": "Recolher tudo",
"Expand all": "Expandir tudo", "Expand all": "Expandir tudo",
"Send now instead": "Enviar agora mesmo", "Send now instead": "Enviar agora mesmo",
"This message is rich text": "Esta mensagem está em texto formatado",
"This message is plain text": "Esta mensagem está em texto simples",
"Switch to plain text": "Mudar para texto simples", "Switch to plain text": "Mudar para texto simples",
"Switch to rich text": "Mudar para texto formatado", "Switch to rich text": "Mudar para texto formatado",
"{used} of {total} used": "{used} de {total} usados", "{used} of {total} used": "{used} de {total} usados",
+2
View File
@@ -1367,6 +1367,8 @@ export const catalog: Catalog = {
"Collapse all": "Свернуть все", "Collapse all": "Свернуть все",
"Expand all": "Развернуть все", "Expand all": "Развернуть все",
"Send now instead": "Отправить сейчас", "Send now instead": "Отправить сейчас",
"This message is rich text": "Это письмо в формате HTML",
"This message is plain text": "Это письмо в виде простого текста",
"Switch to plain text": "Переключиться на обычный текст", "Switch to plain text": "Переключиться на обычный текст",
"Switch to rich text": "Переключиться на форматированный текст", "Switch to rich text": "Переключиться на форматированный текст",
"{used} of {total} used": "Использовано {used} из {total}", "{used} of {total} used": "Использовано {used} из {total}",
+2
View File
@@ -1361,6 +1361,8 @@ export const catalog: Catalog = {
"Collapse all": "Згорнути все", "Collapse all": "Згорнути все",
"Expand all": "Розгорнути все", "Expand all": "Розгорнути все",
"Send now instead": "Надіслати зараз", "Send now instead": "Надіслати зараз",
"This message is rich text": "Цей лист у форматі HTML",
"This message is plain text": "Цей лист у вигляді простого тексту",
"Switch to plain text": "Перейти на звичайний текст", "Switch to plain text": "Перейти на звичайний текст",
"Switch to rich text": "Перейти на форматований текст", "Switch to rich text": "Перейти на форматований текст",
"{used} of {total} used": "Використано {used} з {total}", "{used} of {total} used": "Використано {used} з {total}",
+2
View File
@@ -1372,6 +1372,8 @@ export const catalog: Catalog = {
"Collapse all": "全部折叠", "Collapse all": "全部折叠",
"Expand all": "全部展开", "Expand all": "全部展开",
"Send now instead": "改为立即发送", "Send now instead": "改为立即发送",
"This message is rich text": "这封邮件是富文本",
"This message is plain text": "这封邮件是纯文本",
"Switch to plain text": "切换为纯文本", "Switch to plain text": "切换为纯文本",
"Switch to rich text": "切换为富文本", "Switch to rich text": "切换为富文本",
"{used} of {total} used": "已使用 {used},共 {total}", "{used} of {total} used": "已使用 {used},共 {total}",
@@ -18,7 +18,7 @@ function draft(over: Partial<Draft> = {}): Draft {
requestReceipt: false, priority: "normal", requestReceipt: false, priority: "normal",
showCc: false, showBcc: false, showReplyTo: false, showCc: false, showBcc: false, showReplyTo: false,
minimized: false, maximized: false, dirty: false, savedAt: null, minimized: false, maximized: false, dirty: false, savedAt: null,
saving: false, sending: false, error: null, signatureHtml: "", replyMode: null, sendAt: null, saving: false, sending: false, error: null, signatureHtml: "", replyMode: null, formatOffer: null, sendAt: null,
...over, ...over,
}; };
} }
@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useCompose } from "@/store/compose";
import { useMail } from "@/store/mail";
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
import type { Email, Identity } from "@/jmap/types";
/*
* Offering to answer a message in the format it was written in (#407).
*
* The trap is `htmlBody`: RFC 8621 derives it, so a plain-text message has one
* too, holding its text/plain part. Reading that as "there is HTML" would
* offer a switch to rich text on every plain-text message, and never offer the
* switch to plain text where it is actually wanted.
*/
const base = {
messageId: ["<[email protected]>"], subject: "Numbers", references: [], inReplyTo: [],
keywords: {}, attachments: [], receivedAt: "2026-09-04T10:00:00Z", mailboxIds: {},
from: [{ name: "Ann", email: "[email protected]" }], to: [{ name: "John", email: "[email protected]" }], cc: [],
};
/** A real multipart/alternative: two parts, one of them text/html. */
const RICH = {
...base, id: "m1",
htmlBody: [{ partId: "2", type: "text/html" }],
textBody: [{ partId: "1", type: "text/plain" }],
bodyValues: { "1": { value: "hi", isEncodingProblem: false, isTruncated: false }, "2": { value: "<p>hi</p>", isEncodingProblem: false, isTruncated: false } },
} as unknown as Email;
/** Plain text, as Stalwart returns it: both lists name the same text/plain part. */
const PLAIN = {
...base, id: "m2",
htmlBody: [{ partId: "1", type: "text/plain" }],
textBody: [{ partId: "1", type: "text/plain" }],
bodyValues: { "1": { value: "hi", isEncodingProblem: false, isTruncated: false } },
} as unknown as Email;
const IDENTITIES = [{ id: "i1", name: "John", email: "[email protected]", replyTo: null }] as unknown as Identity[];
function draftFor(email: Email, mode: "reply" | "replyAll" | "forward") {
useMail.setState({
accountId: "a1",
identities: IDENTITIES as never,
getEmails: (async () => [email]) as never,
defaultIdentity: (() => IDENTITIES[0]) as never,
loadIdentities: (async () => IDENTITIES) as never,
roleId: (() => null) as never,
});
return useCompose.getState().reply(email, mode).then((key) => useCompose.getState().drafts.find((d) => d.key === key)!);
}
const composeIn = (format: "html" | "text") => useSettings.setState({ settings: { ...DEFAULT_SETTINGS, composeFormat: format } });
beforeEach(() => {
useCompose.setState({ drafts: [], activeKey: null });
useSettings.setState({ settings: { ...DEFAULT_SETTINGS } });
});
describe("answering a message written in the other format", () => {
it("offers rich text when a plain-text reply answers a rich message", async () => {
composeIn("text");
const d = await draftFor(RICH, "reply");
expect(d.format).toBe("text");
expect(d.formatOffer).toBe("html");
});
it("offers plain text when a rich reply answers a plain-text message", async () => {
composeIn("html");
const d = await draftFor(PLAIN, "reply");
expect(d.format).toBe("html");
expect(d.formatOffer).toBe("text");
});
it("offers nothing when the formats already agree", async () => {
composeIn("html");
expect((await draftFor(RICH, "reply")).formatOffer).toBeNull();
composeIn("text");
expect((await draftFor(PLAIN, "reply")).formatOffer).toBeNull();
});
it("reads the part's own type, not the derived htmlBody list", async () => {
// PLAIN has an htmlBody; it names the text/plain part. Offering a switch
// to rich text here would fire on every plain-text message there is.
composeIn("text");
expect((await draftFor(PLAIN, "reply")).formatOffer).toBeNull();
});
it("offers on a reply all and on a forward, where the same formatting is lost", async () => {
composeIn("text");
expect((await draftFor(RICH, "replyAll")).formatOffer).toBe("html");
expect((await draftFor(RICH, "forward")).formatOffer).toBe("html");
});
it("carries both bodies either way, so switching has something to switch to", async () => {
composeIn("text");
const d = await draftFor(RICH, "reply");
expect(d.text).toContain("hi");
expect(d.html).toContain("hi");
});
it("makes no offer on a message started from scratch", () => {
composeIn("text");
const key = useCompose.getState().open();
expect(useCompose.getState().drafts.find((x) => x.key === key)!.formatOffer).toBeNull();
});
});
+16 -1
View File
@@ -4,7 +4,7 @@ import type { Email, EmailAddress, EmailBodyPart, Id, Identity, SetResponse } fr
import { formatFullDate, uid } from "@/lib/format"; import { formatFullDate, uid } from "@/lib/format";
import { formatAddress, parseMailto, sameAddress, uniqueAddresses } from "@/lib/address"; import { formatAddress, parseMailto, sameAddress, uniqueAddresses } from "@/lib/address";
import { escapeHtml, htmlToText, quoteText, replySubject, textToHtml } from "@/lib/text/text"; import { escapeHtml, htmlToText, quoteText, replySubject, textToHtml } from "@/lib/text/text";
import { sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/text/html"; import { hasHtmlAlternative, sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/text/html";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
import { useMail, FULL_PROPS, BODY_PROPS } from "./mail"; import { useMail, FULL_PROPS, BODY_PROPS } from "./mail";
import { useSession } from "./session"; import { useSession } from "./session";
@@ -75,6 +75,12 @@ export interface Draft {
/** Original identity signature HTML currently embedded, to replace on identity switch. */ /** Original identity signature HTML currently embedded, to replace on identity switch. */
signatureHtml: string; signatureHtml: string;
replyMode: "reply" | "replyAll" | "forward" | null; replyMode: "reply" | "replyAll" | "forward" | null;
/**
* The format the message being answered was written in, when it is not the
* one this draft opened in (#407). The composer offers the switch; answering
* it either way, or dismissing it, clears this.
*/
formatOffer: "html" | "text" | null;
mailboxIdOnSend?: Id | null; mailboxIdOnSend?: Id | null;
/** When set, hand the message to the server held until this instant. */ /** When set, hand the message to the server held until this instant. */
sendAt: number | null; sendAt: number | null;
@@ -145,6 +151,7 @@ function blankDraft(init: Partial<Draft> = {}): Draft {
error: null, error: null,
signatureHtml: "", signatureHtml: "",
replyMode: null, replyMode: null,
formatOffer: null,
sendAt: null, sendAt: null,
...init, ...init,
}; };
@@ -379,6 +386,13 @@ export const useCompose = create<ComposeState>((set, get) => ({
const textPart = full.textBody?.[0]; const textPart = full.textBody?.[0];
const origHtml = htmlPart?.partId ? (full.bodyValues?.[htmlPart.partId]?.value ?? "") : ""; const origHtml = htmlPart?.partId ? (full.bodyValues?.[htmlPart.partId]?.value ?? "") : "";
const origText = textPart?.partId ? (full.bodyValues?.[textPart.partId]?.value ?? "") : ""; const origText = textPart?.partId ? (full.bodyValues?.[textPart.partId]?.value ?? "") : "";
/*
* What the message being answered was really written in. `htmlBody` is
* derived, so its presence proves nothing -- hasHtmlAlternative() reads the
* part's own type. Getting this wrong would offer every plain-text message
* a switch to rich text it does not need.
*/
const origFormat = hasHtmlAlternative(htmlPart, origHtml) ? "html" : "text";
const accountId = mail.accountId!; const accountId = mail.accountId!;
const attachments: ComposeAttachment[] = []; const attachments: ComposeAttachment[] = [];
const cidMap: Record<string, string> = {}; const cidMap: Record<string, string> = {};
@@ -434,6 +448,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
relatedKeyword: mode === "forward" ? "$forwarded" : "$answered", relatedKeyword: mode === "forward" ? "$forwarded" : "$answered",
signatureHtml: sigHtml, signatureHtml: sigHtml,
replyMode: mode, replyMode: mode,
formatOffer: origFormat === s.composeFormat ? null : origFormat,
}); });
set((st) => ({ drafts: [...st.drafts, d], activeKey: d.key })); set((st) => ({ drafts: [...st.drafts, d], activeKey: d.key }));
return d.key; return d.key;
+3
View File
@@ -1570,6 +1570,9 @@ a.menu-item:hover { color: var(--fg); }
.composer-head .title { flex: 1; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .composer-head .title { flex: 1; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.composer-head .status { color: var(--fg-faint); font-size: .8em; margin-right: 6px; white-space: nowrap; } .composer-head .status { color: var(--fg-faint); font-size: .8em; margin-right: 6px; white-space: nowrap; }
.composer-body { display: flex; flex-direction: column; flex: 1; min-height: 0; } .composer-body { display: flex; flex-direction: column; flex: 1; min-height: 0; }
/* An offer the draft makes about itself, above the editor: quiet, one line, and dismissible. */
.composer-notice { display: flex; align-items: center; gap: 8px; padding: 6px 10px 6px 14px; background: var(--accent-soft); color: var(--accent-soft-fg); border-bottom: 1px solid var(--border); font-size: .85em; flex: 0 0 auto; }
.composer-notice span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.composer-fields { flex: 0 0 auto; padding: 0 12px; } .composer-fields { flex: 0 0 auto; padding: 0 12px; }
.composer-field { display: flex; align-items: center; gap: 8px; min-height: 40px; border-bottom: 1px solid var(--border); padding: 4px 0; } .composer-field { display: flex; align-items: center; gap: 8px; min-height: 40px; border-bottom: 1px solid var(--border); padding: 4px 0; }
.composer-field > label { color: var(--fg-muted); width: 42px; flex: 0 0 auto; font-size: .92em; } .composer-field > label { color: var(--fg-muted); width: 42px; flex: 0 0 auto; font-size: .92em; }
+19 -2
View File
@@ -148,10 +148,11 @@ export function Composer({ draft }: { draft: Draft }) {
}; };
const toggleFormat = () => { const toggleFormat = () => {
// Whichever way the format is changed, the offer has been answered.
if (d.format === "html") { if (d.format === "html") {
patch({ format: "text", text: htmlToText(d.html) }); patch({ format: "text", text: htmlToText(d.html), formatOffer: null });
} else { } else {
patch({ format: "html", html: textToHtml(d.text, { linkify: false, quoteColors: false }).replace(/\n/g, "<br>") }); patch({ format: "html", html: textToHtml(d.text, { linkify: false, quoteColors: false }).replace(/\n/g, "<br>"), formatOffer: null });
} }
}; };
@@ -261,6 +262,22 @@ export function Composer({ draft }: { draft: Draft }) {
)} )}
</div> </div>
</div> </div>
{/*
Replying in one format to a message written in the other loses
something either way: the formatting of a rich reply, or the plain
text somebody chose to write in. The draft opens in the format the
settings ask for, and this offers the other one for this message
only, rather than quietly overriding the setting (#407).
*/}
{d.formatOffer && (
<div className="composer-notice">
<span>{d.formatOffer === "html" ? translate("This message is rich text") : translate("This message is plain text")}</span>
<button type="button" className="btn btn-sm" onClick={toggleFormat}>
{d.formatOffer === "html" ? translate("Switch to rich text") : translate("Switch to plain text")}
</button>
<button type="button" className="icon-btn sm" aria-label={translate("Dismiss")} onClick={() => patch({ formatOffer: null })}><X size={14} /></button>
</div>
)}
{d.format === "html" ? ( {d.format === "html" ? (
<RichEditor ref={editorRef} html={d.html} onChange={onHtml} placeholder={translate("Write your message…")} spellcheck={settings.spellcheck} onFiles={(files) => addFiles(key, files)} showToolbar={showToolbar} autoFocus={initialFocus === "body"} /> <RichEditor ref={editorRef} html={d.html} onChange={onHtml} placeholder={translate("Write your message…")} spellcheck={settings.spellcheck} onFiles={(files) => addFiles(key, files)} showToolbar={showToolbar} autoFocus={initialFocus === "body"} />
) : ( ) : (
@@ -0,0 +1,63 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Composer } from "../Composer";
import { useCompose, type Draft } from "@/store/compose";
import { useMail } from "@/store/mail";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
/**
* The bar the composer shows when the draft's format doesn't match the message
* it is answering (#407). A store test can say the offer was made; only the
* component can say that pressing it converts the body and puts the bar away.
*/
window.matchMedia = ((q: string) => ({ matches: false, media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
const REPLY: Partial<Draft> = {
key: "d1", replyMode: "reply", subject: "Re: Numbers",
format: "text", text: "\n\nOn Friday, Ann wrote:\n> hi", html: "<div><br></div><div class=\"ihm-quote\">hi</div>",
formatOffer: "html",
};
describe("the format offer in the composer", () => {
let host: HTMLDivElement;
let root: Root;
const bar = () => document.querySelector(".composer-notice");
const draft = () => useCompose.getState().drafts[0]!;
const button = (label: string) => Array.from(document.querySelectorAll<HTMLElement>(".composer-notice button")).find((b) => b.textContent === label || b.getAttribute("aria-label") === label)!;
beforeEach(() => {
useMail.setState({ accountId: "a1", identities: [] as never });
useCompose.setState({ drafts: [], activeKey: null, pendingSends: {} });
const key = useCompose.getState().open();
useCompose.getState().update(key, REPLY);
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
act(() => root.render(<Composer draft={draft()} />));
});
afterEach(() => { act(() => root.unmount()); host.remove(); });
it("offers the message's own format, and says which it is", () => {
expect(bar()?.textContent).toContain("This message is rich text");
expect(button("Switch to rich text")).toBeTruthy();
});
it("switches this draft and puts the bar away", () => {
act(() => button("Switch to rich text").click());
act(() => root.render(<Composer draft={draft()} />));
expect(draft().format).toBe("html");
// The quoted reply came across, rather than the editor opening empty.
expect(draft().html).toContain("Ann wrote");
expect(bar()).toBeNull();
});
it("dismisses without changing the format", () => {
act(() => button("Dismiss").click());
act(() => root.render(<Composer draft={draft()} />));
expect(draft().format).toBe("text");
expect(bar()).toBeNull();
});
});