diff --git a/frontend/src/components/compose/ComposeDock.tsx b/frontend/src/components/compose/ComposeDock.tsx new file mode 100644 index 0000000..02bcbd0 --- /dev/null +++ b/frontend/src/components/compose/ComposeDock.tsx @@ -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>({}); + const menuRef = useRef(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. +
+ {overflow > 0 && ( +
+ {menuOpen && ( +
+ {items + .filter((i) => i.hidden) + .map(({ win }) => ( + + ))} +
+ )} + +
+ )} + {/* 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 }) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/compose/Composer.tsx b/frontend/src/components/compose/Composer.tsx index ed7d6dd..f6b57b8 100644 --- a/frontend/src/components/compose/Composer.tsx +++ b/frontend/src/components/compose/Composer.tsx @@ -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(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 */}
- - fileInputRef.current?.click()} title={t("compose.attach")}> - - - - {t("compose.attach")} - - { - if (e.target.files && e.target.files.length) void addFiles(e.target.files); - e.target.value = ""; - }} - /> - setShowToolbar((v) => !v)} - title={t("compose.fmtBold")} - active={showToolbar} - > - A - -
- 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. + + ) : ( + <> + + fileInputRef.current?.click()} title={t("compose.attach")}> + + + + {t("compose.attach")} - {moreOpen && ( -
- - - -
- )} -
+ { + if (e.target.files && e.target.files.length) void addFiles(e.target.files); + e.target.value = ""; + }} + /> + setShowToolbar((v) => !v)} + title={t("compose.fmtBold")} + active={showToolbar} + > + A + +
+ setMoreOpen((v) => !v)} title={t("compose.more")}> + {t("compose.more")} + + {moreOpen && ( +
+ + + +
+ )} +
+ + )}
- {status === "saving" && {t("compose.savingDraft")}} - {status === "saved" && {t("compose.draftSaved")}} - {status === "error" && {t("compose.sendFailed")}} + {!minimized && status === "saving" && {t("compose.savingDraft")}} + {!minimized && status === "saved" && {t("compose.draftSaved")}} + {!minimized && status === "error" && ( + {t("compose.sendFailed")} + )} + {!fullWindow && onMinimize && ( + + + {minimized ? ( + + ) : ( + + )} + + + )} {!fullWindow && onToggleExpand && ( - + {expanded ? ( + + ) : ( + + )} )} @@ -655,138 +727,146 @@ export function Composer({
- {/* From */} -
- {t("compose.from")} - -
- - {t("compose.replyLabel")} -
- {fromOpen && ( -
- {accounts.map((a) => ( - - ))} + {t("compose.from")} + +
+ + {t("compose.replyLabel")}
- )} -
+ {fromOpen && ( +
+ {accounts.map((a) => ( + + ))} +
+ )} +
- {/* Recipients */} - { - markDirty(); - setTo(v); - }} - autoFocus={init.kind === "new" || init.kind === "forward"} - rightSlot={ - !showCc ? ( - - ) : undefined - } - /> - {showCc && ( - <> - { - markDirty(); - setCc(v); - }} - /> - { - markDirty(); - setBcc(v); - }} - /> - - )} - { - markDirty(); - setSubject(e.target.value); - }} - /> - - {(attachments.length > 0 || attachError) && ( -
- {attachments.map((att) => ( - - - - - {att.filename} - {formatBytes(att.size_bytes)} + {/* Recipients */} + { + markDirty(); + setTo(v); + }} + autoFocus={init.kind === "new" || init.kind === "forward"} + rightSlot={ + !showCc ? ( - - ))} - {attachError && {attachError}} -
- )} - - {/* Body — wrapped so a drop here is detected as the writing area. */} -
- {bodyReady ? ( - { - markDirty(); - setBodyHtml(html); - }} - showToolbar={showToolbar} - /> - ) : ( -
…
+ ) : undefined + } + /> + {showCc && ( + <> + { + markDirty(); + setCc(v); + }} + /> + { + markDirty(); + setBcc(v); + }} + /> + )} + { + markDirty(); + setSubject(e.target.value); + }} + /> + + {(attachments.length > 0 || attachError) && ( +
+ {attachments.map((att) => ( + + + + + {att.filename} + {formatBytes(att.size_bytes)} + + + ))} + {attachError && {attachError}} +
+ )} + + {/* Body — wrapped so a drop here is detected as the writing area. */} +
+ {bodyReady ? ( + { + 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} + /> + ) : ( +
…
+ )} +
); diff --git a/frontend/src/components/compose/windows.ts b/frontend/src/components/compose/windows.ts new file mode 100644 index 0000000..012429d --- /dev/null +++ b/frontend/src/components/compose/windows.ts @@ -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, + }; +} diff --git a/frontend/src/components/list/DraftsList.tsx b/frontend/src/components/list/DraftsList.tsx index 6fcb95b..dea93c8 100644 --- a/frontend/src/components/list/DraftsList.tsx +++ b/frontend/src/components/list/DraftsList.tsx @@ -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 (