diff --git a/frontend/src/components/compose/ComposeDock.tsx b/frontend/src/components/compose/ComposeDock.tsx
index 02bcbd0..14e800c 100644
--- a/frontend/src/components/compose/ComposeDock.tsx
+++ b/frontend/src/components/compose/ComposeDock.tsx
@@ -36,6 +36,7 @@ export function ComposeDock({
onRestore,
onToggleExpand,
onClose,
+ fullScreen = false,
}: {
windows: ComposeWin[];
/** Live geometry of the reading pane; an expanded window is laid over it. */
@@ -44,6 +45,8 @@ export function ComposeDock({
onRestore: (id: string) => void;
onToggleExpand: (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 { vw, vh } = useViewportSize();
@@ -62,6 +65,29 @@ export function ComposeDock({
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) => (
+
+ onClose(win.id)} fullWindow />
+
+ ))}
+ >
+ );
+ }
+
const { items, overflow } = layoutDock(vw, vh, windows, pane.width);
const title = (w: ComposeWin) =>
diff --git a/frontend/src/components/compose/Composer.tsx b/frontend/src/components/compose/Composer.tsx
index f6b57b8..72b4bae 100644
--- a/frontend/src/components/compose/Composer.tsx
+++ b/frontend/src/components/compose/Composer.tsx
@@ -619,7 +619,8 @@ export function Composer({
- {t("compose.attach")}
+ {/* Phone width: the paperclip alone, so the close button still fits. */}
+ {t("compose.attach")}
)}
{!fullWindow && (
+ // No second browser window to pop out into on a phone.
+
)}
diff --git a/frontend/src/components/list/MessageList.tsx b/frontend/src/components/list/MessageList.tsx
index 1688dc0..2aeeb20 100644
--- a/frontend/src/components/list/MessageList.tsx
+++ b/frontend/src/components/list/MessageList.tsx
@@ -98,6 +98,7 @@ export function MessageList({
checkedIds,
dimmedIds,
onToggleCheck,
+ compact = false,
}: {
threads: ThreadSummary[];
/** Which page of the list this is. Changing it scrolls back to the top. */
@@ -113,6 +114,8 @@ export function MessageList({
checkedIds: Set;
dimmedIds?: Set;
onToggleCheck: (id: string) => void;
+ /** Phone layout: sender, subject and snippet stacked; no hover-only actions. */
+ compact?: boolean;
}) {
const t = useT();
const parentRef = useRef(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"
/>
)}
-
+
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 ${
- checkMode ? "flex" : "hidden group-hover/av:flex"
+ checkMode ? "flex" : compact ? "flex opacity-0" : "hidden group-hover/av:flex"
}`}
>
+ {compact ? (
+
+
+
+ {names}
+
+ {thread.message_count > 1 && (
+
+ {thread.message_count}
+
+ )}
+
+ {thread.has_attachments && }
+ {thread.pinned && (
+
+ )}
+
+ {timeLabel(thread.last_message_at)}
+
+
+
+
+
+ {thread.subject}
+
+
{thread.snippet}
+
+ ) : (
+ <>
{/* Sender column */}
+ >
+ )}
);
diff --git a/frontend/src/components/thread/ThreadView.tsx b/frontend/src/components/thread/ThreadView.tsx
index 77f9ced..fd00123 100644
--- a/frontend/src/components/thread/ThreadView.tsx
+++ b/frontend/src/components/thread/ThreadView.tsx
@@ -4,6 +4,7 @@ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useT } from "../../i18n";
import { api } from "../../lib/api";
import { printMessages } from "../../lib/print";
+import { useIsMobile } from "../../lib/useIsMobile";
import { getDateLocale, getHour12 } from "../../lib/timeFormat";
import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
import { PluginSlot } from "../../plugins/PluginSlot";
@@ -684,16 +685,26 @@ export function ThreadView({
);
const composerRef = useRef(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).
useEffect(() => {
- if (composing) composerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
- }, [composing]);
+ if (composing && !mobile) {
+ composerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
+ }
+ }, [composing, mobile]);
const composerFor = (id: string) =>
composing?.anchorId === id ? (
-
- setComposing(null)} />
-
+ mobile ? (
+
+ setComposing(null)} fullWindow />
+
+ ) : (
+
+ setComposing(null)} />
+
+ )
) : null;
return (
diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts
index ee2b9ce..9bacd91 100644
--- a/frontend/src/i18n/en.ts
+++ b/frontend/src/i18n/en.ts
@@ -68,6 +68,7 @@ export const en = {
"mail.noConversations": "No conversations.",
"mail.me": "me",
+ "nav.menu": "Menu",
"mail.selectConversation": "Select a conversation.",
"mail.loadingThread": "Loading thread…",
"mail.expandAll": "Expand all",
diff --git a/frontend/src/lib/useIsMobile.ts b/frontend/src/lib/useIsMobile.ts
new file mode 100644
index 0000000..fa140cb
--- /dev/null
+++ b/frontend/src/lib/useIsMobile.ts
@@ -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;
+}
diff --git a/frontend/src/routes/MailLayout.tsx b/frontend/src/routes/MailLayout.tsx
index 5b055ee..3fe45cd 100644
--- a/frontend/src/routes/MailLayout.tsx
+++ b/frontend/src/routes/MailLayout.tsx
@@ -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("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(() => (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" ? (
+ <>
+
+
+ {t("mail.restore")}
+
+
+
+ {t("mail.delete")}
+
+ >
+ ) : (
+ <>
+
+
+ {t("mail.archive")}
+
+
+
+ {t("mail.pin")}
+
+
+
+ {t("mail.delete")}
+
+ >
+ );
+
// "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 (
-
+
- {/* Sidebar */}
+ {mobile && (
+ // Tapping outside the drawer closes it.
+
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) */}
-
+ {/* Calendar/Contacts have no phone layout yet, so no way into them there. */}
+ {!mobile &&
}
)}
+ {!mobile && (
setCollapsed((v) => !v)}
title={collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar")}
@@ -606,10 +680,11 @@ export function MailLayout() {
>
+ )}
-
+
}
+ {/* Settings has no phone layout yet either. */}
@@ -743,14 +819,38 @@ export function MailLayout() {
- {!collapsed &&
setSidebarW((w) => clampSidebar(w + dx))} />}
+ {!collapsed && !mobile && (
+ setSidebarW((w) => clampSidebar(w + dx))} />
+ )}
- {/* Message list */}
+ {/* Message list (the whole screen on a phone, until a thread opens) */}
-
+
+ {mobile && (
+
setDrawerOpen(true)}
+ className="-ml-1 shrink-0 rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
+ >
+
+
+
+
+ )}
{!isDrafts && (
-
+
)}
+ {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.
+
+ {checkedIds.size}
+ {bulkButtons}
+
+ )}
{isDrafts && !term.trim() ? (
)}
-
+
)}
+ {mobile && (
+
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"
+ >
+
+
+ )}
-
setListW((w) => clampList(w + dx))} />
+ {!mobile && setListW((w) => clampList(w + dx))} />}
- {/* Thread pane */}
-
+ {/* Thread pane (full screen on a phone, shown only with something open) */}
+
{paneDraft ? (
// Keyed by draft: picking a different one must build a fresh editor,
// because RichTextEditor seeds its body exactly once.
@@ -974,40 +1105,10 @@ export function MailLayout() {
onClose={closePaneDraft}
fullWindow
/>
- ) : checkedIds.size > 0 ? (
+ ) : checkedIds.size > 0 && !mobile ? (
- {currentFolder === "archive" ||
- currentFolder === "trash" ||
- currentFolder === "junk" ? (
- <>
-
-
- {t("mail.restore")}
-
-
-
- {t("mail.delete")}
-
- >
- ) : (
- <>
-
-
- {t("mail.archive")}
-
-
-
- {t("mail.pin")}
-
-
-
- {t("mail.delete")}
-
- >
- )}
+ {bulkButtons}
{checkedIds.size === 1
@@ -1018,6 +1119,19 @@ export function MailLayout() {
) : threadId && detailQuery.data ? (
+ {mobile && (
+
navigate("/mail")}
+ className="-ml-1 mr-1 rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
+ >
+
+
+
+
+ )}
{currentFolder === "archive" ? (
<>
@@ -1060,9 +1174,10 @@ export function MailLayout() {
>
)}
-
+
{t("mail.messageCount", { count: detailQuery.data.messages.length })}
+ {!mobile && (
+ )}
)}
- {rightOpen && (
+ {rightOpen && !mobile && (
<>
setRightW((w) => clampRight(w - dx))} />
@@ -1122,6 +1238,7 @@ export function MailLayout() {
onRestore={restoreComposer}
onToggleExpand={toggleComposerExpand}
onClose={closeComposer}
+ fullScreen={mobile}
/>
);