mail list redesign, folder actions, empty-folder, unread badges & empty-state art

Frontend:
- Redesign the mail list rows (avatar + sender + subject/snippet + right rail),
  even-spaced attachment/pin/archive/trash icons, hover-reveal actions, pink
  always-on pin, monocolor paperclip, row gaps, wider sender-subject gap.
- Sidebar folder badges: Inbox unread + Drafts counts (live via WS + mutations).
- Trash/Spam/Archive: hover Restore icon (replaces Archive), permanent delete in
  Trash; "Empty trash" / "Empty spam" buttons on the sort bar.
- Reading pane empty state: email illustration above an enlarged
  "Select a conversation".
- Search field clear (X) button; mark-read after a 4s dwell instead of instantly.

Backend:
- GET /api/mailboxes/counts (inbox unread + drafts) for the sidebar badges.
- POST /api/folders/{special}/empty + empty_folder_job to permanently clear a
  Trash/Spam/Archive folder (server EXPUNGE + DB rows + thread cleanup).

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 07:40:29 +02:00
co-authored by Claude Opus 4.8
parent d650f16feb
commit fdb6ee002e
22 changed files with 1758 additions and 68 deletions
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 151 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="64" height="64" viewBox="0 0 48 48" style="color: rgb(74, 85, 101); opacity: 1; transform: rotate(0deg);"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="4"><path d="m13 8l-7 6l7 7"></path><path d="M6 14h22.994c6.883 0 12.728 5.62 12.996 12.5c.284 7.27-5.723 13.5-12.996 13.5H11.998"></path></g></svg>

After

Width:  |  Height:  |  Size: 471 B

@@ -0,0 +1,6 @@
// Email illustration for the empty reading pane.
import illustration from "../../icons/email-illustration.svg";
export function EmptyIllustration({ className = "" }: { className?: string }) {
return <img src={illustration} alt="" aria-hidden="true" className={className} />;
}
+2
View File
@@ -15,6 +15,7 @@ import pin from "../../icons/pin.svg?raw";
import print from "../../icons/print.svg?raw";
import replyAll from "../../icons/reply-all-line.svg?raw";
import reply from "../../icons/reply.svg?raw";
import restore from "../../icons/restore.svg?raw";
import sent from "../../icons/sent-02.svg?raw";
import settings from "../../icons/settings.svg?raw";
import show from "../../icons/show.svg?raw";
@@ -35,6 +36,7 @@ const RAW: Record<string, string> = {
print,
reply,
"reply-all": replyAll,
restore,
sent,
settings,
show,
+175 -38
View File
@@ -2,7 +2,7 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react";
import { useT } from "../../i18n";
import type { AccountOut, ThreadSummary } from "../../lib/types";
import type { Address, ThreadSummary } from "../../lib/types";
import { Icon } from "../Icon";
function timeLabel(iso: string): string {
@@ -15,26 +15,71 @@ function timeLabel(iso: string): string {
return d.toLocaleDateString([], { year: "2-digit", month: "short", day: "numeric" });
}
// Stable per-sender hue so a sender's avatar keeps a consistent colour.
function hue(key: string): number {
let h = 0;
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 360;
return h;
}
function initials(p: Address | undefined): string {
const name = (p?.name || p?.email || "?").trim();
const words = name.split(/\s+/).filter(Boolean);
if (words.length >= 2) return (words[0][0] + words[words.length - 1][0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
}
// Monocolor paperclip (inherits currentColor) — replaces the old emoji.
function Paperclip() {
return (
<svg
width="17"
height="17"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className="shrink-0"
>
<path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" />
</svg>
);
}
// Virtualized thread list (SPEC §2 list/MessageList.tsx).
export function MessageList({
threads,
accounts,
selectedId,
folder,
onSelect,
onArchive,
onRestore,
onTrash,
onDeletePermanent,
onTogglePin,
}: {
threads: ThreadSummary[];
accounts: AccountOut[];
selectedId?: string;
folder: string;
onSelect: (id: string) => void;
onArchive: (id: string) => void;
onRestore: (id: string) => void;
onTrash: (id: string) => void;
onDeletePermanent: (id: string) => void;
onTogglePin: (id: string, pinned: boolean) => void;
}) {
const t = useT();
const parentRef = useRef<HTMLDivElement>(null);
const colorFor = (id: string) => accounts.find((a) => a.id === id)?.color ?? "#888888";
// In Trash/Spam/Archive the middle action restores; elsewhere it archives.
const isRemovalFolder = folder === "trash" || folder === "junk" || folder === "archive";
const virtualizer = useVirtualizer({
count: threads.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 82,
estimateSize: () => 75, // 68 row + 7px gap
overscan: 8,
});
@@ -49,10 +94,24 @@ export function MessageList({
const thread = threads[item.index];
const unread = thread.unread_count > 0;
const selected = selectedId === thread.id;
const primary = thread.participants[0];
const names =
thread.participants.map((p) => p.name || p.email).join(", ") || "(no one)";
const extra = Math.max(0, thread.participants.length - 1);
const avatarBg = `hsl(${hue(primary?.email || primary?.name || "?")} 45% 45%)`;
const stop = (fn: () => void) => (e: React.MouseEvent) => {
e.stopPropagation();
fn();
};
return (
<button
<div
key={thread.id}
role="button"
tabIndex={0}
onClick={() => onSelect(thread.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onSelect(thread.id);
}}
style={{
position: "absolute",
top: 0,
@@ -61,41 +120,119 @@ export function MessageList({
height: item.size,
transform: `translateY(${item.start}px)`,
}}
className={`flex flex-col gap-1 border-b border-app-border px-6 py-2.5 text-left ${
selected
? "bg-app-active"
: "hover:bg-app-hover"
}`}
className="group absolute inset-x-0 cursor-pointer px-2 pt-[7px]"
>
<div className="flex items-center gap-2">
<span className="flex shrink-0 gap-0.5">
{thread.account_ids.map((id) => (
<span
key={id}
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: colorFor(id) }}
/>
))}
</span>
<span className={`flex-1 truncate text-sm ${unread ? "font-semibold text-app-fg" : "text-app-fg"}`}>
{thread.participants.map((p) => p.name || p.email).join(", ") || "(no one)"}
</span>
{thread.message_count > 1 && (
<span className="shrink-0 rounded-full bg-app-hover px-1.5 text-[11px] text-app-muted">
{thread.message_count}
<div
className={`flex h-full items-center gap-3 rounded-lg px-3 ${
selected ? "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>
)}
{thread.pinned && (
<Icon name="pin" size={13} className="text-[rgb(220_129_143)]" />
)}
<span className="shrink-0 text-[11px] text-app-faint">{timeLabel(thread.last_message_at)}</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>
)}
</div>
{/* Sender column */}
<div className="flex w-40 min-w-0 shrink items-center gap-1.5">
<span
className={`truncate text-sm ${
unread ? "font-semibold 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">
{thread.message_count}
</span>
)}
</div>
{/* 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"
}`}
>
{thread.subject}
</div>
<div className="truncate text-xs text-app-faint">{thread.snippet}</div>
</div>
{/* Right rail: four evenly-spaced icons, vertically centred. The
paperclip and pin keep fixed slots (archive/trash reserve their
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">
{/* 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">
{thread.has_attachments && <Paperclip />}
</span>
{/* Pin: pink and always shown when pinned; otherwise revealed on hover. */}
<button
type="button"
title={thread.pinned ? t("mail.unpin") : t("mail.pin")}
onClick={stop(() => onTogglePin(thread.id, thread.pinned))}
className={
thread.pinned
? "text-[rgb(220_129_143)]"
: "invisible text-app-muted hover:text-app-fg group-hover:visible"
}
>
<Icon name="pin" size={17} />
</button>
{/* Archive (or Restore in Trash/Spam/Archive) + delete: hover-only,
but reserve their slots always. In Trash, delete is permanent. */}
{isRemovalFolder ? (
<button
type="button"
title={t("mail.restore")}
onClick={stop(() => onRestore(thread.id))}
className="invisible text-app-muted hover:text-app-fg group-hover:visible"
>
<Icon name="restore" size={17} />
</button>
) : (
<button
type="button"
title={t("mail.archive")}
onClick={stop(() => onArchive(thread.id))}
className="invisible text-app-muted hover:text-app-fg group-hover:visible"
>
<Icon name="archive" size={15} />
</button>
)}
<button
type="button"
title={t("mail.delete")}
onClick={stop(() =>
folder === "trash" ? onDeletePermanent(thread.id) : onTrash(thread.id),
)}
className="invisible 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">
{timeLabel(thread.last_message_at)}
</span>
</div>
</div>
<div className={`truncate text-[13px] ${unread ? "font-medium text-app-fg" : "text-app-muted"}`}>
{thread.subject}
{thread.has_attachments && <span className="ml-1 text-app-faint">📎</span>}
</div>
<div className="truncate text-xs text-app-faint">{thread.snippet}</div>
</button>
</div>
);
})}
</div>
@@ -423,6 +423,8 @@ export function ThreadView({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["thread"] });
queryClient.invalidateQueries({ queryKey: ["threads"] });
// Mark read/unread changes the Inbox unread badge.
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
},
});
const move = useMutation({
@@ -431,6 +433,7 @@ export function ThreadView({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["thread"] });
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
},
});
@@ -518,7 +521,7 @@ export function ThreadView({
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={t("mail.print")}
aria-label={t("mail.print")}
onClick={() => void printMessages(subject, messages)}
onClick={() => void printMessages(subject, visual)}
>
<Icon name="print" size={16} />
</button>
+4
View File
@@ -12,6 +12,7 @@ export const en = {
"common.remove": "Remove",
"common.edit": "Edit",
"common.close": "Close",
"common.clear": "Clear",
"common.back": "Back",
"common.next": "Next",
"common.signOut": "Sign out",
@@ -78,6 +79,9 @@ export const en = {
"mail.unpin": "Unpin",
"mail.delete": "Delete",
"mail.restore": "Restore",
"mail.emptyTrash": "Empty trash",
"mail.emptySpam": "Empty spam",
"mail.emptyConfirm": "Permanently delete everything in this folder? This can't be undone.",
"mail.loadFailed": "Couldn't load this message.",
"mail.imagesBlocked": "Images from this sender are blocked.",
"mail.loadImages": "Load images",
+4
View File
@@ -8,6 +8,7 @@ import type {
DiscoverResponse,
DraftInput,
DraftOut,
FolderCounts,
MailboxOut,
QuotaOut,
RecipientSuggestion,
@@ -156,6 +157,7 @@ export const api = {
patchSettings: (patch: Partial<Pick<UserSettings, "thread_order" | "tz">>) =>
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
quota: () => apiFetch<QuotaOut>("/settings/quota"),
folderCounts: () => apiFetch<FolderCounts>("/mailboxes/counts"),
// --- compose ---
suggestRecipients: (q: string) =>
@@ -196,6 +198,8 @@ export const api = {
apiFetch<{ status: string }>(`/threads/${id}/restore`, { method: "POST" }),
deleteThreadPermanent: (id: string) =>
apiFetch<{ status: string }>(`/threads/${id}/delete`, { method: "POST" }),
emptyFolder: (special: string) =>
apiFetch<{ status: string }>(`/folders/${special}/empty`, { method: "POST" }),
listDrafts: () => apiFetch<DraftOut[]>("/drafts"),
remoteDrafts: () => apiFetch<RemoteDraftOut[]>("/drafts/remote"),
adoptDraft: (messageId: string) =>
+5
View File
@@ -151,6 +151,11 @@ export interface QuotaOut {
quota_bytes: number;
}
export interface FolderCounts {
inbox_unread: number;
drafts: number;
}
// --- M1: compose -------------------------------------------------------------
export interface RecipientSuggestion {
+6
View File
@@ -47,16 +47,22 @@ export function connectWs(queryClient: QueryClient): () => void {
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["quota"] });
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
if (typeof payload.thread_id === "string") {
queryClient.invalidateQueries({ queryKey: ["thread", payload.thread_id] });
}
}
if (name === "flags.changed") {
// Read/unread changes affect the Inbox unread badge.
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
}
if (name === "account.state_changed") {
queryClient.invalidateQueries({ queryKey: ["accounts"] });
}
if (name === "message.sent") {
// The sent message's draft is deleted server-side; refresh the Drafts list.
queryClient.invalidateQueries({ queryKey: ["drafts"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
}
};
+138 -26
View File
@@ -8,6 +8,7 @@ import { Icon } from "../components/Icon";
import { MessageList } from "../components/list/MessageList";
import { Resizer } from "../components/Resizer";
import { ThemeToggle } from "../components/ThemeToggle";
import { EmptyIllustration } from "../components/EmptyIllustration";
import { ThreadView } from "../components/thread/ThreadView";
import { useT } from "../i18n";
import type { MessageKey } from "../i18n/en";
@@ -102,6 +103,7 @@ export function MailLayout() {
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
const threadOrder: "asc" | "desc" = settingsQuery.data?.thread_order ?? "asc";
const quotaQuery = useQuery({ queryKey: ["quota"], queryFn: api.quota });
const countsQuery = useQuery({ queryKey: ["folder-counts"], queryFn: api.folderCounts });
const allThreads: ThreadSummary[] = term.trim()
? (searchQuery.data?.threads ?? []).map((h) => h.thread)
@@ -133,11 +135,19 @@ export function MailLayout() {
const markRead = useMutation({
mutationFn: (id: string) => api.threadFlags(id, ["\\Seen"], []),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["threads"] }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
},
});
// Auto-mark an opened thread read after a short dwell (4s), not instantly — and
// cancel it if you leave before then, so a quick glance doesn't mark it read.
useEffect(() => {
const detail = detailQuery.data;
if (detail && detail.messages.some((m) => m.is_unread)) markRead.mutate(detail.id);
if (!detail || !detail.messages.some((m) => m.is_unread)) return;
const id = detail.id;
const timer = window.setTimeout(() => markRead.mutate(id), 4000);
return () => window.clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [detailQuery.data?.id]);
@@ -154,6 +164,7 @@ export function MailLayout() {
setComposerExpanded(false);
queryClient.invalidateQueries({ queryKey: ["drafts"] });
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
};
// Leaving a folder closes any open composer/draft editor: the reading pane
@@ -171,6 +182,7 @@ export function MailLayout() {
const afterMove = () => {
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["quota"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
navigate("/mail");
};
@@ -184,7 +196,7 @@ export function MailLayout() {
/* ignore */
}
window.setTimeout(() => {
for (const key of ["threads", "drafts", "drafts-remote", "quota"]) {
for (const key of ["threads", "drafts", "drafts-remote", "quota", "folder-counts"]) {
queryClient.invalidateQueries({ queryKey: [key] });
}
setRefreshing(false);
@@ -227,6 +239,55 @@ export function MailLayout() {
queryClient.invalidateQueries({ queryKey: ["thread", detail.id] });
};
// Per-thread actions for the mail-list hover buttons (act on a given id, not the
// currently-open thread). Close the reading pane only if the acted thread is open.
const listAfterMove = (id: string) => {
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["quota"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
if (id === threadId) navigate("/mail");
};
const listArchive = async (id: string) => {
await api.archiveThread(id);
listAfterMove(id);
};
const listTrash = async (id: string) => {
await api.trashThread(id);
listAfterMove(id);
};
const listRestore = async (id: string) => {
await api.restoreThread(id);
listAfterMove(id);
};
const listDeletePermanent = async (id: string) => {
await api.deleteThreadPermanent(id);
listAfterMove(id);
};
const listTogglePin = async (id: string, pinned: boolean) => {
await api.threadFlags(id, pinned ? [] : ["\\Flagged"], pinned ? ["\\Flagged"] : []);
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["thread", 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);
const emptyCurrentFolder = async () => {
if (!window.confirm(t("mail.emptyConfirm"))) return;
setEmptying(true);
try {
await api.emptyFolder(currentFolder);
} catch {
/* ignore */
}
navigate("/mail"); // an open thread from this folder is about to be deleted
window.setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
setEmptying(false);
}, 1500);
};
const openRelative = (delta: number) => {
const list = threadsRef.current;
if (list.length === 0) return;
@@ -293,7 +354,14 @@ export function MailLayout() {
</button>
</div>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-1 text-sm">
{FOLDERS.map((f) => (
{FOLDERS.map((f) => {
const badge =
f.view === "folder:inbox"
? countsQuery.data?.inbox_unread
: f.view === "folder:drafts"
? countsQuery.data?.drafts
: undefined;
return (
<SidebarItem
key={f.view}
active={view === f.view}
@@ -304,10 +372,14 @@ export function MailLayout() {
navigate("/mail");
}}
>
<Icon name={f.icon} size={18} />
{!collapsed && t(f.label)}
<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>
) : null}
</SidebarItem>
))}
);
})}
{/* Per-account views are hidden entirely when collapsed. */}
{!collapsed && (
<>
@@ -335,7 +407,7 @@ export function MailLayout() {
title={collapsed ? t("nav.settings") : undefined}
to="/settings"
>
<Icon name="settings" size={16} />
<Icon name="settings" size={22} />
{!collapsed && t("nav.settings")}
</Link>
<button
@@ -371,13 +443,31 @@ export function MailLayout() {
className="flex shrink-0 flex-col border-r border-app-border bg-app-surface"
>
<div className="flex h-[52px] shrink-0 items-center border-b border-app-border px-6">
<input
ref={searchRef}
className="w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 text-sm 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)}
/>
<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"
placeholder={t("nav.search")}
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
{term && (
<button
type="button"
title={t("common.clear")}
aria-label={t("common.clear")}
onClick={() => {
setTerm("");
searchRef.current?.focus();
}}
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-1 text-app-faint hover:bg-app-hover hover:text-app-fg"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
)}
</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">
@@ -390,14 +480,25 @@ export function MailLayout() {
<FilterTab active={filter === "pinned"} onClick={() => setFilter("pinned")}>
{t("filter.pinned")}
</FilterTab>
<button
className="-mr-1.5 ml-auto rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={listOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")}
aria-label={listOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")}
onClick={() => setListOrder((o) => (o === "newest" ? "oldest" : "newest"))}
>
<SortIcon />
</button>
<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"
onClick={emptyCurrentFolder}
disabled={emptying}
>
{currentFolder === "trash" ? t("mail.emptyTrash") : t("mail.emptySpam")}
</button>
)}
<button
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={listOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")}
aria-label={listOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")}
onClick={() => setListOrder((o) => (o === "newest" ? "oldest" : "newest"))}
>
<SortIcon />
</button>
</div>
</div>
)}
<div className="min-h-0 flex-1">
@@ -415,7 +516,17 @@ export function MailLayout() {
}}
/>
) : (
<MessageList threads={pageThreads} accounts={accounts} selectedId={threadId} onSelect={(id) => navigate(`/mail/t/${id}`)} />
<MessageList
threads={pageThreads}
selectedId={threadId}
folder={currentFolder}
onSelect={(id) => navigate(`/mail/t/${id}`)}
onArchive={listArchive}
onRestore={listRestore}
onTrash={listTrash}
onDeletePermanent={listDeletePermanent}
onTogglePin={listTogglePin}
/>
)}
</div>
<div className="flex items-center justify-between border-t border-app-border px-6 py-1.5 text-xs text-app-muted">
@@ -519,8 +630,9 @@ export function MailLayout() {
</div>
</div>
) : (
<div className="flex h-full items-center justify-center text-app-faint">
{t("mail.selectConversation")}
<div className="flex h-full flex-col items-center justify-center gap-6 text-app-faint">
<EmptyIllustration className="w-56 max-w-[55%]" />
<span className="text-3xl">{t("mail.selectConversation")}</span>
</div>
)}