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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user