mail UX: restore-to-folder, draft/attachment fixes, printing, sidebar collapse
Backend: - Restore returns each message to its original folder (e.g. Sent/Archive) instead of always the Inbox: new messages.restore_mailbox_id (migration 0010) + restore_thread_job. - Drafts: expunge reconciliation for the Drafts poll (provider.list_uids / _reconcile_drafts) clears phantom "remote" drafts; send + discard drop the mirrored DB row; adopting a remote draft carries its real attachments into the compose flow. - Thread detail/list are folder-aware: individually trashed/spam messages are hidden outside their folder, Trash/Spam show only their own messages, and the mail-list message count matches the view. Frontend: - Thread reading panel: fix newest-first card overlap/spacing (expand-all and single expand), hide Reply/Forward on trashed messages, close the Actions menu on message-body (iframe) clicks, scroll to the inline composer, and prompt Save draft / Discard on the X close. - Composer: remount when switching drafts, dirty-tracking to stop duplicate IMAP mirrors, and close the From/More dropdowns on outside click. - Sidebar: collapse/expand toggle (icons-only ~60px rail) and a log-out icon on Sign out. - Mail-standard printing in a new window with reliable page margins (lib/print.ts). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
@@ -137,16 +137,37 @@ export function Composer({
|
||||
const [showToolbar, setShowToolbar] = useState(true);
|
||||
const [fromOpen, setFromOpen] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const moreRef = useRef<HTMLDivElement>(null);
|
||||
const fromRef = useRef<HTMLDivElement>(null);
|
||||
const [attachments, setAttachments] = useState<AttachmentOut[]>([]);
|
||||
const [attachError, setAttachError] = useState("");
|
||||
const [status, setStatus] = useState<"" | "saving" | "saved" | "sending" | "error">("");
|
||||
const [undo, setUndo] = useState<{ outboxId: string } | null>(null);
|
||||
const [showClosePrompt, setShowClosePrompt] = useState(false);
|
||||
const draftId = useRef<string | null>(init.draftId ?? null);
|
||||
const sentRef = useRef(false); // send initiated -> don't mirror to Drafts on close
|
||||
// Whether the user actually edited anything. Merely opening a draft to view it
|
||||
// must not re-save/re-mirror it (that spawned duplicate IMAP copies).
|
||||
const dirtyRef = useRef(false);
|
||||
const markDirty = () => {
|
||||
dirtyRef.current = true;
|
||||
};
|
||||
const undoTimer = useRef<number | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const editorRef = useRef<RichTextEditorHandle>(null);
|
||||
|
||||
// Close the From / More dropdowns on any click outside them.
|
||||
useEffect(() => {
|
||||
if (!moreOpen && !fromOpen) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (moreOpen && moreRef.current && !moreRef.current.contains(target)) setMoreOpen(false);
|
||||
if (fromOpen && fromRef.current && !fromRef.current.contains(target)) setFromOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [moreOpen, fromOpen]);
|
||||
|
||||
const account = accounts.find((a) => a.id === accountId);
|
||||
const fromName = account?.display_name || "";
|
||||
const fromEmail = account?.email ?? "";
|
||||
@@ -204,6 +225,7 @@ export function Composer({
|
||||
}, []);
|
||||
|
||||
const addFiles = async (files: FileList) => {
|
||||
markDirty();
|
||||
setAttachError("");
|
||||
for (const file of Array.from(files)) {
|
||||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||
@@ -219,8 +241,10 @@ export function Composer({
|
||||
}
|
||||
};
|
||||
|
||||
const removeAttachment = (id: string) =>
|
||||
const removeAttachment = (id: string) => {
|
||||
markDirty();
|
||||
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
};
|
||||
|
||||
// The editor's seed content. New messages get the account signature (2 blank
|
||||
// lines above it); replies keep the quoted original in a marked block so the
|
||||
@@ -263,6 +287,7 @@ export function Composer({
|
||||
useEffect(() => {
|
||||
if (undo) return;
|
||||
const handle = window.setTimeout(async () => {
|
||||
if (!dirtyRef.current) return; // nothing the user changed -> don't touch the draft
|
||||
if (!to && !subject && !bodyHtml) return;
|
||||
setStatus("saving");
|
||||
try {
|
||||
@@ -284,8 +309,9 @@ export function Composer({
|
||||
() => () => {
|
||||
if (undoTimer.current) window.clearTimeout(undoTimer.current);
|
||||
// On close, mirror the draft to the IMAP Drafts folder so it can be
|
||||
// continued on any mail client (skip if it was sent or discarded).
|
||||
if (draftId.current && !sentRef.current) {
|
||||
// continued on any mail client — but only if the user actually edited it
|
||||
// (skip if it was sent, discarded, or merely opened to view).
|
||||
if (draftId.current && !sentRef.current && dirtyRef.current) {
|
||||
void api.publishDraft(draftId.current).catch(() => {});
|
||||
}
|
||||
},
|
||||
@@ -333,6 +359,26 @@ export function Composer({
|
||||
onClose();
|
||||
};
|
||||
|
||||
// X button: if there's a draft worth keeping, ask before closing; otherwise
|
||||
// (nothing typed, no draft) just close.
|
||||
const requestClose = () => {
|
||||
if (dirtyRef.current || draftId.current) setShowClosePrompt(true);
|
||||
else onClose();
|
||||
};
|
||||
|
||||
const saveDraftAndClose = async () => {
|
||||
setShowClosePrompt(false);
|
||||
try {
|
||||
if (dirtyRef.current) {
|
||||
if (draftId.current) await api.updateDraft(draftId.current, payload());
|
||||
else draftId.current = (await api.createDraft(payload())).id;
|
||||
}
|
||||
} catch {
|
||||
/* the close-time IMAP mirror is best-effort too */
|
||||
}
|
||||
onClose(); // the unmount effect mirrors the (dirty) draft to the IMAP Drafts folder
|
||||
};
|
||||
|
||||
const popOut = async () => {
|
||||
// Persist first so the new window can load this exact draft.
|
||||
try {
|
||||
@@ -367,12 +413,44 @@ export function Composer({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col overflow-hidden border-app-border bg-app-elevated ${
|
||||
className={`relative flex flex-col overflow-hidden border-app-border bg-app-elevated ${
|
||||
fullWindow
|
||||
? "h-full"
|
||||
: `rounded-xl border shadow-2xl ${expanded ? "h-full" : ""}`
|
||||
}`}
|
||||
>
|
||||
{showClosePrompt && (
|
||||
<div
|
||||
className="absolute inset-0 z-40 flex items-center justify-center bg-black/40 p-4"
|
||||
onClick={() => setShowClosePrompt(false)}
|
||||
>
|
||||
<div
|
||||
className="w-72 rounded-lg border border-app-border bg-app-elevated p-4 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="mb-4 text-sm text-app-fg">{t("compose.closePrompt")}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowClosePrompt(false);
|
||||
void discard();
|
||||
}}
|
||||
className="rounded-md px-3 py-1.5 text-sm text-red-500 hover:bg-app-hover"
|
||||
>
|
||||
{t("compose.discard")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveDraftAndClose()}
|
||||
className="rounded-md bg-app-accent px-3 py-1.5 text-sm font-medium text-app-accent-fg hover:opacity-90"
|
||||
>
|
||||
{t("compose.saveDraft")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Header: Send · Attach · formatting toggle · More · window controls */}
|
||||
<div className="flex items-center gap-1.5 border-b border-app-border bg-app-surface px-3 py-2">
|
||||
<button
|
||||
@@ -410,7 +488,7 @@ export function Composer({
|
||||
>
|
||||
<span className="text-sm font-semibold underline">A</span>
|
||||
</HeaderButton>
|
||||
<div className="relative">
|
||||
<div className="relative" ref={moreRef}>
|
||||
<HeaderButton onClick={() => setMoreOpen((v) => !v)} title={t("compose.more")}>
|
||||
{t("compose.more")}
|
||||
</HeaderButton>
|
||||
@@ -469,7 +547,7 @@ export function Composer({
|
||||
</svg>
|
||||
</HeaderButton>
|
||||
)}
|
||||
<HeaderButton onClick={onClose} title={t("common.close")}>
|
||||
<HeaderButton onClick={requestClose} title={t("common.close")}>
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
@@ -478,7 +556,10 @@ export function Composer({
|
||||
</div>
|
||||
|
||||
{/* From */}
|
||||
<div className="relative flex items-center gap-2 border-b border-app-border px-3 py-2">
|
||||
<div
|
||||
className="relative flex items-center gap-2 border-b border-app-border px-3 py-2"
|
||||
ref={fromRef}
|
||||
>
|
||||
<span className="w-10 shrink-0 text-xs font-semibold text-app-fg">{t("compose.from")}</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -500,6 +581,7 @@ export function Composer({
|
||||
key={a.id}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-app-hover"
|
||||
onClick={() => {
|
||||
markDirty();
|
||||
setAccountId(a.id);
|
||||
setFromOpen(false);
|
||||
}}
|
||||
@@ -517,7 +599,10 @@ export function Composer({
|
||||
<RecipientChips
|
||||
label={t("compose.to")}
|
||||
value={to}
|
||||
onChange={setTo}
|
||||
onChange={(v) => {
|
||||
markDirty();
|
||||
setTo(v);
|
||||
}}
|
||||
autoFocus={init.kind === "new" || init.kind === "forward"}
|
||||
rightSlot={
|
||||
!showCc ? (
|
||||
@@ -533,15 +618,32 @@ export function Composer({
|
||||
/>
|
||||
{showCc && (
|
||||
<>
|
||||
<RecipientChips label={t("compose.cc")} value={cc} onChange={setCc} />
|
||||
<RecipientChips label={t("compose.bcc")} value={bcc} onChange={setBcc} />
|
||||
<RecipientChips
|
||||
label={t("compose.cc")}
|
||||
value={cc}
|
||||
onChange={(v) => {
|
||||
markDirty();
|
||||
setCc(v);
|
||||
}}
|
||||
/>
|
||||
<RecipientChips
|
||||
label={t("compose.bcc")}
|
||||
value={bcc}
|
||||
onChange={(v) => {
|
||||
markDirty();
|
||||
setBcc(v);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
className="border-b border-app-border bg-transparent px-3 py-2 text-sm text-app-fg outline-none placeholder:text-app-faint"
|
||||
placeholder={t("compose.subject")}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
onChange={(e) => {
|
||||
markDirty();
|
||||
setSubject(e.target.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
{(attachments.length > 0 || attachError) && (
|
||||
@@ -575,7 +677,10 @@ export function Composer({
|
||||
<RichTextEditor
|
||||
ref={editorRef}
|
||||
initialHtml={initialHtml}
|
||||
onInput={setBodyHtml}
|
||||
onInput={(html) => {
|
||||
markDirty();
|
||||
setBodyHtml(html);
|
||||
}}
|
||||
showToolbar={showToolbar}
|
||||
/>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user