feat: add mail layout settings and update email list components

This commit is contained in:
Linus Rath
2026-03-28 01:40:41 +01:00
parent 574bd7ecef
commit 308adf0101
10 changed files with 534 additions and 228 deletions
+14 -9
View File
@@ -53,6 +53,7 @@ export default function Home() {
const t = useTranslations(); const t = useTranslations();
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const { appName } = useConfig(); const { appName } = useConfig();
const mailLayout = useSettingsStore((state) => state.mailLayout);
const [showComposer, setShowComposer] = useState(false); const [showComposer, setShowComposer] = useState(false);
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose'); const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
const [composerDraftText, setComposerDraftText] = useState(""); const [composerDraftText, setComposerDraftText] = useState("");
@@ -970,6 +971,10 @@ export default function Home() {
// Get current mailbox name for mobile header // Get current mailbox name for mobile header
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox"; 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 // Handle email selection with mobile view switching
const handleEmailSelect = async (email: { id: string }) => { const handleEmailSelect = async (email: { id: string }) => {
@@ -1020,6 +1025,9 @@ export default function Home() {
setConversationEmails([]); setConversationEmails([]);
} }
selectEmail(null); selectEmail(null);
if (isTablet) {
setTabletListVisible(true);
}
setActiveView("list"); setActiveView("list");
}; };
@@ -1202,10 +1210,9 @@ export default function Home() {
// Tablet/Desktop: fixed width with collapse animation // Tablet/Desktop: fixed width with collapse animation
"md:flex-shrink-0 md:shadow-sm", "md:flex-shrink-0 md:shadow-sm",
!isResizing && "transition-all duration-200 ease-out", !isResizing && "transition-all duration-200 ease-out",
// Tablet: collapse when email selected shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
isTablet && !tabletListVisible && "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 */} {/* Mobile Header for List View */}
<MobileHeader <MobileHeader
@@ -1507,7 +1514,7 @@ export default function Home() {
</div> </div>
{/* Email list resize handle (desktop only) */} {/* Email list resize handle (desktop only) */}
{!isMobile && !isTablet && ( {!isMobile && !isTablet && !isFocusedMailLayout && (
<ResizeHandle <ResizeHandle
onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }} onResizeStart={() => { dragStartWidth.current = emailListWidth; setIsResizing(true); }}
onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)} 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", "max-md:fixed max-md:inset-0 max-md:z-30",
isMobile && activeView !== "viewer" && "max-md:hidden", isMobile && activeView !== "viewer" && "max-md:hidden",
// Tablet/Desktop: relative // Tablet/Desktop: relative
"md:relative" "md:relative",
shouldHideViewerPane && "md:hidden"
)} )}
> >
{/* Inline Composer - shown in viewer pane */} {/* Inline Composer - shown in viewer pane */}
@@ -1652,10 +1660,7 @@ export default function Home() {
}} }}
onDownloadAttachment={handleDownloadAttachment} onDownloadAttachment={handleDownloadAttachment}
onQuickReply={handleQuickReply} onQuickReply={handleQuickReply}
onBack={() => { onBack={handleMobileBack}
setTabletListVisible(true);
selectEmail(null);
}}
onNavigateNext={handleNavigateNext} onNavigateNext={handleNavigateNext}
onNavigatePrev={handleNavigatePrev} onNavigatePrev={handleNavigatePrev}
onShowShortcuts={() => setShowShortcutsModal(true)} onShowShortcuts={() => setShowShortcutsModal(true)}
+1
View File
@@ -28,6 +28,7 @@ const RESTRICTABLE_SETTINGS = [
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' }, { key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] }, { key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'permanent'] },
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' }, { 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: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] }, { key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' }, { key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
@@ -38,6 +38,7 @@ describe('EmailListItem tag badge', () => {
useSettingsStore.setState({ useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS], emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false, showPreview: false,
mailLayout: 'split',
}); });
useEmailStore.setState({ useEmailStore.setState({
selectedEmailIds: new Set<string>(), selectedEmailIds: new Set<string>(),
@@ -104,4 +105,17 @@ describe('EmailListItem tag badge', () => {
render(<EmailListItem email={email} />); render(<EmailListItem email={email} />);
expect(screen.getByText('Hello World')).toBeInTheDocument(); 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(<EmailListItem email={email} />);
expect(screen.getByText('Test Subject')).toBeInTheDocument();
expect(screen.getByText(/Inline preview content/)).toBeInTheDocument();
expect(container.querySelector('p')).toBeNull();
});
}); });
+124 -71
View File
@@ -35,6 +35,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore(); const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const { identities } = useAuthStore(); const { identities } = useAuthStore();
const isChecked = selectedEmailIds.has(email.id); const isChecked = selectedEmailIds.has(email.id);
@@ -44,6 +45,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
const isAnswered = email.keywords?.$answered; const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded; const isForwarded = email.keywords?.$forwarded;
const sender = email.from?.[0]; const sender = email.from?.[0];
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color tag using keyword definitions from settings // Resolve color tag using keyword definitions from settings
const colorTagId = getEmailColorTag(email.keywords); const colorTagId = getEmailColorTag(email.keywords);
@@ -114,15 +117,19 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
} }
}} }}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }} style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
> >
<div className="flex items-start px-4" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}> <div
className={cn('px-4', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
{/* Checkbox - only visible when in selection mode */} {/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && ( {selectedEmailIds.size > 0 && (
<button <button
onClick={handleCheckboxClick} onClick={handleCheckboxClick}
className={cn( className={cn(
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200", "p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
!isFocusedMailLayout && 'mt-2',
"hover:bg-muted/50 hover:scale-110", "hover:bg-muted/50 hover:scale-110",
"active:scale-95", "active:scale-95",
"animate-in fade-in zoom-in-95 duration-150", "animate-in fade-in zoom-in-95 duration-150",
@@ -145,7 +152,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
)} )}
{/* Avatar */} {/* Avatar */}
{density !== 'extra-compact' && ( {!isFocusedMailLayout && density !== 'extra-compact' && (
<Avatar <Avatar
name={sender?.name} name={sender?.name}
email={sender?.email} email={sender?.email}
@@ -156,85 +163,131 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
{/* Content */} {/* Content */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* First Line: Sender and Date */} {isFocusedMailLayout ? (
<div className="flex items-center justify-between gap-2 mb-1"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex min-w-0 flex-1 items-center gap-3">
<span className={cn( <span className={cn(
"truncate text-sm", 'w-32 shrink-0 truncate text-sm lg:w-40',
isUnread isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
? "font-bold text-foreground" )}>
: "font-medium text-muted-foreground" {sender?.name || sender?.email || 'Unknown'}
)}> </span>
{sender?.name || sender?.email || "Unknown"} <div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
</span> <span className={cn(
<div className="flex items-center gap-1.5"> 'shrink-0 truncate',
{isStarred && ( isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" /> )}>
)} {email.subject || t('no_subject')}
{isImportant && (
<span className="px-1.5 py-0.5 text-xs bg-warning/15 text-warning dark:text-warning rounded font-medium">
Important
</span> </span>
)} {inlinePreview && (
<EmailIdentityBadge email={email} identities={identities} compact={true} /> <span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
{isAnswered && !isForwarded && ( )}
<Reply className="w-3.5 h-3.5 text-muted-foreground" /> </div>
)} </div>
{isForwarded && !isAnswered && ( <div className="flex items-center gap-2.5 shrink-0">
<Forward className="w-3.5 h-3.5 text-muted-foreground" /> {isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
)} {isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
{isAnswered && isForwarded && ( {isAnswered && isForwarded && (
<> <>
<Reply className="w-3.5 h-3.5 text-muted-foreground" /> <Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" /> <Forward className="w-3.5 h-3.5 text-muted-foreground" />
</> </>
)} )}
{email.hasAttachment && ( {email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" /> {keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
)} <span className={cn(
'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(email.receivedAt)}
</span>
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 flex-shrink-0"> ) : (
{keywordDef && ( <>
<span className={cn( {/* First Line: Sender and Date */}
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full", <div className="flex items-center justify-between gap-2 mb-1">
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted" <div className="flex items-center gap-2 min-w-0 flex-1">
)}> <span className={cn(
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} /> "truncate text-sm",
{keywordDef.label} isUnread
</span> ? "font-bold text-foreground"
)} : "font-medium text-muted-foreground"
<span className={cn( )}>
"text-xs tabular-nums", {sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{isImportant && (
<span className="px-1.5 py-0.5 text-xs bg-warning/15 text-warning dark:text-warning rounded font-medium">
Important
</span>
)}
<EmailIdentityBadge email={email} identities={identities} compact={true} />
{isAnswered && !isForwarded && (
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isForwarded && !isAnswered && (
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isAnswered && isForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDef && (
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
{/* Second Line: Subject */}
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread isUnread
? "text-foreground font-semibold" ? "font-semibold text-foreground"
: "text-muted-foreground" : "font-normal text-foreground/90"
)}> )}>
{formatDate(email.receivedAt)} {email.subject || t('no_subject')}
</span> </div>
</div>
</div>
{/* Second Line: Subject */} {/* Third Line: Preview (controlled by showPreview setting) */}
<div className={cn( {showPreview && density !== 'extra-compact' && (
"mb-1 line-clamp-1 text-sm", <p className={cn(
isUnread "text-sm leading-relaxed line-clamp-2",
? "font-semibold text-foreground" isUnread
: "font-normal text-foreground/90" ? "text-muted-foreground"
)}> : "text-muted-foreground/80"
{email.subject || t('no_subject')} )}>
</div> {email.preview || "No preview available"}
</p>
{/* Third Line: Preview (controlled by showPreview setting) */} )}
{showPreview && density !== 'extra-compact' && ( </>
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{email.preview || "No preview available"}
</p>
)} )}
</div> </div>
</div> </div>
+7 -2
View File
@@ -103,11 +103,16 @@ export function EmailList({
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const isFocusedMailLayout = mailLayout === 'focus';
const estimateSize = useCallback(() => { const estimateSize = useCallback(() => {
if (isFocusedMailLayout) {
return { 'extra-compact': 32, compact: 40, regular: 46, comfortable: 54 }[density];
}
const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density]; const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
return (showPreview && density !== 'extra-compact') ? base + 36 : base; return (showPreview && density !== 'extra-compact') ? base + 36 : base;
}, [density, showPreview]); }, [density, isFocusedMailLayout, showPreview]);
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: threadGroups.length, count: threadGroups.length,
@@ -244,7 +249,7 @@ export function EmailList({
useEffect(() => { useEffect(() => {
virtualizer.measure(); virtualizer.measure();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [density, showPreview]); }, [density, isFocusedMailLayout, showPreview]);
return ( return (
<div className={cn("flex flex-col min-h-0", className)}> <div className={cn("flex flex-col min-h-0", className)}>
+4 -2
View File
@@ -887,8 +887,10 @@ export function EmailViewer({
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition); const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled); const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
const timeFormat = useSettingsStore((state) => state.timeFormat); const timeFormat = useSettingsStore((state) => state.timeFormat);
const isFocusedMailLayout = mailLayout === 'focus';
// Detect if current mailbox is Junk folder // Detect if current mailbox is Junk folder
const isInJunkFolder = currentMailboxRole === 'junk'; const isInJunkFolder = currentMailboxRole === 'junk';
@@ -2790,7 +2792,7 @@ export function EmailViewer({
<> <>
{/* Left: Reply actions */} {/* Left: Reply actions */}
<div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}> <div className={cn("flex items-center gap-0", showBackButton ? "sm:gap-1" : "sm:gap-0.5")}>
{showBackButton && isTablet && !tabletListVisible && onBack && ( {showBackButton && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -3524,7 +3526,7 @@ export function EmailViewer({
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}> <div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start justify-between gap-2 lg:gap-4"> <div className="flex items-start justify-between gap-2 lg:gap-4">
{/* Back button (for below-subject mode on tablet) */} {/* Back button (for below-subject mode on tablet) */}
{toolbarPosition === 'below-subject' && isTablet && !tabletListVisible && onBack && ( {toolbarPosition === 'below-subject' && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
+254 -140
View File
@@ -59,7 +59,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
const emailKeywords = useSettingsStore((state) => state.emailKeywords); const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const isChecked = selectedEmailIds.has(email.id); const isChecked = selectedEmailIds.has(email.id);
const isFocusedMailLayout = mailLayout === 'focus';
const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : '';
// Resolve color and keyword definition from keyword definitions if not passed directly // Resolve color and keyword definition from keyword definitions if not passed directly
const tagId = getEmailColorTag(email.keywords); const tagId = getEmailColorTag(email.keywords);
@@ -131,15 +134,19 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)} )}
onClick={handleClick} onClick={handleClick}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }} style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
> >
<div className="flex items-start px-3" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}> <div
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
{/* Checkbox - only visible when in selection mode */} {/* Checkbox - only visible when in selection mode */}
{selectedEmailIds.size > 0 && ( {selectedEmailIds.size > 0 && (
<button <button
onClick={handleCheckboxClick} onClick={handleCheckboxClick}
className={cn( className={cn(
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200", "p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
!isFocusedMailLayout && 'mt-2',
"hover:bg-muted/50 hover:scale-110", "hover:bg-muted/50 hover:scale-110",
"active:scale-95", "active:scale-95",
"animate-in fade-in zoom-in-95 duration-150", "animate-in fade-in zoom-in-95 duration-150",
@@ -160,7 +167,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div> </div>
)} )}
{density !== 'extra-compact' && ( {!isFocusedMailLayout && density !== 'extra-compact' && (
<Avatar <Avatar
name={sender?.name} name={sender?.name}
email={sender?.email} email={sender?.email}
@@ -170,76 +177,121 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1"> {isFocusedMailLayout ? (
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center justify-between gap-3">
<span className={cn( <div className="flex min-w-0 flex-1 items-center gap-3">
"truncate text-sm", <span className={cn(
isUnread 'w-32 shrink-0 truncate text-sm lg:w-40',
? "font-bold text-foreground" isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
: "font-medium text-muted-foreground" )}>
)}> {sender?.name || sender?.email || 'Unknown'}
{sender?.name || sender?.email || "Unknown"} </span>
</span> <div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<div className="flex items-center gap-1.5"> <span className={cn(
{isStarred && ( 'shrink-0 truncate',
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" /> isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
)} )}>
{isAnswered && !isForwarded && ( {email.subject || '(no subject)'}
<Reply className="w-3.5 h-3.5 text-muted-foreground" /> </span>
)} {inlinePreview && (
{isForwarded && !isAnswered && ( <span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
<Forward className="w-3.5 h-3.5 text-muted-foreground" /> )}
)} </div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
{isAnswered && isForwarded && ( {isAnswered && isForwarded && (
<> <>
<Reply className="w-3.5 h-3.5 text-muted-foreground" /> <Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" /> <Forward className="w-3.5 h-3.5 text-muted-foreground" />
</> </>
)} )}
{email.hasAttachment && ( {email.hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" /> {resolvedKeywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || 'bg-gray-400')} />}
)} <span className={cn(
'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(email.receivedAt)}
</span>
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 flex-shrink-0"> ) : (
{resolvedKeywordDef && ( <>
<span className={cn( <div className="flex items-center justify-between gap-2 mb-1">
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full", <div className="flex items-center gap-2 min-w-0 flex-1">
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted" <span className={cn(
)}> "truncate text-sm",
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} /> isUnread
{resolvedKeywordDef.label} ? "font-bold text-foreground"
</span> : "font-medium text-muted-foreground"
)} )}>
<span className={cn( {sender?.name || sender?.email || "Unknown"}
"text-xs tabular-nums", </span>
<div className="flex items-center gap-1.5">
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{isAnswered && !isForwarded && (
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isForwarded && !isAnswered && (
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isAnswered && isForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{resolvedKeywordDef && (
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[resolvedKeywordDef.color]?.dot || "bg-gray-400")} />
{resolvedKeywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
</div>
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread isUnread
? "text-foreground font-semibold" ? "font-semibold text-foreground"
: "text-muted-foreground" : "font-normal text-foreground/90"
)}> )}>
{formatDate(email.receivedAt)} {email.subject || "(no subject)"}
</span> </div>
</div>
</div>
<div className={cn( {showPreview && density !== 'extra-compact' && (
"mb-1 line-clamp-1 text-sm", <p className={cn(
isUnread "text-sm leading-relaxed line-clamp-2",
? "font-semibold text-foreground" isUnread
: "font-normal text-foreground/90" ? "text-muted-foreground"
)}> : "text-muted-foreground/80"
{email.subject || "(no subject)"} )}>
</div> {email.preview || "No preview available"}
</p>
{showPreview && density !== 'extra-compact' && ( )}
<p className={cn( </>
"text-sm leading-relaxed line-clamp-2",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{email.preview || "No preview available"}
</p>
)} )}
</div> </div>
</div> </div>
@@ -281,8 +333,11 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const t = useTranslations('threads'); const t = useTranslations('threads');
const showPreview = useSettingsStore((state) => state.showPreview); const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density); const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const isMobile = useUIStore((state) => state.isMobile); const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; 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(); const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
@@ -406,15 +461,19 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)} )}
onClick={handleHeaderClick} onClick={handleHeaderClick}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }} style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
> >
<div className="flex items-start px-3" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}> <div
className={cn('px-3', isFocusedMailLayout ? 'flex items-center py-2.5' : 'flex items-start')}
style={isFocusedMailLayout ? { gap: '12px' } : { gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
{/* Checkbox for thread selection - only visible when in selection mode */} {/* Checkbox for thread selection - only visible when in selection mode */}
{selectedEmailIds.size > 0 && ( {selectedEmailIds.size > 0 && (
<button <button
onClick={handleThreadCheckboxClick} onClick={handleThreadCheckboxClick}
className={cn( className={cn(
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200", "p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
!isFocusedMailLayout && 'mt-2',
"hover:bg-muted/50 hover:scale-110", "hover:bg-muted/50 hover:scale-110",
"active:scale-95", "active:scale-95",
"animate-in fade-in zoom-in-95 duration-150", "animate-in fade-in zoom-in-95 duration-150",
@@ -429,7 +488,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</button> </button>
)} )}
{!isMobile && ( {!isMobile && !isFocusedMailLayout && (
<button <button
data-expand-toggle data-expand-toggle
onClick={(e) => { onClick={(e) => {
@@ -461,7 +520,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div> </div>
)} )}
{density !== 'extra-compact' && ( {!isFocusedMailLayout && density !== 'extra-compact' && (
<Avatar <Avatar
name={latestEmail.from?.[0]?.name} name={latestEmail.from?.[0]?.name}
email={latestEmail.from?.[0]?.email} email={latestEmail.from?.[0]?.email}
@@ -471,88 +530,143 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1"> {isFocusedMailLayout ? (
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center justify-between gap-3">
<span className={cn( <div className="flex min-w-0 flex-1 items-center gap-3">
"truncate text-sm", <span className={cn(
hasUnread 'w-32 shrink-0 truncate text-sm lg:w-44',
? "font-bold text-foreground" hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
: "font-medium text-muted-foreground" )}>
)}> {participantNames.join(', ')}
{participantNames.join(", ")} </span>
</span> <span
<span className={cn(
className={cn( 'inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium',
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium", hasUnread ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
hasUnread )}
? "bg-primary text-primary-foreground" title={t('messages_tooltip', { count: emailCount })}
: "bg-muted text-muted-foreground" >
)} <MessageSquare className="w-3 h-3" />
title={t('messages_tooltip', { count: emailCount })} {emailCount}
> </span>
<MessageSquare className="w-3 h-3" /> <div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
{emailCount} <span className={cn(
</span> 'shrink-0 truncate',
<div className="flex items-center gap-1.5"> hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
{hasStarred && ( )}>
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" /> {latestEmail.subject || '(no subject)'}
)} </span>
{hasAnswered && !hasForwarded && ( {inlinePreview && (
<Reply className="w-3.5 h-3.5 text-muted-foreground" /> <span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
)} )}
{hasForwarded && !hasAnswered && ( </div>
<Forward className="w-3.5 h-3.5 text-muted-foreground" /> </div>
)} <div className="flex items-center gap-2.5 shrink-0">
{hasStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{hasAnswered && !hasForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
{hasForwarded && !hasAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
{hasAnswered && hasForwarded && ( {hasAnswered && hasForwarded && (
<> <>
<Reply className="w-3.5 h-3.5 text-muted-foreground" /> <Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" /> <Forward className="w-3.5 h-3.5 text-muted-foreground" />
</> </>
)} )}
{hasAttachment && ( {hasAttachment && <Paperclip className="w-3.5 h-3.5 text-muted-foreground" />}
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" /> {keywordDef && <span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />}
)} <span className={cn(
'text-xs tabular-nums',
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
)}>
{formatDate(latestEmail.receivedAt)}
</span>
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 flex-shrink-0"> ) : (
{keywordDef && ( <>
<span className={cn( <div className="flex items-center justify-between gap-2 mb-1">
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full", <div className="flex items-center gap-2 min-w-0 flex-1">
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted" <span className={cn(
)}> "truncate text-sm",
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} /> hasUnread
{keywordDef.label} ? "font-bold text-foreground"
</span> : "font-medium text-muted-foreground"
)} )}>
<span className={cn( {participantNames.join(", ")}
"text-xs tabular-nums", </span>
<span
className={cn(
"flex-shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-xs rounded-full font-medium",
hasUnread
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}
title={t('messages_tooltip', { count: emailCount })}
>
<MessageSquare className="w-3 h-3" />
{emailCount}
</span>
<div className="flex items-center gap-1.5">
{hasStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{hasAnswered && !hasForwarded && (
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
)}
{hasForwarded && !hasAnswered && (
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
)}
{hasAnswered && hasForwarded && (
<>
<Reply className="w-3.5 h-3.5 text-muted-foreground" />
<Forward className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{keywordDef && (
<span className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full",
KEYWORD_PALETTE[keywordDef.color]?.bg || "bg-muted"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", KEYWORD_PALETTE[keywordDef.color]?.dot || "bg-gray-400")} />
{keywordDef.label}
</span>
)}
<span className={cn(
"text-xs tabular-nums",
hasUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(latestEmail.receivedAt)}
</span>
</div>
</div>
<div className={cn(
"mb-1 line-clamp-1 text-sm",
hasUnread hasUnread
? "text-foreground font-semibold" ? "font-semibold text-foreground"
: "text-muted-foreground" : "font-normal text-foreground/90"
)}> )}>
{formatDate(latestEmail.receivedAt)} {latestEmail.subject || "(no subject)"}
</span> </div>
</div>
</div>
<div className={cn( {showPreview && density !== 'extra-compact' && (
"mb-1 line-clamp-1 text-sm", <p className={cn(
hasUnread "text-sm leading-relaxed line-clamp-2",
? "font-semibold text-foreground" hasUnread
: "font-normal text-foreground/90" ? "text-muted-foreground"
)}> : "text-muted-foreground/80"
{latestEmail.subject || "(no subject)"} )}>
</div> {latestEmail.preview || "No preview available"}
</p>
{showPreview && density !== 'extra-compact' && ( )}
<p className={cn( </>
"text-sm leading-relaxed line-clamp-2",
hasUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{latestEmail.preview || "No preview available"}
</p>
)} )}
</div> </div>
</div> </div>
@@ -570,7 +684,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
/> />
</div> </div>
{isExpanded && !isMobile && ( {isExpanded && !isMobile && !isFocusedMailLayout && (
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200"> <div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
{isLoading ? ( {isLoading ? (
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground"> <div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
+102 -3
View File
@@ -4,16 +4,92 @@ import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
import { useSettingsStore } from '@/stores/settings-store'; 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 { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store'; import { useEmailStore } from '@/stores/email-store';
import { cn } from '@/lib/utils'; 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 { TrustedSendersModal } from '@/components/trusted-senders-modal';
import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react'; import { ChevronRight, AlertTriangle, FolderSync, Loader2, Mail } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store'; 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 (
<div className="mt-3 rounded-xl border border-border bg-background p-3">
<div>
<div className="text-sm font-medium text-foreground">{t(`mail_layout.${value}`)}</div>
<div className="mt-1 text-xs text-muted-foreground">{t(`mail_layout.${value}_description`)}</div>
</div>
<div className="mt-3 overflow-hidden rounded-lg border border-border bg-muted/20">
<div className="flex h-28">
<div className="w-11 border-r border-border bg-muted/40" />
{isSplit ? (
<>
<div className="w-28 border-r border-border bg-background">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
<div
key={row.subject}
className={cn(
'border-b border-border px-2 py-1.5 text-[10px] last:border-b-0',
row.selected && 'bg-primary/10'
)}
>
<div className="truncate font-medium text-foreground">{row.sender}</div>
<div className="truncate text-muted-foreground">{row.subject}</div>
</div>
))}
</div>
<div className="flex-1 bg-background px-3 py-2">
<div className="h-2.5 w-20 rounded bg-foreground/10" />
<div className="mt-2 h-2 w-full rounded bg-foreground/10" />
<div className="mt-1.5 h-2 w-5/6 rounded bg-foreground/10" />
<div className="mt-1.5 h-2 w-2/3 rounded bg-foreground/10" />
</div>
</>
) : (
<div className="flex-1 bg-background px-2 py-2">
<div className="space-y-1.5">
{MAIL_LAYOUT_PREVIEW_ROWS.map((row) => (
<div
key={row.subject}
className={cn(
'rounded-md px-2 py-1 text-[10px]',
row.selected ? 'bg-primary/10' : 'bg-muted/20'
)}
>
<div className="truncate text-foreground">
<span className="font-medium">{row.sender}</span>
<span className="mx-1.5 text-muted-foreground">{row.subject}</span>
<span className="text-muted-foreground/80">{row.preview}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
export function EmailSettings() { export function EmailSettings() {
const t = useTranslations('settings.email_behavior'); const t = useTranslations('settings.email_behavior');
const { appName } = useConfig(); const { appName } = useConfig();
@@ -39,6 +115,7 @@ export function EmailSettings() {
deleteAction, deleteAction,
permanentlyDeleteJunk, permanentlyDeleteJunk,
showPreview, showPreview,
mailLayout,
disableThreading, disableThreading,
autoSelectReplyIdentity, autoSelectReplyIdentity,
plainTextMode, plainTextMode,
@@ -63,6 +140,8 @@ export function EmailSettings() {
return t('trusted_senders.count_other', { count }); return t('trusted_senders.count_other', { count });
}; };
const isFocusedLayout = mailLayout === 'focus';
const handleReorganizeArchive = async () => { const handleReorganizeArchive = async () => {
const { client } = useAuthStore.getState(); const { client } = useAuthStore.getState();
const { mailboxes, fetchMailboxes } = useEmailStore.getState(); const { mailboxes, fetchMailboxes } = useEmailStore.getState();
@@ -208,9 +287,29 @@ export function EmailSettings() {
/> />
</SettingItem> </SettingItem>
{!isSettingHidden('mailLayout') && (
<SettingItem label={t('mail_layout.label')} description={t('mail_layout.description')} locked={isSettingLocked('mailLayout')}>
<div className="w-[22rem] max-w-full">
<RadioGroup
value={mailLayout}
onChange={(value) => updateSetting('mailLayout', value as MailLayout)}
options={[
{ value: 'split', label: t('mail_layout.split') },
{ value: 'focus', label: t('mail_layout.focus') },
]}
/>
<MailLayoutPreview value={mailLayout} t={t} />
</div>
</SettingItem>
)}
{/* Show Preview */} {/* Show Preview */}
{!isSettingHidden('showPreview') && ( {!isSettingHidden('showPreview') && (
<SettingItem label={t('show_preview.label')} description={t('show_preview.description')} locked={isSettingLocked('showPreview')}> <SettingItem
label={t('show_preview.label')}
description={isFocusedLayout ? t('show_preview.focus_description') : t('show_preview.description')}
locked={isSettingLocked('showPreview')}
>
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} /> <ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
</SettingItem> </SettingItem>
)} )}
+10 -1
View File
@@ -799,9 +799,18 @@
"label": "Permanently Delete Junk", "label": "Permanently Delete Junk",
"description": "Permanently delete emails from the Junk/Spam folder instead of moving them to Trash" "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": { "show_preview": {
"label": "Show Preview Text", "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": { "disable_threading": {
"label": "Disable Conversation Grouping", "label": "Disable Conversation Grouping",
+4
View File
@@ -33,6 +33,7 @@ export type MailAttachmentAction = 'preview' | 'download';
export type AttachmentPosition = 'beside-sender' | 'below-header'; export type AttachmentPosition = 'beside-sender' | 'below-header';
export type ToolbarPosition = 'top' | 'below-subject'; export type ToolbarPosition = 'top' | 'below-subject';
export type ArchiveMode = 'single' | 'year' | 'month'; export type ArchiveMode = 'single' | 'year' | 'month';
export type MailLayout = 'split' | 'focus';
export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam'; export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam';
export type HoverActionsMode = 'inline' | 'floating'; export type HoverActionsMode = 'inline' | 'floating';
@@ -105,6 +106,7 @@ interface SettingsState {
deleteAction: DeleteAction; deleteAction: DeleteAction;
permanentlyDeleteJunk: boolean; // Permanently delete emails from junk/spam instead of moving to trash permanentlyDeleteJunk: boolean; // Permanently delete emails from junk/spam instead of moving to trash
showPreview: boolean; showPreview: boolean;
mailLayout: MailLayout;
emailsPerPage: number; emailsPerPage: number;
externalContentPolicy: ExternalContentPolicy; externalContentPolicy: ExternalContentPolicy;
mailAttachmentAction: MailAttachmentAction; mailAttachmentAction: MailAttachmentAction;
@@ -225,6 +227,7 @@ const DEFAULT_SETTINGS = {
deleteAction: 'trash' as DeleteAction, deleteAction: 'trash' as DeleteAction,
permanentlyDeleteJunk: false, permanentlyDeleteJunk: false,
showPreview: true, showPreview: true,
mailLayout: 'split' as MailLayout,
emailsPerPage: 50, emailsPerPage: 50,
externalContentPolicy: 'ask' as ExternalContentPolicy, externalContentPolicy: 'ask' as ExternalContentPolicy,
mailAttachmentAction: 'preview' as MailAttachmentAction, mailAttachmentAction: 'preview' as MailAttachmentAction,
@@ -335,6 +338,7 @@ export const useSettingsStore = create<SettingsState>()(
markAsReadDelay: state.markAsReadDelay, markAsReadDelay: state.markAsReadDelay,
deleteAction: state.deleteAction, deleteAction: state.deleteAction,
showPreview: state.showPreview, showPreview: state.showPreview,
mailLayout: state.mailLayout,
emailsPerPage: state.emailsPerPage, emailsPerPage: state.emailsPerPage,
externalContentPolicy: state.externalContentPolicy, externalContentPolicy: state.externalContentPolicy,
mailAttachmentAction: state.mailAttachmentAction, mailAttachmentAction: state.mailAttachmentAction,