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
+37 -6
View File
@@ -1,11 +1,24 @@
// A vertical drag handle for adjusting the width of the panel to its left. // A drag handle between two panels. Vertical (the default) sits between panels
export function Resizer({ onDelta }: { onDelta: (deltaX: number) => void }) { // side by side and reports horizontal movement; horizontal sits between panels
// stacked one above the other (the "reading pane at the bottom" layout) and
// reports vertical movement.
export function Resizer({
onDelta,
orientation = "vertical",
}: {
onDelta: (delta: number) => void;
orientation?: "vertical" | "horizontal";
}) {
const vertical = orientation === "vertical";
const cursor = vertical ? "col-resize" : "row-resize";
const onMouseDown = (e: React.MouseEvent) => { const onMouseDown = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
let lastX = e.clientX; let last = vertical ? e.clientX : e.clientY;
const onMove = (ev: MouseEvent) => { const onMove = (ev: MouseEvent) => {
onDelta(ev.clientX - lastX); const now = vertical ? ev.clientX : ev.clientY;
lastX = ev.clientX; onDelta(now - last);
last = now;
}; };
const onUp = () => { const onUp = () => {
document.removeEventListener("mousemove", onMove); document.removeEventListener("mousemove", onMove);
@@ -16,9 +29,27 @@ export function Resizer({ onDelta }: { onDelta: (deltaX: number) => void }) {
document.addEventListener("mousemove", onMove); document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp); document.addEventListener("mouseup", onUp);
document.body.style.userSelect = "none"; document.body.style.userSelect = "none";
document.body.style.cursor = "col-resize"; document.body.style.cursor = cursor;
}; };
if (!vertical) {
return (
// The divider itself: a solid 3px line between the list and the reading
// pane, accent-coloured on hover.
<div
onMouseDown={onMouseDown}
className="group relative z-20 h-[3px] w-full shrink-0 cursor-row-resize bg-app-border hover:bg-app-accent"
role="separator"
aria-orientation="horizontal"
>
{/* A taller invisible strip, so 3px is not all there is to grab. */}
<div className="absolute inset-x-0 -inset-y-1" />
{/* Small pill grab handle in the middle for easier grabbing. */}
<div className="absolute left-1/2 top-1/2 h-[7px] w-8 -translate-x-1/2 -translate-y-1/2 rounded-full border border-app-border bg-app-elevated group-hover:border-app-accent group-hover:bg-app-accent" />
</div>
);
}
return ( return (
<div <div
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
@@ -39,8 +39,9 @@ export function ComposeDock({
fullScreen = false, fullScreen = false,
}: { }: {
windows: ComposeWin[]; windows: ComposeWin[];
/** Live geometry of the reading pane; an expanded window is laid over it. */ /** Live geometry of the reading pane; an expanded window is laid over it.
pane: { width: number; right: number }; * `height` only when the pane does not run the full height of the screen. */
pane: { width: number; right: number; height?: number };
onMinimize: (id: string) => void; onMinimize: (id: string) => void;
onRestore: (id: string) => void; onRestore: (id: string) => void;
onToggleExpand: (id: string) => void; onToggleExpand: (id: string) => void;
@@ -88,7 +89,7 @@ export function ComposeDock({
); );
} }
const { items, overflow } = layoutDock(vw, vh, windows, pane.width); const { items, overflow } = layoutDock(vw, vh, windows, pane.width, pane.height ?? 0);
const title = (w: ComposeWin) => const title = (w: ComposeWin) =>
(titles[w.id] ?? w.init.subject ?? "").trim() || t("compose.newMessage"); (titles[w.id] ?? w.init.subject ?? "").trim() || t("compose.newMessage");
+7 -1
View File
@@ -143,12 +143,15 @@ export interface DockLayout {
* @param paneW Live width of the reading pane. An expanded window matches it, * @param paneW Live width of the reading pane. An expanded window matches it,
* so expanding tracks the sidebar/list resizers and the viewport * so expanding tracks the sidebar/list resizers and the viewport
* instead of standing at one hard-coded width. * instead of standing at one hard-coded width.
* @param paneH The pane's height when it does not span the screen (the reading
* pane under the list); 0 means full height.
*/ */
export function layoutDock( export function layoutDock(
vw: number, vw: number,
vh: number, vh: number,
windows: ComposeWin[], windows: ComposeWin[],
paneW = 0, paneW = 0,
paneH = 0,
): DockLayout { ): DockLayout {
const budget = Math.max(0, vw - GUTTER); const budget = Math.max(0, vw - GUTTER);
const wantOpen = windows.filter((w) => !w.minimized); const wantOpen = windows.filter((w) => !w.minimized);
@@ -193,8 +196,11 @@ export function layoutDock(
const barW = const barW =
barsShown > 0 ? Math.min(BAR_MAX, Math.floor((avail - GAP * (barsShown - 1)) / barsShown)) : 0; barsShown > 0 ? Math.min(BAR_MAX, Math.floor((avail - GAP * (barsShown - 1)) / barsShown)) : 0;
// Expanded, a window is as tall as the pane it covers: the full height beside
// the list, or only the lower pane when the reading pane sits under the list.
const expandedH = paneH > 0 ? paneH : vh;
const heightOf = (w: ComposeWin) => const heightOf = (w: ComposeWin) =>
Math.max(0, Math.min(w.expanded ? vh : WINDOW_H, vh - EDGE)); Math.max(0, Math.min(w.expanded ? expandedH : WINDOW_H, vh - EDGE));
return { return {
items: windows.map((w) => ({ items: windows.map((w) => ({
@@ -0,0 +1,53 @@
import { useEffect, useRef } from "react";
import { useT } from "../../i18n";
import { ReadingPanePicker } from "./ReadingPanePicker";
/**
* "Mailbox display": the reading-pane choice, opened from the list's ⋯ menu.
* Anchored below that menu. Picking a layout applies it and closes the popover;
* ✕, Escape or a click elsewhere closes it without a change.
*/
export function MailboxDisplay({ onClose }: { onClose: () => void }) {
const t = useT();
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
window.addEventListener("keydown", onKey);
document.addEventListener("mousedown", onDown);
return () => {
window.removeEventListener("keydown", onKey);
document.removeEventListener("mousedown", onDown);
};
}, [onClose]);
return (
<div
ref={ref}
role="dialog"
aria-label={t("display.title")}
className="absolute right-0 top-full z-30 mt-1 w-[360px] rounded-xl border border-app-border bg-app-elevated p-4 shadow-2xl"
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold text-app-fg">{t("display.title")}</h3>
<button
type="button"
onClick={onClose}
title={t("common.close")}
aria-label={t("common.close")}
className="rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden>
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</div>
<p className="mb-2 text-[13px] font-medium text-app-fg">{t("display.readingPane")}</p>
<ReadingPanePicker onPick={onClose} />
</div>
);
}
@@ -0,0 +1,91 @@
import { useT } from "../../i18n";
import { READING_PANES, type ReadingPane, setReadingPane, useReadingPane } from "../../lib/readingPane";
// One list row in a preview: an avatar dot and two lines of text.
function Row({ wide = false }: { wide?: boolean }) {
return (
<div className="flex items-center gap-1">
<span className="h-2 w-2 shrink-0 rounded-full bg-app-faint" />
<span className="flex flex-1 flex-col gap-[3px]">
<span className={`h-[3px] rounded-full bg-app-faint ${wide ? "w-full" : "w-4/5"}`} />
<span className={`h-[3px] rounded-full bg-app-border ${wide ? "w-3/4" : "w-3/5"}`} />
</span>
</div>
);
}
// A miniature of the mail view in each arrangement.
function Preview({ pane }: { pane: ReadingPane }) {
const box = "h-[54px] w-full overflow-hidden rounded-md border border-app-border bg-app-bg p-1";
if (pane === "side") {
return (
<div className={`${box} flex gap-1`}>
<div className="flex w-[38%] flex-col gap-1.5 pt-0.5">
<Row />
<Row />
</div>
<div className="flex-1 rounded-sm bg-app-border" />
</div>
);
}
if (pane === "bottom") {
return (
<div className={`${box} flex flex-col gap-1`}>
<div className="flex flex-col gap-1.5 pt-0.5">
<Row wide />
</div>
<div className="flex-1 rounded-sm bg-app-border" />
</div>
);
}
return (
<div className={`${box} flex flex-col gap-1.5 pt-1.5`}>
<Row wide />
<Row wide />
</div>
);
}
/** The three reading-pane arrangements as selectable cards (Side / Bottom / Off).
* `onPick` runs after a choice — the ⋯ menu's popover uses it to close itself. */
export function ReadingPanePicker({ onPick }: { onPick?: (pane: ReadingPane) => void }) {
const t = useT();
const current = useReadingPane();
return (
<div role="radiogroup" aria-label={t("display.readingPane")} className="grid grid-cols-3 gap-2">
{READING_PANES.map((pane) => {
const on = pane === current;
return (
<button
key={pane}
type="button"
role="radio"
aria-checked={on}
onClick={() => {
setReadingPane(pane);
onPick?.(pane);
}}
className={`flex flex-col gap-2 rounded-lg border p-2 text-left transition-colors ${
on
? "border-app-accent bg-app-accent/10"
: "border-app-border hover:bg-app-hover"
}`}
>
<Preview pane={pane} />
<span className="flex items-center gap-2 px-0.5 text-[14px] text-app-fg">
<span
aria-hidden
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 ${
on ? "border-app-accent" : "border-app-faint"
}`}
>
{on && <span className="h-2 w-2 rounded-full bg-app-accent" />}
</span>
{t(`display.${pane}` as const)}
</span>
</button>
);
})}
</div>
);
}
+6
View File
@@ -462,6 +462,12 @@ export const en = {
"admin.saving": "Saving…", "admin.saving": "Saving…",
"admin.saved": "Saved", "admin.saved": "Saved",
"admin.quotaInvalid": "Enter a size greater than 0.", "admin.quotaInvalid": "Enter a size greater than 0.",
"display.title": "Mailbox display",
"display.readingPane": "Reading pane",
"display.side": "Side",
"display.bottom": "Bottom",
"display.off": "Off",
"display.phoneNote": "On a phone, mail always opens in a single panel.",
"admin.mail": "Outgoing system mail", "admin.mail": "Outgoing system mail",
"admin.mailHelp": "admin.mailHelp":
"The mail account the server sends password-reset links from. Without it, “Forgot password?” sends nothing.", "The mail account the server sends password-reset links from. Without it, “Forgot password?” sends nothing.",
+48
View File
@@ -0,0 +1,48 @@
// Where the reading pane sits on a desktop: beside the list ("side", the
// classic three columns), under it ("bottom"), or nowhere ("off") — then the
// list and the open conversation take turns in one panel, as on a phone.
//
// Per device, in localStorage, like the theme: the right answer depends on the
// screen, so a laptop and a large monitor may well want different ones. Phones
// always use their own single-panel layout and ignore it.
import { useSyncExternalStore } from "react";
export type ReadingPane = "side" | "bottom" | "off";
export const READING_PANES: readonly ReadingPane[] = ["side", "bottom", "off"];
const KEY = "mail.readingPane";
const EVENT = "webmail:reading-pane";
function read(): ReadingPane {
try {
const v = localStorage.getItem(KEY);
return v === "bottom" || v === "off" ? v : "side";
} catch {
return "side";
}
}
function subscribe(onChange: () => void): () => void {
// Our own writes (same tab) and other tabs' writes (the storage event).
window.addEventListener(EVENT, onChange);
window.addEventListener("storage", onChange);
return () => {
window.removeEventListener(EVENT, onChange);
window.removeEventListener("storage", onChange);
};
}
export function useReadingPane(): ReadingPane {
return useSyncExternalStore(subscribe, read, () => "side");
}
export function setReadingPane(value: ReadingPane): void {
try {
localStorage.setItem(KEY, value);
} catch {
/* private mode: the choice simply does not stick */
}
window.dispatchEvent(new Event(EVENT));
}
+107 -23
View File
@@ -1,5 +1,5 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { Link, useMatch, useNavigate, useSearchParams } from "react-router-dom";
import signetLogo from "../../icons/ultimo-logo-signet.svg"; import signetLogo from "../../icons/ultimo-logo-signet.svg";
@@ -25,12 +25,14 @@ import { ThemeToggle } from "../components/ThemeToggle";
import { EmptyIllustration } from "../components/EmptyIllustration"; import { EmptyIllustration } from "../components/EmptyIllustration";
import { BackfillBanner } from "../components/BackfillBanner"; import { BackfillBanner } from "../components/BackfillBanner";
import { ThreadView } from "../components/thread/ThreadView"; import { ThreadView } from "../components/thread/ThreadView";
import { MailboxDisplay } from "../components/mail/MailboxDisplay";
import { RightPanel, type PanelTab } from "../components/mail/RightPanel"; import { RightPanel, type PanelTab } from "../components/mail/RightPanel";
import { useT } from "../i18n"; import { useT } from "../i18n";
import type { MessageKey } from "../i18n/en"; import type { MessageKey } from "../i18n/en";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { setTimeFormat } from "../lib/timeFormat"; import { setTimeFormat } from "../lib/timeFormat";
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types"; import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
import { useReadingPane } from "../lib/readingPane";
import { useIsMobile } from "../lib/useIsMobile"; import { useIsMobile } from "../lib/useIsMobile";
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts"; import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
import { PluginSlot } from "../plugins/PluginSlot"; 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 // 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. // screen — with the sidebar as a slide-in drawer. Desktop is untouched by it.
const mobile = useIsMobile(); 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 [drawerOpen, setDrawerOpen] = useState(false);
const [term, setTerm] = useState(""); const [term, setTerm] = useState("");
// Debounced copy of the search box for search-as-you-type (avoids a request per key). // 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 // size answers to the two resizers and the right panel as well as the
// viewport, so it is measured rather than derived. // viewport, so it is measured rather than derived.
const paneRef = useRef<HTMLElement>(null); 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(() => { useEffect(() => {
const el = paneRef.current; const el = listShowing ? listRef.current : paneRef.current;
if (!el) return; if (!el) return;
const measure = () => { const measure = () => {
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
// `right` is the gap to the viewport edge, which is what the dock needs // `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 // to line an expanded window up with the pane — it is non-zero whenever
// the right panel is open. // the right panel is open. `height` only under the list: everywhere else
setPane({ width: Math.round(r.width), right: Math.round(window.innerWidth - r.right) }); // 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); const ro = new ResizeObserver(measure);
ro.observe(el); ro.observe(el);
window.addEventListener("resize", measure); window.addEventListener("resize", measure);
@@ -133,12 +164,17 @@ export function MailLayout() {
ro.disconnect(); ro.disconnect();
window.removeEventListener("resize", measure); window.removeEventListener("resize", measure);
}; };
}, []); }, [listShowing, bottom]);
const searchRef = useRef<HTMLInputElement>(null); const searchRef = useRef<HTMLInputElement>(null);
// Resizable panel widths (persisted). Drag the dividers to adjust. // Resizable panel widths (persisted). Drag the dividers to adjust.
const [sidebarW, setSidebarW] = useState(() => Number(localStorage.getItem("panel.sidebar")) || 240); const [sidebarW, setSidebarW] = useState(() => Number(localStorage.getItem("panel.sidebar")) || 240);
const [listW, setListW] = useState(() => Number(localStorage.getItem("panel.list")) || 384); 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. // Collapsed sidebar shows icons only (persisted). The mobile drawer is always full.
const [collapsedPref, setCollapsed] = useState( const [collapsedPref, setCollapsed] = useState(
() => localStorage.getItem("panel.collapsed") === "1", () => localStorage.getItem("panel.collapsed") === "1",
@@ -154,6 +190,9 @@ export function MailLayout() {
const clampRight = (w: number) => Math.min(Math.max(w, 280), 560); const clampRight = (w: number) => Math.min(Math.max(w, 280), 560);
useEffect(() => localStorage.setItem("panel.sidebar", String(sidebarW)), [sidebarW]); useEffect(() => localStorage.setItem("panel.sidebar", String(sidebarW)), [sidebarW]);
useEffect(() => localStorage.setItem("panel.list", String(listW)), [listW]); 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( useEffect(
() => localStorage.setItem("panel.collapsed", collapsedPref ? "1" : "0"), () => localStorage.setItem("panel.collapsed", collapsedPref ? "1" : "0"),
[collapsedPref], [collapsedPref],
@@ -611,6 +650,10 @@ export function MailLayout() {
}; };
const [moreOpen, setMoreOpen] = useState(false); 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({ const markAllRead = useMutation({
mutationFn: () => api.markAllRead(view), mutationFn: () => api.markAllRead(view),
onSuccess: () => { onSuccess: () => {
@@ -888,13 +931,20 @@ export function MailLayout() {
<Resizer onDelta={(dx) => setSidebarW((w) => clampSidebar(w + dx))} /> <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 <div
style={mobile ? undefined : { width: listW }} ref={listRef}
style={single ? undefined : bottom ? { height: listH } : { width: listW }}
className={ className={
mobile single
? `relative min-w-0 flex-1 flex-col bg-app-surface ${paneShown ? "hidden" : "flex"}` ? `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 <div
@@ -963,6 +1013,20 @@ export function MailLayout() {
</> </>
)} )}
</div> </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> </div>
{!isDrafts && ( {!isDrafts && (
<div <div
@@ -1055,16 +1119,29 @@ export function MailLayout() {
> >
{t("mail.markAllRead")} {t("mail.markAllRead")}
</button> </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> </div>
</> </>
)} )}
{displayOpen && <MailboxDisplay onClose={closeDisplay} />}
</div> </div>
</div> </div>
</div> </div>
)} )}
{mobile && checkedIds.size > 0 && ( {single && checkedIds.size > 0 && (
// The desktop shows bulk actions in the reading pane; a phone has none // Side by side, bulk actions show in the reading pane; with a single
// beside the list, so they sit above it. // 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]"> <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> <span className="px-2 text-[13px] text-app-muted">{checkedIds.size}</span>
{bulkButtons} {bulkButtons}
@@ -1160,12 +1237,17 @@ export function MailLayout() {
)} )}
</div> </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) */} {/* Thread pane (full screen on a phone, shown only with something open) */}
<main <main
ref={paneRef} 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 ? ( {paneDraft ? (
// Keyed by draft: picking a different one must build a fresh editor, // Keyed by draft: picking a different one must build a fresh editor,
@@ -1176,9 +1258,9 @@ export function MailLayout() {
onClose={closePaneDraft} onClose={closePaneDraft}
fullWindow fullWindow
/> />
) : checkedIds.size > 0 && !mobile ? ( ) : checkedIds.size > 0 && !single ? (
<div className="flex h-full flex-col"> <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} {bulkButtons}
</div> </div>
<div className="flex flex-1 items-center justify-center text-3xl text-app-faint"> <div className="flex flex-1 items-center justify-center text-3xl text-app-faint">
@@ -1189,8 +1271,8 @@ export function MailLayout() {
</div> </div>
) : threadId && detailQuery.data ? ( ) : threadId && detailQuery.data ? (
<div className="flex h-full flex-col"> <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}>
{mobile && ( {single && (
<button <button
type="button" type="button"
title={t("common.back")} title={t("common.back")}
@@ -1261,7 +1343,7 @@ export function MailLayout() {
<path d="M15 4v16" /> <path d="M15 4v16" />
</svg> </svg>
</button> </button>
) : ( ) : bottom ? null : ( // under the list it lives in the list's header only
<button <button
className={`rounded p-1.5 hover:bg-app-hover hover:text-app-fg ${rightOpen ? "text-app-fg" : "text-app-muted"}`} className={`rounded p-1.5 hover:bg-app-hover hover:text-app-fg ${rightOpen ? "text-app-fg" : "text-app-muted"}`}
title={t("panel.toggle")} title={t("panel.toggle")}
@@ -1289,7 +1371,7 @@ export function MailLayout() {
</div> </div>
) : ( ) : (
<div className="flex h-full flex-col items-center justify-center gap-6 text-app-faint"> <div className="flex h-full flex-col items-center justify-center gap-6 text-app-faint">
{!rightOpen && ( {!rightOpen && !panelToggleInList && (
<button <button
className="absolute right-3 top-3 rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg" 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")} title={t("panel.toggle")}
@@ -1302,11 +1384,13 @@ export function MailLayout() {
</svg> </svg>
</button> </button>
)} )}
<EmptyIllustration className="w-56 max-w-[55%]" /> {/* Under the list the pane is short: a smaller picture and line. */}
<span className="text-3xl">{t("mail.selectConversation")}</span> <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> </div>
)} )}
</main> </main>
</div>
{mobile && sheetUsed && ( {mobile && sheetUsed && (
<> <>
<div <div
@@ -5,6 +5,7 @@ import { Link, useLocation, useNavigate, useParams, useSearchParams } from "reac
import { RichTextEditor } from "../../components/compose/RichTextEditor"; import { RichTextEditor } from "../../components/compose/RichTextEditor";
import { TypedPasswordInput } from "../../components/TypedPasswordInput"; import { TypedPasswordInput } from "../../components/TypedPasswordInput";
import { ReadingPanePicker } from "../../components/mail/ReadingPanePicker";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api"; import { api, ApiError } from "../../lib/api";
import type { CalendarOut, ReminderPref } from "../../lib/types"; import type { CalendarOut, ReminderPref } from "../../lib/types";
@@ -748,6 +749,14 @@ function AppearanceSection() {
))} ))}
</div> </div>
</div> </div>
<div className="space-y-2 border-b border-app-border py-3 text-sm">
<span className="text-app-muted">{t("display.readingPane")}</span>
<div className="max-w-md">
<ReadingPanePicker />
</div>
{/* Saved per device, like the theme: the right choice depends on the screen. */}
<p className="text-xs text-app-faint">{t("display.phoneNote")}</p>
</div>
<label className="flex items-center justify-between border-b border-app-border py-2 text-sm"> <label className="flex items-center justify-between border-b border-app-border py-2 text-sm">
<span className="text-app-muted">{t("settings.threadOrder")}</span> <span className="text-app-muted">{t("settings.threadOrder")}</span>
<select <select