Mail: a smartphone layout
Below 768px (or a touch screen under 500px tall) the mail view becomes one column at a time: the list full screen, with a menu button beside the search box that slides the sidebar in as a 70%-wide drawer (tap outside, pick a folder or press Esc to close), and a floating compose button. Opening a conversation or a draft shows the reading pane full screen with a back arrow; composers, including reply/reply-all/forward, open full screen. List rows stack sender/date, subject and snippet; tapping an avatar selects a row and the bulk actions sit above the list. The app switcher, Settings link, select-all box, resizers and right panel are hidden on a phone, and the composer header drops the Attach label and pop-out at phone width. Everything is gated on useIsMobile(); the desktop layout renders as before (checked by screenshot comparison at 1440x900). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ 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 { useIsMobile } from "../lib/useIsMobile";
|
||||
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
|
||||
import { PluginSlot } from "../plugins/PluginSlot";
|
||||
|
||||
@@ -63,6 +64,10 @@ export function MailLayout() {
|
||||
const match = useMatch("/mail/t/:threadId");
|
||||
const threadId = match?.params.threadId;
|
||||
const [view, setView] = useState<string>("folder:inbox");
|
||||
// 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();
|
||||
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).
|
||||
const [debouncedTerm, setDebouncedTerm] = useState("");
|
||||
@@ -126,8 +131,11 @@ export function MailLayout() {
|
||||
// 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);
|
||||
// Collapsed sidebar shows icons only (persisted).
|
||||
const [collapsed, setCollapsed] = useState(() => localStorage.getItem("panel.collapsed") === "1");
|
||||
// Collapsed sidebar shows icons only (persisted). The mobile drawer is always full.
|
||||
const [collapsedPref, setCollapsed] = useState(
|
||||
() => localStorage.getItem("panel.collapsed") === "1",
|
||||
);
|
||||
const collapsed = collapsedPref && !mobile;
|
||||
// Right info panel (Calendar / People / Attachments) — hidden by default.
|
||||
const [rightOpen, setRightOpen] = useState(() => localStorage.getItem("panel.right") === "1");
|
||||
const [rightTab, setRightTab] = useState<PanelTab>(() => (localStorage.getItem("panel.rightTab") as PanelTab) || "calendar");
|
||||
@@ -138,7 +146,10 @@ 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.collapsed", collapsed ? "1" : "0"), [collapsed]);
|
||||
useEffect(
|
||||
() => localStorage.setItem("panel.collapsed", collapsedPref ? "1" : "0"),
|
||||
[collapsedPref],
|
||||
);
|
||||
useEffect(() => localStorage.setItem("panel.right", rightOpen ? "1" : "0"), [rightOpen]);
|
||||
useEffect(() => localStorage.setItem("panel.rightTab", rightTab), [rightTab]);
|
||||
useEffect(() => localStorage.setItem("panel.rightW", String(rightW)), [rightW]);
|
||||
@@ -269,6 +280,7 @@ export function MailLayout() {
|
||||
};
|
||||
const runSavedSearch = (s: SavedSearch) => {
|
||||
setTerm(s.query);
|
||||
setDrawerOpen(false);
|
||||
navigate("/mail");
|
||||
};
|
||||
// Switch folder/account view. Clears any active search so the folder actually
|
||||
@@ -276,8 +288,18 @@ export function MailLayout() {
|
||||
const selectView = (v: string) => {
|
||||
setTerm("");
|
||||
setView(v);
|
||||
setDrawerOpen(false);
|
||||
navigate("/mail");
|
||||
};
|
||||
// Anything else that switches the view (a plugin's sidebar label link) or
|
||||
// leaves the phone layout also puts the drawer away.
|
||||
useEffect(() => setDrawerOpen(false), [view, mobile]);
|
||||
useEffect(() => {
|
||||
if (!drawerOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setDrawerOpen(false);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [drawerOpen]);
|
||||
|
||||
// Auto-mark an opened thread read after a short dwell (4s), not instantly — and
|
||||
// cancel it if you leave before then, so a quick glance doesn't mark it read.
|
||||
@@ -473,6 +495,37 @@ export function MailLayout() {
|
||||
const bulkRestore = () => runBulk((id) => api.restoreThread(id));
|
||||
const bulkDeletePermanent = () => runBulk((id) => api.deleteThreadPermanent(id));
|
||||
|
||||
// Bulk-action buttons over the selected conversations: in the reading pane on
|
||||
// the desktop, above the list on a phone.
|
||||
const bulkButtons =
|
||||
currentFolder === "archive" || currentFolder === "trash" || currentFolder === "junk" ? (
|
||||
<>
|
||||
<ToolbarButton onClick={bulkRestore}>
|
||||
<Icon name="restore" size={16} />
|
||||
{t("mail.restore")}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={currentFolder === "trash" ? bulkDeletePermanent : bulkTrash}>
|
||||
<Icon name="trash" size={16} />
|
||||
{t("mail.delete")}
|
||||
</ToolbarButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ToolbarButton onClick={bulkArchive}>
|
||||
<Icon name="archive" size={16} />
|
||||
{t("mail.archive")}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={bulkPin}>
|
||||
<Icon name="pin" size={16} />
|
||||
{t("mail.pin")}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={bulkTrash}>
|
||||
<Icon name="trash" size={16} />
|
||||
{t("mail.delete")}
|
||||
</ToolbarButton>
|
||||
</>
|
||||
);
|
||||
|
||||
// "Empty" a Trash/Spam/Archive folder: permanently delete everything in it. The
|
||||
// work runs in a worker; refresh the views after it and via the WS message.deleted.
|
||||
const [emptying, setEmptying] = useState(false);
|
||||
@@ -576,13 +629,32 @@ export function MailLayout() {
|
||||
return () => window.removeEventListener("webmail:labels-changed", onChanged);
|
||||
}, [queryClient]);
|
||||
|
||||
// On a phone the reading pane replaces the list rather than sitting beside it.
|
||||
const paneShown = Boolean(paneDraft || threadId);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-app-bg text-app-fg">
|
||||
<div className={`flex bg-app-bg text-app-fg ${mobile ? "h-[100dvh]" : "h-screen"}`}>
|
||||
<BackfillBanner />
|
||||
{/* Sidebar */}
|
||||
{mobile && (
|
||||
// Tapping outside the drawer closes it.
|
||||
<div
|
||||
aria-hidden
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
className={`fixed inset-0 z-40 bg-black/40 transition-opacity duration-200 ${
|
||||
drawerOpen ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{/* Sidebar (a slide-in drawer on phones) */}
|
||||
<aside
|
||||
style={{ width: collapsed ? COLLAPSED_W : sidebarW }}
|
||||
className="flex shrink-0 flex-col border-r border-app-border bg-app-bg"
|
||||
style={mobile ? undefined : { width: collapsed ? COLLAPSED_W : sidebarW }}
|
||||
className={
|
||||
mobile
|
||||
? `fixed inset-y-0 left-0 z-50 flex w-[70vw] flex-col border-r border-app-border bg-app-bg shadow-2xl transition-transform duration-200 ease-out ${
|
||||
drawerOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`
|
||||
: "flex shrink-0 flex-col border-r border-app-border bg-app-bg"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`flex pb-1 pt-[11px] ${
|
||||
@@ -594,10 +666,12 @@ export function MailLayout() {
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={signetLogo} alt="Ultimo" className="h-7 w-auto shrink-0" />
|
||||
<AppSwitcher />
|
||||
{/* Calendar/Contacts have no phone layout yet, so no way into them there. */}
|
||||
{!mobile && <AppSwitcher />}
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex items-center gap-1 ${collapsed ? "flex-col" : ""}`}>
|
||||
{!mobile && (
|
||||
<button
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
title={collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar")}
|
||||
@@ -606,10 +680,11 @@ export function MailLayout() {
|
||||
>
|
||||
<Icon name={collapsed ? "show" : "hide"} size={16} />
|
||||
</button>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`pb-2 pt-2.5 ${collapsed ? "px-2" : "px-3"}`}>
|
||||
<div className={`pb-2 pt-2.5 ${collapsed ? "px-2" : "px-3"} ${mobile ? "hidden" : ""}`}>
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-[15px] font-medium text-app-accent-fg shadow-sm hover:opacity-90"
|
||||
title={collapsed ? t("nav.compose") : undefined}
|
||||
@@ -708,10 +783,11 @@ export function MailLayout() {
|
||||
{!collapsed && <PluginSlot name="sidebar.section" />}
|
||||
</nav>
|
||||
<div className="border-t border-app-border p-2 text-[15px]">
|
||||
{/* Settings has no phone layout yet either. */}
|
||||
<Link
|
||||
className={`flex items-center gap-2.5 rounded-lg py-2 text-app-muted hover:bg-app-hover hover:text-app-fg ${
|
||||
collapsed ? "justify-center px-0" : "px-3"
|
||||
}`}
|
||||
} ${mobile ? "hidden" : ""}`}
|
||||
title={collapsed ? t("nav.settings") : undefined}
|
||||
to="/settings"
|
||||
>
|
||||
@@ -743,14 +819,38 @@ export function MailLayout() {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{!collapsed && <Resizer onDelta={(dx) => setSidebarW((w) => clampSidebar(w + dx))} />}
|
||||
{!collapsed && !mobile && (
|
||||
<Resizer onDelta={(dx) => setSidebarW((w) => clampSidebar(w + dx))} />
|
||||
)}
|
||||
|
||||
{/* Message list */}
|
||||
{/* Message list (the whole screen on a phone, until a thread opens) */}
|
||||
<div
|
||||
style={{ width: listW }}
|
||||
className="flex shrink-0 flex-col border-r border-app-border bg-app-surface"
|
||||
style={mobile ? undefined : { width: listW }}
|
||||
className={
|
||||
mobile
|
||||
? `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"
|
||||
}
|
||||
>
|
||||
<div className="flex h-[52px] shrink-0 items-center border-b border-app-border px-6">
|
||||
<div
|
||||
className={`flex h-[52px] shrink-0 items-center border-b border-app-border ${
|
||||
mobile ? "gap-2 px-3" : "px-6"
|
||||
}`}
|
||||
>
|
||||
{mobile && (
|
||||
<button
|
||||
type="button"
|
||||
title={t("nav.menu")}
|
||||
aria-label={t("nav.menu")}
|
||||
aria-expanded={drawerOpen}
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="-ml-1 shrink-0 rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden>
|
||||
<path d="M4 7h16M4 12h16M4 17h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
ref={searchRef}
|
||||
@@ -799,13 +899,17 @@ export function MailLayout() {
|
||||
</div>
|
||||
</div>
|
||||
{!isDrafts && (
|
||||
<div className="flex h-[52px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg px-6 text-[15px]">
|
||||
<div
|
||||
className={`flex h-[52px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg text-[15px] ${
|
||||
mobile ? "px-3" : "px-6"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("mail.selectAll")}
|
||||
title={t("mail.selectAll")}
|
||||
onClick={toggleSelectAll}
|
||||
className={`ml-[28px] flex h-[17px] w-[17px] shrink-0 items-center justify-center rounded-md border-2 transition-colors ${
|
||||
className={`${mobile ? "hidden" : "flex"} ml-[28px] h-[17px] w-[17px] shrink-0 items-center justify-center rounded-md border-2 transition-colors ${
|
||||
allSelected || someSelected
|
||||
? "border-app-accent bg-app-accent text-white"
|
||||
: "border-app-faint hover:border-app-muted"
|
||||
@@ -892,6 +996,14 @@ export function MailLayout() {
|
||||
</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.
|
||||
<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}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1">
|
||||
{isDrafts && !term.trim() ? (
|
||||
<DraftsList
|
||||
@@ -922,10 +1034,15 @@ export function MailLayout() {
|
||||
checkedIds={checkedIds}
|
||||
dimmedIds={dimmedIds}
|
||||
onToggleCheck={toggleCheck}
|
||||
compact={mobile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-app-border px-6 py-1.5 text-[13px] text-app-muted">
|
||||
<div
|
||||
className={`flex items-center justify-between border-t border-app-border py-1.5 text-[13px] text-app-muted ${
|
||||
mobile ? "px-4 pb-[max(0.375rem,env(safe-area-inset-bottom))]" : "px-6"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
className="-ml-1 rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg disabled:opacity-60"
|
||||
title={t("nav.refresh")}
|
||||
@@ -959,12 +1076,26 @@ export function MailLayout() {
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
{mobile && (
|
||||
<button
|
||||
type="button"
|
||||
title={t("nav.compose")}
|
||||
aria-label={t("nav.compose")}
|
||||
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
|
||||
className="absolute bottom-[calc(3.25rem+env(safe-area-inset-bottom))] right-4 z-30 flex h-14 w-14 items-center justify-center rounded-2xl bg-app-accent text-app-accent-fg shadow-lg active:opacity-90"
|
||||
>
|
||||
<Icon name="compose" size={22} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Resizer onDelta={(dx) => setListW((w) => clampList(w + dx))} />
|
||||
{!mobile && <Resizer onDelta={(dx) => setListW((w) => clampList(w + dx))} />}
|
||||
|
||||
{/* Thread pane */}
|
||||
<main ref={paneRef} className="relative min-w-0 flex-1 bg-app-surface">
|
||||
{/* 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" : ""}`}
|
||||
>
|
||||
{paneDraft ? (
|
||||
// Keyed by draft: picking a different one must build a fresh editor,
|
||||
// because RichTextEditor seeds its body exactly once.
|
||||
@@ -974,40 +1105,10 @@ export function MailLayout() {
|
||||
onClose={closePaneDraft}
|
||||
fullWindow
|
||||
/>
|
||||
) : checkedIds.size > 0 ? (
|
||||
) : checkedIds.size > 0 && !mobile ? (
|
||||
<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]">
|
||||
{currentFolder === "archive" ||
|
||||
currentFolder === "trash" ||
|
||||
currentFolder === "junk" ? (
|
||||
<>
|
||||
<ToolbarButton onClick={bulkRestore}>
|
||||
<Icon name="restore" size={16} />
|
||||
{t("mail.restore")}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={currentFolder === "trash" ? bulkDeletePermanent : bulkTrash}
|
||||
>
|
||||
<Icon name="trash" size={16} />
|
||||
{t("mail.delete")}
|
||||
</ToolbarButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ToolbarButton onClick={bulkArchive}>
|
||||
<Icon name="archive" size={16} />
|
||||
{t("mail.archive")}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={bulkPin}>
|
||||
<Icon name="pin" size={16} />
|
||||
{t("mail.pin")}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={bulkTrash}>
|
||||
<Icon name="trash" size={16} />
|
||||
{t("mail.delete")}
|
||||
</ToolbarButton>
|
||||
</>
|
||||
)}
|
||||
{bulkButtons}
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center text-3xl text-app-faint">
|
||||
{checkedIds.size === 1
|
||||
@@ -1018,6 +1119,19 @@ export function MailLayout() {
|
||||
) : 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 && (
|
||||
<button
|
||||
type="button"
|
||||
title={t("common.back")}
|
||||
aria-label={t("common.back")}
|
||||
onClick={() => navigate("/mail")}
|
||||
className="-ml-1 mr-1 rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M15 5l-7 7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{currentFolder === "archive" ? (
|
||||
<>
|
||||
<ToolbarButton onClick={restore}>
|
||||
@@ -1060,9 +1174,10 @@ export function MailLayout() {
|
||||
</ToolbarButton>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto text-[13px] text-app-faint">
|
||||
<span className={`ml-auto text-[13px] text-app-faint ${mobile ? "hidden" : ""}`}>
|
||||
{t("mail.messageCount", { count: detailQuery.data.messages.length })}
|
||||
</span>
|
||||
{!mobile && (
|
||||
<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")}
|
||||
@@ -1075,6 +1190,7 @@ export function MailLayout() {
|
||||
<path d="M15 4v16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ThreadView
|
||||
@@ -1107,7 +1223,7 @@ export function MailLayout() {
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
{rightOpen && (
|
||||
{rightOpen && !mobile && (
|
||||
<>
|
||||
<Resizer onDelta={(dx) => setRightW((w) => clampRight(w - dx))} />
|
||||
<div style={{ width: rightW }} className="shrink-0 border-l border-app-border">
|
||||
@@ -1122,6 +1238,7 @@ export function MailLayout() {
|
||||
onRestore={restoreComposer}
|
||||
onToggleExpand={toggleComposerExpand}
|
||||
onClose={closeComposer}
|
||||
fullScreen={mobile}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user