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