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