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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"> <{message.from_addr[0].email}></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)}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface MessageEnvelope {
|
||||
has_remote_images: boolean;
|
||||
size_bytes: number;
|
||||
snippet: string;
|
||||
also_in_account_ids: string[];
|
||||
}
|
||||
|
||||
export interface ThreadDetail {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user