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:
2026-09-28 08:09:09 +02:00
co-authored by Claude Opus 5.5
parent cfc89e78b3
commit fe3fd94165
7 changed files with 299 additions and 63 deletions
@@ -36,6 +36,7 @@ export function ComposeDock({
onRestore, onRestore,
onToggleExpand, onToggleExpand,
onClose, onClose,
fullScreen = false,
}: { }: {
windows: ComposeWin[]; windows: ComposeWin[];
/** Live geometry of the reading pane; an expanded window is laid over it. */ /** Live geometry of the reading pane; an expanded window is laid over it. */
@@ -44,6 +45,8 @@ export function ComposeDock({
onRestore: (id: string) => void; onRestore: (id: string) => void;
onToggleExpand: (id: string) => void; onToggleExpand: (id: string) => void;
onClose: (id: string) => void; onClose: (id: string) => void;
/** Phone layout: the newest window fills the screen; the rest wait, mounted. */
fullScreen?: boolean;
}) { }) {
const t = useT(); const t = useT();
const { vw, vh } = useViewportSize(); const { vw, vh } = useViewportSize();
@@ -62,6 +65,29 @@ export function ComposeDock({
if (windows.length === 0) return null; if (windows.length === 0) return null;
if (fullScreen) {
// One window at a time, the most recently activated on top. The others stay
// mounted (hidden) for the same reason as below — an unmount loses the
// body — and come forward as the one above them is sent or closed.
const top = windows.reduce((a, b) => (b.activated > a.activated ? b : a)).id;
return (
<>
{windows.map((win) => (
<div
key={win.id}
className={
win.id === top
? "fixed inset-0 z-50 bg-app-elevated pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)]"
: "hidden"
}
>
<Composer init={win.init} onClose={() => onClose(win.id)} fullWindow />
</div>
))}
</>
);
}
const { items, overflow } = layoutDock(vw, vh, windows, pane.width); const { items, overflow } = layoutDock(vw, vh, windows, pane.width);
const title = (w: ComposeWin) => const title = (w: ComposeWin) =>
+5 -1
View File
@@ -619,7 +619,8 @@ export function Composer({
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"> <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" /> <path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" />
</svg> </svg>
{t("compose.attach")} {/* Phone width: the paperclip alone, so the close button still fits. */}
<span className="max-md:hidden">{t("compose.attach")}</span>
</HeaderButton> </HeaderButton>
<input <input
ref={fileInputRef} ref={fileInputRef}
@@ -713,11 +714,14 @@ export function Composer({
</HeaderButton> </HeaderButton>
)} )}
{!fullWindow && ( {!fullWindow && (
// No second browser window to pop out into on a phone.
<div className="contents max-md:hidden">
<HeaderButton onClick={popOut} title={t("compose.popOut")}> <HeaderButton onClick={popOut} title={t("compose.popOut")}>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"> <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 3H3v10h10v-3M9 3h4v4M13 3L7 9" /> <path d="M6 3H3v10h10v-3M9 3h4v4M13 3L7 9" />
</svg> </svg>
</HeaderButton> </HeaderButton>
</div>
)} )}
<HeaderButton onClick={requestClose} title={t("common.close")}> <HeaderButton onClick={requestClose} title={t("common.close")}>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"> <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
+54 -2
View File
@@ -98,6 +98,7 @@ export function MessageList({
checkedIds, checkedIds,
dimmedIds, dimmedIds,
onToggleCheck, onToggleCheck,
compact = false,
}: { }: {
threads: ThreadSummary[]; threads: ThreadSummary[];
/** Which page of the list this is. Changing it scrolls back to the top. */ /** Which page of the list this is. Changing it scrolls back to the top. */
@@ -113,6 +114,8 @@ export function MessageList({
checkedIds: Set<string>; checkedIds: Set<string>;
dimmedIds?: Set<string>; dimmedIds?: Set<string>;
onToggleCheck: (id: string) => void; onToggleCheck: (id: string) => void;
/** Phone layout: sender, subject and snippet stacked; no hover-only actions. */
compact?: boolean;
}) { }) {
const t = useT(); const t = useT();
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
@@ -214,7 +217,11 @@ export function MessageList({
className="absolute -left-[21px] top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-app-accent" className="absolute -left-[21px] top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-app-accent"
/> />
)} )}
<div className={checkMode ? "hidden" : "contents group-hover/av:hidden"}> <div
className={
checkMode ? "hidden" : compact ? "contents" : "contents group-hover/av:hidden"
}
>
<span <span
className="relative flex h-[33px] w-[33px] items-center justify-center overflow-hidden rounded-full text-[13px] font-semibold text-white" className="relative flex h-[33px] w-[33px] items-center justify-center overflow-hidden rounded-full text-[13px] font-semibold text-white"
style={{ backgroundColor: avatarBg }} style={{ backgroundColor: avatarBg }}
@@ -233,8 +240,10 @@ export function MessageList({
aria-label={t("mail.select")} aria-label={t("mail.select")}
aria-pressed={checked} aria-pressed={checked}
onClick={stop(() => onToggleCheck(thread.id))} onClick={stop(() => onToggleCheck(thread.id))}
// No hover on a touch screen: the avatar itself is the (invisible)
// selection target until selection mode shows the boxes.
className={`absolute inset-0 items-center justify-center ${ className={`absolute inset-0 items-center justify-center ${
checkMode ? "flex" : "hidden group-hover/av:flex" checkMode ? "flex" : compact ? "flex opacity-0" : "hidden group-hover/av:flex"
}`} }`}
> >
<span <span
@@ -253,6 +262,47 @@ export function MessageList({
</button> </button>
</div> </div>
{compact ? (
<div className="flex min-w-0 flex-1 flex-col justify-center">
<div className="flex min-w-0 items-center gap-1.5">
<span
className={`min-w-0 truncate text-[15px] ${
unread ? "font-black text-app-fg" : "text-app-fg"
}`}
>
{names}
</span>
{thread.message_count > 1 && (
<span className="shrink-0 rounded-full bg-app-hover px-1.5 text-[12px] text-app-muted">
{thread.message_count}
</span>
)}
<span className="ml-auto flex shrink-0 items-center gap-1.5 pl-2 text-app-faint">
{thread.has_attachments && <Paperclip />}
{thread.pinned && (
<Icon name="pin" size={15} className="text-[rgb(220_129_143)]" />
)}
<span
className={`whitespace-nowrap text-[13px] tabular-nums ${
unread ? "font-bold" : ""
}`}
>
{timeLabel(thread.last_message_at)}
</span>
</span>
</div>
<div
className={`flex min-w-0 items-center gap-1.5 text-[14px] ${
unread ? "font-black text-app-fg" : "text-app-fg"
}`}
>
<LabelChips labels={labelMap[thread.id] ?? []} />
<span className="truncate">{thread.subject}</span>
</div>
<div className="truncate text-xs text-app-faint">{thread.snippet}</div>
</div>
) : (
<>
{/* Sender column */} {/* Sender column */}
<div className="flex w-[200px] min-w-0 shrink items-center gap-1.5"> <div className="flex w-[200px] min-w-0 shrink items-center gap-1.5">
<span <span
@@ -347,6 +397,8 @@ export function MessageList({
{timeLabel(thread.last_message_at)} {timeLabel(thread.last_message_at)}
</span> </span>
</div> </div>
</>
)}
</div> </div>
</div> </div>
); );
+16 -5
View File
@@ -4,6 +4,7 @@ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { printMessages } from "../../lib/print"; import { printMessages } from "../../lib/print";
import { useIsMobile } from "../../lib/useIsMobile";
import { getDateLocale, getHour12 } from "../../lib/timeFormat"; import { getDateLocale, getHour12 } from "../../lib/timeFormat";
import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types"; import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
import { PluginSlot } from "../../plugins/PluginSlot"; import { PluginSlot } from "../../plugins/PluginSlot";
@@ -684,16 +685,26 @@ export function ThreadView({
); );
const composerRef = useRef<HTMLDivElement | null>(null); const composerRef = useRef<HTMLDivElement | null>(null);
// On a phone, reply/reply-all/forward open full screen rather than inline.
const mobile = useIsMobile();
// Bring the inline composer into view when it opens (reply/reply-all/forward). // Bring the inline composer into view when it opens (reply/reply-all/forward).
useEffect(() => { useEffect(() => {
if (composing) composerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); if (composing && !mobile) {
}, [composing]); composerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, [composing, mobile]);
const composerFor = (id: string) => const composerFor = (id: string) =>
composing?.anchorId === id ? ( composing?.anchorId === id ? (
<div ref={composerRef} className="px-4 py-3"> mobile ? (
<Composer init={composing.init} onClose={() => setComposing(null)} /> <div className="fixed inset-0 z-50 bg-app-elevated pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)]">
</div> <Composer init={composing.init} onClose={() => setComposing(null)} fullWindow />
</div>
) : (
<div ref={composerRef} className="px-4 py-3">
<Composer init={composing.init} onClose={() => setComposing(null)} />
</div>
)
) : null; ) : null;
return ( return (
+1
View File
@@ -68,6 +68,7 @@ export const en = {
"mail.noConversations": "No conversations.", "mail.noConversations": "No conversations.",
"mail.me": "me", "mail.me": "me",
"nav.menu": "Menu",
"mail.selectConversation": "Select a conversation.", "mail.selectConversation": "Select a conversation.",
"mail.loadingThread": "Loading thread…", "mail.loadingThread": "Loading thread…",
"mail.expandAll": "Expand all", "mail.expandAll": "Expand all",
+25
View File
@@ -0,0 +1,25 @@
import { useEffect, useState } from "react";
// Phones: narrower than Tailwind's `md`, or a touch screen too short to be
// anything but a phone on its side. Tablets and desktops keep the full layout.
const MOBILE_QUERY = "(max-width: 767px), (pointer: coarse) and (max-height: 500px)";
function matches(): boolean {
return typeof window !== "undefined" && typeof window.matchMedia === "function"
? window.matchMedia(MOBILE_QUERY).matches
: false;
}
/** Whether the smartphone layout applies; follows rotation and window resizes. */
export function useIsMobile(): boolean {
const [mobile, setMobile] = useState(matches);
useEffect(() => {
if (typeof window.matchMedia !== "function") return;
const mq = window.matchMedia(MOBILE_QUERY);
const onChange = () => setMobile(mq.matches);
onChange();
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return mobile;
}
+172 -55
View File
@@ -29,6 +29,7 @@ import type { MessageKey } from "../i18n/en";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { setTimeFormat } from "../lib/timeFormat"; import { setTimeFormat } from "../lib/timeFormat";
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types"; import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
import { useIsMobile } from "../lib/useIsMobile";
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts"; import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
import { PluginSlot } from "../plugins/PluginSlot"; import { PluginSlot } from "../plugins/PluginSlot";
@@ -63,6 +64,10 @@ export function MailLayout() {
const match = useMatch("/mail/t/:threadId"); const match = useMatch("/mail/t/:threadId");
const threadId = match?.params.threadId; const threadId = match?.params.threadId;
const [view, setView] = useState<string>("folder:inbox"); 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(""); const [term, setTerm] = useState("");
// Debounced copy of the search box for search-as-you-type (avoids a request per key). // Debounced copy of the search box for search-as-you-type (avoids a request per key).
const [debouncedTerm, setDebouncedTerm] = useState(""); const [debouncedTerm, setDebouncedTerm] = useState("");
@@ -126,8 +131,11 @@ export function MailLayout() {
// Resizable panel widths (persisted). Drag the dividers to adjust. // Resizable panel widths (persisted). Drag the dividers to adjust.
const [sidebarW, setSidebarW] = useState(() => Number(localStorage.getItem("panel.sidebar")) || 240); const [sidebarW, setSidebarW] = useState(() => Number(localStorage.getItem("panel.sidebar")) || 240);
const [listW, setListW] = useState(() => Number(localStorage.getItem("panel.list")) || 384); const [listW, setListW] = useState(() => Number(localStorage.getItem("panel.list")) || 384);
// Collapsed sidebar shows icons only (persisted). // Collapsed sidebar shows icons only (persisted). The mobile drawer is always full.
const [collapsed, setCollapsed] = useState(() => localStorage.getItem("panel.collapsed") === "1"); const [collapsedPref, setCollapsed] = useState(
() => localStorage.getItem("panel.collapsed") === "1",
);
const collapsed = collapsedPref && !mobile;
// Right info panel (Calendar / People / Attachments) — hidden by default. // Right info panel (Calendar / People / Attachments) — hidden by default.
const [rightOpen, setRightOpen] = useState(() => localStorage.getItem("panel.right") === "1"); const [rightOpen, setRightOpen] = useState(() => localStorage.getItem("panel.right") === "1");
const [rightTab, setRightTab] = useState<PanelTab>(() => (localStorage.getItem("panel.rightTab") as PanelTab) || "calendar"); 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); const clampRight = (w: number) => Math.min(Math.max(w, 280), 560);
useEffect(() => localStorage.setItem("panel.sidebar", String(sidebarW)), [sidebarW]); useEffect(() => localStorage.setItem("panel.sidebar", String(sidebarW)), [sidebarW]);
useEffect(() => localStorage.setItem("panel.list", String(listW)), [listW]); 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.right", rightOpen ? "1" : "0"), [rightOpen]);
useEffect(() => localStorage.setItem("panel.rightTab", rightTab), [rightTab]); useEffect(() => localStorage.setItem("panel.rightTab", rightTab), [rightTab]);
useEffect(() => localStorage.setItem("panel.rightW", String(rightW)), [rightW]); useEffect(() => localStorage.setItem("panel.rightW", String(rightW)), [rightW]);
@@ -269,6 +280,7 @@ export function MailLayout() {
}; };
const runSavedSearch = (s: SavedSearch) => { const runSavedSearch = (s: SavedSearch) => {
setTerm(s.query); setTerm(s.query);
setDrawerOpen(false);
navigate("/mail"); navigate("/mail");
}; };
// Switch folder/account view. Clears any active search so the folder actually // Switch folder/account view. Clears any active search so the folder actually
@@ -276,8 +288,18 @@ export function MailLayout() {
const selectView = (v: string) => { const selectView = (v: string) => {
setTerm(""); setTerm("");
setView(v); setView(v);
setDrawerOpen(false);
navigate("/mail"); 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 // 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. // 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 bulkRestore = () => runBulk((id) => api.restoreThread(id));
const bulkDeletePermanent = () => runBulk((id) => api.deleteThreadPermanent(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 // "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. // work runs in a worker; refresh the views after it and via the WS message.deleted.
const [emptying, setEmptying] = useState(false); const [emptying, setEmptying] = useState(false);
@@ -576,13 +629,32 @@ export function MailLayout() {
return () => window.removeEventListener("webmail:labels-changed", onChanged); return () => window.removeEventListener("webmail:labels-changed", onChanged);
}, [queryClient]); }, [queryClient]);
// On a phone the reading pane replaces the list rather than sitting beside it.
const paneShown = Boolean(paneDraft || threadId);
return ( 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 /> <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 <aside
style={{ width: collapsed ? COLLAPSED_W : sidebarW }} style={mobile ? undefined : { width: collapsed ? COLLAPSED_W : sidebarW }}
className="flex shrink-0 flex-col border-r border-app-border bg-app-bg" 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 <div
className={`flex pb-1 pt-[11px] ${ className={`flex pb-1 pt-[11px] ${
@@ -594,10 +666,12 @@ export function MailLayout() {
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img src={signetLogo} alt="Ultimo" className="h-7 w-auto shrink-0" /> <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>
)} )}
<div className={`flex items-center gap-1 ${collapsed ? "flex-col" : ""}`}> <div className={`flex items-center gap-1 ${collapsed ? "flex-col" : ""}`}>
{!mobile && (
<button <button
onClick={() => setCollapsed((v) => !v)} onClick={() => setCollapsed((v) => !v)}
title={collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar")} title={collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar")}
@@ -606,10 +680,11 @@ export function MailLayout() {
> >
<Icon name={collapsed ? "show" : "hide"} size={16} /> <Icon name={collapsed ? "show" : "hide"} size={16} />
</button> </button>
)}
<ThemeToggle /> <ThemeToggle />
</div> </div>
</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 <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" 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} title={collapsed ? t("nav.compose") : undefined}
@@ -708,10 +783,11 @@ export function MailLayout() {
{!collapsed && <PluginSlot name="sidebar.section" />} {!collapsed && <PluginSlot name="sidebar.section" />}
</nav> </nav>
<div className="border-t border-app-border p-2 text-[15px]"> <div className="border-t border-app-border p-2 text-[15px]">
{/* Settings has no phone layout yet either. */}
<Link <Link
className={`flex items-center gap-2.5 rounded-lg py-2 text-app-muted hover:bg-app-hover hover:text-app-fg ${ 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" collapsed ? "justify-center px-0" : "px-3"
}`} } ${mobile ? "hidden" : ""}`}
title={collapsed ? t("nav.settings") : undefined} title={collapsed ? t("nav.settings") : undefined}
to="/settings" to="/settings"
> >
@@ -743,14 +819,38 @@ export function MailLayout() {
</div> </div>
</aside> </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 <div
style={{ width: listW }} style={mobile ? undefined : { width: listW }}
className="flex shrink-0 flex-col border-r border-app-border bg-app-surface" 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"> <div className="relative w-full">
<input <input
ref={searchRef} ref={searchRef}
@@ -799,13 +899,17 @@ export function MailLayout() {
</div> </div>
</div> </div>
{!isDrafts && ( {!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 <button
type="button" type="button"
aria-label={t("mail.selectAll")} aria-label={t("mail.selectAll")}
title={t("mail.selectAll")} title={t("mail.selectAll")}
onClick={toggleSelectAll} 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 allSelected || someSelected
? "border-app-accent bg-app-accent text-white" ? "border-app-accent bg-app-accent text-white"
: "border-app-faint hover:border-app-muted" : "border-app-faint hover:border-app-muted"
@@ -892,6 +996,14 @@ export function MailLayout() {
</div> </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.
<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"> <div className="min-h-0 flex-1">
{isDrafts && !term.trim() ? ( {isDrafts && !term.trim() ? (
<DraftsList <DraftsList
@@ -922,10 +1034,15 @@ export function MailLayout() {
checkedIds={checkedIds} checkedIds={checkedIds}
dimmedIds={dimmedIds} dimmedIds={dimmedIds}
onToggleCheck={toggleCheck} onToggleCheck={toggleCheck}
compact={mobile}
/> />
)} )}
</div> </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 <button
className="-ml-1 rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg disabled:opacity-60" className="-ml-1 rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg disabled:opacity-60"
title={t("nav.refresh")} title={t("nav.refresh")}
@@ -959,12 +1076,26 @@ export function MailLayout() {
<span /> <span />
)} )}
</div> </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> </div>
<Resizer onDelta={(dx) => setListW((w) => clampList(w + dx))} /> {!mobile && <Resizer onDelta={(dx) => setListW((w) => clampList(w + dx))} />}
{/* Thread pane */} {/* 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"> <main
ref={paneRef}
className={`relative min-w-0 flex-1 bg-app-surface ${mobile && !paneShown ? "hidden" : ""}`}
>
{paneDraft ? ( {paneDraft ? (
// Keyed by draft: picking a different one must build a fresh editor, // Keyed by draft: picking a different one must build a fresh editor,
// because RichTextEditor seeds its body exactly once. // because RichTextEditor seeds its body exactly once.
@@ -974,40 +1105,10 @@ export function MailLayout() {
onClose={closePaneDraft} onClose={closePaneDraft}
fullWindow fullWindow
/> />
) : checkedIds.size > 0 ? ( ) : checkedIds.size > 0 && !mobile ? (
<div className="flex h-full flex-col"> <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="flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-[15px]">
{currentFolder === "archive" || {bulkButtons}
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>
</>
)}
</div> </div>
<div className="flex flex-1 items-center justify-center text-3xl text-app-faint"> <div className="flex flex-1 items-center justify-center text-3xl text-app-faint">
{checkedIds.size === 1 {checkedIds.size === 1
@@ -1018,6 +1119,19 @@ export function MailLayout() {
) : threadId && detailQuery.data ? ( ) : threadId && detailQuery.data ? (
<div className="flex h-full flex-col"> <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="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" ? ( {currentFolder === "archive" ? (
<> <>
<ToolbarButton onClick={restore}> <ToolbarButton onClick={restore}>
@@ -1060,9 +1174,10 @@ export function MailLayout() {
</ToolbarButton> </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 })} {t("mail.messageCount", { count: detailQuery.data.messages.length })}
</span> </span>
{!mobile && (
<button <button
className={`rounded p-1.5 hover:bg-app-hover hover:text-app-fg ${rightOpen ? "text-app-fg" : "text-app-muted"}`} className={`rounded p-1.5 hover:bg-app-hover hover:text-app-fg ${rightOpen ? "text-app-fg" : "text-app-muted"}`}
title={t("panel.toggle")} title={t("panel.toggle")}
@@ -1075,6 +1190,7 @@ export function MailLayout() {
<path d="M15 4v16" /> <path d="M15 4v16" />
</svg> </svg>
</button> </button>
)}
</div> </div>
<div className="min-h-0 flex-1 overflow-hidden"> <div className="min-h-0 flex-1 overflow-hidden">
<ThreadView <ThreadView
@@ -1107,7 +1223,7 @@ export function MailLayout() {
</div> </div>
)} )}
</main> </main>
{rightOpen && ( {rightOpen && !mobile && (
<> <>
<Resizer onDelta={(dx) => setRightW((w) => clampRight(w - dx))} /> <Resizer onDelta={(dx) => setRightW((w) => clampRight(w - dx))} />
<div style={{ width: rightW }} className="shrink-0 border-l border-app-border"> <div style={{ width: rightW }} className="shrink-0 border-l border-app-border">
@@ -1122,6 +1238,7 @@ export function MailLayout() {
onRestore={restoreComposer} onRestore={restoreComposer}
onToggleExpand={toggleComposerExpand} onToggleExpand={toggleComposerExpand}
onClose={closeComposer} onClose={closeComposer}
fullScreen={mobile}
/> />
</div> </div>
); );