Mail list: name everyone who wrote in the thread, and call the user "me"

The sender column showed participants[0], and participants are rebuilt
newest message first — so replying to a thread put the user in that slot
and the list named them instead of the person who wrote in.

Thread summaries (list and search) now carry `senders`: the distinct From
addresses of the thread's visible messages, oldest first, with the same
Trash/Spam visibility as the message count. The list joins them and shows
the user's own account addresses as "me" ("me, Alice" for a thread the
user started), and the avatar prefers the other side of the conversation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 20:17:37 +02:00
co-authored by Claude Opus 5.5
parent 901156526c
commit 08d428de8a
7 changed files with 125 additions and 30 deletions
+14 -5
View File
@@ -102,6 +102,11 @@ export function MessageList({
});
const labelMap = labelsQuery.data ?? {};
// The user's own addresses, shown as "me" in the sender column.
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
const myEmails = new Set((accounts.data ?? []).map((a) => a.email.toLowerCase()));
const isMe = (a: Address) => myEmails.has(a.email.toLowerCase());
const virtualizer = useVirtualizer({
count: threads.length,
getScrollElement: () => parentRef.current,
@@ -128,11 +133,15 @@ export function MessageList({
const selected = selectedId === thread.id;
const checked = checkedIds.has(thread.id);
const dimmed = dimmedIds?.has(thread.id) ?? false;
// `participants` is from + to + cc, newest message first — so the first
// entry is the sender and the rest are recipients (usually just you).
// The list shows the sender only.
const primary = thread.participants[0];
const names = primary ? primary.name || primary.email : "(no one)";
// The sender column lists everyone who wrote in the thread, oldest first,
// with the user's own accounts shown as "me" — so a thread you started
// and someone answered reads "me, Alice". Recipients are not listed.
const senders = thread.senders.length ? thread.senders : thread.participants.slice(0, 1);
const names = senders.length
? senders.map((a) => (isMe(a) ? t("mail.me") : a.name || a.email)).join(", ")
: "(no one)";
// The avatar is the other side of the conversation where there is one.
const primary = senders.find((a) => !isMe(a)) ?? senders[0];
// The badge still counts everyone involved — the name is narrowed, the
// "how many people are in this" signal is not.
const extra = Math.max(0, thread.participants.length - 1);
+1
View File
@@ -67,6 +67,7 @@ export const en = {
"nav.refresh": "Refresh all folders",
"mail.noConversations": "No conversations.",
"mail.me": "me",
"mail.selectConversation": "Select a conversation.",
"mail.loadingThread": "Loading thread…",
"mail.expandAll": "Expand all",
+2
View File
@@ -32,6 +32,8 @@ export interface ThreadSummary {
subject: string;
snippet: string;
participants: Address[];
/** Distinct From addresses of the thread's visible messages, oldest first. */
senders: Address[];
account_ids: string[];
message_count: number;
unread_count: number;