From fe3fd94165173ad50ce7d2894443729ddc556a75 Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Mon, 28 Sep 2026 08:09:09 +0200 Subject: [PATCH] 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 --- .../src/components/compose/ComposeDock.tsx | 26 ++ frontend/src/components/compose/Composer.tsx | 6 +- frontend/src/components/list/MessageList.tsx | 56 ++++- frontend/src/components/thread/ThreadView.tsx | 21 +- frontend/src/i18n/en.ts | 1 + frontend/src/lib/useIsMobile.ts | 25 ++ frontend/src/routes/MailLayout.tsx | 227 +++++++++++++----- 7 files changed, 299 insertions(+), 63 deletions(-) create mode 100644 frontend/src/lib/useIsMobile.ts 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) */}
-
+
+ )}
{!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 && ( + + )}
- 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 && ( + + )} {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} />
);