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
+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)}