From b1bdae6e59208e5ae35e1750e25817c6bc6583ef Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Sat, 3 Oct 2026 18:13:43 +0200 Subject: [PATCH] Reading pane layouts: Side, Bottom (under the list) and Off (single panel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/components/Resizer.tsx | 43 +++++- .../src/components/compose/ComposeDock.tsx | 7 +- frontend/src/components/compose/windows.ts | 8 +- .../src/components/mail/MailboxDisplay.tsx | 53 +++++++ .../src/components/mail/ReadingPanePicker.tsx | 91 ++++++++++++ frontend/src/i18n/en.ts | 6 + frontend/src/lib/readingPane.ts | 48 +++++++ frontend/src/routes/MailLayout.tsx | 130 ++++++++++++++---- frontend/src/routes/settings/Settings.tsx | 9 ++ 9 files changed, 362 insertions(+), 33 deletions(-) create mode 100644 frontend/src/components/mail/MailboxDisplay.tsx create mode 100644 frontend/src/components/mail/ReadingPanePicker.tsx create mode 100644 frontend/src/lib/readingPane.ts diff --git a/frontend/src/components/Resizer.tsx b/frontend/src/components/Resizer.tsx index 505cf82..57d595a 100644 --- a/frontend/src/components/Resizer.tsx +++ b/frontend/src/components/Resizer.tsx @@ -1,11 +1,24 @@ -// A vertical drag handle for adjusting the width of the panel to its left. -export function Resizer({ onDelta }: { onDelta: (deltaX: number) => void }) { +// A drag handle between two panels. Vertical (the default) sits between panels +// 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) => { e.preventDefault(); - let lastX = e.clientX; + let last = vertical ? e.clientX : e.clientY; const onMove = (ev: MouseEvent) => { - onDelta(ev.clientX - lastX); - lastX = ev.clientX; + const now = vertical ? ev.clientX : ev.clientY; + onDelta(now - last); + last = now; }; const onUp = () => { document.removeEventListener("mousemove", onMove); @@ -16,9 +29,27 @@ export function Resizer({ onDelta }: { onDelta: (deltaX: number) => void }) { document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); 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. +
+ {/* A taller invisible strip, so 3px is not all there is to grab. */} +
+ {/* Small pill grab handle in the middle for easier grabbing. */} +
+
+ ); + } + return (
void; onRestore: (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) => (titles[w.id] ?? w.init.subject ?? "").trim() || t("compose.newMessage"); diff --git a/frontend/src/components/compose/windows.ts b/frontend/src/components/compose/windows.ts index 012429d..280e144 100644 --- a/frontend/src/components/compose/windows.ts +++ b/frontend/src/components/compose/windows.ts @@ -143,12 +143,15 @@ export interface DockLayout { * @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. + * @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( vw: number, vh: number, windows: ComposeWin[], paneW = 0, + paneH = 0, ): DockLayout { const budget = Math.max(0, vw - GUTTER); const wantOpen = windows.filter((w) => !w.minimized); @@ -193,8 +196,11 @@ export function layoutDock( const barW = 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) => - 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 { items: windows.map((w) => ({ diff --git a/frontend/src/components/mail/MailboxDisplay.tsx b/frontend/src/components/mail/MailboxDisplay.tsx new file mode 100644 index 0000000..d5f51e3 --- /dev/null +++ b/frontend/src/components/mail/MailboxDisplay.tsx @@ -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(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 ( +
+
+

{t("display.title")}

+ +
+

{t("display.readingPane")}

+ +
+ ); +} diff --git a/frontend/src/components/mail/ReadingPanePicker.tsx b/frontend/src/components/mail/ReadingPanePicker.tsx new file mode 100644 index 0000000..803832c --- /dev/null +++ b/frontend/src/components/mail/ReadingPanePicker.tsx @@ -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 ( +
+ + + + + +
+ ); +} + +// 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 ( +
+
+ + +
+
+
+ ); + } + if (pane === "bottom") { + return ( +
+
+ +
+
+
+ ); + } + return ( +
+ + +
+ ); +} + +/** 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 ( +
+ {READING_PANES.map((pane) => { + const on = pane === current; + return ( + + ); + })} +
+ ); +} 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.
{checkedIds.size} {bulkButtons} @@ -1160,12 +1237,17 @@ export function MailLayout() { )}
- {!mobile && setListW((w) => clampList(w + dx))} />} + {!single && + (bottom ? ( + setListH((h) => clampListH(h + dy))} /> + ) : ( + setListW((w) => clampList(w + dx))} /> + ))} {/* Thread pane (full screen on a phone, shown only with something open) */}
{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 ? (
-
+
{bulkButtons}
@@ -1189,8 +1271,8 @@ export function MailLayout() {
) : threadId && detailQuery.data ? (
-
- {mobile && ( +
+ {single && ( - ) : ( + ) : bottom ? null : ( // under the list it lives in the list's header only )} - - {t("mail.selectConversation")} + {/* Under the list the pane is short: a smaller picture and line. */} + + {t("mail.selectConversation")}
)}
+
{mobile && sheetUsed && ( <>
+
+ {t("display.readingPane")} +
+ +
+ {/* Saved per device, like the theme: the right choice depends on the screen. */} +

{t("display.phoneNote")}

+