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