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:
@@ -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) => (
|
||||
<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 title = (w: ComposeWin) =>
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
{t("compose.attach")}
|
||||
{/* Phone width: the paperclip alone, so the close button still fits. */}
|
||||
<span className="max-md:hidden">{t("compose.attach")}</span>
|
||||
</HeaderButton>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -713,11 +714,14 @@ export function Composer({
|
||||
</HeaderButton>
|
||||
)}
|
||||
{!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")}>
|
||||
<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" />
|
||||
</svg>
|
||||
</HeaderButton>
|
||||
</div>
|
||||
)}
|
||||
<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">
|
||||
|
||||
@@ -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<string>;
|
||||
dimmedIds?: Set<string>;
|
||||
onToggleCheck: (id: string) => void;
|
||||
/** Phone layout: sender, subject and snippet stacked; no hover-only actions. */
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
<div className={checkMode ? "hidden" : "contents group-hover/av:hidden"}>
|
||||
<div
|
||||
className={
|
||||
checkMode ? "hidden" : compact ? "contents" : "contents group-hover/av:hidden"
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="relative flex h-[33px] w-[33px] items-center justify-center overflow-hidden rounded-full text-[13px] font-semibold text-white"
|
||||
style={{ backgroundColor: avatarBg }}
|
||||
@@ -233,8 +240,10 @@ export function MessageList({
|
||||
aria-label={t("mail.select")}
|
||||
aria-pressed={checked}
|
||||
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 ${
|
||||
checkMode ? "flex" : "hidden group-hover/av:flex"
|
||||
checkMode ? "flex" : compact ? "flex opacity-0" : "hidden group-hover/av:flex"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@@ -253,6 +262,47 @@ export function MessageList({
|
||||
</button>
|
||||
</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 */}
|
||||
<div className="flex w-[200px] min-w-0 shrink items-center gap-1.5">
|
||||
<span
|
||||
@@ -347,6 +397,8 @@ export function MessageList({
|
||||
{timeLabel(thread.last_message_at)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<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).
|
||||
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 ? (
|
||||
<div ref={composerRef} className="px-4 py-3">
|
||||
<Composer init={composing.init} onClose={() => setComposing(null)} />
|
||||
</div>
|
||||
mobile ? (
|
||||
<div className="fixed inset-0 z-50 bg-app-elevated pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)]">
|
||||
<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;
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user