Compose: a dock of windows that survive the app around them

Clicking Compose with a window already open cleared what you had typed.
MailLayout bumped a React key on every open to force a fresh editor, and
switching folders ran an effect that closed the composer outright. Both
were deliberate when only one composer could exist at a time.

Underneath them sat a sharper constraint: RichTextEditor seeds its
contentEditable exactly once, so ANY remount silently discards the body.
Every part of this change is shaped by keeping a composer mounted.

Windows now live in one ordered list, each with a stable id used as its
React key, rendered by ComposeDock as a single keyed array — open windows
and collapsed title bars together. Splitting the open one into a child
slot of its own would hand its state to whichever window took its place,
which is exactly what happened the first time around.

  - A minimize control, first of the four window icons, collapses a
    window to a title bar showing its subject (or "New message"). The
    subtree is hidden, never unmounted.
  - Clicking Compose parks the open windows and appends a new one, so
    each click pushes the previous window one place left.
  - Restoring a window touches only that window; several can be open at
    once. When they stop fitting, the least recently reached-for ones
    are DRAWN as bars — state untouched, so they spring back open when
    room returns.
  - Expanding matches the reading pane, measured live, and moves the
    window to the end of the row: the row is right-anchored, so a bar
    after it would shove an expanded window off the pane and across the
    list and sidebar.
  - A window is given its height rather than sizing to its content. Left
    to itself it grew with the body until the top ran off the screen,
    and the `flex-1` body never became scrollable because there was no
    leftover space to divide.
  - Leaving a folder un-expands a window instead of closing it.
  - The dock row is pointer-events-none — its box spans the tallest
    window in it, and as a bare div it shielded the mail list beside it.
  - Drafts opened from the Drafts folder now load in the reading pane,
    keyed by draft id, with the open one marked in the list.

Also fixes a blank draft saved for every composer opened: the signature
effect guarded itself with a "first run" flag, which StrictMode's double
invocation spends on the first pass. The second pass rewrote the
signature of an untouched composer, marking it dirty. Keyed on the
account instead, so it is idempotent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
2026-09-21 21:32:41 +02:00
co-authored by Claude Opus 5
parent e4a521ae20
commit 9f9e959a05
7 changed files with 987 additions and 249 deletions
@@ -0,0 +1,150 @@
import { useEffect, useRef, useState } from "react";
import { useT } from "../../i18n";
import { Composer } from "./Composer";
import { CHIP_W, type ComposeWin, DOCK_INSET, layoutDock } from "./windows";
function useViewportSize() {
const read = () =>
typeof window === "undefined"
? { vw: 1280, vh: 800 }
: { vw: window.innerWidth, vh: window.innerHeight };
const [size, setSize] = useState(read);
useEffect(() => {
const onResize = () => setSize(read());
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return size;
}
/**
* The bottom-of-screen dock: composers side by side, each either a full window
* or a collapsed title bar, in a row that never reorders.
*
* Every window here stays mounted for as long as it is open — collapsed, and
* even pushed off the end of the row into the overflow menu, it is hidden with
* CSS and never unmounted. That is not a detail: `RichTextEditor` seeds its
* contentEditable exactly once, so an unmount-and-remount silently throws away
* whatever the user had typed. Stable `id`s as React keys keep it that way when
* the list around a window changes.
*/
export function ComposeDock({
windows,
pane,
onMinimize,
onRestore,
onToggleExpand,
onClose,
}: {
windows: ComposeWin[];
/** Live geometry of the reading pane; an expanded window is laid over it. */
pane: { width: number; right: number };
onMinimize: (id: string) => void;
onRestore: (id: string) => void;
onToggleExpand: (id: string) => void;
onClose: (id: string) => void;
}) {
const t = useT();
const { vw, vh } = useViewportSize();
const [menuOpen, setMenuOpen] = useState(false);
const [titles, setTitles] = useState<Record<string, string>>({});
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!menuOpen) return;
const onDown = (e: MouseEvent) => {
if (!menuRef.current?.contains(e.target as Node)) setMenuOpen(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [menuOpen]);
if (windows.length === 0) return null;
const { items, overflow } = layoutDock(vw, vh, windows, pane.width);
const title = (w: ComposeWin) =>
(titles[w.id] ?? w.init.subject ?? "").trim() || t("compose.newMessage");
const noteTitle = (id: string, s: string) =>
setTitles((prev) => (prev[id] === s ? prev : { ...prev, [id]: s }));
return (
// The row's box spans the tallest window in it, so a bare div here would be
// an invisible shield over the mail list beside it. Only the windows
// themselves take the pointer.
<div className="pointer-events-none fixed bottom-0 right-4 z-40 flex items-end gap-2">
{overflow > 0 && (
<div className="pointer-events-auto relative" ref={menuRef}>
{menuOpen && (
<div className="absolute bottom-full left-0 mb-1 w-64 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 shadow-xl">
{items
.filter((i) => i.hidden)
.map(({ win }) => (
<button
key={win.id}
className="block w-full truncate px-3 py-1.5 text-left text-sm text-app-fg hover:bg-app-hover"
onClick={() => {
setMenuOpen(false);
onRestore(win.id);
}}
>
{title(win)}
</button>
))}
</div>
)}
<button
type="button"
title={t("compose.moreWindows")}
onClick={() => setMenuOpen((v) => !v)}
// Padded to the exact height of a title bar: the header's own
// px-3/py-2 plus its button's py-1.5, and one extra pixel at the
// bottom standing in for the header's border-b.
className="rounded-t-xl border border-app-border bg-app-surface px-3 pb-[9px] pt-2 text-sm font-medium text-app-muted shadow-2xl hover:text-app-fg"
style={{ width: CHIP_W }}
>
<span className="block py-1.5">+{overflow}</span>
</button>
</div>
)}
{/* ONE keyed array for every window, open and collapsed alike. React
reconciles by key within a single child list, so a window keeps its
component instance — and its typed body — as it moves along the row.
Splitting the open window into a child slot of its own would hand its
state to whatever window took its place. */}
{items.map(({ win, open, hidden, width, height }) => (
<div
key={win.id}
// Hidden, not dropped: an unmounted composer loses its draft body.
className={hidden ? "hidden" : "pointer-events-auto"}
style={
hidden
? undefined
: {
width,
height: height ?? undefined,
// Expanded, the window covers the reading pane exactly. Sliding
// it out of the dock's right-hand gutter is what keeps the
// list resizer — which sits on the pane's left edge —
// clickable instead of buried under the window.
transform:
open && win.expanded ? `translateX(${DOCK_INSET - pane.right}px)` : undefined,
}
}
>
<Composer
init={win.init}
onTitle={(s) => noteTitle(win.id, s)}
minimized={!open}
expanded={open && win.expanded}
fitContainer
onMinimize={() => (open ? onMinimize(win.id) : onRestore(win.id))}
onToggleExpand={() => onToggleExpand(win.id)}
onClose={() => onClose(win.id)}
/>
</div>
))}
</div>
);
}
+289 -209
View File
@@ -113,14 +113,33 @@ export function Composer({
onClose, onClose,
expanded = false, expanded = false,
onToggleExpand, onToggleExpand,
minimized = false,
onMinimize,
onTitle,
fitContainer = false,
fullWindow = false, fullWindow = false,
}: { }: {
init: ComposerInit; init: ComposerInit;
onClose: () => void; onClose: () => void;
expanded?: boolean; expanded?: boolean;
onToggleExpand?: () => void; onToggleExpand?: () => void;
// Collapsed to a title bar in the dock. The whole subtree stays mounted —
// everything below the header is hidden with CSS, never unmounted, because
// RichTextEditor seeds its contentEditable once and a remount would throw the
// typed body away.
minimized?: boolean;
onMinimize?: () => void;
// Reports the live subject so the dock's overflow menu can name a window by
// what was typed into it, not by the (static) subject it was opened with.
onTitle?: (title: string) => void;
// The container fixes this composer's height, so a long body scrolls instead
// of pushing the window taller. False only for the inline reply inside a
// thread, which grows with what you write and scrolls with the thread.
fitContainer?: boolean;
fullWindow?: boolean; fullWindow?: boolean;
}) { }) {
// Whether something other than the content decides how tall this is.
const sized = fullWindow || fitContainer;
const t = useT(); const t = useT();
const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts }); const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
const accounts: AccountOut[] = accountsQuery.data ?? []; const accounts: AccountOut[] = accountsQuery.data ?? [];
@@ -154,6 +173,13 @@ export function Composer({
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, [undo]); }, [undo]);
const [showClosePrompt, setShowClosePrompt] = useState(false); const [showClosePrompt, setShowClosePrompt] = useState(false);
// Through a ref so a fresh callback identity on every parent render can't turn
// this into a render loop.
const onTitleRef = useRef(onTitle);
onTitleRef.current = onTitle;
useEffect(() => {
onTitleRef.current?.(subject);
}, [subject]);
const draftId = useRef<string | null>(init.draftId ?? null); const draftId = useRef<string | null>(init.draftId ?? null);
const sentRef = useRef(false); // send initiated -> don't mirror to Drafts on close 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 // Whether the user actually edited anything. Merely opening a draft to view it
@@ -168,7 +194,7 @@ export function Composer({
// Close the From / More dropdowns on any click outside them. // Close the From / More dropdowns on any click outside them.
useEffect(() => { useEffect(() => {
if (!moreOpen && !fromOpen) return; if (minimized || (!moreOpen && !fromOpen)) return;
const onDown = (e: MouseEvent) => { const onDown = (e: MouseEvent) => {
const target = e.target as Node; const target = e.target as Node;
if (moreOpen && moreRef.current && !moreRef.current.contains(target)) setMoreOpen(false); if (moreOpen && moreRef.current && !moreRef.current.contains(target)) setMoreOpen(false);
@@ -176,7 +202,7 @@ export function Composer({
}; };
document.addEventListener("mousedown", onDown); document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown);
}, [moreOpen, fromOpen]); }, [moreOpen, fromOpen, minimized]);
const account = accounts.find((a) => a.id === accountId); const account = accounts.find((a) => a.id === accountId);
const fromName = account?.display_name || ""; const fromName = account?.display_name || "";
@@ -184,12 +210,15 @@ export function Composer({
const signature = account?.signature ?? ""; const signature = account?.signature ?? "";
// When the From account changes, swap the signature block for the new one. // When the From account changes, swap the signature block for the new one.
const firstAccount = useRef(true); //
// Keyed on the account rather than a "first run" flag: under StrictMode the
// mount effect runs twice, so a flag is already spent by the second pass,
// which then rewrites the signature of an untouched composer — marking it
// dirty and leaving a blank draft behind for every window opened.
const lastAccount = useRef(init.accountId);
useEffect(() => { useEffect(() => {
if (firstAccount.current) { if (lastAccount.current === accountId) return;
firstAccount.current = false; lastAccount.current = accountId;
return;
}
editorRef.current?.replaceSignature(accounts.find((a) => a.id === accountId)?.signature ?? ""); editorRef.current?.replaceSignature(accounts.find((a) => a.id === accountId)?.signature ?? "");
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [accountId]); }, [accountId]);
@@ -447,8 +476,14 @@ export function Composer({
// X button: if there's a draft worth keeping, ask before closing; otherwise // X button: if there's a draft worth keeping, ask before closing; otherwise
// (nothing typed, no draft) just close. // (nothing typed, no draft) just close.
const requestClose = () => { const requestClose = () => {
if (dirtyRef.current || draftId.current) setShowClosePrompt(true); if (!dirtyRef.current && !draftId.current) {
else onClose(); onClose();
return;
}
// The prompt is an overlay on this window; in a collapsed title bar there is
// nowhere to draw it. Restore first, then ask.
if (minimized) onMinimize?.();
setShowClosePrompt(true);
}; };
const saveDraftAndClose = async () => { const saveDraftAndClose = async () => {
@@ -511,7 +546,9 @@ export function Composer({
className={`relative 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 fullWindow
? "h-full" ? "h-full"
: `rounded-xl border shadow-2xl ${expanded ? "h-full" : ""}` : minimized
? "rounded-t-xl border-x border-t shadow-2xl"
: `rounded-xl border shadow-2xl ${sized ? "h-full" : ""}`
}`} }`}
> >
{dragKind && ( {dragKind && (
@@ -553,82 +590,113 @@ export function Composer({
)} )}
{/* Header: Send · Attach · formatting toggle · More · window controls */} {/* 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"> <div className="flex items-center gap-1.5 border-b border-app-border bg-app-surface px-3 py-2">
<button {minimized ? (
type="button" // Collapsed: the subject stands in for the action buttons, and the whole
onClick={send} // bar is the restore target.
disabled={status === "sending" || (!to && !cc)} <button
className="flex items-center gap-2 rounded-md bg-app-accent px-3 py-1.5 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50" type="button"
> onClick={onMinimize}
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"> title={t("compose.restore")}
<path d="M14.5 1.5L7.2 8.8" /> className="min-w-0 flex-1 truncate py-1.5 text-left text-sm font-medium text-app-fg"
<path d="M14.5 1.5L9.8 14.5 7.2 8.8 1.5 6.2 14.5 1.5z" /> >
</svg> {subject.trim() || t("compose.newMessage")}
{status === "sending" ? t("compose.sending") : t("compose.send")} </button>
</button> ) : (
<HeaderButton onClick={() => fileInputRef.current?.click()} title={t("compose.attach")}> <>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"> <button
<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" /> type="button"
</svg> onClick={send}
{t("compose.attach")} disabled={status === "sending" || (!to && !cc)}
</HeaderButton> className="flex items-center gap-2 rounded-md bg-app-accent px-3 py-1.5 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
<input >
ref={fileInputRef} <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
type="file" <path d="M14.5 1.5L7.2 8.8" />
multiple <path d="M14.5 1.5L9.8 14.5 7.2 8.8 1.5 6.2 14.5 1.5z" />
className="hidden" </svg>
onChange={(e) => { {status === "sending" ? t("compose.sending") : t("compose.send")}
if (e.target.files && e.target.files.length) void addFiles(e.target.files); </button>
e.target.value = ""; <HeaderButton onClick={() => fileInputRef.current?.click()} title={t("compose.attach")}>
}} <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
/> <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" />
<HeaderButton </svg>
onClick={() => setShowToolbar((v) => !v)} {t("compose.attach")}
title={t("compose.fmtBold")}
active={showToolbar}
>
<span className="text-sm font-semibold underline">A</span>
</HeaderButton>
<div className="relative" ref={moreRef}>
<HeaderButton onClick={() => setMoreOpen((v) => !v)} title={t("compose.more")}>
{t("compose.more")}
</HeaderButton> </HeaderButton>
{moreOpen && ( <input
<div className="absolute left-0 top-full z-30 mt-1 w-44 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 text-sm shadow-xl"> ref={fileInputRef}
<button type="file"
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover" multiple
onClick={() => { className="hidden"
setMoreOpen(false); onChange={(e) => {
setShowCc(true); if (e.target.files && e.target.files.length) void addFiles(e.target.files);
}} e.target.value = "";
> }}
{t("compose.cc")} / {t("compose.bcc")} />
</button> <HeaderButton
<button onClick={() => setShowToolbar((v) => !v)}
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover disabled:opacity-40" title={t("compose.fmtBold")}
disabled={!signature} active={showToolbar}
onClick={() => { >
setMoreOpen(false); <span className="text-sm font-semibold underline">A</span>
editorRef.current?.insertSignature(signature); </HeaderButton>
}} <div className="relative" ref={moreRef}>
> <HeaderButton onClick={() => setMoreOpen((v) => !v)} title={t("compose.more")}>
{t("compose.insertSignature")} {t("compose.more")}
</button> </HeaderButton>
<button {moreOpen && (
className="block w-full px-3 py-1.5 text-left text-red-500 hover:bg-app-hover" <div className="absolute left-0 top-full z-30 mt-1 w-44 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 text-sm shadow-xl">
onClick={() => { <button
setMoreOpen(false); className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
void discard(); onClick={() => {
}} setMoreOpen(false);
> setShowCc(true);
{t("compose.discard")} }}
</button> >
</div> {t("compose.cc")} / {t("compose.bcc")}
)} </button>
</div> <button
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover disabled:opacity-40"
disabled={!signature}
onClick={() => {
setMoreOpen(false);
editorRef.current?.insertSignature(signature);
}}
>
{t("compose.insertSignature")}
</button>
<button
className="block w-full px-3 py-1.5 text-left text-red-500 hover:bg-app-hover"
onClick={() => {
setMoreOpen(false);
void discard();
}}
>
{t("compose.discard")}
</button>
</div>
)}
</div>
</>
)}
<div className="ml-auto flex items-center gap-1 text-xs text-app-faint"> <div className="ml-auto flex items-center gap-1 text-xs text-app-faint">
{status === "saving" && <span className="mr-1">{t("compose.savingDraft")}</span>} {!minimized && status === "saving" && <span className="mr-1">{t("compose.savingDraft")}</span>}
{status === "saved" && <span className="mr-1">{t("compose.draftSaved")}</span>} {!minimized && status === "saved" && <span className="mr-1">{t("compose.draftSaved")}</span>}
{status === "error" && <span className="mr-1 text-red-500">{t("compose.sendFailed")}</span>} {!minimized && status === "error" && (
<span className="mr-1 text-red-500">{t("compose.sendFailed")}</span>
)}
{!fullWindow && onMinimize && (
<HeaderButton
onClick={onMinimize}
title={minimized ? t("compose.restore") : t("compose.minimize")}
>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
{minimized ? (
<path d="M4 10l4-4 4 4" strokeLinejoin="round" />
) : (
<path d="M3 12h10" />
)}
</svg>
</HeaderButton>
)}
{!fullWindow && onToggleExpand && ( {!fullWindow && onToggleExpand && (
<HeaderButton <HeaderButton
onClick={onToggleExpand} onClick={onToggleExpand}
@@ -636,7 +704,11 @@ export function Composer({
active={expanded} active={expanded}
> >
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"> <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 2H2v4M10 2h4v4M6 14H2v-4M10 14h4v-4" /> {expanded ? (
<path d="M2 6h4V2M14 6h-4V2M2 10h4v4M14 10h-4v4" />
) : (
<path d="M6 2H2v4M10 2h4v4M6 14H2v-4M10 14h4v-4" />
)}
</svg> </svg>
</HeaderButton> </HeaderButton>
)} )}
@@ -655,138 +727,146 @@ export function Composer({
</div> </div>
</div> </div>
{/* From */} {/* Hidden rather than unmounted while collapsed: the editor seeds its
<div contentEditable once, so dropping this subtree would lose the body. */}
className="relative flex items-center gap-2 border-b border-app-border px-3 py-2" <div className={minimized ? "hidden" : "contents"}>
ref={fromRef} {/* From */}
> <div
<span className="w-10 shrink-0 text-xs font-semibold text-app-fg">{t("compose.from")}</span> className="relative flex items-center gap-2 border-b border-app-border px-3 py-2"
<button ref={fromRef}
type="button"
onClick={() => accounts.length > 1 && setFromOpen((v) => !v)}
className="flex items-center gap-1.5 rounded px-1 py-0.5 text-sm hover:bg-app-hover"
> >
{fromName && <span className="font-medium text-app-accent">{fromName}</span>} <span className="w-10 shrink-0 text-xs font-semibold text-app-fg">{t("compose.from")}</span>
<span className="text-app-muted">{fromName ? `<${fromEmail}>` : fromEmail}</span> <button
{accounts.length > 1 && <span className="text-app-faint">{caret}</span>} type="button"
</button> onClick={() => accounts.length > 1 && setFromOpen((v) => !v)}
<div className="ml-auto flex items-center gap-1 text-xs text-app-muted"> className="flex items-center gap-1.5 rounded px-1 py-0.5 text-sm hover:bg-app-hover"
<Icon name="reply" size={13} /> >
{t("compose.replyLabel")} {fromName && <span className="font-medium text-app-accent">{fromName}</span>}
</div> <span className="text-app-muted">{fromName ? `<${fromEmail}>` : fromEmail}</span>
{fromOpen && ( {accounts.length > 1 && <span className="text-app-faint">{caret}</span>}
<div className="absolute left-12 top-full z-30 mt-1 w-80 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 shadow-xl"> </button>
{accounts.map((a) => ( <div className="ml-auto flex items-center gap-1 text-xs text-app-muted">
<button <Icon name="reply" size={13} />
key={a.id} {t("compose.replyLabel")}
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);
}}
>
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: a.color }} />
{a.display_name && <span className="font-medium text-app-fg">{a.display_name}</span>}
<span className="text-app-muted">{a.email}</span>
</button>
))}
</div> </div>
)} {fromOpen && (
</div> <div className="absolute left-12 top-full z-30 mt-1 w-80 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 shadow-xl">
{accounts.map((a) => (
<button
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);
}}
>
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: a.color }} />
{a.display_name && <span className="font-medium text-app-fg">{a.display_name}</span>}
<span className="text-app-muted">{a.email}</span>
</button>
))}
</div>
)}
</div>
{/* Recipients */} {/* Recipients */}
<RecipientChips <RecipientChips
label={t("compose.to")} label={t("compose.to")}
value={to} value={to}
onChange={(v) => { onChange={(v) => {
markDirty(); markDirty();
setTo(v); setTo(v);
}} }}
autoFocus={init.kind === "new" || init.kind === "forward"} autoFocus={init.kind === "new" || init.kind === "forward"}
rightSlot={ rightSlot={
!showCc ? ( !showCc ? (
<button
type="button"
className="text-xs text-app-muted hover:text-app-fg"
onClick={() => setShowCc(true)}
>
{t("compose.cc")} {t("compose.bcc")}
</button>
) : undefined
}
/>
{showCc && (
<>
<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) => {
markDirty();
setSubject(e.target.value);
}}
/>
{(attachments.length > 0 || attachError) && (
<div className="flex flex-wrap items-center gap-2 border-b border-app-border px-3 py-2">
{attachments.map((att) => (
<span
key={att.id}
className="flex items-center gap-1.5 rounded border border-app-border bg-app-hover px-2 py-1 text-xs text-app-fg"
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<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>
<span className="max-w-[12rem] truncate">{att.filename}</span>
<span className="text-app-faint">{formatBytes(att.size_bytes)}</span>
<button <button
type="button" type="button"
onClick={() => removeAttachment(att.id)} className="text-xs text-app-muted hover:text-app-fg"
className="text-app-faint hover:text-app-fg" onClick={() => setShowCc(true)}
aria-label={`remove ${att.filename}`}
> >
✕ {t("compose.cc")} {t("compose.bcc")}
</button> </button>
</span> ) : undefined
))} }
{attachError && <span className="text-xs text-red-500">{attachError}</span>} />
</div> {showCc && (
)} <>
<RecipientChips
{/* Body — wrapped so a drop here is detected as the writing area. */} label={t("compose.cc")}
<div ref={editorAreaRef} className="flex min-h-0 flex-1 flex-col"> value={cc}
{bodyReady ? ( onChange={(v) => {
<RichTextEditor markDirty();
ref={editorRef} setCc(v);
initialHtml={initialHtml} }}
onInput={(html) => { />
markDirty(); <RecipientChips
setBodyHtml(html); label={t("compose.bcc")}
}} value={bcc}
showToolbar={showToolbar} onChange={(v) => {
/> markDirty();
) : ( setBcc(v);
<div className="min-h-[250px] flex-1 px-3 py-3 text-sm text-app-faint">…</div> }}
/>
</>
)} )}
<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) => {
markDirty();
setSubject(e.target.value);
}}
/>
{(attachments.length > 0 || attachError) && (
<div className="flex flex-wrap items-center gap-2 border-b border-app-border px-3 py-2">
{attachments.map((att) => (
<span
key={att.id}
className="flex items-center gap-1.5 rounded border border-app-border bg-app-hover px-2 py-1 text-xs text-app-fg"
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<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>
<span className="max-w-[12rem] truncate">{att.filename}</span>
<span className="text-app-faint">{formatBytes(att.size_bytes)}</span>
<button
type="button"
onClick={() => removeAttachment(att.id)}
className="text-app-faint hover:text-app-fg"
aria-label={`remove ${att.filename}`}
>
✕
</button>
</span>
))}
{attachError && <span className="text-xs text-red-500">{attachError}</span>}
</div>
)}
{/* Body — wrapped so a drop here is detected as the writing area. */}
<div ref={editorAreaRef} className="flex min-h-0 flex-1 flex-col">
{bodyReady ? (
<RichTextEditor
ref={editorRef}
initialHtml={initialHtml}
onInput={(html) => {
markDirty();
setBodyHtml(html);
}}
showToolbar={showToolbar}
// With a height to divide, the body takes what is left and
// scrolls. A floor here would instead push the window past that
// height — and `overflow-hidden` would clip the difference.
minHeight={sized ? "min-h-0" : undefined}
/>
) : (
<div className="min-h-[250px] flex-1 px-3 py-3 text-sm text-app-faint">…</div>
)}
</div>
</div> </div>
</div> </div>
); );
+209
View File
@@ -0,0 +1,209 @@
// The compose-window list and the dock's layout arithmetic, kept out of the
// components so both can be reasoned about — and tested — on their own.
import type { ComposerInit } from "./Composer";
export interface ComposeWin {
id: string;
init: ComposerInit;
minimized: boolean;
expanded: boolean;
/**
* Bumped whenever the window is opened or restored. Several windows can be
* open at once; when they no longer fit side by side, the least recently
* activated ones give way — so the window you just reached for is the one
* that stays on screen.
*/
activated: number;
}
/**
* Add a window, parking whatever was open beside it rather than replacing it.
*
* Reopening a draft that is already in the dock brings that window back instead
* of starting a second editor: two composers on one draft would race each
* other's autosave.
*/
export function openWindow(
prev: ComposeWin[],
id: string,
init: ComposerInit,
expand: boolean,
seq: number,
): ComposeWin[] {
const already = init.draftId ? prev.find((w) => w.init.draftId === init.draftId) : undefined;
if (already) {
return prev.map((w) =>
w.id === already.id
? { ...w, minimized: false, expanded: w.expanded || expand, activated: seq }
: w,
);
}
return [
...prev.map((w) => ({ ...w, minimized: true, expanded: false })),
{ id, init, minimized: false, expanded: expand, activated: seq },
];
}
/**
* Bring one window back. Deliberately the only window this touches: whatever
* else was on the row — open or collapsed — is left exactly as the user had it.
*/
export function restoreWindow(prev: ComposeWin[], id: string, seq: number): ComposeWin[] {
return prev.map((w) => (w.id === id ? { ...w, minimized: false, activated: seq } : w));
}
export function minimizeWindow(prev: ComposeWin[], id: string): ComposeWin[] {
return prev.map((w) => (w.id === id ? { ...w, minimized: true, expanded: false } : w));
}
/**
* Expanding is a focus mode — the window claims the row, so the others park.
* Collapsing back out of it leaves everyone alone.
*
* Expanding also moves the window to the end of the row. The row is anchored to
* the right edge, so any title bar sitting after a window pushes it leftwards:
* expanded from the middle it would slide off the reading pane and sprawl over
* the list and sidebar. Last is the only position where an expanded window
* lands where it belongs. It keeps that place after collapsing rather than
* springing back, so the row stays where you last left it.
*/
export function toggleExpand(prev: ComposeWin[], id: string, seq: number): ComposeWin[] {
const target = prev.find((w) => w.id === id);
if (!target) return prev;
const expanded = !target.expanded;
const next = prev.map((w) => {
if (w.id === id) return { ...w, minimized: false, expanded, activated: seq };
return expanded ? { ...w, minimized: true, expanded: false } : w;
});
if (!expanded) return next;
const i = next.findIndex((w) => w.id === id);
return i === next.length - 1 ? next : [...next.slice(0, i), ...next.slice(i + 1), next[i]];
}
/** Escape, and anything else that wants the windows out of the way but kept. */
export function minimizeAll(prev: ComposeWin[]): ComposeWin[] {
return prev.map((w) => (w.minimized ? w : { ...w, minimized: true, expanded: false }));
}
/**
* Leaving a folder un-expands an expanded window so the reading pane shows the
* folder you navigated to. Returns `prev` untouched when there is nothing to do,
* so it is safe to call from an effect.
*/
export function collapseExpanded(prev: ComposeWin[]): ComposeWin[] {
return prev.some((w) => w.expanded)
? prev.map((w) => (w.expanded ? { ...w, expanded: false, minimized: true } : w))
: prev;
}
// ── dock layout ─────────────────────────────────────────────────────────────
const WINDOW_W = 760; // 47.5rem, the width the composer has always had
const WINDOW_H = 520; // 32.5rem
// Fallback for an expanded window before the reading pane has been measured.
const EXPANDED_W = 1112; // 69.5rem
const EDGE = 8; // the sliver of desktop left above an expanded window
const OPEN_MIN = 420; // below this a composer is too cramped to write in
// A bar carries all four window icons (~140px) plus the header's own padding,
// so anything under ~16rem truncates the subject to a letter or two. Below that
// floor a bar is worth less than a slot in the overflow menu.
const BAR_MAX = 320;
const BAR_MIN = 256;
const GAP = 8;
/** The dock's inset from the right edge of the screen (Tailwind `right-4`). */
export const DOCK_INSET = 16;
const GUTTER = 2 * DOCK_INSET; // that much air on each side of the row
export const CHIP_W = 56;
export interface DockItem {
win: ComposeWin;
/** Drawn as a full composer rather than a title bar. */
open: boolean;
/** Tucked into the overflow menu — still mounted, just not on the row. */
hidden: boolean;
width: number;
/**
* Fixed pixel height for an open window, `null` for a title bar (which is
* as tall as its own header). A window must be told its height: left to size
* itself it grows with the body until the top runs off the screen, and the
* editor — sized with `flex-1` — never becomes scrollable, because there is
* no leftover space for it to divide.
*/
height: number | null;
}
export interface DockLayout {
/** Every window, in row order. Positions never move as windows open and close. */
items: DockItem[];
overflow: number;
}
/**
* @param paneW Live width of the reading pane. An expanded window matches it,
* so expanding tracks the sidebar/list resizers and the viewport
* instead of standing at one hard-coded width.
*/
export function layoutDock(
vw: number,
vh: number,
windows: ComposeWin[],
paneW = 0,
): DockLayout {
const budget = Math.max(0, vw - GUTTER);
const wantOpen = windows.filter((w) => !w.minimized);
// How many open windows fit side by side at their floor width? The rest are
// drawn as title bars with their state untouched, so they spring back open as
// soon as one closes or the window is widened.
let fitting = wantOpen.length;
while (fitting > 1 && fitting * OPEN_MIN + (fitting - 1) * GAP > budget) fitting -= 1;
const open = new Set(
[...wantOpen]
.sort((a, b) => b.activated - a.activated)
.slice(0, fitting)
.map((w) => w.id),
);
const shown = wantOpen.filter((w) => open.has(w.id));
// Anything that didn't make the cut needs a chip to reach it by, and on a
// narrow screen that room has to come out of the windows themselves — on a
// wide one it costs nothing, since a window never wants more than WINDOW_W.
const reserve = windows.length > fitting ? CHIP_W + GAP : 0;
const openBudget = Math.max(0, budget - reserve);
const expandedW = paneW > 0 ? paneW : EXPANDED_W;
const want = (w: ComposeWin) => Math.min(w.expanded ? expandedW : WINDOW_W, openBudget);
const gaps = Math.max(0, shown.length - 1) * GAP;
const roomy = shown.reduce((n, w) => n + want(w), 0) + gaps <= openBudget;
const share = shown.length > 0 ? Math.floor((openBudget - gaps) / shown.length) : 0;
const widthOf = (w: ComposeWin) => (roomy ? want(w) : Math.min(want(w), share));
const openTotal = shown.reduce((n, w) => n + widthOf(w), 0) + gaps;
// Whatever width is left belongs to the title bars; the leftmost ones fall
// into the overflow menu when even that runs out.
const bars = windows.filter((w) => !open.has(w.id));
const barFit = (space: number) => Math.max(0, Math.floor((space + GAP) / (BAR_MIN + GAP)));
let avail = budget - (shown.length > 0 ? openTotal + GAP : 0);
let barsShown = Math.min(bars.length, barFit(avail));
if (barsShown < bars.length) {
avail -= CHIP_W + GAP;
barsShown = Math.min(bars.length, barFit(avail));
}
const hidden = new Set(bars.slice(0, bars.length - barsShown).map((w) => w.id));
const barW =
barsShown > 0 ? Math.min(BAR_MAX, Math.floor((avail - GAP * (barsShown - 1)) / barsShown)) : 0;
const heightOf = (w: ComposeWin) =>
Math.max(0, Math.min(w.expanded ? vh : WINDOW_H, vh - EDGE));
return {
items: windows.map((w) => ({
win: w,
open: open.has(w.id),
hidden: hidden.has(w.id),
width: open.has(w.id) ? widthOf(w) : barW,
height: open.has(w.id) ? heightOf(w) : null,
})),
overflow: hidden.size,
};
}
+14 -1
View File
@@ -15,6 +15,7 @@ function DraftRow({
preview, preview,
time, time,
badge, badge,
selected,
onClick, onClick,
}: { }: {
recipients: string; recipients: string;
@@ -22,12 +23,15 @@ function DraftRow({
preview: string; preview: string;
time: string; time: string;
badge?: string; badge?: string;
selected: boolean;
onClick: () => void; onClick: () => void;
}) { }) {
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className="flex w-full flex-col gap-1 border-b border-app-border px-6 py-2.5 text-left hover:bg-app-hover" className={`flex w-full flex-col gap-1 border-b border-app-border px-6 py-2.5 text-left ${
selected ? "bg-app-active" : "hover:bg-app-hover"
}`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="flex-1 truncate text-sm text-app-fg">{recipients}</span> <span className="flex-1 truncate text-sm text-app-fg">{recipients}</span>
@@ -49,11 +53,14 @@ function DraftRow({
export function DraftsList({ export function DraftsList({
drafts, drafts,
remoteDrafts, remoteDrafts,
selectedId,
onOpen, onOpen,
onOpenRemote, onOpenRemote,
}: { }: {
drafts: DraftOut[]; drafts: DraftOut[];
remoteDrafts: RemoteDraftOut[]; remoteDrafts: RemoteDraftOut[];
/** Id of the draft currently loaded in the reading pane. */
selectedId?: string;
onOpen: (draft: DraftOut) => void; onOpen: (draft: DraftOut) => void;
onOpenRemote: (draft: RemoteDraftOut) => void; onOpenRemote: (draft: RemoteDraftOut) => void;
}) { }) {
@@ -70,6 +77,7 @@ export function DraftsList({
subject: string; subject: string;
preview: string; preview: string;
badge?: string; badge?: string;
selected: boolean;
onClick: () => void; onClick: () => void;
}; };
const rows: Row[] = [ const rows: Row[] = [
@@ -79,6 +87,7 @@ export function DraftsList({
recipients: d.payload.to.join(", ") || t("compose.noRecipient"), recipients: d.payload.to.join(", ") || t("compose.noRecipient"),
subject: d.payload.subject || t("compose.noSubject"), subject: d.payload.subject || t("compose.noSubject"),
preview: d.payload.text.trim(), preview: d.payload.text.trim(),
selected: d.id === selectedId,
onClick: () => onOpen(d), onClick: () => onOpen(d),
})), })),
...remoteDrafts.map((d) => ({ ...remoteDrafts.map((d) => ({
@@ -88,6 +97,9 @@ export function DraftsList({
subject: d.subject || t("compose.noSubject"), subject: d.subject || t("compose.noSubject"),
preview: d.snippet.trim(), preview: d.snippet.trim(),
badge: t("compose.otherClient"), badge: t("compose.otherClient"),
// A remote draft becomes a local one the moment it is opened, so it never
// appears in the list as the selected row.
selected: false,
onClick: () => onOpenRemote(d), onClick: () => onOpenRemote(d),
})), })),
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); ].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
@@ -102,6 +114,7 @@ export function DraftsList({
preview={row.preview} preview={row.preview}
time={timeLabel(row.date)} time={timeLabel(row.date)}
badge={row.badge} badge={row.badge}
selected={row.selected}
onClick={row.onClick} onClick={row.onClick}
/> />
))} ))}
+3
View File
@@ -203,6 +203,9 @@ export const en = {
"compose.from": "From", "compose.from": "From",
"compose.attach": "Attach", "compose.attach": "Attach",
"compose.more": "More", "compose.more": "More",
"compose.minimize": "Minimize",
"compose.restore": "Restore",
"compose.moreWindows": "More drafts",
"compose.expand": "Expand", "compose.expand": "Expand",
"compose.collapse": "Collapse", "compose.collapse": "Collapse",
"compose.popOut": "Pop out", "compose.popOut": "Pop out",
+106 -39
View File
@@ -4,7 +4,17 @@ import { Link, useMatch, useNavigate, useSearchParams } from "react-router-dom";
import signetLogo from "../../icons/ultimo-logo-signet.svg"; import signetLogo from "../../icons/ultimo-logo-signet.svg";
import { AppSwitcher } from "../components/AppSwitcher"; import { AppSwitcher } from "../components/AppSwitcher";
import { ComposeDock } from "../components/compose/ComposeDock";
import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer"; import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer";
import {
collapseExpanded,
type ComposeWin,
minimizeAll,
minimizeWindow,
openWindow,
restoreWindow,
toggleExpand,
} from "../components/compose/windows";
import { DraftsList } from "../components/list/DraftsList"; import { DraftsList } from "../components/list/DraftsList";
import { Icon } from "../components/Icon"; import { Icon } from "../components/Icon";
import { MessageList } from "../components/list/MessageList"; import { MessageList } from "../components/list/MessageList";
@@ -80,12 +90,37 @@ export function MailLayout() {
else next.add(id); else next.add(id);
return next; return next;
}); });
const [composing, setComposing] = useState<ComposerInit | null>(null); // Every open compose window, oldest first — the dock lays them out left to
const [composerExpanded, setComposerExpanded] = useState(false); // right and keeps each one mounted for its whole life.
// Bumped each time a composer is opened so the <Composer> remounts (fresh state) const [composeWins, setComposeWins] = useState<ComposeWin[]>([]);
// when switching between drafts instead of keeping the previous draft's editor. const composeSeq = useRef(0);
const [composeKey, setComposeKey] = useState(0); // A draft opened from the Drafts folder is edited in the reading pane, the
// way a thread is read there — not in a floating window.
const [paneDraft, setPaneDraft] = useState<ComposerInit | null>(null);
const [replySignal, setReplySignal] = useState<{ kind: string; seq: number }>(); const [replySignal, setReplySignal] = useState<{ kind: string; seq: number }>();
// The reading pane's live width, so an expanded composer can match it. Its
// size answers to the two resizers and the right panel as well as the
// viewport, so it is measured rather than derived.
const paneRef = useRef<HTMLElement>(null);
const [pane, setPane] = useState({ width: 0, right: 0 });
useEffect(() => {
const el = paneRef.current;
if (!el) return;
const measure = () => {
const r = el.getBoundingClientRect();
// `right` is the gap to the viewport edge, which is what the dock needs
// to line an expanded window up with the pane — it is non-zero whenever
// the right panel is open.
setPane({ width: Math.round(r.width), right: Math.round(window.innerWidth - r.right) });
};
const ro = new ResizeObserver(measure);
ro.observe(el);
window.addEventListener("resize", measure);
return () => {
ro.disconnect();
window.removeEventListener("resize", measure);
};
}, []);
const searchRef = useRef<HTMLInputElement>(null); const searchRef = useRef<HTMLInputElement>(null);
// Resizable panel widths (persisted). Drag the dividers to adjust. // Resizable panel widths (persisted). Drag the dividers to adjust.
@@ -255,31 +290,63 @@ export function MailLayout() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [detailQuery.data?.id]); }, [detailQuery.data?.id]);
// Open the composer with fresh state, remounting it if one is already open // Opening a composer parks whatever was open in the dock instead of replacing
// (e.g. clicking a different draft replaces the current editor). // it: each Compose click adds a window and pushes the previous one to its
// left. Nothing is ever remounted, so nothing typed is ever lost.
const openComposer = (init: ComposerInit, expand = false) => { const openComposer = (init: ComposerInit, expand = false) => {
setComposing(init); const seq = ++composeSeq.current;
setComposerExpanded(expand); setComposeWins((prev) => openWindow(prev, `c${seq}`, init, expand, seq));
setComposeKey((k) => k + 1);
}; };
const closeComposer = () => { // `composeSeq` doubles as the activation clock the dock uses to decide which
setComposing(null); // windows stay open when they no longer all fit.
setComposerExpanded(false); const restoreComposer = (id: string) =>
setComposeWins((prev) => restoreWindow(prev, id, ++composeSeq.current));
const minimizeComposer = (id: string) => setComposeWins((prev) => minimizeWindow(prev, id));
const toggleComposerExpand = (id: string) =>
setComposeWins((prev) => toggleExpand(prev, id, ++composeSeq.current));
const refreshDrafts = () => {
queryClient.invalidateQueries({ queryKey: ["drafts"] }); queryClient.invalidateQueries({ queryKey: ["drafts"] });
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] }); queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
}; };
// Leaving a folder closes any open composer/draft editor: the reading pane const closeComposer = (id: string) => {
// should reflect the folder you navigated to, not a leftover draft editor. setComposeWins((prev) => prev.filter((w) => w.id !== id));
refreshDrafts();
};
const openDraftInPane = (init: ComposerInit) => {
setPaneDraft(init);
if (threadId) navigate("/mail"); // the pane shows one thing at a time
};
const closePaneDraft = () => {
setPaneDraft(null);
refreshDrafts();
};
// The pane belongs to whatever you navigated to: opening a thread or leaving
// the folder puts the draft away. (It is saved either way — the composer
// mirrors a dirty draft on unmount.)
useEffect(() => {
if (threadId) setPaneDraft(null);
}, [threadId]);
useEffect(() => {
setPaneDraft(null);
}, [view]);
// Leaving a folder un-expands an expanded composer so the reading pane shows
// the folder you navigated to — but the window itself stays, collapsed into
// the dock at worst. Closing it here used to discard work in progress.
const firstView = useRef(true); const firstView = useRef(true);
useEffect(() => { useEffect(() => {
if (firstView.current) { if (firstView.current) {
firstView.current = false; firstView.current = false;
return; return;
} }
closeComposer(); setComposeWins(collapseExpanded);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [view]); }, [view]);
@@ -450,7 +517,7 @@ export function MailLayout() {
onForward: () => setReplySignal({ kind: "forward", seq: Date.now() }), onForward: () => setReplySignal({ kind: "forward", seq: Date.now() }),
onArchive: archive, onArchive: archive,
onGoInbox: () => selectView("folder:inbox"), onGoInbox: () => selectView("folder:inbox"),
onEscape: () => setComposing(null), onEscape: () => setComposeWins(minimizeAll),
onNext: () => openRelative(1), onNext: () => openRelative(1),
onPrev: () => openRelative(-1), onPrev: () => openRelative(-1),
}); });
@@ -830,13 +897,14 @@ export function MailLayout() {
<DraftsList <DraftsList
drafts={draftsQuery.data ?? []} drafts={draftsQuery.data ?? []}
remoteDrafts={remoteDraftsQuery.data ?? []} remoteDrafts={remoteDraftsQuery.data ?? []}
onOpen={(draft) => openComposer(draftToInit(draft), true)} selectedId={paneDraft?.draftId}
onOpen={(draft) => openDraftInPane(draftToInit(draft))}
onOpenRemote={async (remote) => { onOpenRemote={async (remote) => {
// Adopt the server draft into an editable local draft, then open it. // Adopt the server draft into an editable local draft, then open it.
const draft = await api.adoptDraft(remote.message_id); const draft = await api.adoptDraft(remote.message_id);
queryClient.invalidateQueries({ queryKey: ["drafts"] }); queryClient.invalidateQueries({ queryKey: ["drafts"] });
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] }); queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
openComposer(draftToInit(draft), true); openDraftInPane(draftToInit(draft));
}} }}
/> />
) : ( ) : (
@@ -896,8 +964,17 @@ export function MailLayout() {
<Resizer onDelta={(dx) => setListW((w) => clampList(w + dx))} /> <Resizer onDelta={(dx) => setListW((w) => clampList(w + dx))} />
{/* Thread pane */} {/* Thread pane */}
<main className="relative min-w-0 flex-1 bg-app-surface"> <main ref={paneRef} className="relative min-w-0 flex-1 bg-app-surface">
{checkedIds.size > 0 ? ( {paneDraft ? (
// Keyed by draft: picking a different one must build a fresh editor,
// because RichTextEditor seeds its body exactly once.
<Composer
key={paneDraft.draftId}
init={paneDraft}
onClose={closePaneDraft}
fullWindow
/>
) : checkedIds.size > 0 ? (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-[15px]"> <div className="flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-[15px]">
{currentFolder === "archive" || {currentFolder === "archive" ||
@@ -1029,24 +1106,6 @@ export function MailLayout() {
<span className="text-3xl">{t("mail.selectConversation")}</span> <span className="text-3xl">{t("mail.selectConversation")}</span>
</div> </div>
)} )}
{composing && (
<div
className={
composerExpanded
? "absolute inset-0 z-30"
: "absolute bottom-0 right-4 z-30 w-[47.5rem] max-w-[calc(100%-2rem)]"
}
>
<Composer
key={composeKey}
init={composing}
expanded={composerExpanded}
onToggleExpand={() => setComposerExpanded((v) => !v)}
onClose={closeComposer}
/>
</div>
)}
</main> </main>
{rightOpen && ( {rightOpen && (
<> <>
@@ -1056,6 +1115,14 @@ export function MailLayout() {
</div> </div>
</> </>
)} )}
<ComposeDock
windows={composeWins}
pane={pane}
onMinimize={minimizeComposer}
onRestore={restoreComposer}
onToggleExpand={toggleComposerExpand}
onClose={closeComposer}
/>
</div> </div>
); );
} }
+216
View File
@@ -0,0 +1,216 @@
import { describe, expect, it } from "vitest";
import type { ComposerInit } from "../components/compose/Composer";
import {
collapseExpanded,
type ComposeWin,
layoutDock,
minimizeAll,
minimizeWindow,
openWindow,
restoreWindow,
toggleExpand,
} from "../components/compose/windows";
const init = (draftId?: string): ComposerInit => ({ accountId: "a1", kind: "new", draftId });
let clock = 0;
const tick = () => (clock += 1);
/** Open `n` windows the way repeated Compose clicks would. */
function opened(n: number): ComposeWin[] {
clock = 0;
let wins: ComposeWin[] = [];
for (let i = 1; i <= n; i += 1) wins = openWindow(wins, `c${i}`, init(), false, tick());
return wins;
}
const ids = (wins: { id: string }[]) => wins.map((w) => w.id);
const byId = (wins: ComposeWin[], id: string) => wins.find((w) => w.id === id)!;
const openIds = (wins: ComposeWin[]) => ids(wins.filter((w) => !w.minimized));
describe("compose window list", () => {
it("parks the open window instead of replacing it", () => {
const wins = opened(3);
expect(ids(wins)).toEqual(["c1", "c2", "c3"]);
expect(wins.map((w) => w.minimized)).toEqual([true, true, false]);
});
it("keeps every window's identity as the list grows", () => {
const first = opened(1)[0];
const later = openWindow(
openWindow([first], "c2", init(), false, tick()),
"c3",
init(),
false,
tick(),
);
// Same object identity for c1's init: nothing was rebuilt underneath it.
expect(later[0].init).toBe(first.init);
});
it("reopening a draft restores its window rather than adding a second", () => {
const wins = openWindow(opened(2), "c3", init("draft-1"), false, tick());
const again = openWindow(wins, "c4", init("draft-1"), false, tick());
expect(again).toHaveLength(3);
expect(openIds(again)).toEqual(["c3"]);
});
it("restoring a window leaves every other window as it was", () => {
// c3 open, c1 and c2 collapsed; restoring c1 must not park c3.
const wins = restoreWindow(opened(3), "c1", tick());
expect(openIds(wins)).toEqual(["c1", "c3"]);
expect(ids(wins)).toEqual(["c1", "c2", "c3"]); // and nothing moved
});
it("restoring marks the window as the most recently reached for", () => {
const wins = restoreWindow(opened(3), "c1", tick());
const newest = [...wins].sort((a, b) => b.activated - a.activated)[0];
expect(newest.id).toBe("c1");
});
it("expanding claims the row; collapsing out of it leaves the others alone", () => {
const big = toggleExpand(opened(2), "c1", tick());
expect(byId(big, "c1")).toMatchObject({ minimized: false, expanded: true });
expect(byId(big, "c2").minimized).toBe(true);
const back = restoreWindow(big, "c2", tick());
const small = toggleExpand(back, "c1", tick());
expect(openIds(small).sort()).toEqual(["c1", "c2"]);
});
it("expanding moves the window to the end of the row", () => {
// The row is right-anchored: a bar after an expanded window would shove it
// left, off the reading pane and across the list.
const wins = toggleExpand(opened(3), "c1", tick());
expect(ids(wins)).toEqual(["c2", "c3", "c1"]);
expect(byId(wins, "c1").expanded).toBe(true);
});
it("a window already last is not shuffled, and collapsing moves nothing", () => {
const wins = opened(3);
const big = toggleExpand(wins, "c3", tick());
expect(ids(big)).toEqual(["c1", "c2", "c3"]);
// And it stays put on the way back down.
expect(ids(toggleExpand(big, "c3", tick()))).toEqual(["c1", "c2", "c3"]);
expect(ids(toggleExpand(toggleExpand(wins, "c1", tick()), "c1", tick()))).toEqual([
"c2",
"c3",
"c1",
]);
});
it("a folder switch parks an expanded window without closing it", () => {
const big = toggleExpand(opened(2), "c1", tick());
const after = collapseExpanded(big);
expect(byId(after, "c1")).toMatchObject({ minimized: true, expanded: false });
// Nothing expanded -> the same array, so an effect calling this can't loop.
expect(collapseExpanded(after)).toBe(after);
});
it("minimize and Escape park windows without dropping them", () => {
expect(minimizeWindow(opened(2), "c2").every((w) => w.minimized)).toBe(true);
const all = minimizeAll(opened(3));
expect(all).toHaveLength(3);
expect(all.every((w) => w.minimized)).toBe(true);
});
});
describe("dock layout", () => {
it("never reorders the row", () => {
const wins = restoreWindow(opened(4), "c1", tick());
expect(ids(layoutDock(1600, 950, wins).items.map((i) => i.win))).toEqual(["c1", "c2", "c3", "c4"]);
});
it("draws several open windows side by side when they fit", () => {
const wins = restoreWindow(opened(3), "c1", tick());
const { items } = layoutDock(1600, 950, wins);
expect(items.filter((i) => i.open).map((i) => i.win.id)).toEqual(["c1", "c3"]);
});
it("parks the least recently reached-for window when they stop fitting", () => {
// Three open windows can't fit in 1000px at 420 apiece.
let wins = opened(3);
wins = restoreWindow(wins, "c1", tick());
wins = restoreWindow(wins, "c2", tick());
expect(openIds(wins)).toEqual(["c1", "c2", "c3"]);
const { items } = layoutDock(1000, 950, wins);
// c3 was activated first of the three, so it is the one drawn as a bar —
// its state is untouched, so it reopens when there is room again.
expect(items.filter((i) => i.open).map((i) => i.win.id)).toEqual(["c1", "c2"]);
expect(layoutDock(2400, 950, wins).items.every((i) => i.open)).toBe(true);
});
it("moves the oldest bars into the overflow menu when the row fills up", () => {
const { items, overflow } = layoutDock(1600, 950, opened(6));
expect(overflow).toBeGreaterThan(0);
expect(overflow).toBeLessThan(5); // some bars still on screen
expect(items.filter((i) => !i.open && !i.hidden)[0].width).toBeGreaterThanOrEqual(256);
});
it("never lets the row run off the left edge", () => {
for (const vw of [520, 760, 1024, 1280, 1600, 2560]) {
for (const n of [1, 2, 3, 6, 12]) {
let wins = opened(n);
// Worst case: the user has restored everything, so every window wants
// to be a full composer.
for (const w of wins) wins = restoreWindow(wins, w.id, tick());
const { items, overflow } = layoutDock(vw, 950, wins);
const visible = items.filter((i) => !i.hidden);
const chip = overflow > 0 ? 56 + 8 : 0;
const width =
chip + visible.reduce((n2, i) => n2 + i.width + 8, 0) - (visible.length ? 8 : 0);
expect(width, `vw=${vw} n=${n}`).toBeLessThanOrEqual(vw - 32);
}
}
});
it("an expanded window matches the reading pane", () => {
const wins = toggleExpand(opened(2), "c2", tick());
for (const paneW of [640, 980, 1240]) {
const { items } = layoutDock(1600, 950, wins, paneW);
expect(items.find((i) => i.win.id === "c2")?.width).toBe(paneW);
}
// Unmeasured (first paint, or no pane at all) falls back to a fixed width
// rather than collapsing the window to nothing.
expect(layoutDock(1600, 950, wins).items.find((i) => i.win.id === "c2")?.width).toBe(1112);
});
it("an expanded window is the rightmost thing on the row", () => {
// What the alignment in ComposeDock assumes: it slides the last item over
// to sit on the pane, which is only correct if nothing follows it.
for (const id of ["c1", "c2", "c3"]) {
const { items } = layoutDock(1600, 950, toggleExpand(opened(3), id, tick()), 900);
expect(items[items.length - 1].win.id, `expanded ${id}`).toBe(id);
expect(items[items.length - 1].win.expanded).toBe(true);
}
});
it("an expanded window still fits the row when the pane is wider than it", () => {
const wins = toggleExpand(opened(2), "c1", tick());
const { items } = layoutDock(900, 950, wins, 2000);
expect(items.find((i) => i.win.id === "c1")?.width).toBeLessThanOrEqual(900 - 32);
});
it("gives every open window a height, so a long body scrolls instead of growing", () => {
const { items } = layoutDock(1600, 950, opened(2));
expect(items.find((i) => i.open)?.height).toBe(520);
// A title bar is as tall as its own header; the dock doesn't size it.
expect(items.find((i) => !i.open)?.height).toBeNull();
});
it("an expanded window takes the viewport, less a sliver of desktop", () => {
const { items } = layoutDock(1600, 950, toggleExpand(opened(1), "c1", tick()));
expect(items[0].height).toBe(942);
});
it("never stands taller than the screen", () => {
for (const vh of [320, 480, 700, 950, 1440]) {
const plain = layoutDock(1600, vh, opened(1)).items[0].height ?? 0;
const big = layoutDock(1600, vh, toggleExpand(opened(1), "c1", tick())).items[0].height ?? 0;
expect(plain, `vh=${vh}`).toBeLessThanOrEqual(vh - 8);
expect(big, `vh=${vh}`).toBeLessThanOrEqual(vh - 8);
}
});
});