feat: add mail layout settings and update email list components
This commit is contained in:
+14
-9
@@ -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 */}
|
||||
<MobileHeader
|
||||
@@ -1507,7 +1514,7 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
{/* Email list resize handle (desktop only) */}
|
||||
{!isMobile && !isTablet && (
|
||||
{!isMobile && !isTablet && !isFocusedMailLayout && (
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { 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)}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -38,6 +38,7 @@ describe('EmailListItem tag badge', () => {
|
||||
useSettingsStore.setState({
|
||||
emailKeywords: [...DEFAULT_KEYWORDS],
|
||||
showPreview: false,
|
||||
mailLayout: 'split',
|
||||
});
|
||||
useEmailStore.setState({
|
||||
selectedEmailIds: new Set<string>(),
|
||||
@@ -104,4 +105,17 @@ describe('EmailListItem tag badge', () => {
|
||||
render(<EmailListItem email={email} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)' }}
|
||||
>
|
||||
<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)' }}
|
||||
>
|
||||
<div className="flex items-start px-4" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
|
||||
{/* Checkbox - only visible when in selection mode */}
|
||||
{selectedEmailIds.size > 0 && (
|
||||
<button
|
||||
onClick={handleCheckboxClick}
|
||||
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",
|
||||
"active:scale-95",
|
||||
"animate-in fade-in zoom-in-95 duration-150",
|
||||
@@ -145,7 +152,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
)}
|
||||
|
||||
{/* Avatar */}
|
||||
{density !== 'extra-compact' && (
|
||||
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
@@ -156,6 +163,50 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{isFocusedMailLayout ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span className={cn(
|
||||
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||
)}>
|
||||
{sender?.name || sender?.email || 'Unknown'}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<span className={cn(
|
||||
'shrink-0 truncate',
|
||||
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{email.subject || t('no_subject')}
|
||||
</span>
|
||||
{inlinePreview && (
|
||||
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||
)}
|
||||
</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" />}
|
||||
{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 && (
|
||||
<>
|
||||
<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" />}
|
||||
{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>
|
||||
) : (
|
||||
<>
|
||||
{/* First Line: Sender and Date */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
@@ -236,6 +287,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
{email.preview || "No preview available"}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -103,11 +103,16 @@ export function EmailList({
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
|
||||
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];
|
||||
return (showPreview && density !== 'extra-compact') ? base + 36 : base;
|
||||
}, [density, showPreview]);
|
||||
}, [density, isFocusedMailLayout, showPreview]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: threadGroups.length,
|
||||
@@ -244,7 +249,7 @@ export function EmailList({
|
||||
useEffect(() => {
|
||||
virtualizer.measure();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [density, showPreview]);
|
||||
}, [density, isFocusedMailLayout, showPreview]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col min-h-0", className)}>
|
||||
|
||||
@@ -887,8 +887,10 @@ export function EmailViewer({
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
|
||||
const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
|
||||
// Detect if current mailbox is Junk folder
|
||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||
@@ -2790,7 +2792,7 @@ export function EmailViewer({
|
||||
<>
|
||||
{/* Left: Reply actions */}
|
||||
<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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -3524,7 +3526,7 @@ export function EmailViewer({
|
||||
<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">
|
||||
{/* Back button (for below-subject mode on tablet) */}
|
||||
{toolbarPosition === 'below-subject' && isTablet && !tabletListVisible && onBack && (
|
||||
{toolbarPosition === 'below-subject' && ((isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -59,7 +59,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
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
|
||||
const tagId = getEmailColorTag(email.keywords);
|
||||
@@ -131,15 +134,19 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: 'var(--list-item-height)' }}
|
||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||
>
|
||||
<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)' }}
|
||||
>
|
||||
<div className="flex items-start px-3" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
|
||||
{/* Checkbox - only visible when in selection mode */}
|
||||
{selectedEmailIds.size > 0 && (
|
||||
<button
|
||||
onClick={handleCheckboxClick}
|
||||
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",
|
||||
"active:scale-95",
|
||||
"animate-in fade-in zoom-in-95 duration-150",
|
||||
@@ -160,7 +167,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
</div>
|
||||
)}
|
||||
|
||||
{density !== 'extra-compact' && (
|
||||
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
@@ -170,6 +177,49 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{isFocusedMailLayout ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span className={cn(
|
||||
'w-32 shrink-0 truncate text-sm lg:w-40',
|
||||
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||
)}>
|
||||
{sender?.name || sender?.email || 'Unknown'}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<span className={cn(
|
||||
'shrink-0 truncate',
|
||||
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{email.subject || '(no subject)'}
|
||||
</span>
|
||||
{inlinePreview && (
|
||||
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||
)}
|
||||
</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 && (
|
||||
<>
|
||||
<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" />}
|
||||
{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 className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
@@ -241,6 +291,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{email.preview || "No preview available"}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -281,8 +333,11 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
const t = useTranslations('threads');
|
||||
const showPreview = useSettingsStore((state) => 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<HTMLDivElement, ThreadListItemPro
|
||||
)}
|
||||
onClick={handleHeaderClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: 'var(--list-item-height)' }}
|
||||
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
|
||||
>
|
||||
<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)' }}
|
||||
>
|
||||
<div className="flex items-start px-3" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}>
|
||||
{/* Checkbox for thread selection - only visible when in selection mode */}
|
||||
{selectedEmailIds.size > 0 && (
|
||||
<button
|
||||
onClick={handleThreadCheckboxClick}
|
||||
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",
|
||||
"active:scale-95",
|
||||
"animate-in fade-in zoom-in-95 duration-150",
|
||||
@@ -429,7 +488,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isMobile && (
|
||||
{!isMobile && !isFocusedMailLayout && (
|
||||
<button
|
||||
data-expand-toggle
|
||||
onClick={(e) => {
|
||||
@@ -461,7 +520,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
</div>
|
||||
)}
|
||||
|
||||
{density !== 'extra-compact' && (
|
||||
{!isFocusedMailLayout && density !== 'extra-compact' && (
|
||||
<Avatar
|
||||
name={latestEmail.from?.[0]?.name}
|
||||
email={latestEmail.from?.[0]?.email}
|
||||
@@ -471,6 +530,59 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{isFocusedMailLayout ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span className={cn(
|
||||
'w-32 shrink-0 truncate text-sm lg:w-44',
|
||||
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
|
||||
)}>
|
||||
{participantNames.join(', ')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs 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 min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<span className={cn(
|
||||
'shrink-0 truncate',
|
||||
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{latestEmail.subject || '(no subject)'}
|
||||
</span>
|
||||
{inlinePreview && (
|
||||
<span className="min-w-0 truncate text-muted-foreground">{inlinePreview}</span>
|
||||
)}
|
||||
</div>
|
||||
</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 && (
|
||||
<>
|
||||
<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" />}
|
||||
{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 className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
@@ -554,6 +666,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{latestEmail.preview || "No preview available"}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -570,7 +684,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isExpanded && !isMobile && (
|
||||
{isExpanded && !isMobile && !isFocusedMailLayout && (
|
||||
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
||||
{isLoading ? (
|
||||
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
||||
|
||||
@@ -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 (
|
||||
<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() {
|
||||
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() {
|
||||
/>
|
||||
</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 */}
|
||||
{!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)} />
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
+10
-1
@@ -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",
|
||||
|
||||
@@ -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<SettingsState>()(
|
||||
markAsReadDelay: state.markAsReadDelay,
|
||||
deleteAction: state.deleteAction,
|
||||
showPreview: state.showPreview,
|
||||
mailLayout: state.mailLayout,
|
||||
emailsPerPage: state.emailsPerPage,
|
||||
externalContentPolicy: state.externalContentPolicy,
|
||||
mailAttachmentAction: state.mailAttachmentAction,
|
||||
|
||||
Reference in New Issue
Block a user