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:
@@ -3,6 +3,7 @@ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { api } from "../../lib/api";
|
||||
import { printMessages } from "../../lib/print";
|
||||
import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
|
||||
import { Composer, type ComposerInit } from "../compose/Composer";
|
||||
import { Icon } from "../Icon";
|
||||
@@ -137,10 +138,12 @@ function ActionsMenu({
|
||||
onReply,
|
||||
onPrint,
|
||||
onDelete,
|
||||
replyDisabled = false,
|
||||
}: {
|
||||
onReply: (kind: string) => void;
|
||||
onPrint: () => void;
|
||||
onDelete: () => void;
|
||||
replyDisabled?: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -150,13 +153,25 @@ function ActionsMenu({
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
// Clicking inside the sandboxed message-body iframe doesn't reach document
|
||||
// mousedown, but it blurs the parent window — close the menu on that too.
|
||||
const onBlur = () => setOpen(false);
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
window.addEventListener("blur", onBlur);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDoc);
|
||||
window.removeEventListener("blur", onBlur);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const item = (label: string, onClick: () => void) => (
|
||||
const item = (label: string, onClick: () => void, disabled = false) => (
|
||||
<button
|
||||
className="block w-full px-3 py-1.5 text-left text-xs text-app-fg hover:bg-app-hover"
|
||||
disabled={disabled}
|
||||
className={`block w-full px-3 py-1.5 text-left text-xs ${
|
||||
disabled
|
||||
? "cursor-not-allowed text-app-faint"
|
||||
: "text-app-fg hover:bg-app-hover"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onClick();
|
||||
@@ -177,10 +192,10 @@ function ActionsMenu({
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 z-20 mt-1 w-52 overflow-hidden rounded-md border border-app-border bg-app-elevated py-1 shadow-lg">
|
||||
{item(t("mail.reply"), () => onReply("reply"))}
|
||||
{item(t("mail.replyAll"), () => onReply("reply_all"))}
|
||||
{item(t("mail.forward"), () => onReply("forward"))}
|
||||
{item(t("mail.forwardAsAttachment"), () => onReply("forward"))}
|
||||
{item(t("mail.reply"), () => onReply("reply"), replyDisabled)}
|
||||
{item(t("mail.replyAll"), () => onReply("reply_all"), replyDisabled)}
|
||||
{item(t("mail.forward"), () => onReply("forward"), replyDisabled)}
|
||||
{item(t("mail.forwardAsAttachment"), () => onReply("forward"), replyDisabled)}
|
||||
{item(t("mail.print"), onPrint)}
|
||||
<div className="my-1 border-t border-app-border" />
|
||||
{item(t("mail.delete"), onDelete)}
|
||||
@@ -199,6 +214,7 @@ function MessageItem({
|
||||
boxClassName,
|
||||
zIndex,
|
||||
showReplyBar,
|
||||
canReply,
|
||||
onToggle,
|
||||
onReply,
|
||||
onToggleRead,
|
||||
@@ -214,6 +230,7 @@ function MessageItem({
|
||||
boxClassName: string;
|
||||
zIndex: number;
|
||||
showReplyBar: boolean;
|
||||
canReply: boolean;
|
||||
onToggle: () => void;
|
||||
onReply: (kind: string) => void;
|
||||
onToggleRead: () => void;
|
||||
@@ -321,14 +338,21 @@ function MessageItem({
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1 text-app-muted hover:text-app-fg"
|
||||
onClick={() => onReply(multiRecipient ? "reply_all" : "reply")}
|
||||
>
|
||||
<Icon name={multiRecipient ? "reply-all" : "reply"} size={14} />
|
||||
{multiRecipient ? t("mail.replyAll") : t("mail.reply")}
|
||||
</button>
|
||||
<ActionsMenu onReply={onReply} onPrint={onPrint} onDelete={onDelete} />
|
||||
{canReply && (
|
||||
<button
|
||||
className="flex items-center gap-1 text-app-muted hover:text-app-fg"
|
||||
onClick={() => onReply(multiRecipient ? "reply_all" : "reply")}
|
||||
>
|
||||
<Icon name={multiRecipient ? "reply-all" : "reply"} size={14} />
|
||||
{multiRecipient ? t("mail.replyAll") : t("mail.reply")}
|
||||
</button>
|
||||
)}
|
||||
<ActionsMenu
|
||||
onReply={onReply}
|
||||
onPrint={onPrint}
|
||||
onDelete={onDelete}
|
||||
replyDisabled={!canReply}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showDetails && (
|
||||
@@ -356,7 +380,7 @@ function MessageItem({
|
||||
<div className="px-4 pb-3">
|
||||
<MessageBody message={message} />
|
||||
</div>
|
||||
{showReplyBar && (
|
||||
{showReplyBar && canReply && (
|
||||
<div className="flex gap-2 border-t border-app-border px-4 py-3">
|
||||
{replyBtn("reply", t("mail.reply"), "reply")}
|
||||
{replyBtn("reply_all", t("mail.replyAll"), "reply-all")}
|
||||
@@ -440,6 +464,7 @@ export function ThreadView({
|
||||
};
|
||||
|
||||
const openReply = (msg: MessageEnvelope, kind: string) => {
|
||||
if (specialUse.get(msg.mailbox_id) === "trash") return; // no reply/forward from Trash
|
||||
setExpanded((prev) => new Set(prev).add(msg.id));
|
||||
setComposing({ anchorId: msg.id, init: buildReplyInit(subject, msg, kind) });
|
||||
};
|
||||
@@ -471,9 +496,15 @@ export function ThreadView({
|
||||
allExpanded ? new Set(newest ? [newest.id] : []) : new Set(messages.map((m) => m.id)),
|
||||
);
|
||||
|
||||
const composerRef = useRef<HTMLDivElement | null>(null);
|
||||
// Bring the inline composer into view when it opens (reply/reply-all/forward).
|
||||
useEffect(() => {
|
||||
if (composing) composerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, [composing]);
|
||||
|
||||
const composerFor = (id: string) =>
|
||||
composing?.anchorId === id ? (
|
||||
<div className="px-4 py-3">
|
||||
<div ref={composerRef} className="px-4 py-3">
|
||||
<Composer init={composing.init} onClose={() => setComposing(null)} />
|
||||
</div>
|
||||
) : null;
|
||||
@@ -487,7 +518,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={() => window.print()}
|
||||
onClick={() => void printMessages(subject, messages)}
|
||||
>
|
||||
<Icon name="print" size={16} />
|
||||
</button>
|
||||
@@ -507,17 +538,23 @@ export function ThreadView({
|
||||
const isExpanded = expanded.has(message.id);
|
||||
const prev = i > 0 ? visual[i - 1] : undefined;
|
||||
const prevCollapsed = prev ? !expanded.has(prev.id) : false;
|
||||
const prevIsNewest = Boolean(prev && newest && prev.id === newest.id);
|
||||
// Overlap onto a preceding collapsed card, and also onto the always-open
|
||||
// newest message — so the newest stacks with its neighbour in both sort
|
||||
// orders (bottom card in oldest-first, top card in newest-first). z-index
|
||||
// keeps the newer/"current" edge on top so text stays visible.
|
||||
const overlap = prevCollapsed || prevIsNewest;
|
||||
// Cards overlap by a negative top margin (tuck up under the card above),
|
||||
// with the newer card kept on top by z-index. The card above is OLDER in
|
||||
// oldest-first but NEWER in newest-first, so the rule differs by order:
|
||||
// - oldest-first: an expanded card tucks up over the older card above it
|
||||
// (its own top stays clear). Only overlap a collapsed card above.
|
||||
// - newest-first: the card above is newer, so tucking an expanded card up
|
||||
// would hide its header under that newer card. Only collapsed cards tuck
|
||||
// up; an expanded card keeps its gap, so its top stays clear of the newer
|
||||
// message and the older card BELOW tucks under it instead.
|
||||
const overlap = order === "desc" ? !isExpanded : prevCollapsed;
|
||||
const margin = i === 0 ? "" : overlap ? "-mt-[20px]" : "mt-2";
|
||||
const z = order === "asc" ? i + 1 : visual.length - i;
|
||||
const boxClassName = `${margin} rounded-lg border border-app-border bg-app-surface ${
|
||||
isExpanded ? "" : "shadow-sm"
|
||||
}`;
|
||||
// Reply/Reply-all/Forward make no sense on a trashed message.
|
||||
const canReply = specialUse.get(message.mailbox_id) !== "trash";
|
||||
return (
|
||||
<Fragment key={message.id}>
|
||||
<MessageItem
|
||||
@@ -529,10 +566,11 @@ export function ThreadView({
|
||||
boxClassName={boxClassName}
|
||||
zIndex={z}
|
||||
showReplyBar={isNewest && composing?.anchorId !== message.id}
|
||||
canReply={canReply}
|
||||
onToggle={() => toggle(message.id)}
|
||||
onReply={(kind) => openReply(message, kind)}
|
||||
onToggleRead={() => toggleRead(message)}
|
||||
onPrint={() => window.print()}
|
||||
onPrint={() => void printMessages(subject, [message])}
|
||||
onDelete={() => deleteMessage(message)}
|
||||
innerRef={isNewest ? (el) => (newestRef.current = el) : undefined}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user