Reading pane layouts: Side, Bottom (under the list) and Off (single panel)

Choose in Settings > Appearance, or the list's ⋯ menu > Mailbox display (a
popover with three preview cards; picking one applies it and closes it).
Stored per device, like the theme; phones keep their own single panel.

- Side: the existing three columns.
- Bottom: the list above, the reading pane below, 45/55 until the 3px divider
  is dragged (height remembered). The pane's top bar takes the list filter
  bar's colour; the empty-pane picture shrinks to fit.
- Off: one panel. The list, until a conversation opens in its place with a
  Back button; bulk actions move to a bar above the list. This reuses the
  phone's list/pane switching with desktop styling.
- In Bottom and Off the side-panel toggle sits beside the search box (Off also
  keeps it in the conversation toolbar, since the list is hidden there).
- An expanded composer covers the reading pane in every arrangement: the lower
  pane under the list, the whole panel with the pane off.

Resizer gains a horizontal orientation; layoutDock takes the pane height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 18:13:43 +02:00
co-authored by Claude Opus 5.5
parent befa1c2404
commit b1bdae6e59
9 changed files with 362 additions and 33 deletions
+107 -23
View File
@@ -1,5 +1,5 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useMatch, useNavigate, useSearchParams } from "react-router-dom";
import signetLogo from "../../icons/ultimo-logo-signet.svg";
@@ -25,12 +25,14 @@ import { ThemeToggle } from "../components/ThemeToggle";
import { EmptyIllustration } from "../components/EmptyIllustration";
import { BackfillBanner } from "../components/BackfillBanner";
import { ThreadView } from "../components/thread/ThreadView";
import { MailboxDisplay } from "../components/mail/MailboxDisplay";
import { RightPanel, type PanelTab } from "../components/mail/RightPanel";
import { useT } from "../i18n";
import type { MessageKey } from "../i18n/en";
import { api } from "../lib/api";
import { setTimeFormat } from "../lib/timeFormat";
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
import { useReadingPane } from "../lib/readingPane";
import { useIsMobile } from "../lib/useIsMobile";
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
import { PluginSlot } from "../plugins/PluginSlot";
@@ -72,6 +74,22 @@ export function MailLayout() {
// Smartphone layout: one column at a time — the list, or the reading pane full
// screen — with the sidebar as a slide-in drawer. Desktop is untouched by it.
const mobile = useIsMobile();
// Desktop arrangement of list and reading pane (Settings → Appearance, or the
// list's ⋯ menu → Mailbox display). A phone always uses its own single panel.
const readingPane = useReadingPane();
const bottom = !mobile && readingPane === "bottom";
// One panel that shows either the list or the open conversation: always on a
// phone, and on a desktop with the reading pane turned off.
const single = mobile || readingPane === "off";
// Under the list or with no reading pane, the side-panel toggle sits in the
// list's header beside the search box — with no reading pane also in the
// conversation's toolbar, since the list is hidden while one is open.
const panelToggleInList = !mobile && readingPane !== "side";
// The reading pane's top bar (conversation or bulk actions). Under the list it
// takes the colour of the list's filter bar, so the two bars read as a pair.
const paneBarClass = `flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border px-3 text-[15px] ${
bottom ? "bg-app-bg" : "bg-app-surface"
}`;
const [drawerOpen, setDrawerOpen] = useState(false);
const [term, setTerm] = useState("");
// Debounced copy of the search box for search-as-you-type (avoids a request per key).
@@ -115,17 +133,30 @@ export function MailLayout() {
// 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 });
const listRef = useRef<HTMLDivElement>(null);
const [pane, setPane] = useState<{ width: number; right: number; height?: number }>({
width: 0,
right: 0,
});
// With the reading pane off, the list and the conversation take turns in one
// panel; an expanded composer covers whichever of them is showing.
const listShowing = readingPane === "off" && !(paneDraft || threadId);
useEffect(() => {
const el = paneRef.current;
const el = listShowing ? listRef.current : 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) });
// the right panel is open. `height` only under the list: everywhere else
// the pane runs the full height of the screen.
setPane({
width: Math.round(r.width),
right: Math.round(window.innerWidth - r.right),
height: bottom ? Math.round(r.height) : undefined,
});
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
window.addEventListener("resize", measure);
@@ -133,12 +164,17 @@ export function MailLayout() {
ro.disconnect();
window.removeEventListener("resize", measure);
};
}, []);
}, [listShowing, bottom]);
const searchRef = useRef<HTMLInputElement>(null);
// Resizable panel widths (persisted). Drag the dividers to adjust.
const [sidebarW, setSidebarW] = useState(() => Number(localStorage.getItem("panel.sidebar")) || 240);
const [listW, setListW] = useState(() => Number(localStorage.getItem("panel.list")) || 384);
// The list's height when the reading pane sits under it.
// Until dragged, the list takes 45% of the height and the reading pane 55%.
const [listH, setListH] = useState(
() => Number(localStorage.getItem("panel.listH")) || Math.round(window.innerHeight * 0.45),
);
// Collapsed sidebar shows icons only (persisted). The mobile drawer is always full.
const [collapsedPref, setCollapsed] = useState(
() => localStorage.getItem("panel.collapsed") === "1",
@@ -154,6 +190,9 @@ export function MailLayout() {
const clampRight = (w: number) => Math.min(Math.max(w, 280), 560);
useEffect(() => localStorage.setItem("panel.sidebar", String(sidebarW)), [sidebarW]);
useEffect(() => localStorage.setItem("panel.list", String(listW)), [listW]);
useEffect(() => localStorage.setItem("panel.listH", String(listH)), [listH]);
// Leave both the list and the reading pane at least a usable strip.
const clampListH = (h: number) => Math.min(Math.max(h, 160), window.innerHeight - 220);
useEffect(
() => localStorage.setItem("panel.collapsed", collapsedPref ? "1" : "0"),
[collapsedPref],
@@ -611,6 +650,10 @@ export function MailLayout() {
};
const [moreOpen, setMoreOpen] = useState(false);
// The list's ⋯ menu → Mailbox display popover. Stable callback: the popover's
// outside-click listener re-binds whenever it changes.
const [displayOpen, setDisplayOpen] = useState(false);
const closeDisplay = useCallback(() => setDisplayOpen(false), []);
const markAllRead = useMutation({
mutationFn: () => api.markAllRead(view),
onSuccess: () => {
@@ -888,13 +931,20 @@ export function MailLayout() {
<Resizer onDelta={(dx) => setSidebarW((w) => clampSidebar(w + dx))} />
)}
{/* Message list (the whole screen on a phone, until a thread opens) */}
{/* List and reading pane. Side by side, the wrapper is layout-neutral
(`contents`); with the pane at the bottom it stacks them. Keeping the
same wrapper in every arrangement keeps both mounted across a switch. */}
<div className={bottom ? "flex min-h-0 min-w-0 flex-1 flex-col" : "contents"}>
{/* Message list (the whole panel in single-panel mode, until a thread opens) */}
<div
style={mobile ? undefined : { width: listW }}
ref={listRef}
style={single ? undefined : bottom ? { height: listH } : { width: listW }}
className={
mobile
single
? `relative min-w-0 flex-1 flex-col bg-app-surface ${paneShown ? "hidden" : "flex"}`
: "flex shrink-0 flex-col border-r border-app-border bg-app-surface"
: bottom
? "flex shrink-0 flex-col bg-app-surface" // the divider below is the 3px resizer
: "flex shrink-0 flex-col border-r border-app-border bg-app-surface"
}
>
<div
@@ -963,6 +1013,20 @@ export function MailLayout() {
</>
)}
</div>
{panelToggleInList && (
<button
className={`ml-2 shrink-0 rounded p-1.5 hover:bg-app-hover hover:text-app-fg ${rightOpen ? "text-app-fg" : "text-app-muted"}`}
title={t("panel.toggle")}
aria-label={t("panel.toggle")}
aria-pressed={rightOpen}
onClick={() => (rightOpen ? setRightOpen(false) : openPanel(rightTab))}
>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M15 4v16" />
</svg>
</button>
)}
</div>
{!isDrafts && (
<div
@@ -1055,16 +1119,29 @@ export function MailLayout() {
>
{t("mail.markAllRead")}
</button>
{/* A phone always reads in a single panel: nothing to choose. */}
{!mobile && (
<button
className="flex w-full items-center px-3 py-2 text-left text-[13px] text-app-fg hover:bg-app-hover"
onClick={() => {
setMoreOpen(false);
setDisplayOpen(true);
}}
>
{t("display.title")}
</button>
)}
</div>
</>
)}
{displayOpen && <MailboxDisplay onClose={closeDisplay} />}
</div>
</div>
</div>
)}
{mobile && checkedIds.size > 0 && (
// The desktop shows bulk actions in the reading pane; a phone has none
// beside the list, so they sit above it.
{single && checkedIds.size > 0 && (
// Side by side, bulk actions show in the reading pane; with a single
// panel there is none beside the list, so they sit above it.
<div className="flex h-[48px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-2 text-[15px]">
<span className="px-2 text-[13px] text-app-muted">{checkedIds.size}</span>
{bulkButtons}
@@ -1160,12 +1237,17 @@ export function MailLayout() {
)}
</div>
{!mobile && <Resizer onDelta={(dx) => setListW((w) => clampList(w + dx))} />}
{!single &&
(bottom ? (
<Resizer orientation="horizontal" onDelta={(dy) => setListH((h) => clampListH(h + dy))} />
) : (
<Resizer onDelta={(dx) => setListW((w) => clampList(w + dx))} />
))}
{/* Thread pane (full screen on a phone, shown only with something open) */}
<main
ref={paneRef}
className={`relative min-w-0 flex-1 bg-app-surface ${mobile && !paneShown ? "hidden" : ""}`}
className={`relative min-h-0 min-w-0 flex-1 bg-app-surface ${single && !paneShown ? "hidden" : ""}`}
>
{paneDraft ? (
// Keyed by draft: picking a different one must build a fresh editor,
@@ -1176,9 +1258,9 @@ export function MailLayout() {
onClose={closePaneDraft}
fullWindow
/>
) : checkedIds.size > 0 && !mobile ? (
) : checkedIds.size > 0 && !single ? (
<div className="flex h-full flex-col">
<div className="flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-[15px]">
<div className={paneBarClass}>
{bulkButtons}
</div>
<div className="flex flex-1 items-center justify-center text-3xl text-app-faint">
@@ -1189,8 +1271,8 @@ export function MailLayout() {
</div>
) : threadId && detailQuery.data ? (
<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]">
{mobile && (
<div className={paneBarClass}>
{single && (
<button
type="button"
title={t("common.back")}
@@ -1261,7 +1343,7 @@ export function MailLayout() {
<path d="M15 4v16" />
</svg>
</button>
) : (
) : bottom ? null : ( // under the list it lives in the list's header only
<button
className={`rounded p-1.5 hover:bg-app-hover hover:text-app-fg ${rightOpen ? "text-app-fg" : "text-app-muted"}`}
title={t("panel.toggle")}
@@ -1289,7 +1371,7 @@ export function MailLayout() {
</div>
) : (
<div className="flex h-full flex-col items-center justify-center gap-6 text-app-faint">
{!rightOpen && (
{!rightOpen && !panelToggleInList && (
<button
className="absolute right-3 top-3 rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={t("panel.toggle")}
@@ -1302,11 +1384,13 @@ export function MailLayout() {
</svg>
</button>
)}
<EmptyIllustration className="w-56 max-w-[55%]" />
<span className="text-3xl">{t("mail.selectConversation")}</span>
{/* Under the list the pane is short: a smaller picture and line. */}
<EmptyIllustration className={bottom ? "w-32 max-w-[40%]" : "w-56 max-w-[55%]"} />
<span className={bottom ? "text-xl" : "text-3xl"}>{t("mail.selectConversation")}</span>
</div>
)}
</main>
</div>
{mobile && sheetUsed && (
<>
<div