Seven app fixes from public ihasmail #25
No files matched your search
@@ -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] },
|
||||||
|
|||||||
@@ -1406,6 +1406,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",
|
||||||
|
|||||||
@@ -1379,6 +1379,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",
|
||||||
|
|||||||
@@ -1384,6 +1384,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",
|
||||||
|
|||||||
@@ -1387,6 +1387,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} を使用",
|
||||||
|
|||||||
@@ -1379,6 +1379,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",
|
||||||
|
|||||||
@@ -1382,6 +1382,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",
|
||||||
|
|||||||
@@ -1381,6 +1381,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}",
|
||||||
|
|||||||
@@ -1375,6 +1375,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}",
|
||||||
|
|||||||
@@ -1386,6 +1386,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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
@@ -76,6 +76,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;
|
||||||
@@ -146,6 +152,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,
|
||||||
};
|
};
|
||||||
@@ -405,6 +412,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> = {};
|
||||||
@@ -460,6 +474,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;
|
||||||
|
|||||||
@@ -1584,6 +1584,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; }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user