collapse cross-account dupes, Lato UI font, mail-list selection & density tuning

Backend:
- Collapse identical cross-account deliveries in a thread: new messages.delivery_id
  (migration 0011) preserves the real Message-ID even when message_id_hdr is
  synthesized (§7.1); get_thread groups by it and reports also_in_account_ids; the
  list count counts distinct deliveries. Backfill script for existing rows.

Frontend:
- Bundle Lato (SIL OFL) as the UI font; unread mail-list rows use Black weight.
- Mail-list selection: hover-avatar checkbox, tri-state "select all", bulk
  Archive/Pin/Delete (Restore/Delete in Archive/Trash/Spam) with a count in the
  reading pane; unread blue dot; "also delivered to" note in the thread.
- Spacing/size tuning: sender-column width, icon gaps, avatar size, bar heights,
  and a round of -1px font sizes across the sidebar, mail list, and reading pane.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
2026-07-22 21:49:48 +02:00
co-authored by Claude Opus 4.8
parent fdb6ee002e
commit b4e07f11aa
28 changed files with 757 additions and 1291 deletions
+3 -3
View File
@@ -32,13 +32,13 @@ function DraftRow({
<div className="flex items-center gap-2">
<span className="flex-1 truncate text-sm text-app-fg">{recipients}</span>
{badge && (
<span className="shrink-0 rounded bg-app-hover px-1.5 text-[10px] text-app-muted">
<span className="shrink-0 rounded bg-app-hover px-1.5 text-[12px] text-app-muted">
{badge}
</span>
)}
<span className="shrink-0 text-[11px] text-app-faint">{time}</span>
<span className="shrink-0 text-[13px] text-app-faint">{time}</span>
</div>
<div className="truncate text-[13px] font-medium text-app-fg">{subject}</div>
<div className="truncate text-[15px] font-medium text-app-fg">{subject}</div>
{preview && <div className="truncate text-xs text-app-faint">{preview}</div>}
</button>
);
+68 -23
View File
@@ -60,6 +60,8 @@ export function MessageList({
onTrash,
onDeletePermanent,
onTogglePin,
checkedIds,
onToggleCheck,
}: {
threads: ThreadSummary[];
selectedId?: string;
@@ -70,12 +72,18 @@ export function MessageList({
onTrash: (id: string) => void;
onDeletePermanent: (id: string) => void;
onTogglePin: (id: string, pinned: boolean) => void;
checkedIds: Set<string>;
onToggleCheck: (id: string) => void;
}) {
const t = useT();
const parentRef = useRef<HTMLDivElement>(null);
// In Trash/Spam/Archive the middle action restores; elsewhere it archives.
const isRemovalFolder = folder === "trash" || folder === "junk" || folder === "archive";
// Row selection: hovering an avatar reveals a checkbox; once anything is checked,
// every row shows a checkbox (selection mode) until all are cleared.
const checkMode = checkedIds.size > 0;
const virtualizer = useVirtualizer({
count: threads.length,
getScrollElement: () => parentRef.current,
@@ -84,7 +92,7 @@ export function MessageList({
});
if (threads.length === 0) {
return <div className="p-6 text-sm text-app-faint">{t("mail.noConversations")}</div>;
return <div className="p-6 text-[15px] text-app-faint">{t("mail.noConversations")}</div>;
}
return (
@@ -94,6 +102,7 @@ export function MessageList({
const thread = threads[item.index];
const unread = thread.unread_count > 0;
const selected = selectedId === thread.id;
const checked = checkedIds.has(thread.id);
const primary = thread.participants[0];
const names =
thread.participants.map((p) => p.name || p.email).join(", ") || "(no one)";
@@ -124,35 +133,67 @@ export function MessageList({
>
<div
className={`flex h-full items-center gap-3 rounded-lg px-3 ${
selected ? "bg-app-active" : "group-hover:bg-app-hover"
selected || checked ? "bg-app-active" : "group-hover:bg-app-hover"
}`}
>
{/* Avatar + extra-participants badge */}
<div className="relative shrink-0">
<span
className="flex h-10 w-10 items-center justify-center rounded-full text-xs font-semibold text-white"
style={{ backgroundColor: avatarBg }}
>
{initials(primary)}
</span>
{extra > 0 && (
<span className="absolute -bottom-1 -left-1.5 rounded-full bg-app-hover px-1 text-[9px] font-medium text-app-muted ring-2 ring-app-surface">
+{extra}
</span>
{/* Avatar, replaced by a selection checkbox on avatar-hover or once
selection mode is active. An unread dot sits in the left margin. */}
<div className="group/av relative ml-5 h-[33px] w-[33px] shrink-0">
{unread && (
<span
aria-hidden
className="absolute -left-[21px] top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-app-accent"
/>
)}
<div className={checkMode ? "hidden" : "contents group-hover/av:hidden"}>
<span
className="flex h-[33px] w-[33px] items-center justify-center rounded-full text-[13px] font-semibold text-white"
style={{ backgroundColor: avatarBg }}
>
{initials(primary)}
</span>
{extra > 0 && (
<span className="absolute -bottom-1 -left-1.5 rounded-full bg-app-hover px-1 text-[10px] font-medium text-app-muted ring-2 ring-app-surface">
+{extra}
</span>
)}
</div>
<button
type="button"
aria-label={t("mail.select")}
aria-pressed={checked}
onClick={stop(() => onToggleCheck(thread.id))}
className={`absolute inset-0 items-center justify-center ${
checkMode ? "flex" : "hidden group-hover/av:flex"
}`}
>
<span
className={`flex h-[17px] w-[17px] items-center justify-center rounded-md border-2 transition-colors ${
checked
? "border-app-accent bg-app-accent text-white"
: "border-app-faint hover:border-app-muted"
}`}
>
{checked && (
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M3.5 8.5l3 3 6-7" />
</svg>
)}
</span>
</button>
</div>
{/* Sender column */}
<div className="flex w-40 min-w-0 shrink items-center gap-1.5">
<div className="flex w-[200px] min-w-0 shrink items-center gap-1.5">
<span
className={`truncate text-sm ${
unread ? "font-semibold text-app-fg" : "text-app-fg"
className={`truncate text-[15px] ${
unread ? "font-black text-app-fg" : "text-app-fg"
}`}
>
{names}
</span>
{thread.message_count > 1 && (
<span className="shrink-0 rounded-full bg-app-hover px-1.5 text-[11px] text-app-muted">
<span className="shrink-0 rounded-full bg-app-hover px-1.5 text-[12px] text-app-muted">
{thread.message_count}
</span>
)}
@@ -161,8 +202,8 @@ export function MessageList({
{/* Subject + snippet (extra left margin -> 3x the gap to the sender). */}
<div className="ml-6 flex min-w-0 flex-1 flex-col justify-center">
<div
className={`truncate text-[13px] ${
unread ? "font-semibold text-app-fg" : "text-app-fg"
className={`truncate text-[14px] ${
unread ? "font-black text-app-fg" : "text-app-fg"
}`}
>
{thread.subject}
@@ -175,7 +216,7 @@ export function MessageList({
space while hidden), and the date is an overlay on the right that
is simply hidden on hover to reveal archive + trash — so nothing
ever shifts between the normal and hover states. */}
<div className="relative flex shrink-0 items-center justify-end gap-4">
<div className="relative flex shrink-0 items-center justify-end gap-5">
{/* Attachment: fixed slot (reserved even when empty) so the rail
width — and thus the excerpt width — is the same in every row. */}
<span className="flex w-[17px] shrink-0 justify-center text-app-faint">
@@ -221,13 +262,17 @@ export function MessageList({
onClick={stop(() =>
folder === "trash" ? onDeletePermanent(thread.id) : onTrash(thread.id),
)}
className="invisible text-app-muted hover:text-app-fg group-hover:visible"
className="invisible mr-[7px] text-app-muted hover:text-app-fg group-hover:visible"
>
<Icon name="trash" size={17} />
</button>
{/* Date overlay, aligned to the right edge (over the archive/trash
slots); hidden on hover so the actions show through. */}
<span className="absolute inset-y-0 right-0 flex items-center whitespace-nowrap text-[13px] tabular-nums text-app-faint group-hover:hidden">
<span
className={`absolute inset-y-0 right-0 flex items-center whitespace-nowrap text-[13px] tabular-nums text-app-faint group-hover:hidden ${
unread ? "font-bold" : ""
}`}
>
{timeLabel(thread.last_message_at)}
</span>
</div>
+18 -7
View File
@@ -188,7 +188,7 @@ function ActionsMenu({
onClick={() => setOpen((v) => !v)}
>
{t("mail.actions")}
<span className="text-[9px]">▾</span>
<span className="text-[11px]">▾</span>
</button>
{open && (
<div className="absolute right-0 z-20 mt-1 w-52 overflow-hidden rounded-md border border-app-border bg-app-elevated py-1 shadow-lg">
@@ -215,6 +215,7 @@ function MessageItem({
zIndex,
showReplyBar,
canReply,
alsoIn,
onToggle,
onReply,
onToggleRead,
@@ -231,6 +232,7 @@ function MessageItem({
zIndex: number;
showReplyBar: boolean;
canReply: boolean;
alsoIn: string[];
onToggle: () => void;
onReply: (kind: string) => void;
onToggleRead: () => void;
@@ -286,9 +288,9 @@ function MessageItem({
onClick={onToggle}
>
{avatar()}
<span className="min-w-0 flex-1 truncate text-sm">
<span className="min-w-0 flex-1 truncate text-[15px]">
{senderName}
<span className="ml-2 text-app-faint">{message.snippet}</span>
<span className="ml-2 text-xs text-app-faint">{message.snippet}</span>
</span>
<span className="shrink-0 text-xs text-app-faint">{fmtRelative(message.date)}</span>
</button>
@@ -302,7 +304,7 @@ function MessageItem({
className="flex w-full items-baseline justify-between gap-2 text-left"
onClick={onToggle}
>
<span className="min-w-0 truncate text-sm">
<span className="min-w-0 truncate text-[15px]">
{senderName}
{message.from_addr[0]?.email && (
<span className="text-app-faint"> &lt;{message.from_addr[0].email}&gt;</span>
@@ -320,7 +322,7 @@ function MessageItem({
<span className="truncate">
{t("mail.to")} {recipientSummary(message.to_addr)}
</span>
<span className="text-[9px] text-app-faint">{showDetails ? "▲" : "▼"}</span>
<span className="text-[11px] text-app-faint">{showDetails ? "▲" : "▼"}</span>
</button>
<div className="flex shrink-0 items-center gap-3 text-xs">
{location && <span className="font-semibold text-app-fg">{location}</span>}
@@ -355,6 +357,11 @@ function MessageItem({
/>
</div>
</div>
{alsoIn.length > 0 && (
<div className="mt-0.5 truncate text-xs text-app-faint">
{t("mail.alsoIn")} {alsoIn.join(", ")}
</div>
)}
{showDetails && (
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 border-t border-app-border pt-2 text-xs text-app-muted">
<dt className="text-app-faint">{t("mail.from")}</dt>
@@ -514,8 +521,8 @@ export function ThreadView({
return (
<div data-testid="thread-view" className="flex h-full flex-col overflow-y-auto bg-app-bg">
<div className="sticky top-0 z-10 flex h-[44px] shrink-0 items-center justify-between gap-2 border-b border-app-border bg-app-surface px-4">
<h2 className="truncate text-base font-semibold text-app-fg">{subject || "(no subject)"}</h2>
<div className="sticky top-0 z-10 flex h-[52px] shrink-0 items-center justify-between gap-2 border-b border-app-border bg-app-surface px-4">
<h2 className="truncate text-[17px] font-semibold text-app-fg">{subject || "(no subject)"}</h2>
<div className="flex shrink-0 items-center gap-1">
<button
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
@@ -558,6 +565,9 @@ export function ThreadView({
}`;
// Reply/Reply-all/Forward make no sense on a trashed message.
const canReply = specialUse.get(message.mailbox_id) !== "trash";
const alsoIn = (message.also_in_account_ids ?? [])
.map((id) => accounts?.find((a) => a.id === id)?.email)
.filter((e): e is string => Boolean(e));
return (
<Fragment key={message.id}>
<MessageItem
@@ -570,6 +580,7 @@ export function ThreadView({
zIndex={z}
showReplyBar={isNewest && composing?.anchorId !== message.id}
canReply={canReply}
alsoIn={alsoIn}
onToggle={() => toggle(message.id)}
onReply={(kind) => openReply(message, kind)}
onToggleRead={() => toggleRead(message)}