Seven app fixes from public ihasmail #25
No files matched your search
@@ -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, formatOffer: null, sendAt: null,
|
saving: false, sending: false, error: null, signatureHtml: "", replyMode: null, quoteHtml: "", quoteText: "", formatOffer: null, sendAt: null,
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,24 @@ describe("answering a message written in the other format", () => {
|
|||||||
expect(d.html).toContain("hi");
|
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("<p>hi</p>");
|
||||||
|
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", () => {
|
it("makes no offer on a message started from scratch", () => {
|
||||||
composeIn("text");
|
composeIn("text");
|
||||||
const key = useCompose.getState().open();
|
const key = useCompose.getState().open();
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ 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 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
|
* 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
|
* one this draft opened in (#407). The composer offers the switch; answering
|
||||||
@@ -152,6 +160,8 @@ function blankDraft(init: Partial<Draft> = {}): Draft {
|
|||||||
error: null,
|
error: null,
|
||||||
signatureHtml: "",
|
signatureHtml: "",
|
||||||
replyMode: null,
|
replyMode: null,
|
||||||
|
quoteHtml: "",
|
||||||
|
quoteText: "",
|
||||||
formatOffer: null,
|
formatOffer: null,
|
||||||
sendAt: null,
|
sendAt: null,
|
||||||
...init,
|
...init,
|
||||||
@@ -474,6 +484,8 @@ 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,
|
||||||
|
quoteHtml,
|
||||||
|
quoteText: quoteTxt,
|
||||||
formatOffer: origFormat === s.composeFormat ? null : origFormat,
|
formatOffer: origFormat === s.composeFormat ? null : origFormat,
|
||||||
});
|
});
|
||||||
set((st) => ({ drafts: [...st.drafts, d], activeKey: d.key }));
|
set((st) => ({ drafts: [...st.drafts, d], activeKey: d.key }));
|
||||||
|
|||||||
@@ -147,12 +147,26 @@ export function Composer({ draft }: { draft: Draft }) {
|
|||||||
patch({ sendAt: at.getTime() });
|
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 = () => {
|
const toggleFormat = () => {
|
||||||
// Whichever way the format is changed, the offer has been answered.
|
// 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), formatOffer: null });
|
const at = d.quoteHtml ? d.html.indexOf('<div class="ihm-quote">') : -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 {
|
} else {
|
||||||
patch({ format: "html", html: textToHtml(d.text, { linkify: false, quoteColors: false }).replace(/\n/g, "<br>"), 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, "<br>");
|
||||||
|
patch({ format: "html", html: keeps ? asHtml + d.quoteHtml : asHtml, formatOffer: null });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
window.matchMedia = ((q: string) => ({ matches: false, media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
|
||||||
|
|
||||||
|
const QUOTE_HTML = '<div class="ihm-quote"><br><div>On Friday, Ann wrote:</div><blockquote><p>Look at <b>this</b></p></blockquote></div>';
|
||||||
|
const QUOTE_TEXT = "\n\nOn Friday, Ann wrote:\n> Look at this";
|
||||||
|
|
||||||
const REPLY: Partial<Draft> = {
|
const REPLY: Partial<Draft> = {
|
||||||
key: "d1", replyMode: "reply", subject: "Re: Numbers",
|
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>",
|
format: "text", text: QUOTE_TEXT, html: `<div><br></div>${QUOTE_HTML}`,
|
||||||
|
quoteHtml: QUOTE_HTML, quoteText: QUOTE_TEXT,
|
||||||
formatOffer: "html",
|
formatOffer: "html",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,6 +58,46 @@ describe("the format offer in the composer", () => {
|
|||||||
expect(bar()).toBeNull();
|
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(<Composer draft={draft()} />));
|
||||||
|
expect(draft().html).toContain("<b>this</b>");
|
||||||
|
expect(draft().html).toContain("<blockquote>");
|
||||||
|
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(<Composer draft={draft()} />));
|
||||||
|
act(() => button("Switch to rich text").click());
|
||||||
|
act(() => root.render(<Composer draft={draft()} />));
|
||||||
|
expect(draft().html).toContain("Thanks, that helps.");
|
||||||
|
expect(draft().html).toContain("<b>this</b>");
|
||||||
|
// 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: `<div>Thanks.</div>${QUOTE_HTML}`, formatOffer: "text" });
|
||||||
|
});
|
||||||
|
act(() => root.render(<Composer draft={draft()} />));
|
||||||
|
act(() => button("Switch to plain text").click());
|
||||||
|
act(() => root.render(<Composer draft={draft()} />));
|
||||||
|
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("<blockquote>");
|
||||||
|
});
|
||||||
|
|
||||||
it("dismisses without changing the format", () => {
|
it("dismisses without changing the format", () => {
|
||||||
act(() => button("Dismiss").click());
|
act(() => button("Dismiss").click());
|
||||||
act(() => root.render(<Composer draft={draft()} />));
|
act(() => root.render(<Composer draft={draft()} />));
|
||||||
|
|||||||
Reference in new issue
Block a user