Compare commits
3
Commits
c5742f19da
...
9f9e959a05
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f9e959a05 | ||
|
|
e4a521ae20 | ||
|
|
ea137c32c3 |
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useRef } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { api } from "../../lib/api";
|
||||
@@ -56,6 +56,7 @@ function Paperclip() {
|
||||
// Virtualized thread list (SPEC §2 list/MessageList.tsx).
|
||||
export function MessageList({
|
||||
threads,
|
||||
page,
|
||||
selectedId,
|
||||
folder,
|
||||
onSelect,
|
||||
@@ -69,6 +70,8 @@ export function MessageList({
|
||||
onToggleCheck,
|
||||
}: {
|
||||
threads: ThreadSummary[];
|
||||
/** Which page of the list this is. Changing it scrolls back to the top. */
|
||||
page: number;
|
||||
selectedId?: string;
|
||||
folder: string;
|
||||
onSelect: (id: string) => void;
|
||||
@@ -106,6 +109,12 @@ export function MessageList({
|
||||
overscan: 8,
|
||||
});
|
||||
|
||||
// A new page starts at the top. Paging while scrolled down would otherwise
|
||||
// drop you into the middle of the next fifty with no sense of having moved.
|
||||
useEffect(() => {
|
||||
parentRef.current?.scrollTo({ top: 0 });
|
||||
}, [page]);
|
||||
|
||||
if (threads.length === 0) {
|
||||
return <div className="p-6 text-[15px] text-app-faint">{t("mail.noConversations")}</div>;
|
||||
}
|
||||
|
||||
@@ -203,6 +203,9 @@ export const en = {
|
||||
"compose.from": "From",
|
||||
"compose.attach": "Attach",
|
||||
"compose.more": "More",
|
||||
"compose.minimize": "Minimize",
|
||||
"compose.restore": "Restore",
|
||||
"compose.moreWindows": "More drafts",
|
||||
"compose.expand": "Expand",
|
||||
"compose.collapse": "Collapse",
|
||||
"compose.popOut": "Pop out",
|
||||
|
||||
@@ -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,18 +897,20 @@ 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));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MessageList
|
||||
threads={pageThreads}
|
||||
page={page}
|
||||
selectedId={threadId}
|
||||
folder={currentFolder}
|
||||
onSelect={(id) => navigate(`/mail/t/${id}`)}
|
||||
@@ -895,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" ||
|
||||
@@ -1028,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 && (
|
||||
<>
|
||||
@@ -1055,6 +1115,14 @@ export function MailLayout() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<ComposeDock
|
||||
windows={composeWins}
|
||||
pane={pane}
|
||||
onMinimize={minimizeComposer}
|
||||
onRestore={restoreComposer}
|
||||
onToggleExpand={toggleComposerExpand}
|
||||
onClose={closeComposer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -110,6 +110,10 @@ export default function mount(el, ctx) {
|
||||
if (!name) { adding = null; render(); return; }
|
||||
await ctx.api.pluginFetch("/labels", { method: "POST", body: JSON.stringify({ name }) });
|
||||
adding = null;
|
||||
// Creating a label while the section is collapsed would hide the thing you
|
||||
// just made, so open it — and remember that, the way the chevron does.
|
||||
collapsed = false;
|
||||
try { localStorage.setItem("labels.collapsed", "0"); } catch { /* ignore */ }
|
||||
window.dispatchEvent(new Event("webmail:labels-changed"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user