+ );
+}
diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts
index d835f5f..621c9c1 100644
--- a/frontend/src/i18n/en.ts
+++ b/frontend/src/i18n/en.ts
@@ -462,6 +462,12 @@ export const en = {
"admin.saving": "Saving…",
"admin.saved": "Saved",
"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.mailHelp":
"The mail account the server sends password-reset links from. Without it, “Forgot password?” sends nothing.",
diff --git a/frontend/src/lib/readingPane.ts b/frontend/src/lib/readingPane.ts
new file mode 100644
index 0000000..01a3534
--- /dev/null
+++ b/frontend/src/lib/readingPane.ts
@@ -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));
+}
diff --git a/frontend/src/routes/MailLayout.tsx b/frontend/src/routes/MailLayout.tsx
index bd305b9..5f5e630 100644
--- a/frontend/src/routes/MailLayout.tsx
+++ b/frontend/src/routes/MailLayout.tsx
@@ -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(null);
- const [pane, setPane] = useState({ width: 0, right: 0 });
+ const listRef = useRef(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(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() {
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. */}
+
+ {/* Message list (the whole panel in single-panel mode, until a thread opens) */}
)}
+ {panelToggleInList && (
+
+ )}
{!isDrafts && (
{t("mail.markAllRead")}
+ {/* A phone always reads in a single panel: nothing to choose. */}
+ {!mobile && (
+
+ )}
>
)}
+ {displayOpen && }
)}
- {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.