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)}
+5
View File
@@ -79,6 +79,11 @@ export const en = {
"mail.unpin": "Unpin",
"mail.delete": "Delete",
"mail.restore": "Restore",
"mail.select": "Select conversation",
"mail.selectAll": "Select all",
"mail.conversationSelected": "1 conversation selected",
"mail.conversationsSelected": "{count} conversations selected",
"mail.alsoIn": "Also delivered to",
"mail.emptyTrash": "Empty trash",
"mail.emptySpam": "Empty spam",
"mail.emptyConfirm": "Permanently delete everything in this folder? This can't be undone.",
+74 -1
View File
@@ -2,6 +2,79 @@
@tailwind components;
@tailwind utilities;
/* Lato (SIL OFL) — the UI font. Browsers only fetch the weights/styles actually
rendered, so declaring the full family is cheap. */
@font-face {
font-family: "Lato";
src: url("../fonts/Lato-Thin.ttf") format("truetype");
font-weight: 100;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Lato";
src: url("../fonts/Lato-ThinItalic.ttf") format("truetype");
font-weight: 100;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "Lato";
src: url("../fonts/Lato-Light.ttf") format("truetype");
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Lato";
src: url("../fonts/Lato-LightItalic.ttf") format("truetype");
font-weight: 300;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "Lato";
src: url("../fonts/Lato-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Lato";
src: url("../fonts/Lato-Italic.ttf") format("truetype");
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "Lato";
src: url("../fonts/Lato-Bold.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Lato";
src: url("../fonts/Lato-BoldItalic.ttf") format("truetype");
font-weight: 700;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "Lato";
src: url("../fonts/Lato-Black.ttf") format("truetype");
font-weight: 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Lato";
src: url("../fonts/Lato-BlackItalic.ttf") format("truetype");
font-weight: 900;
font-style: italic;
font-display: swap;
}
/* Semantic theme tokens as "R G B" triplets (consumed by tailwind.config.js as
rgb(var(--x) / <alpha>)). Light is the default; .dark on <html> flips them. */
:root {
@@ -42,7 +115,7 @@ body,
}
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-family: "Lato", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background-color: rgb(var(--app-bg));
color: rgb(var(--app-fg));
-webkit-font-smoothing: antialiased;
+1
View File
@@ -57,6 +57,7 @@ export interface MessageEnvelope {
has_remote_images: boolean;
size_bytes: number;
snippet: string;
also_in_account_ids: string[];
}
export interface ThreadDetail {
+121 -14
View File
@@ -54,6 +54,16 @@ export function MailLayout() {
);
const [page, setPage] = useState(0);
const [refreshing, setRefreshing] = useState(false);
// Mail-list selection (checkboxes). Lives here so the reading pane can show the
// bulk-action bar / count while any conversation is selected.
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
const toggleCheck = (id: string) =>
setCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const [composing, setComposing] = useState<ComposerInit | null>(null);
const [composerExpanded, setComposerExpanded] = useState(false);
// Bumped each time a composer is opened so the <Composer> remounts (fresh state)
@@ -124,6 +134,13 @@ export function MailLayout() {
// Reset to the first page whenever the underlying list identity changes.
useEffect(() => setPage(0), [view, filter, term, listOrder]);
// Master "select all" state over the currently listed threads.
const listedSelectedCount = pageThreads.filter((th) => checkedIds.has(th.id)).length;
const allSelected = pageThreads.length > 0 && listedSelectedCount === pageThreads.length;
const someSelected = listedSelectedCount > 0 && !allSelected;
const toggleSelectAll = () =>
setCheckedIds(allSelected ? new Set() : new Set(pageThreads.map((th) => th.id)));
const threadsRef = useRef<ThreadSummary[]>(threads);
threadsRef.current = threads;
@@ -269,6 +286,32 @@ export function MailLayout() {
queryClient.invalidateQueries({ queryKey: ["thread", id] });
};
// Selection resets when you leave the folder.
useEffect(() => setCheckedIds(new Set()), [view]);
// Bulk actions run the given operation over every selected thread, then clear the
// selection and refresh (navigating away if the open thread was among them).
const runBulk = async (action: (id: string) => Promise<unknown>) => {
const ids = [...checkedIds];
for (const id of ids) {
try {
await action(id);
} catch {
/* keep going */
}
}
setCheckedIds(new Set());
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["quota"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
if (threadId && ids.includes(threadId)) navigate("/mail");
};
const bulkArchive = () => runBulk((id) => api.archiveThread(id));
const bulkPin = () => runBulk((id) => api.threadFlags(id, ["\\Flagged"], []));
const bulkTrash = () => runBulk((id) => api.trashThread(id));
const bulkRestore = () => runBulk((id) => api.restoreThread(id));
const bulkDeletePermanent = () => runBulk((id) => api.deleteThreadPermanent(id));
// "Empty" a Trash/Spam/Archive folder: permanently delete everything in it. The
// work runs in a worker; refresh the views after it and via the WS message.deleted.
const [emptying, setEmptying] = useState(false);
@@ -327,7 +370,7 @@ export function MailLayout() {
}`}
>
{!collapsed && (
<span className="text-xs font-semibold uppercase tracking-wide text-app-faint">
<span className="text-[13px] font-semibold uppercase tracking-wide text-app-faint">
{t("common.appName")}
</span>
)}
@@ -345,7 +388,7 @@ export function MailLayout() {
</div>
<div className={`pb-2 ${collapsed ? "px-2" : "px-3"}`}>
<button
className="flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-sm font-medium text-app-accent-fg shadow-sm hover:opacity-90"
className="flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-[15px] font-medium text-app-accent-fg shadow-sm hover:opacity-90"
title={collapsed ? t("nav.compose") : undefined}
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
>
@@ -353,7 +396,7 @@ export function MailLayout() {
{!collapsed && t("nav.compose")}
</button>
</div>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-1 text-sm">
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-1 text-[15px]">
{FOLDERS.map((f) => {
const badge =
f.view === "folder:inbox"
@@ -375,7 +418,7 @@ export function MailLayout() {
<Icon name={f.icon} size={f.icon === "trash" ? 20 : 18} />
{!collapsed && <span className="flex-1 truncate">{t(f.label)}</span>}
{!collapsed && badge ? (
<span className="ml-auto shrink-0 tabular-nums text-xs text-app-muted">{badge}</span>
<span className="ml-auto shrink-0 tabular-nums text-[13px] text-app-muted">{badge}</span>
) : null}
</SidebarItem>
);
@@ -383,7 +426,7 @@ export function MailLayout() {
{/* Per-account views are hidden entirely when collapsed. */}
{!collapsed && (
<>
<div className="px-3 pb-1 pt-3 text-[11px] font-semibold uppercase tracking-wide text-app-faint">
<div className="px-3 pb-1 pt-3 text-[12px] font-semibold uppercase tracking-wide text-app-faint">
{t("settings.accounts")}
</div>
{accounts.map((a) => (
@@ -399,7 +442,7 @@ export function MailLayout() {
</>
)}
</nav>
<div className="border-t border-app-border p-2 text-sm">
<div className="border-t border-app-border p-2 text-[15px]">
<Link
className={`flex items-center gap-2.5 rounded-lg py-2 text-app-muted hover:bg-app-hover hover:text-app-fg ${
collapsed ? "justify-center px-0" : "px-3"
@@ -426,7 +469,7 @@ export function MailLayout() {
</button>
{!collapsed && quotaQuery.data && (
<div
className="mt-1 border-t border-app-border px-3 pb-1 pt-2 text-xs text-app-faint"
className="mt-1 border-t border-app-border px-3 pb-1 pt-2 text-[13px] text-app-faint"
title={t("nav.storageQuota")}
>
{quotaLabel(quotaQuery.data)}
@@ -446,7 +489,7 @@ export function MailLayout() {
<div className="relative w-full">
<input
ref={searchRef}
className="w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 pr-8 text-sm text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
className="w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 pr-8 text-[15px] text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
placeholder={t("nav.search")}
value={term}
onChange={(e) => setTerm(e.target.value)}
@@ -470,7 +513,28 @@ export function MailLayout() {
</div>
</div>
{!isDrafts && (
<div className="flex h-[44px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg px-6 text-sm">
<div className="flex h-[52px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg px-6 text-[15px]">
<button
type="button"
aria-label={t("mail.selectAll")}
title={t("mail.selectAll")}
onClick={toggleSelectAll}
className={`ml-[28px] flex h-[17px] w-[17px] shrink-0 items-center justify-center rounded-md border-2 transition-colors ${
allSelected || someSelected
? "border-app-accent bg-app-accent text-white"
: "border-app-faint hover:border-app-muted"
}`}
>
{allSelected ? (
<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>
) : someSelected ? (
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round">
<path d="M4 8h8" />
</svg>
) : null}
</button>
<FilterTab active={filter === "all"} onClick={() => setFilter("all")}>
{t("filter.all")}
</FilterTab>
@@ -483,7 +547,7 @@ export function MailLayout() {
<div className="-mr-1.5 ml-auto flex items-center gap-1">
{(currentFolder === "trash" || currentFolder === "junk") && (
<button
className="rounded px-2 py-1 text-xs font-medium text-red-500 hover:bg-app-hover disabled:opacity-50"
className="rounded px-2 py-1 text-[13px] font-medium text-red-500 hover:bg-app-hover disabled:opacity-50"
onClick={emptyCurrentFolder}
disabled={emptying}
>
@@ -526,10 +590,12 @@ export function MailLayout() {
onTrash={listTrash}
onDeletePermanent={listDeletePermanent}
onTogglePin={listTogglePin}
checkedIds={checkedIds}
onToggleCheck={toggleCheck}
/>
)}
</div>
<div className="flex items-center justify-between border-t border-app-border px-6 py-1.5 text-xs text-app-muted">
<div className="flex items-center justify-between border-t border-app-border px-6 py-1.5 text-[13px] text-app-muted">
<button
className="-ml-1 rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg disabled:opacity-60"
title={t("nav.refresh")}
@@ -569,9 +635,50 @@ export function MailLayout() {
{/* Thread pane */}
<main className="relative min-w-0 flex-1 bg-app-surface">
{threadId && detailQuery.data ? (
{checkedIds.size > 0 ? (
<div className="flex h-full flex-col">
<div className="flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-sm">
<div className="flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-[15px]">
{currentFolder === "archive" ||
currentFolder === "trash" ||
currentFolder === "junk" ? (
<>
<ToolbarButton onClick={bulkRestore}>
<Icon name="restore" size={16} />
{t("mail.restore")}
</ToolbarButton>
<ToolbarButton
onClick={currentFolder === "trash" ? bulkDeletePermanent : bulkTrash}
>
<Icon name="trash" size={16} />
{t("mail.delete")}
</ToolbarButton>
</>
) : (
<>
<ToolbarButton onClick={bulkArchive}>
<Icon name="archive" size={16} />
{t("mail.archive")}
</ToolbarButton>
<ToolbarButton onClick={bulkPin}>
<Icon name="pin" size={16} />
{t("mail.pin")}
</ToolbarButton>
<ToolbarButton onClick={bulkTrash}>
<Icon name="trash" size={16} />
{t("mail.delete")}
</ToolbarButton>
</>
)}
</div>
<div className="flex flex-1 items-center justify-center text-3xl text-app-faint">
{checkedIds.size === 1
? t("mail.conversationSelected")
: t("mail.conversationsSelected", { count: checkedIds.size })}
</div>
</div>
) : threadId && detailQuery.data ? (
<div className="flex h-full flex-col">
<div className="flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-[15px]">
{currentFolder === "archive" ? (
<>
<ToolbarButton onClick={restore}>
@@ -614,7 +721,7 @@ export function MailLayout() {
</ToolbarButton>
</>
)}
<span className="ml-auto text-xs text-app-faint">
<span className="ml-auto text-[13px] text-app-faint">
{t("mail.messageCount", { count: detailQuery.data.messages.length })}
</span>
</div>
+1
View File
@@ -26,6 +26,7 @@ function msg(id: string, minutes: number, unread = false): MessageEnvelope {
has_remote_images: false,
size_bytes: 1024,
snippet: `snippet ${id}`,
also_in_account_ids: [],
};
}