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:
@@ -8,12 +8,16 @@ import compose from "../../icons/compose.svg?raw";
|
||||
import drafts from "../../icons/drafts-28-regular.svg?raw";
|
||||
import expandAll from "../../icons/expand-all.svg?raw";
|
||||
import forward from "../../icons/forward.svg?raw";
|
||||
import hide from "../../icons/hide.svg?raw";
|
||||
import inbox from "../../icons/inbox.svg?raw";
|
||||
import logOut from "../../icons/log-out.svg?raw";
|
||||
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 sent from "../../icons/sent-02.svg?raw";
|
||||
import settings from "../../icons/settings.svg?raw";
|
||||
import show from "../../icons/show.svg?raw";
|
||||
import spam from "../../icons/spam.svg?raw";
|
||||
import trash from "../../icons/trash.svg?raw";
|
||||
|
||||
@@ -24,12 +28,16 @@ const RAW: Record<string, string> = {
|
||||
drafts,
|
||||
"expand-all": expandAll,
|
||||
forward,
|
||||
hide,
|
||||
inbox,
|
||||
"log-out": logOut,
|
||||
pin,
|
||||
print,
|
||||
reply,
|
||||
"reply-all": replyAll,
|
||||
sent,
|
||||
settings,
|
||||
show,
|
||||
spam,
|
||||
trash,
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -38,6 +38,8 @@ export const en = {
|
||||
"nav.mail": "Mail",
|
||||
"nav.settings": "Settings",
|
||||
"nav.compose": "Compose",
|
||||
"nav.collapseSidebar": "Collapse sidebar",
|
||||
"nav.expandSidebar": "Expand sidebar",
|
||||
"nav.allAccounts": "All accounts",
|
||||
"nav.inbox": "Inbox",
|
||||
"nav.archive": "Archive",
|
||||
@@ -103,6 +105,8 @@ export const en = {
|
||||
"compose.send": "Send",
|
||||
"compose.sending": "Sending…",
|
||||
"compose.discard": "Discard",
|
||||
"compose.saveDraft": "Save draft",
|
||||
"compose.closePrompt": "Save this message as a draft?",
|
||||
"compose.sent": "Message sent.",
|
||||
"compose.undo": "Undo",
|
||||
"compose.sendFailed": "Send failed.",
|
||||
|
||||
@@ -94,8 +94,8 @@ export const api = {
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
return apiFetch<ThreadListOut>(`/threads?${params.toString()}`);
|
||||
},
|
||||
thread: (id: string, sort: "asc" | "desc" = "asc") =>
|
||||
apiFetch<ThreadDetail>(`/threads/${id}?sort=${sort}`),
|
||||
thread: (id: string, sort: "asc" | "desc" = "asc", view = "unified") =>
|
||||
apiFetch<ThreadDetail>(`/threads/${id}?sort=${sort}&view=${encodeURIComponent(view)}`),
|
||||
|
||||
search: (q: string, cursor?: string) => {
|
||||
const params = new URLSearchParams({ q });
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// Mail-style printing: assemble a clean, print-ready document (message headers +
|
||||
// sanitized body, one section per message for a thread) in a new window and invoke
|
||||
// the browser's print dialog. Reuses the same sanitized HTML the reader shows.
|
||||
|
||||
import { api } from "./api";
|
||||
import type { Address, AttachmentOut, MessageEnvelope } from "./types";
|
||||
|
||||
function esc(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function fmtAddr(a: Address): string {
|
||||
if (!a.email) return a.name || "";
|
||||
return a.name ? `${a.name} <${a.email}>` : a.email;
|
||||
}
|
||||
|
||||
function fmtList(addrs: Address[]): string {
|
||||
return addrs.map(fmtAddr).filter(Boolean).join(", ");
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(undefined, {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function fmtBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// Fetch a message's sanitized HTML (a full document) and return just its <body>.
|
||||
async function bodyHtml(messageId: string): Promise<string> {
|
||||
try {
|
||||
const resp = await fetch(api.messageHtmlUrl(messageId), { credentials: "include" });
|
||||
if (!resp.ok) throw new Error(String(resp.status));
|
||||
const doc = new DOMParser().parseFromString(await resp.text(), "text/html");
|
||||
return doc.body?.innerHTML ?? "";
|
||||
} catch {
|
||||
return `<p style="color:#999">[message content unavailable]</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function attachmentsFor(msg: MessageEnvelope): Promise<AttachmentOut[]> {
|
||||
if (!msg.has_attachments) return [];
|
||||
try {
|
||||
return await api.messageAttachments(msg.id);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function metaRow(label: string, value: string): string {
|
||||
return value
|
||||
? `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`
|
||||
: "";
|
||||
}
|
||||
|
||||
function section(msg: MessageEnvelope, subject: string, body: string, atts: AttachmentOut[]): string {
|
||||
const attachLine = atts.length
|
||||
? `<tr><th>Attachments</th><td>${atts
|
||||
.map((a) => `${esc(a.filename)} (${fmtBytes(a.size_bytes)})`)
|
||||
.join(", ")}</td></tr>`
|
||||
: "";
|
||||
return `<section class="wmc-print-msg">
|
||||
<div class="wmc-print-head">
|
||||
<div class="wmc-print-subject">${esc(subject || "(no subject)")}</div>
|
||||
<table class="wmc-print-meta">
|
||||
${metaRow("From", fmtList(msg.from_addr))}
|
||||
${metaRow("To", fmtList(msg.to_addr))}
|
||||
${metaRow("Cc", fmtList(msg.cc_addr))}
|
||||
${metaRow("Date", fmtDate(msg.date))}
|
||||
${attachLine}
|
||||
</table>
|
||||
</div>
|
||||
<div class="wmc-print-body">${body}</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
const MARGIN = "16mm";
|
||||
|
||||
const PRINT_CSS = `
|
||||
/* Let the sheet be the full page; the margin is content padding so it always
|
||||
prints, regardless of the browser's "Margins" print-dialog setting. */
|
||||
@page { margin: 0; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
color: #111; background: #e9e9ec; font-size: 12pt; line-height: 1.5;
|
||||
-webkit-print-color-adjust: exact; print-color-adjust: exact;
|
||||
}
|
||||
/* On screen, present it like a padded sheet of paper. */
|
||||
@media screen {
|
||||
body {
|
||||
max-width: 210mm; margin: 24px auto; padding: ${MARGIN};
|
||||
background: #fff; box-shadow: 0 1px 8px rgba(0,0,0,.15);
|
||||
}
|
||||
}
|
||||
/* In print the padding IS the page margin (survives a "None" margin setting). */
|
||||
@media print {
|
||||
html, body { background: #fff; }
|
||||
body { padding: ${MARGIN}; }
|
||||
}
|
||||
.wmc-print-msg { padding-bottom: 18px; }
|
||||
.wmc-print-msg + .wmc-print-msg { border-top: 2px solid #ddd; padding-top: 18px; }
|
||||
.wmc-print-head { break-inside: avoid; margin-bottom: 12px; }
|
||||
.wmc-print-subject { font-size: 16pt; font-weight: 600; margin: 0 0 8px; }
|
||||
.wmc-print-meta { width: 100%; border-collapse: collapse; font-size: 10.5pt; }
|
||||
.wmc-print-meta th {
|
||||
text-align: left; vertical-align: top; color: #666; font-weight: 500;
|
||||
width: 72px; padding: 1px 8px 1px 0; white-space: nowrap;
|
||||
}
|
||||
.wmc-print-meta td { vertical-align: top; padding: 1px 0; color: #111; }
|
||||
.wmc-print-body { color: #1a1a1a; word-break: break-word; }
|
||||
.wmc-print-body img { max-width: 100%; height: auto; }
|
||||
.wmc-print-body table { max-width: 100%; border-collapse: collapse; }
|
||||
.wmc-print-body blockquote {
|
||||
margin: 0 0 0 12px; padding-left: 12px; border-left: 3px solid #e0e0e0; color: #555;
|
||||
}
|
||||
.wmc-print-body a { color: #2563eb; }
|
||||
.wmc-print-body pre { white-space: pre-wrap; word-wrap: break-word; }
|
||||
`;
|
||||
|
||||
// Wait for images to finish (so they're in the print), capped so a slow/broken
|
||||
// image can never block printing indefinitely.
|
||||
async function waitForImages(win: Window, timeoutMs = 3000): Promise<void> {
|
||||
const pending = Array.from(win.document.images).filter((img) => !img.complete);
|
||||
if (pending.length === 0) return;
|
||||
await Promise.race([
|
||||
Promise.all(
|
||||
pending.map(
|
||||
(img) =>
|
||||
new Promise<void>((res) => {
|
||||
img.addEventListener("load", () => res(), { once: true });
|
||||
img.addEventListener("error", () => res(), { once: true });
|
||||
}),
|
||||
),
|
||||
),
|
||||
new Promise<void>((res) => setTimeout(res, timeoutMs)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a print-ready window for one or more messages and invoke the print dialog.
|
||||
* Pass a single message to print just that email, or all thread messages (ascending)
|
||||
* to print the conversation.
|
||||
*/
|
||||
export async function printMessages(subject: string, messages: MessageEnvelope[]): Promise<void> {
|
||||
if (messages.length === 0) return;
|
||||
|
||||
// Open the window synchronously (inside the click) so it isn't blocked as a popup.
|
||||
const win = window.open("", "_blank", "width=820,height=1000");
|
||||
if (!win) {
|
||||
window.alert("Please allow pop-ups for this site to print.");
|
||||
return;
|
||||
}
|
||||
const title = esc(subject || "(no subject)");
|
||||
win.document.write(
|
||||
`<!doctype html><meta charset="utf-8"><title>${title}</title>` +
|
||||
`<p style="font:12pt system-ui;color:#888;padding:16px">Preparing…</p>`,
|
||||
);
|
||||
win.document.close();
|
||||
|
||||
const [bodies, atts] = await Promise.all([
|
||||
Promise.all(messages.map((m) => bodyHtml(m.id))),
|
||||
Promise.all(messages.map((m) => attachmentsFor(m))),
|
||||
]);
|
||||
if (win.closed) return; // the user dismissed the window while it was preparing
|
||||
|
||||
const sections = messages
|
||||
.map((m, i) => section(m, m.subject || subject, bodies[i], atts[i]))
|
||||
.join("");
|
||||
// Write the whole document in one pass so the print stylesheet parses cleanly.
|
||||
win.document.open();
|
||||
win.document.write(
|
||||
`<!doctype html><html><head><meta charset="utf-8">` +
|
||||
`<base href="${window.location.origin}/">` +
|
||||
`<title>${title}</title><style>${PRINT_CSS}</style></head>` +
|
||||
`<body>${sections}</body></html>`,
|
||||
);
|
||||
win.document.close();
|
||||
|
||||
await waitForImages(win);
|
||||
if (win.closed) return;
|
||||
win.focus();
|
||||
win.onafterprint = () => win.close();
|
||||
win.print();
|
||||
}
|
||||
@@ -26,6 +26,9 @@ function quotaLabel(q: QuotaOut): string {
|
||||
// Threads shown per page in the mail list.
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
// Collapsed sidebar width: just wide enough for the icons.
|
||||
const COLLAPSED_W = 60;
|
||||
|
||||
// Unified per-folder views. "Spam" maps to the IMAP special-use "junk".
|
||||
const FOLDERS: { view: string; icon: string; label: MessageKey }[] = [
|
||||
{ view: "folder:inbox", icon: "inbox", label: "nav.inbox" },
|
||||
@@ -52,17 +55,23 @@ export function MailLayout() {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [composing, setComposing] = useState<ComposerInit | null>(null);
|
||||
const [composerExpanded, setComposerExpanded] = useState(false);
|
||||
// Bumped each time a composer is opened so the <Composer> remounts (fresh state)
|
||||
// when switching between drafts instead of keeping the previous draft's editor.
|
||||
const [composeKey, setComposeKey] = useState(0);
|
||||
const [replySignal, setReplySignal] = useState<{ kind: string; seq: number }>();
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Resizable panel widths (persisted). Drag the dividers to adjust.
|
||||
const [sidebarW, setSidebarW] = useState(() => Number(localStorage.getItem("panel.sidebar")) || 240);
|
||||
const [listW, setListW] = useState(() => Number(localStorage.getItem("panel.list")) || 384);
|
||||
// Collapsed sidebar shows icons only (persisted).
|
||||
const [collapsed, setCollapsed] = useState(() => localStorage.getItem("panel.collapsed") === "1");
|
||||
const clampSidebar = (w: number) => Math.min(Math.max(w, 180), 420);
|
||||
// The list can be dragged up to 75% of the window width.
|
||||
const clampList = (w: number) => Math.min(Math.max(w, 260), window.innerWidth * 0.75);
|
||||
useEffect(() => localStorage.setItem("panel.sidebar", String(sidebarW)), [sidebarW]);
|
||||
useEffect(() => localStorage.setItem("panel.list", String(listW)), [listW]);
|
||||
useEffect(() => localStorage.setItem("panel.collapsed", collapsed ? "1" : "0"), [collapsed]);
|
||||
useEffect(() => localStorage.setItem("mail.listOrder", listOrder), [listOrder]);
|
||||
|
||||
const isDrafts = view === "folder:drafts";
|
||||
@@ -117,8 +126,8 @@ export function MailLayout() {
|
||||
threadsRef.current = threads;
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ["thread", threadId, threadOrder],
|
||||
queryFn: () => api.thread(threadId!, threadOrder),
|
||||
queryKey: ["thread", threadId, threadOrder, view],
|
||||
queryFn: () => api.thread(threadId!, threadOrder, view),
|
||||
enabled: Boolean(threadId),
|
||||
});
|
||||
|
||||
@@ -132,6 +141,33 @@ export function MailLayout() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [detailQuery.data?.id]);
|
||||
|
||||
// Open the composer with fresh state, remounting it if one is already open
|
||||
// (e.g. clicking a different draft replaces the current editor).
|
||||
const openComposer = (init: ComposerInit, expand = false) => {
|
||||
setComposing(init);
|
||||
setComposerExpanded(expand);
|
||||
setComposeKey((k) => k + 1);
|
||||
};
|
||||
|
||||
const closeComposer = () => {
|
||||
setComposing(null);
|
||||
setComposerExpanded(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["drafts"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
|
||||
};
|
||||
|
||||
// Leaving a folder closes any open composer/draft editor: the reading pane
|
||||
// should reflect the folder you navigated to, not a leftover draft editor.
|
||||
const firstView = useRef(true);
|
||||
useEffect(() => {
|
||||
if (firstView.current) {
|
||||
firstView.current = false;
|
||||
return;
|
||||
}
|
||||
closeComposer();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view]);
|
||||
|
||||
const afterMove = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["quota"] });
|
||||
@@ -201,7 +237,7 @@ export function MailLayout() {
|
||||
|
||||
useKeyboardShortcuts({
|
||||
onSearch: () => searchRef.current?.focus(),
|
||||
onCompose: () => accountsQuery.data?.[0] && setComposing({ accountId: accountsQuery.data[0].id, kind: "new" }),
|
||||
onCompose: () => accountsQuery.data?.[0] && openComposer({ accountId: accountsQuery.data[0].id, kind: "new" }),
|
||||
onReply: () => setReplySignal({ kind: "reply", seq: Date.now() }),
|
||||
onReplyAll: () => setReplySignal({ kind: "reply_all", seq: Date.now() }),
|
||||
onForward: () => setReplySignal({ kind: "forward", seq: Date.now() }),
|
||||
@@ -221,22 +257,39 @@ export function MailLayout() {
|
||||
<div className="flex h-screen bg-app-bg text-app-fg">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
style={{ width: sidebarW }}
|
||||
style={{ width: collapsed ? COLLAPSED_W : sidebarW }}
|
||||
className="flex shrink-0 flex-col border-r border-app-border bg-app-bg"
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 pb-1 pt-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-app-faint">
|
||||
{t("common.appName")}
|
||||
</span>
|
||||
<ThemeToggle />
|
||||
<div
|
||||
className={`flex pb-1 pt-3 ${
|
||||
collapsed ? "flex-col items-center gap-1 px-2" : "items-center justify-between px-3"
|
||||
}`}
|
||||
>
|
||||
{!collapsed && (
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-app-faint">
|
||||
{t("common.appName")}
|
||||
</span>
|
||||
)}
|
||||
<div className={`flex items-center gap-1 ${collapsed ? "flex-col" : ""}`}>
|
||||
<button
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
title={collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar")}
|
||||
aria-label={collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar")}
|
||||
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
>
|
||||
<Icon name={collapsed ? "show" : "hide"} size={16} />
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 pb-2">
|
||||
<div className={`pb-2 ${collapsed ? "px-2" : "px-3"}`}>
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-sm font-medium text-app-accent-fg shadow-sm hover:opacity-90"
|
||||
onClick={() => accounts[0] && setComposing({ accountId: accounts[0].id, kind: "new" })}
|
||||
title={collapsed ? t("nav.compose") : undefined}
|
||||
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
|
||||
>
|
||||
<Icon name="compose" size={16} />
|
||||
{t("nav.compose")}
|
||||
{!collapsed && t("nav.compose")}
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-1 text-sm">
|
||||
@@ -244,44 +297,62 @@ export function MailLayout() {
|
||||
<SidebarItem
|
||||
key={f.view}
|
||||
active={view === f.view}
|
||||
collapsed={collapsed}
|
||||
title={collapsed ? t(f.label) : undefined}
|
||||
onClick={() => {
|
||||
setView(f.view);
|
||||
navigate("/mail");
|
||||
}}
|
||||
>
|
||||
<Icon name={f.icon} size={18} />
|
||||
{t(f.label)}
|
||||
</SidebarItem>
|
||||
))}
|
||||
<div className="px-3 pb-1 pt-3 text-[11px] font-semibold uppercase tracking-wide text-app-faint">
|
||||
{t("settings.accounts")}
|
||||
</div>
|
||||
{accounts.map((a) => (
|
||||
<SidebarItem
|
||||
key={a.id}
|
||||
active={view === `account:${a.id}`}
|
||||
onClick={() => { setView(`account:${a.id}`); navigate("/mail"); }}
|
||||
>
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: a.color }} />
|
||||
<span className="truncate">{a.email}</span>
|
||||
{!collapsed && t(f.label)}
|
||||
</SidebarItem>
|
||||
))}
|
||||
{/* Per-account views are hidden entirely when collapsed. */}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="px-3 pb-1 pt-3 text-[11px] font-semibold uppercase tracking-wide text-app-faint">
|
||||
{t("settings.accounts")}
|
||||
</div>
|
||||
{accounts.map((a) => (
|
||||
<SidebarItem
|
||||
key={a.id}
|
||||
active={view === `account:${a.id}`}
|
||||
onClick={() => { setView(`account:${a.id}`); navigate("/mail"); }}
|
||||
>
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: a.color }} />
|
||||
<span className="truncate">{a.email}</span>
|
||||
</SidebarItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<div className="border-t border-app-border p-2 text-sm">
|
||||
<Link className="block rounded-lg px-3 py-2 text-app-muted hover:bg-app-hover hover:text-app-fg" to="/settings">
|
||||
⚙ {t("nav.settings")}
|
||||
<Link
|
||||
className={`flex items-center gap-2.5 rounded-lg py-2 text-app-muted hover:bg-app-hover hover:text-app-fg ${
|
||||
collapsed ? "justify-center px-0" : "px-3"
|
||||
}`}
|
||||
title={collapsed ? t("nav.settings") : undefined}
|
||||
to="/settings"
|
||||
>
|
||||
<Icon name="settings" size={16} />
|
||||
{!collapsed && t("nav.settings")}
|
||||
</Link>
|
||||
<button
|
||||
className="block w-full rounded-lg px-3 py-2 text-left text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
className={`flex w-full items-center gap-2.5 rounded-lg py-2 text-left text-app-muted hover:bg-app-hover hover:text-app-fg ${
|
||||
collapsed ? "justify-center px-0" : "px-3"
|
||||
}`}
|
||||
title={collapsed ? t("common.signOut") : undefined}
|
||||
onClick={async () => {
|
||||
await api.logout();
|
||||
queryClient.clear();
|
||||
navigate("/login");
|
||||
}}
|
||||
>
|
||||
{t("common.signOut")}
|
||||
<Icon name="log-out" size={16} />
|
||||
{!collapsed && t("common.signOut")}
|
||||
</button>
|
||||
{quotaQuery.data && (
|
||||
{!collapsed && quotaQuery.data && (
|
||||
<div
|
||||
className="mt-1 border-t border-app-border px-3 pb-1 pt-2 text-xs text-app-faint"
|
||||
title={t("nav.storageQuota")}
|
||||
@@ -292,7 +363,7 @@ export function MailLayout() {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Resizer onDelta={(dx) => setSidebarW((w) => clampSidebar(w + dx))} />
|
||||
{!collapsed && <Resizer onDelta={(dx) => setSidebarW((w) => clampSidebar(w + dx))} />}
|
||||
|
||||
{/* Message list */}
|
||||
<div
|
||||
@@ -334,17 +405,13 @@ export function MailLayout() {
|
||||
<DraftsList
|
||||
drafts={draftsQuery.data ?? []}
|
||||
remoteDrafts={remoteDraftsQuery.data ?? []}
|
||||
onOpen={(draft) => {
|
||||
setComposing(draftToInit(draft));
|
||||
setComposerExpanded(true);
|
||||
}}
|
||||
onOpen={(draft) => openComposer(draftToInit(draft), true)}
|
||||
onOpenRemote={async (remote) => {
|
||||
// Adopt the server draft into an editable local draft, then open it.
|
||||
const draft = await api.adoptDraft(remote.message_id);
|
||||
queryClient.invalidateQueries({ queryKey: ["drafts"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
|
||||
setComposing(draftToInit(draft));
|
||||
setComposerExpanded(true);
|
||||
openComposer(draftToInit(draft), true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -466,15 +533,11 @@ export function MailLayout() {
|
||||
}
|
||||
>
|
||||
<Composer
|
||||
key={composeKey}
|
||||
init={composing}
|
||||
expanded={composerExpanded}
|
||||
onToggleExpand={() => setComposerExpanded((v) => !v)}
|
||||
onClose={() => {
|
||||
setComposing(null);
|
||||
setComposerExpanded(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["drafts"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
|
||||
}}
|
||||
onClose={closeComposer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -570,11 +633,26 @@ function SortIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarItem({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||
function SidebarItem({
|
||||
active,
|
||||
onClick,
|
||||
collapsed = false,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
collapsed?: boolean;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-2.5 rounded-lg px-3 py-1.5 text-left ${
|
||||
title={title}
|
||||
className={`flex w-full items-center gap-2.5 rounded-lg py-1.5 text-left ${
|
||||
collapsed ? "justify-center px-0" : "px-3"
|
||||
} ${
|
||||
active ? "bg-app-active font-medium text-app-fg" : "text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
}`}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user