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:
2026-10-08 20:20:21 +02:00
co-authored by Claude Opus 5.5
parent 843b48cd59
commit d0caf76b03
6 changed files with 134 additions and 8 deletions
+19 -7
View File
@@ -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">