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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -113,14 +113,33 @@ export function Composer({
|
||||
onClose,
|
||||
expanded = false,
|
||||
onToggleExpand,
|
||||
minimized = false,
|
||||
onMinimize,
|
||||
onTitle,
|
||||
fitContainer = false,
|
||||
fullWindow = false,
|
||||
}: {
|
||||
init: ComposerInit;
|
||||
onClose: () => void;
|
||||
expanded?: boolean;
|
||||
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;
|
||||
}) {
|
||||
// Whether something other than the content decides how tall this is.
|
||||
const sized = fullWindow || fitContainer;
|
||||
const t = useT();
|
||||
const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
||||
const accounts: AccountOut[] = accountsQuery.data ?? [];
|
||||
@@ -154,6 +173,13 @@ export function Composer({
|
||||
return () => window.clearInterval(id);
|
||||
}, [undo]);
|
||||
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 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
|
||||
@@ -168,7 +194,7 @@ export function Composer({
|
||||
|
||||
// Close the From / More dropdowns on any click outside them.
|
||||
useEffect(() => {
|
||||
if (!moreOpen && !fromOpen) return;
|
||||
if (minimized || (!moreOpen && !fromOpen)) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (moreOpen && moreRef.current && !moreRef.current.contains(target)) setMoreOpen(false);
|
||||
@@ -176,7 +202,7 @@ export function Composer({
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [moreOpen, fromOpen]);
|
||||
}, [moreOpen, fromOpen, minimized]);
|
||||
|
||||
const account = accounts.find((a) => a.id === accountId);
|
||||
const fromName = account?.display_name || "";
|
||||
@@ -184,12 +210,15 @@ export function Composer({
|
||||
const signature = account?.signature ?? "";
|
||||
|
||||
// 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(() => {
|
||||
if (firstAccount.current) {
|
||||
firstAccount.current = false;
|
||||
return;
|
||||
}
|
||||
if (lastAccount.current === accountId) return;
|
||||
lastAccount.current = accountId;
|
||||
editorRef.current?.replaceSignature(accounts.find((a) => a.id === accountId)?.signature ?? "");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [accountId]);
|
||||
@@ -447,8 +476,14 @@ export function Composer({
|
||||
// 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();
|
||||
if (!dirtyRef.current && !draftId.current) {
|
||||
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 () => {
|
||||
@@ -511,7 +546,9 @@ export function Composer({
|
||||
className={`relative flex flex-col overflow-hidden border-app-border bg-app-elevated ${
|
||||
fullWindow
|
||||
? "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 && (
|
||||
@@ -553,82 +590,113 @@ export function Composer({
|
||||
)}
|
||||
{/* 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
|
||||
type="button"
|
||||
onClick={send}
|
||||
disabled={status === "sending" || (!to && !cc)}
|
||||
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"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14.5 1.5L7.2 8.8" />
|
||||
<path d="M14.5 1.5L9.8 14.5 7.2 8.8 1.5 6.2 14.5 1.5z" />
|
||||
</svg>
|
||||
{status === "sending" ? t("compose.sending") : t("compose.send")}
|
||||
</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">
|
||||
<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>
|
||||
{t("compose.attach")}
|
||||
</HeaderButton>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files && e.target.files.length) void addFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<HeaderButton
|
||||
onClick={() => setShowToolbar((v) => !v)}
|
||||
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")}
|
||||
{minimized ? (
|
||||
// Collapsed: the subject stands in for the action buttons, and the whole
|
||||
// bar is the restore target.
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMinimize}
|
||||
title={t("compose.restore")}
|
||||
className="min-w-0 flex-1 truncate py-1.5 text-left text-sm font-medium text-app-fg"
|
||||
>
|
||||
{subject.trim() || t("compose.newMessage")}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={send}
|
||||
disabled={status === "sending" || (!to && !cc)}
|
||||
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"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14.5 1.5L7.2 8.8" />
|
||||
<path d="M14.5 1.5L9.8 14.5 7.2 8.8 1.5 6.2 14.5 1.5z" />
|
||||
</svg>
|
||||
{status === "sending" ? t("compose.sending") : t("compose.send")}
|
||||
</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">
|
||||
<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>
|
||||
{t("compose.attach")}
|
||||
</HeaderButton>
|
||||
{moreOpen && (
|
||||
<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">
|
||||
<button
|
||||
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
setShowCc(true);
|
||||
}}
|
||||
>
|
||||
{t("compose.cc")} / {t("compose.bcc")}
|
||||
</button>
|
||||
<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>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files && e.target.files.length) void addFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<HeaderButton
|
||||
onClick={() => setShowToolbar((v) => !v)}
|
||||
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>
|
||||
{moreOpen && (
|
||||
<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">
|
||||
<button
|
||||
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
setShowCc(true);
|
||||
}}
|
||||
>
|
||||
{t("compose.cc")} / {t("compose.bcc")}
|
||||
</button>
|
||||
<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">
|
||||
{status === "saving" && <span className="mr-1">{t("compose.savingDraft")}</span>}
|
||||
{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 === "saving" && <span className="mr-1">{t("compose.savingDraft")}</span>}
|
||||
{!minimized && status === "saved" && <span className="mr-1">{t("compose.draftSaved")}</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 && (
|
||||
<HeaderButton
|
||||
onClick={onToggleExpand}
|
||||
@@ -636,7 +704,11 @@ export function Composer({
|
||||
active={expanded}
|
||||
>
|
||||
<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>
|
||||
</HeaderButton>
|
||||
)}
|
||||
@@ -655,138 +727,146 @@ export function Composer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* From */}
|
||||
<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"
|
||||
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"
|
||||
{/* Hidden rather than unmounted while collapsed: the editor seeds its
|
||||
contentEditable once, so dropping this subtree would lose the body. */}
|
||||
<div className={minimized ? "hidden" : "contents"}>
|
||||
{/* From */}
|
||||
<div
|
||||
className="relative flex items-center gap-2 border-b border-app-border px-3 py-2"
|
||||
ref={fromRef}
|
||||
>
|
||||
{fromName && <span className="font-medium text-app-accent">{fromName}</span>}
|
||||
<span className="text-app-muted">{fromName ? `<${fromEmail}>` : fromEmail}</span>
|
||||
{accounts.length > 1 && <span className="text-app-faint">{caret}</span>}
|
||||
</button>
|
||||
<div className="ml-auto flex items-center gap-1 text-xs text-app-muted">
|
||||
<Icon name="reply" size={13} />
|
||||
{t("compose.replyLabel")}
|
||||
</div>
|
||||
{fromOpen && (
|
||||
<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>
|
||||
))}
|
||||
<span className="w-10 shrink-0 text-xs font-semibold text-app-fg">{t("compose.from")}</span>
|
||||
<button
|
||||
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="text-app-muted">{fromName ? `<${fromEmail}>` : fromEmail}</span>
|
||||
{accounts.length > 1 && <span className="text-app-faint">{caret}</span>}
|
||||
</button>
|
||||
<div className="ml-auto flex items-center gap-1 text-xs text-app-muted">
|
||||
<Icon name="reply" size={13} />
|
||||
{t("compose.replyLabel")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{fromOpen && (
|
||||
<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 */}
|
||||
<RecipientChips
|
||||
label={t("compose.to")}
|
||||
value={to}
|
||||
onChange={(v) => {
|
||||
markDirty();
|
||||
setTo(v);
|
||||
}}
|
||||
autoFocus={init.kind === "new" || init.kind === "forward"}
|
||||
rightSlot={
|
||||
!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>
|
||||
{/* Recipients */}
|
||||
<RecipientChips
|
||||
label={t("compose.to")}
|
||||
value={to}
|
||||
onChange={(v) => {
|
||||
markDirty();
|
||||
setTo(v);
|
||||
}}
|
||||
autoFocus={init.kind === "new" || init.kind === "forward"}
|
||||
rightSlot={
|
||||
!showCc ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAttachment(att.id)}
|
||||
className="text-app-faint hover:text-app-fg"
|
||||
aria-label={`remove ${att.filename}`}
|
||||
className="text-xs text-app-muted hover:text-app-fg"
|
||||
onClick={() => setShowCc(true)}
|
||||
>
|
||||
✕
|
||||
{t("compose.cc")} {t("compose.bcc")}
|
||||
</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}
|
||||
/>
|
||||
) : (
|
||||
<div className="min-h-[250px] flex-1 px-3 py-3 text-sm text-app-faint">…</div>
|
||||
) : 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
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,7 @@ function DraftRow({
|
||||
preview,
|
||||
time,
|
||||
badge,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
recipients: string;
|
||||
@@ -22,12 +23,15 @@ function DraftRow({
|
||||
preview: string;
|
||||
time: string;
|
||||
badge?: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
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">
|
||||
<span className="flex-1 truncate text-sm text-app-fg">{recipients}</span>
|
||||
@@ -49,11 +53,14 @@ function DraftRow({
|
||||
export function DraftsList({
|
||||
drafts,
|
||||
remoteDrafts,
|
||||
selectedId,
|
||||
onOpen,
|
||||
onOpenRemote,
|
||||
}: {
|
||||
drafts: DraftOut[];
|
||||
remoteDrafts: RemoteDraftOut[];
|
||||
/** Id of the draft currently loaded in the reading pane. */
|
||||
selectedId?: string;
|
||||
onOpen: (draft: DraftOut) => void;
|
||||
onOpenRemote: (draft: RemoteDraftOut) => void;
|
||||
}) {
|
||||
@@ -70,6 +77,7 @@ export function DraftsList({
|
||||
subject: string;
|
||||
preview: string;
|
||||
badge?: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
const rows: Row[] = [
|
||||
@@ -79,6 +87,7 @@ export function DraftsList({
|
||||
recipients: d.payload.to.join(", ") || t("compose.noRecipient"),
|
||||
subject: d.payload.subject || t("compose.noSubject"),
|
||||
preview: d.payload.text.trim(),
|
||||
selected: d.id === selectedId,
|
||||
onClick: () => onOpen(d),
|
||||
})),
|
||||
...remoteDrafts.map((d) => ({
|
||||
@@ -88,6 +97,9 @@ export function DraftsList({
|
||||
subject: d.subject || t("compose.noSubject"),
|
||||
preview: d.snippet.trim(),
|
||||
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),
|
||||
})),
|
||||
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
@@ -102,6 +114,7 @@ export function DraftsList({
|
||||
preview={row.preview}
|
||||
time={timeLabel(row.date)}
|
||||
badge={row.badge}
|
||||
selected={row.selected}
|
||||
onClick={row.onClick}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user