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,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.
|
||||
<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 (
|
||||
<div
|
||||
onMouseDown={onMouseDown}
|
||||
|
||||
@@ -39,8 +39,9 @@ export function ComposeDock({
|
||||
fullScreen = false,
|
||||
}: {
|
||||
windows: ComposeWin[];
|
||||
/** Live geometry of the reading pane; an expanded window is laid over it. */
|
||||
pane: { width: number; right: number };
|
||||
/** Live geometry of the reading pane; an expanded window is laid over it.
|
||||
* `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;
|
||||
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");
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Link, useLocation, useNavigate, useParams, useSearchParams } from "reac
|
||||
|
||||
import { RichTextEditor } from "../../components/compose/RichTextEditor";
|
||||
import { TypedPasswordInput } from "../../components/TypedPasswordInput";
|
||||
import { ReadingPanePicker } from "../../components/mail/ReadingPanePicker";
|
||||
import { useT } from "../../i18n";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import type { CalendarOut, ReminderPref } from "../../lib/types";
|
||||
@@ -748,6 +749,14 @@ function AppearanceSection() {
|
||||
))}
|
||||
</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">
|
||||
<span className="text-app-muted">{t("settings.threadOrder")}</span>
|
||||
<select
|
||||
|
||||
Reference in New Issue
Block a user