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:
@@ -4,7 +4,17 @@ import { Link, useMatch, useNavigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
import signetLogo from "../../icons/ultimo-logo-signet.svg";
|
||||
import { AppSwitcher } from "../components/AppSwitcher";
|
||||
import { ComposeDock } from "../components/compose/ComposeDock";
|
||||
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 { Icon } from "../components/Icon";
|
||||
import { MessageList } from "../components/list/MessageList";
|
||||
@@ -80,12 +90,37 @@ export function MailLayout() {
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
const [composing, setComposing] = useState<ComposerInit | null>(null);
|
||||
const [composerExpanded, setComposerExpanded] = useState(false);
|
||||
// Bumped each time a composer is opened so the <Composer> remounts (fresh state)
|
||||
// when switching between drafts instead of keeping the previous draft's editor.
|
||||
const [composeKey, setComposeKey] = useState(0);
|
||||
// Every open compose window, oldest first — the dock lays them out left to
|
||||
// right and keeps each one mounted for its whole life.
|
||||
const [composeWins, setComposeWins] = useState<ComposeWin[]>([]);
|
||||
const composeSeq = useRef(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 }>();
|
||||
// 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);
|
||||
|
||||
// Resizable panel widths (persisted). Drag the dividers to adjust.
|
||||
@@ -255,31 +290,63 @@ export function MailLayout() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [detailQuery.data?.id]);
|
||||
|
||||
// Open the composer with fresh state, remounting it if one is already open
|
||||
// (e.g. clicking a different draft replaces the current editor).
|
||||
// Opening a composer parks whatever was open in the dock instead of replacing
|
||||
// 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) => {
|
||||
setComposing(init);
|
||||
setComposerExpanded(expand);
|
||||
setComposeKey((k) => k + 1);
|
||||
const seq = ++composeSeq.current;
|
||||
setComposeWins((prev) => openWindow(prev, `c${seq}`, init, expand, seq));
|
||||
};
|
||||
|
||||
const closeComposer = () => {
|
||||
setComposing(null);
|
||||
setComposerExpanded(false);
|
||||
// `composeSeq` doubles as the activation clock the dock uses to decide which
|
||||
// windows stay open when they no longer all fit.
|
||||
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-remote"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
||||
};
|
||||
|
||||
// Leaving a folder closes any open composer/draft editor: the reading pane
|
||||
// should reflect the folder you navigated to, not a leftover draft editor.
|
||||
const closeComposer = (id: string) => {
|
||||
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);
|
||||
useEffect(() => {
|
||||
if (firstView.current) {
|
||||
firstView.current = false;
|
||||
return;
|
||||
}
|
||||
closeComposer();
|
||||
setComposeWins(collapseExpanded);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view]);
|
||||
|
||||
@@ -450,7 +517,7 @@ export function MailLayout() {
|
||||
onForward: () => setReplySignal({ kind: "forward", seq: Date.now() }),
|
||||
onArchive: archive,
|
||||
onGoInbox: () => selectView("folder:inbox"),
|
||||
onEscape: () => setComposing(null),
|
||||
onEscape: () => setComposeWins(minimizeAll),
|
||||
onNext: () => openRelative(1),
|
||||
onPrev: () => openRelative(-1),
|
||||
});
|
||||
@@ -830,13 +897,14 @@ export function MailLayout() {
|
||||
<DraftsList
|
||||
drafts={draftsQuery.data ?? []}
|
||||
remoteDrafts={remoteDraftsQuery.data ?? []}
|
||||
onOpen={(draft) => openComposer(draftToInit(draft), true)}
|
||||
selectedId={paneDraft?.draftId}
|
||||
onOpen={(draft) => openDraftInPane(draftToInit(draft))}
|
||||
onOpenRemote={async (remote) => {
|
||||
// Adopt the server draft into an editable local draft, then open it.
|
||||
const draft = await api.adoptDraft(remote.message_id);
|
||||
queryClient.invalidateQueries({ queryKey: ["drafts"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
|
||||
openComposer(draftToInit(draft), true);
|
||||
openDraftInPane(draftToInit(draft));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -896,8 +964,17 @@ export function MailLayout() {
|
||||
<Resizer onDelta={(dx) => setListW((w) => clampList(w + dx))} />
|
||||
|
||||
{/* Thread pane */}
|
||||
<main className="relative min-w-0 flex-1 bg-app-surface">
|
||||
{checkedIds.size > 0 ? (
|
||||
<main ref={paneRef} className="relative min-w-0 flex-1 bg-app-surface">
|
||||
{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-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-[15px]">
|
||||
{currentFolder === "archive" ||
|
||||
@@ -1029,24 +1106,6 @@ export function MailLayout() {
|
||||
<span className="text-3xl">{t("mail.selectConversation")}</span>
|
||||
</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>
|
||||
{rightOpen && (
|
||||
<>
|
||||
@@ -1056,6 +1115,14 @@ export function MailLayout() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<ComposeDock
|
||||
windows={composeWins}
|
||||
pane={pane}
|
||||
onMinimize={minimizeComposer}
|
||||
onRestore={restoreComposer}
|
||||
onToggleExpand={toggleComposerExpand}
|
||||
onClose={closeComposer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user