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 <noreply@anthropic.com>
This commit is contained in:
@@ -1143,6 +1143,8 @@ export function Composer({
|
||||
<RichTextEditor
|
||||
ref={editorRef}
|
||||
initialHtml={initialHtml}
|
||||
// A new reply: type straight away, above the quoted original.
|
||||
focusOnStart={Boolean(init.quote) && !init.html && init.kind !== "forward"}
|
||||
onInput={(html) => {
|
||||
markDirty();
|
||||
setBodyHtml(html);
|
||||
|
||||
@@ -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
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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<string>,
|
||||
): 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<string>;
|
||||
}) {
|
||||
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)"}
|
||||
</span>
|
||||
);
|
||||
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 (
|
||||
<div
|
||||
@@ -631,6 +640,8 @@ export function ThreadView({
|
||||
for (const a of accounts ?? []) m.set(a.email.toLowerCase(), a.color);
|
||||
return m;
|
||||
}, [accounts]);
|
||||
// Your own addresses (every account's): replies never go back to them.
|
||||
const ownAddresses = useMemo(() => 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 && (
|
||||
<div data-testid="composer-anchor">
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
@@ -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<string>,
|
||||
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) };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user