From 308adf0101735d93700c7005aa4eb8a6e4c7c98a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 28 Mar 2026 01:40:41 +0100 Subject: [PATCH] feat: add mail layout settings and update email list components --- app/[locale]/page.tsx | 23 +- app/admin/policy/page.tsx | 1 + .../email/__tests__/email-list-item.test.tsx | 14 + components/email/email-list-item.tsx | 195 +++++---- components/email/email-list.tsx | 9 +- components/email/email-viewer.tsx | 6 +- components/email/thread-list-item.tsx | 394 +++++++++++------- components/settings/email-settings.tsx | 105 ++++- locales/en/common.json | 11 +- stores/settings-store.ts | 4 + 10 files changed, 534 insertions(+), 228 deletions(-) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 3d4b0273..4541c9f1 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -53,6 +53,7 @@ export default function Home() { const t = useTranslations(); const tCommon = useTranslations('common'); const { appName } = useConfig(); + const mailLayout = useSettingsStore((state) => state.mailLayout); const [showComposer, setShowComposer] = useState(false); const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose'); const [composerDraftText, setComposerDraftText] = useState(""); @@ -970,6 +971,10 @@ 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 hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail); + const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent); + const shouldHideViewerPane = !isMobile && isFocusedMailLayout && !hasViewerContent; // Handle email selection with mobile view switching const handleEmailSelect = async (email: { id: string }) => { @@ -1020,6 +1025,9 @@ export default function Home() { setConversationEmails([]); } selectEmail(null); + if (isTablet) { + setTabletListVisible(true); + } setActiveView("list"); }; @@ -1202,10 +1210,9 @@ export default function Home() { // Tablet/Desktop: fixed width with collapse animation "md:flex-shrink-0 md:shadow-sm", !isResizing && "transition-all duration-200 ease-out", - // Tablet: collapse when email selected - isTablet && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0" + shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0" )} - style={!isMobile && !(isTablet && !tabletListVisible) ? { width: emailListWidth } : undefined} + style={!isMobile && !shouldCollapseListPane ? { width: emailListWidth } : undefined} > {/* Mobile Header for List View */} {/* Email list resize handle (desktop only) */} - {!isMobile && !isTablet && ( + {!isMobile && !isTablet && !isFocusedMailLayout && ( { dragStartWidth.current = emailListWidth; setIsResizing(true); }} onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)} @@ -1524,7 +1531,8 @@ export default function Home() { "max-md:fixed max-md:inset-0 max-md:z-30", isMobile && activeView !== "viewer" && "max-md:hidden", // Tablet/Desktop: relative - "md:relative" + "md:relative", + shouldHideViewerPane && "md:hidden" )} > {/* Inline Composer - shown in viewer pane */} @@ -1652,10 +1660,7 @@ export default function Home() { }} onDownloadAttachment={handleDownloadAttachment} onQuickReply={handleQuickReply} - onBack={() => { - setTabletListVisible(true); - selectEmail(null); - }} + onBack={handleMobileBack} onNavigateNext={handleNavigateNext} onNavigatePrev={handleNavigatePrev} onShowShortcuts={() => setShowShortcutsModal(true)} diff --git a/app/admin/policy/page.tsx b/app/admin/policy/page.tsx index d8490ad3..4fb09b5f 100644 --- a/app/admin/policy/page.tsx +++ b/app/admin/policy/page.tsx @@ -28,6 +28,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: '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' }, diff --git a/components/email/__tests__/email-list-item.test.tsx b/components/email/__tests__/email-list-item.test.tsx index 5d1e55f1..99da5bcc 100644 --- a/components/email/__tests__/email-list-item.test.tsx +++ b/components/email/__tests__/email-list-item.test.tsx @@ -38,6 +38,7 @@ describe('EmailListItem tag badge', () => { useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, + mailLayout: 'split', }); useEmailStore.setState({ selectedEmailIds: new Set(), @@ -104,4 +105,17 @@ describe('EmailListItem tag badge', () => { render(); expect(screen.getByText('Hello World')).toBeInTheDocument(); }); + + it('renders inline preview text in focused mail layout', () => { + useSettingsStore.setState({ + showPreview: true, + mailLayout: 'focus', + }); + const email = makeEmail({ preview: 'Inline preview content' }); + const { container } = render(); + + expect(screen.getByText('Test Subject')).toBeInTheDocument(); + expect(screen.getByText(/Inline preview content/)).toBeInTheDocument(); + expect(container.querySelector('p')).toBeNull(); + }); }); diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 5863557e..4019787f 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -35,6 +35,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore(); const showPreview = useSettingsStore((state) => state.showPreview); const density = useSettingsStore((state) => state.density); + const mailLayout = useSettingsStore((state) => state.mailLayout); const emailKeywords = useSettingsStore((state) => state.emailKeywords); const { identities } = useAuthStore(); const isChecked = selectedEmailIds.has(email.id); @@ -44,6 +45,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl const isAnswered = email.keywords?.$answered; const isForwarded = email.keywords?.$forwarded; const sender = email.from?.[0]; + const isFocusedMailLayout = mailLayout === 'focus'; + const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; // Resolve color tag using keyword definitions from settings const colorTagId = getEmailColorTag(email.keywords); @@ -114,15 +117,19 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl } }} onContextMenu={handleContextMenu} - style={{ minHeight: 'var(--list-item-height)' }} + style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }} > -
+
{/* Checkbox - only visible when in selection mode */} {selectedEmailIds.size > 0 && (
)} - {density !== 'extra-compact' && ( + {!isFocusedMailLayout && density !== 'extra-compact' && ( ( )}
-
-
- - {sender?.name || sender?.email || "Unknown"} - -
- {isStarred && ( - - )} - {isAnswered && !isForwarded && ( - - )} - {isForwarded && !isAnswered && ( - - )} + {isFocusedMailLayout ? ( +
+
+ + {sender?.name || sender?.email || 'Unknown'} + +
+ + {email.subject || '(no subject)'} + + {inlinePreview && ( + {inlinePreview} + )} +
+
+
+ {isStarred && } + {isAnswered && !isForwarded && } + {isForwarded && !isAnswered && } {isAnswered && isForwarded && ( <> )} - {email.hasAttachment && ( - - )} + {email.hasAttachment && } + {resolvedKeywordDef && } + + {formatDate(email.receivedAt)} +
-
- {resolvedKeywordDef && ( - - - {resolvedKeywordDef.label} - - )} - +
+
+ + {sender?.name || sender?.email || "Unknown"} + +
+ {isStarred && ( + + )} + {isAnswered && !isForwarded && ( + + )} + {isForwarded && !isAnswered && ( + + )} + {isAnswered && isForwarded && ( + <> + + + + )} + {email.hasAttachment && ( + + )} +
+
+
+ {resolvedKeywordDef && ( + + + {resolvedKeywordDef.label} + + )} + + {formatDate(email.receivedAt)} + +
+
+ +
- {formatDate(email.receivedAt)} - -
-
+ {email.subject || "(no subject)"} +
-
- {email.subject || "(no subject)"} -
- - {showPreview && density !== 'extra-compact' && ( -

- {email.preview || "No preview available"} -

+ {showPreview && density !== 'extra-compact' && ( +

+ {email.preview || "No preview available"} +

+ )} + )}
@@ -281,8 +333,11 @@ export const ThreadListItem = React.forwardRef state.showPreview); const density = useSettingsStore((state) => state.density); + const mailLayout = useSettingsStore((state) => state.mailLayout); const isMobile = useUIStore((state) => state.isMobile); const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; + const isFocusedMailLayout = mailLayout === 'focus'; + const inlinePreview = showPreview && latestEmail.preview ? ` ${latestEmail.preview}` : ''; const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); @@ -406,15 +461,19 @@ export const ThreadListItem = React.forwardRef -
+
{/* Checkbox for thread selection - only visible when in selection mode */} {selectedEmailIds.size > 0 && (
- {isExpanded && !isMobile && ( + {isExpanded && !isMobile && !isFocusedMailLayout && (
{isLoading ? (
diff --git a/components/settings/email-settings.tsx b/components/settings/email-settings.tsx index 3e4e30ea..6c8813bc 100644 --- a/components/settings/email-settings.tsx +++ b/components/settings/email-settings.tsx @@ -4,16 +4,92 @@ import { useState, useCallback } from 'react'; import { useTranslations } from 'next-intl'; import { useConfig } from '@/hooks/use-config'; import { useSettingsStore } from '@/stores/settings-store'; -import type { ArchiveMode, HoverAction } from '@/stores/settings-store'; +import type { ArchiveMode, HoverAction, MailLayout } from '@/stores/settings-store'; import { ALL_HOVER_ACTIONS } from '@/stores/settings-store'; import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; import { cn } from '@/lib/utils'; -import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; +import { RadioGroup, SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { TrustedSendersModal } from '@/components/trusted-senders-modal'; import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react'; import { usePolicyStore } from '@/stores/policy-store'; +const MAIL_LAYOUT_PREVIEW_ROWS = [ + { sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false }, + { sender: 'Nadia', subject: 'Design sync', preview: 'Pushed updated mocks and notes.', selected: true }, + { sender: 'Billing', subject: 'Invoice 1042', preview: 'Your receipt is attached.', selected: false }, +]; + +function MailLayoutPreview({ + value, + t, +}: { + value: MailLayout; + t: (key: string) => string; +}) { + const isSplit = value === 'split'; + + return ( +
+
+
{t(`mail_layout.${value}`)}
+
{t(`mail_layout.${value}_description`)}
+
+ +
+
+
+ + {isSplit ? ( + <> +
+ {MAIL_LAYOUT_PREVIEW_ROWS.map((row) => ( +
+
{row.sender}
+
{row.subject}
+
+ ))} +
+
+
+
+
+
+
+ + ) : ( +
+
+ {MAIL_LAYOUT_PREVIEW_ROWS.map((row) => ( +
+
+ {row.sender} + {row.subject} + {row.preview} +
+
+ ))} +
+
+ )} +
+
+
+ ); +} + export function EmailSettings() { const t = useTranslations('settings.email_behavior'); const { appName } = useConfig(); @@ -39,6 +115,7 @@ export function EmailSettings() { deleteAction, permanentlyDeleteJunk, showPreview, + mailLayout, disableThreading, autoSelectReplyIdentity, plainTextMode, @@ -63,6 +140,8 @@ export function EmailSettings() { return t('trusted_senders.count_other', { count }); }; + const isFocusedLayout = mailLayout === 'focus'; + const handleReorganizeArchive = async () => { const { client } = useAuthStore.getState(); const { mailboxes, fetchMailboxes } = useEmailStore.getState(); @@ -208,9 +287,29 @@ export function EmailSettings() { /> + {!isSettingHidden('mailLayout') && ( + +
+ updateSetting('mailLayout', value as MailLayout)} + options={[ + { value: 'split', label: t('mail_layout.split') }, + { value: 'focus', label: t('mail_layout.focus') }, + ]} + /> + +
+
+ )} + {/* Show Preview */} {!isSettingHidden('showPreview') && ( - + updateSetting('showPreview', checked)} /> )} diff --git a/locales/en/common.json b/locales/en/common.json index e31f833a..578ac44f 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -799,9 +799,18 @@ "label": "Permanently Delete Junk", "description": "Permanently delete emails from the Junk/Spam folder instead of moving them to Trash" }, + "mail_layout": { + "label": "Mail Layout", + "description": "Choose between the classic split reading pane and a Gmail-style focused reading flow.", + "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." + }, "show_preview": { "label": "Show Preview Text", - "description": "Display email preview in the list" + "description": "Display email preview in the list", + "focus_description": "Display inline preview text inside the focused one-line message list" }, "disable_threading": { "label": "Disable Conversation Grouping", diff --git a/stores/settings-store.ts b/stores/settings-store.ts index c32f9a2c..8a75158d 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -33,6 +33,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 HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam'; export type HoverActionsMode = 'inline' | 'floating'; @@ -105,6 +106,7 @@ interface SettingsState { deleteAction: DeleteAction; permanentlyDeleteJunk: boolean; // Permanently delete emails from junk/spam instead of moving to trash showPreview: boolean; + mailLayout: MailLayout; emailsPerPage: number; externalContentPolicy: ExternalContentPolicy; mailAttachmentAction: MailAttachmentAction; @@ -225,6 +227,7 @@ const DEFAULT_SETTINGS = { deleteAction: 'trash' as DeleteAction, permanentlyDeleteJunk: false, showPreview: true, + mailLayout: 'split' as MailLayout, emailsPerPage: 50, externalContentPolicy: 'ask' as ExternalContentPolicy, mailAttachmentAction: 'preview' as MailAttachmentAction, @@ -335,6 +338,7 @@ export const useSettingsStore = create()( markAsReadDelay: state.markAsReadDelay, deleteAction: state.deleteAction, showPreview: state.showPreview, + mailLayout: state.mailLayout, emailsPerPage: state.emailsPerPage, externalContentPolicy: state.externalContentPolicy, mailAttachmentAction: state.mailAttachmentAction,