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:
@@ -148,10 +148,11 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
};
|
||||
|
||||
const toggleFormat = () => {
|
||||
// Whichever way the format is changed, the offer has been answered.
|
||||
if (d.format === "html") {
|
||||
patch({ format: "text", text: htmlToText(d.html) });
|
||||
patch({ format: "text", text: htmlToText(d.html), formatOffer: null });
|
||||
} 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>
|
||||
{/*
|
||||
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" ? (
|
||||
<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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user