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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user