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
@@ -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>