diff --git a/frontend/src/components/compose/Composer.tsx b/frontend/src/components/compose/Composer.tsx index 60c1f34..cf00616 100644 --- a/frontend/src/components/compose/Composer.tsx +++ b/frontend/src/components/compose/Composer.tsx @@ -1143,6 +1143,8 @@ export function Composer({ { markDirty(); setBodyHtml(html); diff --git a/frontend/src/components/compose/RichTextEditor.tsx b/frontend/src/components/compose/RichTextEditor.tsx index 3c51945..e8809d7 100644 --- a/frontend/src/components/compose/RichTextEditor.tsx +++ b/frontend/src/components/compose/RichTextEditor.tsx @@ -193,9 +193,19 @@ export const RichTextEditor = forwardRef< /** This message's spell-check language from the compose window's More menu: * "off", "auto" or a language code. Unset follows Settings. */ spellLanguage?: string | null; + /** Start with the caret on the first line (a reply: above the quoted original). */ + focusOnStart?: boolean; } >(function RichTextEditor( - { initialHtml, onInput, onReady, showToolbar = true, minHeight = "min-h-[250px]", spellLanguage: spellOverride }, + { + initialHtml, + onInput, + onReady, + showToolbar = true, + minHeight = "min-h-[250px]", + spellLanguage: spellOverride, + focusOnStart = false, + }, handleRef, ) { const t = useT(); @@ -247,6 +257,16 @@ export const RichTextEditor = forwardRef< el.innerHTML = initialHtml; addQuoteToggle(el, t("compose.showQuoted")); onReady?.(el); + if (focusOnStart) { + // The scroll is the composer's own business (it brings itself into view). + el.focus({ preventScroll: true }); + const range = document.createRange(); + range.setStart(el.firstElementChild ?? el, 0); + range.collapse(true); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/frontend/src/components/thread/ThreadView.tsx b/frontend/src/components/thread/ThreadView.tsx index 9138cce..a67fb4a 100644 --- a/frontend/src/components/thread/ThreadView.tsx +++ b/frontend/src/components/thread/ThreadView.tsx @@ -5,6 +5,7 @@ import { useT } from "../../i18n"; import { personHue } from "../../lib/avatarColor"; import { api } from "../../lib/api"; import { printMessages } from "../../lib/print"; +import { replyTargets } from "../../lib/replyTargets"; import { useIsMobile } from "../../lib/useIsMobile"; import { getDateLocale, getHour12 } from "../../lib/timeFormat"; import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types"; @@ -60,9 +61,12 @@ function attributionFor(msg: MessageEnvelope): string { return `On ${date}, at ${time}, ${who} wrote:`; } -function buildReplyInit(subject: string, msg: MessageEnvelope, kind: string): ComposerInit { - const from = msg.from_addr[0]?.email ?? ""; - const toEmails = msg.to_addr.map((a) => a.email); +function buildReplyInit( + subject: string, + msg: MessageEnvelope, + kind: string, + own: Set, +): ComposerInit { const norm = subject.replace(/^\s*(re|fwd?):\s*/i, ""); const quote = { attribution: attributionFor(msg), messageId: msg.id }; if (kind === "forward") { @@ -76,8 +80,9 @@ function buildReplyInit(subject: string, msg: MessageEnvelope, kind: string): Co forwardFrom: msg.id, }; } - const to = kind === "reply_all" ? [from, ...toEmails].filter(Boolean).join(", ") : from; - const cc = kind === "reply_all" ? msg.cc_addr.map((a) => a.email).join(", ") : ""; + const targets = replyTargets(msg, own, kind); + const to = targets.to.join(", "); + const cc = targets.cc.join(", "); return { accountId: msg.account_id, threadId: msg.thread_id, @@ -315,6 +320,7 @@ function MessageItem({ messageLabels, onToggleLabel, onCreateLabel, + ownAddresses, }: { message: MessageEnvelope; expanded: boolean; @@ -337,6 +343,7 @@ function MessageItem({ messageLabels: string[]; onToggleLabel: (label: string) => void; onCreateLabel: (name: string) => void; + ownAddresses: Set; }) { const t = useT(); const [showDetails, setShowDetails] = useState(false); @@ -367,7 +374,9 @@ function MessageItem({ {message.from_addr[0]?.name || message.from_addr[0]?.email || "(unknown)"} ); - const multiRecipient = message.to_addr.length + message.cc_addr.length > 1; + // Reply all only when it would reach more than one person (not counting you). + const allTargets = replyTargets(message, ownAddresses, "reply_all"); + const multiRecipient = allTargets.to.length + allTargets.cc.length > 1; return (
new Set(accountColor.keys()), [accountColor]); const accentFor = (msg: MessageEnvelope) => accountColor.get((msg.from_addr[0]?.email ?? "").toLowerCase()) ?? null; @@ -646,7 +657,7 @@ export function ThreadView({ const openReply = (msg: MessageEnvelope, kind: string) => { if (specialUse.get(msg.mailbox_id) === "trash") return; // no reply/forward from Trash setExpanded((prev) => new Set(prev).add(msg.id)); - setComposing({ anchorId: msg.id, init: buildReplyInit(subject, msg, kind) }); + setComposing({ anchorId: msg.id, init: buildReplyInit(subject, msg, kind, ownAddresses) }); }; // A mailto: link in the message body opens a fresh composer to that address, @@ -785,6 +796,7 @@ export function ThreadView({ messageLabels={msgLabels[message.id] ?? []} onToggleLabel={(l) => toggleLabel(message.id, l)} onCreateLabel={(name) => void createLabel(name, message.id)} + ownAddresses={ownAddresses} /> {isNewest && (
diff --git a/frontend/src/lib/replyTargets.test.ts b/frontend/src/lib/replyTargets.test.ts new file mode 100644 index 0000000..0a566c6 --- /dev/null +++ b/frontend/src/lib/replyTargets.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import type { MessageEnvelope } from "./types"; +import { replyTargets } from "./replyTargets"; + +const me = new Set(["me@work.com", "me@home.org"]); +const addr = (email: string) => ({ name: "", email }); +const msg = (from: string, to: string[], cc: string[] = []) => + ({ from_addr: [addr(from)], to_addr: to.map(addr), cc_addr: cc.map(addr) }) as unknown as MessageEnvelope; + +describe("replyTargets", () => { + it("answers the sender of someone else's message", () => { + expect(replyTargets(msg("ann@x.com", ["me@work.com"]), me, "reply")).toEqual({ to: ["ann@x.com"], cc: [] }); + }); + + it("answers the people your own message went to, not yourself", () => { + expect(replyTargets(msg("me@work.com", ["ann@x.com"], ["bob@x.com"]), me, "reply")).toEqual({ + to: ["ann@x.com"], + cc: [], + }); + expect(replyTargets(msg("ME@work.com", ["ann@x.com"], ["bob@x.com"]), me, "reply_all")).toEqual({ + to: ["ann@x.com"], + cc: ["bob@x.com"], + }); + }); + + it("leaves every one of your addresses out of reply all", () => { + const m = msg("ann@x.com", ["me@work.com", "bob@x.com"], ["me@home.org", "cy@x.com", "Bob@x.com"]); + expect(replyTargets(m, me, "reply_all")).toEqual({ to: ["ann@x.com", "bob@x.com"], cc: ["cy@x.com"] }); + }); + + it("still answers yourself for a note sent only to yourself", () => { + expect(replyTargets(msg("me@work.com", ["me@work.com"]), me, "reply")).toEqual({ to: ["me@work.com"], cc: [] }); + }); + + it("uses Cc when your message had only Cc recipients", () => { + expect(replyTargets(msg("me@work.com", [], ["bob@x.com"]), me, "reply_all")).toEqual({ to: ["bob@x.com"], cc: [] }); + }); +}); diff --git a/frontend/src/lib/replyTargets.ts b/frontend/src/lib/replyTargets.ts new file mode 100644 index 0000000..79b1339 --- /dev/null +++ b/frontend/src/lib/replyTargets.ts @@ -0,0 +1,40 @@ +import type { MessageEnvelope } from "./types"; + +/** + * Who a reply goes to. To a message from someone else: its sender (reply), and + * everyone on it (reply all). To one of your own messages: the people it was + * sent to — not back to yourself. You are never among the recipients of a reply + * all, under any of your accounts' addresses; a note sent only to yourself + * still answers yourself. + */ +export function replyTargets( + msg: MessageEnvelope, + own: Set, + kind: string, +): { to: string[]; cc: string[] } { + const key = (e: string) => e.trim().toLowerCase(); + const unique = (list: string[], skip: string[] = []) => { + const seen = new Set(skip.map(key)); + return list.filter((e) => { + const k = key(e); + if (!k || seen.has(k)) return false; + seen.add(k); + return true; + }); + }; + const notMe = (e: string) => !own.has(key(e)); + const from = msg.from_addr[0]?.email ?? ""; + const toEmails = msg.to_addr.map((a) => a.email); + const ccEmails = msg.cc_addr.map((a) => a.email); + const mine = own.has(key(from)); + if (mine) { + let to = unique(toEmails.filter(notMe)); + let cc = kind === "reply_all" ? unique(ccEmails.filter(notMe), to) : []; + if (to.length === 0 && cc.length > 0) [to, cc] = [cc, []]; + if (to.length === 0) to = unique(toEmails.length ? toEmails : [from]); // a note to yourself + return { to, cc }; + } + if (kind !== "reply_all") return { to: unique([from]), cc: [] }; + const to = unique([from, ...toEmails.filter(notMe)]); + return { to, cc: unique(ccEmails.filter(notMe), to) }; +} diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index f149f27..b93245b 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -1 +1,14 @@ import "@testing-library/jest-dom/vitest"; + +// jsdom has no Web Workers; the PDF preview (pdf.js) sets one up when its module +// loads. A do-nothing stand-in lets components that import it render in tests. +if (typeof globalThis.Worker === "undefined") { + class TestWorker { + onmessage: ((e: MessageEvent) => void) | null = null; + postMessage(): void {} + terminate(): void {} + addEventListener(): void {} + removeEventListener(): void {} + } + (globalThis as unknown as { Worker: unknown }).Worker = TestWorker; +}