feat: add Reading Pane at Bottom mail layout #262

This commit is contained in:
Linus Rath
2026-05-11 15:35:43 +02:00
parent b3dc2e32b8
commit 2c513129f2
7 changed files with 120 additions and 39 deletions
+35 -11
View File
@@ -203,7 +203,7 @@ export default function Home() {
// Mobile/tablet responsive hooks
const { isMobile, isTablet } = useDeviceDetection();
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore();
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, emailListHeight, setSidebarWidth, setEmailListWidth, setEmailListHeight, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth, resetEmailListHeight } = useUIStore();
const {
emails,
mailboxes,
@@ -637,6 +637,7 @@ export default function Home() {
const parsed = JSON.parse(stored);
if (parsed.sidebarWidth) setSidebarWidth(parsed.sidebarWidth);
if (parsed.emailListWidth) setEmailListWidth(parsed.emailListWidth);
if (parsed.emailListHeight) setEmailListHeight(parsed.emailListHeight);
}
} catch { /* ignore parse errors */ }
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -1713,9 +1714,11 @@ export default function Home() {
// Get current mailbox name for mobile header
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
const isFocusedMailLayout = mailLayout === 'focus';
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent;
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
// Handle email selection with mobile view switching
const handleEmailSelect = async (email: { id: string }) => {
@@ -1974,21 +1977,30 @@ export default function Home() {
{/* Main Content Area */}
<div className={cn("flex flex-col flex-1 min-w-0 h-full", inlineApp && "hidden")}>
<div className="flex flex-1 min-h-0">
{/* Email List - full width on mobile, fixed width on tablet/desktop */}
<div className={cn("flex flex-1 min-h-0", isHorizontalMailLayout && "md:flex-col")}>
{/* Email List - full width on mobile, fixed width/height on tablet/desktop */}
<div
className={cn(
"relative flex flex-col h-full bg-background border-r border-border",
"relative flex flex-col bg-background",
isHorizontalMailLayout ? "md:w-full md:h-auto" : "h-full border-r border-border",
// Mobile: full width, hidden when viewing email
"max-md:flex-1 max-md:border-r-0",
"max-md:flex-1 max-md:border-r-0 max-md:border-b-0",
isMobile && activeView !== "list" && "max-md:hidden",
// Tablet/Desktop: fixed width with collapse animation
shouldHideViewerPane ? "md:flex-1 md:border-r-0" : "md:flex-shrink-0",
"md:shadow-sm",
!isHorizontalMailLayout && (shouldHideViewerPane ? "md:flex-1 md:border-r-0" : "md:flex-shrink-0"),
isHorizontalMailLayout && (shouldHideHorizontalViewerPane ? "md:flex-1" : "md:flex-shrink-0"),
isHorizontalMailLayout && !shouldHideHorizontalViewerPane && "md:shadow-[0_8px_12px_-6px_rgba(0,0,0,0.18)] dark:md:shadow-[0_8px_14px_-6px_rgba(0,0,0,0.55)]",
!isHorizontalMailLayout && "md:shadow-sm",
!isResizing && "transition-all duration-200 ease-out",
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
)}
style={!isMobile && !shouldCollapseListPane && !shouldHideViewerPane ? { width: emailListWidth } : undefined}
style={
isMobile
? undefined
: isHorizontalMailLayout
? (!shouldHideHorizontalViewerPane ? { height: emailListHeight } : undefined)
: (!shouldCollapseListPane && !shouldHideViewerPane ? { width: emailListWidth } : undefined)
}
>
{/* Mobile Header for List View */}
<MobileHeader
@@ -2297,7 +2309,7 @@ export default function Home() {
</div>
{/* Email list resize handle (desktop only) */}
{!isMobile && !isTablet && !isFocusedMailLayout && (
{!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && (
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)}
@@ -2305,17 +2317,29 @@ export default function Home() {
onDoubleClick={resetEmailListWidth}
/>
)}
{!isMobile && !isTablet && isHorizontalMailLayout && !shouldHideHorizontalViewerPane && (
<ResizeHandle
orientation="horizontal"
onResizeStart={() => { dragStartWidth.current = emailListHeight; setIsResizing(true); }}
onResize={(delta) => setEmailListHeight(dragStartWidth.current + delta)}
onResizeEnd={() => { setIsResizing(false); persistColumnWidths(); }}
onDoubleClick={resetEmailListHeight}
/>
)}
{/* Email Viewer / Composer - full screen on mobile, flex on tablet/desktop */}
<div
className={cn(
"flex flex-col h-full bg-background flex-1 min-w-0",
"flex flex-col bg-background flex-1 min-w-0",
isHorizontalMailLayout ? "min-h-0" : "h-full",
// Mobile: full screen overlay when active
"max-md:fixed max-md:inset-0 max-md:z-30",
"max-md:h-full",
isMobile && activeView !== "viewer" && "max-md:hidden",
// Tablet/Desktop: relative
"md:relative",
shouldHideViewerPane && "md:hidden"
shouldHideViewerPane && "md:hidden",
shouldHideHorizontalViewerPane && "md:hidden"
)}
>
{/* Inline Composer - shown in viewer pane */}
+1 -1
View File
@@ -30,7 +30,7 @@ const RESTRICTABLE_SETTINGS = [
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus'] },
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
+23 -14
View File
@@ -8,38 +8,46 @@ interface ResizeHandleProps {
onResize: (delta: number) => void;
onResizeEnd?: () => void;
onDoubleClick?: () => void;
orientation?: "vertical" | "horizontal";
className?: string;
}
const KEYBOARD_STEP = 10;
export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleClick, className }: ResizeHandleProps) {
export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleClick, orientation = "vertical", className }: ResizeHandleProps) {
const isDragging = useRef(false);
const startX = useRef(0);
const startPos = useRef(0);
const isHorizontal = orientation === "horizontal";
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isDragging.current = true;
startX.current = e.clientX;
document.body.style.cursor = "col-resize";
startPos.current = isHorizontal ? e.clientY : e.clientX;
document.body.style.cursor = isHorizontal ? "row-resize" : "col-resize";
document.body.style.userSelect = "none";
onResizeStart?.();
}, [onResizeStart]);
}, [onResizeStart, isHorizontal]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
let delta = 0;
if (e.key === "ArrowLeft") delta = -KEYBOARD_STEP;
else if (e.key === "ArrowRight") delta = KEYBOARD_STEP;
else return;
if (isHorizontal) {
if (e.key === "ArrowUp") delta = -KEYBOARD_STEP;
else if (e.key === "ArrowDown") delta = KEYBOARD_STEP;
else return;
} else {
if (e.key === "ArrowLeft") delta = -KEYBOARD_STEP;
else if (e.key === "ArrowRight") delta = KEYBOARD_STEP;
else return;
}
e.preventDefault();
onResize(delta);
onResizeEnd?.();
}, [onResize, onResizeEnd]);
}, [onResize, onResizeEnd, isHorizontal]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.current) return;
const delta = e.clientX - startX.current;
const delta = (isHorizontal ? e.clientY : e.clientX) - startPos.current;
onResize(delta);
};
@@ -57,24 +65,25 @@ export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleCli
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [onResize, onResizeEnd]);
}, [onResize, onResizeEnd, isHorizontal]);
return (
<div
role="separator"
aria-orientation="vertical"
aria-orientation={isHorizontal ? "horizontal" : "vertical"}
aria-label="Resize"
tabIndex={0}
onMouseDown={handleMouseDown}
onKeyDown={handleKeyDown}
onDoubleClick={onDoubleClick}
className={cn(
"w-1 flex-shrink-0 cursor-col-resize hover:bg-primary/30 active:bg-primary/50 transition-colors relative group",
"flex-shrink-0 hover:bg-primary/30 active:bg-primary/50 transition-colors relative group",
"focus-visible:outline-none focus-visible:bg-primary/40 focus-visible:ring-2 focus-visible:ring-primary/50",
isHorizontal ? "h-1 cursor-row-resize bg-border" : "w-1 cursor-col-resize",
className
)}
>
<div className="absolute inset-y-0 -left-1 -right-1" />
<div className={cn("absolute", isHorizontal ? "inset-x-0 -top-1 -bottom-1" : "inset-y-0 -left-1 -right-1")} />
</div>
);
}
+31 -4
View File
@@ -20,8 +20,6 @@ function MailLayoutPreview({
value: MailLayout;
t: (key: string) => string;
}) {
const isSplit = value === 'split';
return (
<div className="mt-3 rounded-xl border border-border bg-background p-3">
<div>
@@ -33,7 +31,7 @@ function MailLayoutPreview({
<div className="flex h-28">
<div className="w-11 border-r border-border bg-muted/40" />
{isSplit ? (
{value === 'split' && (
<>
<div className="w-28 border-r border-border bg-background">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
@@ -56,7 +54,9 @@ function MailLayoutPreview({
<div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" />
</div>
</>
) : (
)}
{value === 'focus' && (
<div className="flex-1 bg-background px-2 py-2">
<div className="space-y-1.5">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
@@ -76,6 +76,32 @@ function MailLayoutPreview({
</div>
</div>
)}
{value === 'horizontal' && (
<div className="flex-1 flex flex-col bg-background">
<div className="border-b border-border bg-background">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
<div
key={row.subject}
className={cn(
'border-b border-border px-2 py-1 text-[10px] last:border-b-0',
row.selected && 'bg-primary/10'
)}
>
<div className="truncate text-foreground">
<span className="font-medium">{row.sender}</span>
<span className="mx-1.5 text-muted-foreground">{row.subject}</span>
</div>
</div>
))}
</div>
<div className="flex-1 bg-background px-3 py-2">
<div className="h-2 w-20 rounded bg-foreground/10" />
<div className="mt-1.5 h-1.5 w-full rounded bg-foreground/10" />
<div className="mt-1 h-1.5 w-5/6 rounded bg-foreground/10" />
</div>
</div>
)}
</div>
</div>
</div>
@@ -100,6 +126,7 @@ export function LayoutSettings() {
options={[
{ value: 'split', label: tEmail('mail_layout.split') },
{ value: 'focus', label: tEmail('mail_layout.focus') },
{ value: 'horizontal', label: tEmail('mail_layout.horizontal') },
]}
/>
<MailLayoutPreview value={mailLayout} t={tEmail} />
+4 -2
View File
@@ -949,11 +949,13 @@
},
"mail_layout": {
"label": "Mail Layout",
"description": "Choose between the classic split reading pane and a Gmail-style focused reading flow.",
"description": "Choose between the classic split reading pane, a Gmail-style focused reading flow, or a Zimbra-style bottom reading pane.",
"split": "Split pane",
"split_description": "Keep the message list and reading pane visible side by side.",
"focus": "Focused list",
"focus_description": "Show one line per message and open mail full-width while keeping the folder sidebar visible."
"focus_description": "Show one line per message and open mail full-width while keeping the folder sidebar visible.",
"horizontal": "Reading pane at bottom",
"horizontal_description": "Show the message list on top and open the selected message in a reading pane below it."
},
"show_preview": {
"label": "Show Preview Text",
+1 -1
View File
@@ -38,7 +38,7 @@ export type MailAttachmentAction = 'preview' | 'download';
export type AttachmentPosition = 'beside-sender' | 'below-header';
export type ToolbarPosition = 'top' | 'below-subject';
export type ArchiveMode = 'single' | 'year' | 'month';
export type MailLayout = 'split' | 'focus';
export type MailLayout = 'split' | 'focus' | 'horizontal';
export type CalendarHoverPreview = 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s';
export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam';
+25 -6
View File
@@ -11,6 +11,10 @@ const SIDEBAR_DEFAULT = 256;
const EMAIL_LIST_MIN = 240;
const EMAIL_LIST_MAX = 600;
const EMAIL_LIST_DEFAULT = 384;
// Email list height (in pixels) for horizontal "Reading Pane at Bottom" layout
const EMAIL_LIST_HEIGHT_MIN = 160;
const EMAIL_LIST_HEIGHT_MAX = 800;
const EMAIL_LIST_HEIGHT_DEFAULT = 320;
interface UIState {
// Mobile view state
@@ -28,6 +32,7 @@ interface UIState {
// Resizable column widths (desktop only)
sidebarWidth: number;
emailListWidth: number;
emailListHeight: number;
// Sidebar collapsed state (desktop)
sidebarCollapsed: boolean;
@@ -40,8 +45,10 @@ interface UIState {
setDeviceType: (isMobile: boolean, isTablet: boolean, isDesktop: boolean) => void;
setSidebarWidth: (width: number) => void;
setEmailListWidth: (width: number) => void;
setEmailListHeight: (height: number) => void;
resetSidebarWidth: () => void;
resetEmailListWidth: () => void;
resetEmailListHeight: () => void;
persistColumnWidths: () => void;
setSidebarCollapsed: (collapsed: boolean) => void;
toggleSidebarCollapsed: () => void;
@@ -64,6 +71,7 @@ export const useUIStore = create<UIState>((set, get) => ({
isDesktop: true,
sidebarWidth: SIDEBAR_DEFAULT,
emailListWidth: EMAIL_LIST_DEFAULT,
emailListHeight: EMAIL_LIST_HEIGHT_DEFAULT,
sidebarCollapsed: false,
// Actions
@@ -84,26 +92,37 @@ export const useUIStore = create<UIState>((set, get) => ({
setEmailListWidth: (width) =>
set({ emailListWidth: Math.min(EMAIL_LIST_MAX, Math.max(EMAIL_LIST_MIN, width)) }),
setEmailListHeight: (height) =>
set({ emailListHeight: Math.min(EMAIL_LIST_HEIGHT_MAX, Math.max(EMAIL_LIST_HEIGHT_MIN, height)) }),
resetSidebarWidth: () => {
set({ sidebarWidth: SIDEBAR_DEFAULT });
const { emailListWidth } = get();
const { emailListWidth, emailListHeight } = get();
try {
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth: SIDEBAR_DEFAULT, emailListWidth }));
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth: SIDEBAR_DEFAULT, emailListWidth, emailListHeight }));
} catch { /* localStorage may be unavailable */ }
},
resetEmailListWidth: () => {
set({ emailListWidth: EMAIL_LIST_DEFAULT });
const { sidebarWidth } = get();
const { sidebarWidth, emailListHeight } = get();
try {
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth: EMAIL_LIST_DEFAULT }));
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth: EMAIL_LIST_DEFAULT, emailListHeight }));
} catch { /* localStorage may be unavailable */ }
},
resetEmailListHeight: () => {
set({ emailListHeight: EMAIL_LIST_HEIGHT_DEFAULT });
const { sidebarWidth, emailListWidth } = get();
try {
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth, emailListHeight: EMAIL_LIST_HEIGHT_DEFAULT }));
} catch { /* localStorage may be unavailable */ }
},
persistColumnWidths: () => {
const { sidebarWidth, emailListWidth } = get();
const { sidebarWidth, emailListWidth, emailListHeight } = get();
try {
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth }));
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth, emailListHeight }));
} catch { /* localStorage may be unavailable */ }
},