From d0caf76b03e5865e25535490224269765bea398c Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Thu, 8 Oct 2026 20:20:21 +0200 Subject: [PATCH] Replies: cursor on the first line, own messages answered to their recipients A new reply opens with the caret on the empty first line above the quoted original (on a phone the keyboard comes up with it). Replying to one of your own messages goes to the people it was sent to (reply all keeps its Cc), not back to you; your own addresses, under every account, are never among reply-all recipients; a note sent only to yourself still answers yourself. The per-message Reply / Reply all button counts recipients the same way (lib/replyTargets.ts, with tests). The ThreadView test suite runs again: jsdom gets a stand-in Worker (pdf.js sets one up at import). Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/compose/Composer.tsx | 2 + .../src/components/compose/RichTextEditor.tsx | 22 +++++++++- frontend/src/components/thread/ThreadView.tsx | 26 ++++++++---- frontend/src/lib/replyTargets.test.ts | 39 ++++++++++++++++++ frontend/src/lib/replyTargets.ts | 40 +++++++++++++++++++ frontend/src/test/setup.ts | 13 ++++++ 6 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 frontend/src/lib/replyTargets.test.ts create mode 100644 frontend/src/lib/replyTargets.ts 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; +}