diff --git a/web/src/store/__tests__/compose-email.test.ts b/web/src/store/__tests__/compose-email.test.ts index 395ae97..1b574a4 100644 --- a/web/src/store/__tests__/compose-email.test.ts +++ b/web/src/store/__tests__/compose-email.test.ts @@ -18,7 +18,7 @@ function draft(over: Partial = {}): Draft { requestReceipt: false, priority: "normal", showCc: false, showBcc: false, showReplyTo: false, minimized: false, maximized: false, dirty: false, savedAt: null, - saving: false, sending: false, error: null, signatureHtml: "", replyMode: null, formatOffer: null, sendAt: null, + saving: false, sending: false, error: null, signatureHtml: "", replyMode: null, quoteHtml: "", quoteText: "", formatOffer: null, sendAt: null, ...over, }; } diff --git a/web/src/store/__tests__/reply-format-offer.test.ts b/web/src/store/__tests__/reply-format-offer.test.ts index 6f8847c..608d5d6 100644 --- a/web/src/store/__tests__/reply-format-offer.test.ts +++ b/web/src/store/__tests__/reply-format-offer.test.ts @@ -98,6 +98,24 @@ describe("answering a message written in the other format", () => { expect(d.html).toContain("hi"); }); + it("keeps the quoted message in both formats, so a switch can restore it", async () => { + composeIn("text"); + const d = await draftFor(RICH, "reply"); + // The HTML quote is the original's markup, not the text one converted. + expect(d.quoteHtml).toContain("

hi

"); + expect(d.quoteHtml).toContain("ihm-quote"); + expect(d.quoteText).toContain("Ann"); + expect(d.text.endsWith(d.quoteText)).toBe(true); + }); + + it("quotes nothing on a message started from scratch", () => { + composeIn("text"); + const key = useCompose.getState().open(); + const d = useCompose.getState().drafts.find((x) => x.key === key)!; + expect(d.quoteHtml).toBe(""); + expect(d.quoteText).toBe(""); + }); + it("makes no offer on a message started from scratch", () => { composeIn("text"); const key = useCompose.getState().open(); diff --git a/web/src/store/compose.ts b/web/src/store/compose.ts index 41e4f6d..7166125 100644 --- a/web/src/store/compose.ts +++ b/web/src/store/compose.ts @@ -75,6 +75,14 @@ export interface Draft { /** Original identity signature HTML currently embedded, to replace on identity switch. */ signatureHtml: string; replyMode: "reply" | "replyAll" | "forward" | null; + /** + * The quoted message as it was prepared in each format, kept so that + * switching format re-attaches the original rather than a conversion of + * whatever the other format flattened it into. Empty on a draft that quotes + * nothing. + */ + quoteHtml: string; + quoteText: string; /** * 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 @@ -151,6 +159,8 @@ function blankDraft(init: Partial = {}): Draft { error: null, signatureHtml: "", replyMode: null, + quoteHtml: "", + quoteText: "", formatOffer: null, sendAt: null, ...init, @@ -448,6 +458,8 @@ export const useCompose = create((set, get) => ({ relatedKeyword: mode === "forward" ? "$forwarded" : "$answered", signatureHtml: sigHtml, replyMode: mode, + quoteHtml, + quoteText: quoteTxt, formatOffer: origFormat === s.composeFormat ? null : origFormat, }); set((st) => ({ drafts: [...st.drafts, d], activeKey: d.key })); diff --git a/web/src/views/compose/Composer.tsx b/web/src/views/compose/Composer.tsx index 644a032..a08f4f7 100644 --- a/web/src/views/compose/Composer.tsx +++ b/web/src/views/compose/Composer.tsx @@ -147,12 +147,26 @@ export function Composer({ draft }: { draft: Draft }) { patch({ sendAt: at.getTime() }); }; + /* + * Switching format converts what has been written, but the quoted message + * is not something this draft wrote: it was prepared in both formats when + * the reply opened. Converting the plain-text quote into HTML would hand + * back a flattened copy of a message that still exists in its original + * markup, so re-attach that instead, and keep only what the author typed + * above it. Where the quote can no longer be found -- edited, or a draft + * that quotes nothing -- convert the whole body as before. + */ const toggleFormat = () => { // Whichever way the format is changed, the offer has been answered. if (d.format === "html") { - patch({ format: "text", text: htmlToText(d.html), formatOffer: null }); + const at = d.quoteHtml ? d.html.indexOf('
') : -1; + const written = at >= 0 ? htmlToText(d.html.slice(0, at)) : htmlToText(d.html); + patch({ format: "text", text: at >= 0 ? written.replace(/\s+$/, "") + d.quoteText : written, formatOffer: null }); } else { - patch({ format: "html", html: textToHtml(d.text, { linkify: false, quoteColors: false }).replace(/\n/g, "
"), formatOffer: null }); + const keeps = Boolean(d.quoteText) && d.text.endsWith(d.quoteText); + const written = keeps ? d.text.slice(0, d.text.length - d.quoteText.length) : d.text; + const asHtml = textToHtml(written, { linkify: false, quoteColors: false }).replace(/\n/g, "
"); + patch({ format: "html", html: keeps ? asHtml + d.quoteHtml : asHtml, formatOffer: null }); } }; diff --git a/web/src/views/compose/__tests__/format-offer-bar.test.tsx b/web/src/views/compose/__tests__/format-offer-bar.test.tsx index 5d1cc34..8d8626e 100644 --- a/web/src/views/compose/__tests__/format-offer-bar.test.tsx +++ b/web/src/views/compose/__tests__/format-offer-bar.test.tsx @@ -15,9 +15,13 @@ import { useMail } from "@/store/mail"; window.matchMedia = ((q: string) => ({ matches: false, media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia; +const QUOTE_HTML = '

On Friday, Ann wrote:

Look at this

'; +const QUOTE_TEXT = "\n\nOn Friday, Ann wrote:\n> Look at this"; + const REPLY: Partial = { key: "d1", replyMode: "reply", subject: "Re: Numbers", - format: "text", text: "\n\nOn Friday, Ann wrote:\n> hi", html: "

hi
", + format: "text", text: QUOTE_TEXT, html: `

${QUOTE_HTML}`, + quoteHtml: QUOTE_HTML, quoteText: QUOTE_TEXT, formatOffer: "html", }; @@ -54,6 +58,46 @@ describe("the format offer in the composer", () => { expect(bar()).toBeNull(); }); + /* + * The message being quoted was prepared in both formats when the reply + * opened. Switching used to convert the plain-text body it had, handing + * back a flattened copy -- "> Look at this" -- of markup that still + * existed untouched on the draft. + */ + it("restores the original message, rather than converting the flattened quote", () => { + act(() => button("Switch to rich text").click()); + act(() => root.render()); + expect(draft().html).toContain("this"); + expect(draft().html).toContain("
"); + expect(draft().html).not.toContain("> Look at this"); + }); + + it("keeps what the author typed above the quote", () => { + useCompose.getState().update("d1", { text: `Thanks, that helps.${QUOTE_TEXT}` }); + act(() => root.render()); + act(() => button("Switch to rich text").click()); + act(() => root.render()); + expect(draft().html).toContain("Thanks, that helps."); + expect(draft().html).toContain("this"); + // Once only: the typed reply must not arrive with the quote doubled. + expect(draft().html.match(/On Friday, Ann wrote:/g)).toHaveLength(1); + }); + + it("goes back to plain text with the prepared quote, not a re-flattened one", () => { + // A rich draft answering a plain-text message: the offer runs the other way. + act(() => { + useCompose.getState().update("d1", { format: "html", html: `
Thanks.
${QUOTE_HTML}`, formatOffer: "text" }); + }); + act(() => root.render()); + act(() => button("Switch to plain text").click()); + act(() => root.render()); + expect(draft().format).toBe("text"); + expect(draft().text).toContain("Thanks."); + // The prepared plain-text quote, not HTML run through a converter. + expect(draft().text.endsWith(QUOTE_TEXT)).toBe(true); + expect(draft().text).not.toContain("
"); + }); + it("dismisses without changing the format", () => { act(() => button("Dismiss").click()); act(() => root.render());