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 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)}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user