feat: add mobile bottom action bar with reply and email navigation

- Move Reply, Reply All, Forward from toolbar to fixed bottom bar on mobile
- Add Next/Previous email navigation buttons to the bottom bar
- Style bottom bar to match existing NavigationRail horizontal pattern
- Add onNavigateNext/onNavigatePrev props to EmailViewer
- Wire up email list index-based navigation in page.tsx
- Add padding to email content area to prevent bottom bar overlap
This commit is contained in:
Linus Rath
2026-03-15 02:23:09 +01:00
parent 05f02402e3
commit cb74e3bf73
3 changed files with 323 additions and 56 deletions
+13
View File
@@ -808,6 +808,17 @@ export default function Home() {
setActiveView("list");
};
// Navigate to next/previous email in the list
const selectedEmailIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : -1;
const handleNavigateNext = selectedEmailIndex >= 0 && selectedEmailIndex < emails.length - 1
? () => handleEmailSelect(emails[selectedEmailIndex + 1])
: undefined;
const handleNavigatePrev = selectedEmailIndex > 0
? () => handleEmailSelect(emails[selectedEmailIndex - 1])
: undefined;
// Handle opening conversation view on mobile
const handleOpenConversation = async (thread: ThreadGroup) => {
if (!client) return;
@@ -1403,6 +1414,8 @@ export default function Home() {
setTabletListVisible(true);
selectEmail(null);
}}
onNavigateNext={handleNavigateNext}
onNavigatePrev={handleNavigatePrev}
onShowShortcuts={() => setShowShortcutsModal(true)}
currentUserEmail={client?.["username"]}
currentUserName={client?.["username"]?.split("@")[0]}
+305 -55
View File
@@ -19,6 +19,7 @@ import {
ChevronDown,
ChevronUp,
ChevronLeft,
ChevronRight,
Download,
Mail,
Clock,
@@ -89,6 +90,8 @@ interface EmailViewerProps {
onUndoSpam?: () => void;
onMoveToMailbox?: (mailboxId: string) => void;
onBack?: () => void;
onNavigateNext?: () => void;
onNavigatePrev?: () => void;
onShowShortcuts?: () => void;
currentUserEmail?: string;
currentUserName?: string;
@@ -339,7 +342,7 @@ function ContactSidebarPanel({
<div key={i} className="text-sm">
{o.name}
{o.units && o.units.length > 0 && (
<span className="text-muted-foreground"> â {o.units.map(u => u.name).join(", ")}</span>
<span className="text-muted-foreground"> {o.units.map(u => u.name).join(", ")}</span>
)}
</div>
))}
@@ -410,6 +413,8 @@ export function EmailViewer({
onUndoSpam,
onMoveToMailbox,
onBack,
onNavigateNext,
onNavigatePrev,
onShowShortcuts,
currentUserEmail,
currentUserName,
@@ -438,7 +443,7 @@ export function EmailViewer({
}));
// Tablet list visibility
const { isTablet } = useDeviceDetection();
const { isTablet, isMobile } = useDeviceDetection();
const { tabletListVisible } = useUIStore();
const { identities, client } = useAuthStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
@@ -456,6 +461,8 @@ export function EmailViewer({
const moreMenuRef = useRef<HTMLDivElement>(null);
const tagMenuRef = useRef<HTMLDivElement>(null);
const moveMenuRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const [overflowCount, setOverflowCount] = useState(0);
const currentColor = getCurrentColor(email?.keywords);
// Build mailbox tree for move-to dropdown
@@ -522,6 +529,41 @@ export function EmailViewer({
setMoveMenuOpen(false);
}, [email?.id]);
// Dynamically detect which toolbar items overflow and should move to the More menu
useEffect(() => {
const el = toolbarRef.current;
if (!el) return;
const calculate = () => {
const items = Array.from(el.querySelectorAll<HTMLElement>('[data-overflow-item]'));
if (items.length === 0) return;
// Sort descending by priority so highest number (least important) is first
items.sort((a, b) =>
Number(b.dataset.overflowPriority || 0) - Number(a.dataset.overflowPriority || 0)
);
// Show all items to measure their natural widths
items.forEach(item => { item.style.display = ''; });
const containerWidth = el.clientWidth;
const leftGroup = el.firstElementChild as HTMLElement;
const rightGroup = el.lastElementChild as HTMLElement;
const mainGap = parseFloat(getComputedStyle(el).gap) || 0;
// Iteratively hide items until content fits
let count = 0;
const isOverflowing = () =>
leftGroup.scrollWidth + rightGroup.scrollWidth + mainGap > containerWidth + 1;
for (const item of items) {
if (!isOverflowing()) break;
// Skip items already hidden by CSS (e.g., on mobile)
if (item.offsetWidth === 0) continue;
item.style.display = 'none';
count++;
}
setOverflowCount(prev => prev === count ? prev : count);
};
const observer = new ResizeObserver(calculate);
observer.observe(el);
return () => observer.disconnect();
}, [toolbarPosition]);
// Contact sidebar state
const [contactSidebarEmail, setContactSidebarEmail] = useState<string | null>(null);
const contacts = useContactStore((s) => s.contacts);
@@ -1063,6 +1105,144 @@ export function EmailViewer({
key={email.id}
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden animate-in fade-in duration-300 relative", className)}
>
{/* Mobile More menu sidebar overlay */}
{isMobile && moreMenuOpen && (
<div
className="fixed inset-0 bg-black/50 z-[60] sm:hidden"
onClick={() => setMoreMenuOpen(false)}
/>
)}
{isMobile && (
<div className={cn(
"fixed inset-y-0 right-0 w-72 bg-background border-l border-border z-[70] sm:hidden",
"transform transition-transform duration-300 ease-in-out",
"flex flex-col",
moreMenuOpen ? "translate-x-0" : "translate-x-full"
)}>
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="text-sm font-semibold text-foreground">{t('more_actions')}</span>
<Button variant="ghost" size="icon" onClick={() => setMoreMenuOpen(false)} className="h-9 w-9">
<X className="w-5 h-5" />
</Button>
</div>
<div className="flex-1 overflow-y-auto py-2">
<button
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Archive className="w-5 h-5" />
{t('archive')}
</button>
{(onMarkAsSpam || onUndoSpam) && (
<button
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
{isInJunkFolder ? (
<ShieldCheck className="h-5 w-5 text-green-600 dark:text-green-400" />
) : (
<ShieldAlert className="h-5 w-5 text-red-600 dark:text-red-400" />
)}
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
</button>
)}
{/* Move to folder */}
{moveTree.length > 0 && onMoveToMailbox && (
<>
<div className="h-px bg-border my-1" />
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('move_to')}</div>
{(() => {
const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
return nodes.map((node) => {
const Icon = getMoveMailboxIcon(node.role);
const isTarget = moveTargetIds.has(node.id);
return (
<div key={node.id}>
{isTarget ? (
<button
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3"
style={{ paddingLeft: `${1 + depth * 1}rem` }}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="truncate">{node.name}</span>
</button>
) : (
<div
className="px-4 py-2.5 min-h-[44px] text-sm flex items-center gap-3 text-muted-foreground"
style={{ paddingLeft: `${1 + depth * 1}rem` }}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span>{node.name}</span>
</div>
)}
{node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
</div>
);
});
};
return renderMobileNodes(moveTree);
})()}
<div className="h-px bg-border my-1" />
</>
)}
{/* Tags */}
{colorOptions.length > 0 && (
<>
<div className="h-px bg-border my-1" />
<div className="px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
{colorOptions.map((option) => (
<button
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); }}
className={cn(
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
currentColor === option.value && "bg-accent font-medium"
)}
>
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{currentColor === option.value && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
</button>
))}
{currentColor && (
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
>
<X className="w-4 h-4 flex-shrink-0" />
<span>{t('remove_color')}</span>
</button>
)}
<div className="h-px bg-border my-1" />
</>
)}
<button
onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Printer className="w-5 h-5" />
{t('print')}
</button>
<button
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Code className="w-5 h-5" />
{t('view_source')}
</button>
{onShowShortcuts && (
<button
onClick={() => { onShowShortcuts(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
>
<Keyboard className="w-5 h-5" />
{t('keyboard_shortcuts')}
</button>
)}
</div>
</div>
)}
{/* Main email content */}
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
{/* Loading overlay when fetching new email */}
@@ -1081,7 +1261,7 @@ export function EmailViewer({
"max-lg:sticky max-lg:top-0 max-lg:z-10"
)}>
<div className="px-2 sm:px-4 lg:px-6 py-1 sm:py-2">
<div className="flex items-center justify-between gap-0.5 sm:gap-2">
<div ref={toolbarRef} className="flex items-center justify-between gap-0.5 sm:gap-2">
{/* Left: Back + Reply actions */}
<div className="flex items-center gap-0 sm:gap-1">
{isTablet && !tabletListVisible && onBack && (
@@ -1099,31 +1279,31 @@ export function EmailViewer({
variant="ghost"
size="sm"
onClick={() => onReply?.()}
className="flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('tooltips.reply')}
>
<Reply className="w-4 h-4" />
<span className="text-[10px] leading-tight sm:text-sm">{t('reply')}</span>
<span className="hidden sm:inline text-sm">{t('reply')}</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={onReplyAll}
className="flex-col items-center gap-0.5 h-auto py-1.5 px-1.5 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0 sm:px-3"
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0 sm:px-3"
title={t('tooltips.reply_all')}
>
<ReplyAll className="w-4 h-4" />
<span className="text-[10px] leading-tight sm:text-sm">{t('reply_all')}</span>
<span className="hidden sm:inline text-sm">{t('reply_all')}</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={onForward}
className="flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('tooltips.forward')}
>
<Forward className="w-4 h-4" />
<span className="text-[10px] leading-tight sm:text-sm">{t('forward')}</span>
<span className="hidden sm:inline text-sm">{t('forward')}</span>
</Button>
</div>
@@ -1134,11 +1314,13 @@ export function EmailViewer({
<Loader2 className="w-4 h-4 animate-spin" />
</div>
)}
{/* Archive - hidden on mobile, available in More menu */}
{/* Archive - hidden on mobile, overflows to More menu */}
<Button
variant="ghost"
size="sm"
onClick={onArchive}
data-overflow-item
data-overflow-priority="1"
className="hidden sm:inline-flex h-8 gap-1.5"
title={t('tooltips.archive')}
>
@@ -1155,12 +1337,14 @@ export function EmailViewer({
<Trash2 className="w-4 h-4" />
<span className="text-[10px] leading-tight sm:text-sm">{t('delete')}</span>
</Button>
{/* Spam - hidden on mobile, available in More menu */}
{/* Spam - hidden on mobile, overflows to More menu */}
{(onMarkAsSpam || onUndoSpam) && (
<Button
variant="ghost"
size="sm"
onClick={isInJunkFolder ? onUndoSpam : onMarkAsSpam}
data-overflow-item
data-overflow-priority="2"
className={cn(
"hidden sm:inline-flex h-8 gap-1.5",
isInJunkFolder ? "hover:bg-green-50 dark:hover:bg-green-950/30" : "hover:bg-red-50 dark:hover:bg-red-950/30"
@@ -1174,9 +1358,9 @@ export function EmailViewer({
)}
</Button>
)}
{/* Move to folder - hidden on mobile, available in More menu */}
{/* Move to folder - hidden on mobile, overflows to More menu */}
{moveTree.length > 0 && onMoveToMailbox && (
<div ref={moveMenuRef} className="relative hidden sm:block">
<div ref={moveMenuRef} data-overflow-item data-overflow-priority="3" className="relative hidden sm:block">
<button
onClick={() => { setMoveMenuOpen(!moveMenuOpen); setMoreMenuOpen(false); setTagMenuOpen(false); }}
className="h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2"
@@ -1237,10 +1421,10 @@ export function EmailViewer({
<span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span>
</Button>
<div className="w-px h-5 bg-border mx-0.5 hidden sm:block" />
{/* Tag Picker - click-based, hidden on mobile (available in More menu) */}
<div ref={tagMenuRef} className="relative hidden sm:block">
{/* Tag Picker + Divider - hidden on mobile, overflows to More menu */}
<div data-overflow-item data-overflow-priority="4" className="hidden sm:flex items-center">
<div className="w-px h-5 bg-border mx-0.5" />
<div ref={tagMenuRef} className="relative">
<button
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
className={cn(
@@ -1296,12 +1480,15 @@ export function EmailViewer({
</div>
)}
</div>
</div>
{/* Print - hidden on mobile, available in More menu */}
{/* Print - hidden on mobile, overflows to More menu */}
<Button
variant="ghost"
size="sm"
onClick={handlePrint}
data-overflow-item
data-overflow-priority="5"
className="hidden sm:inline-flex h-8 gap-1.5"
title={t('print')}
>
@@ -1321,12 +1508,12 @@ export function EmailViewer({
<MoreVertical className="w-4 h-4 text-muted-foreground" />
<span className="text-[10px] leading-tight sm:hidden">{t('more_actions')}</span>
</Button>
{moreMenuOpen && (
{moreMenuOpen && !isMobile && (
<div className="absolute right-0 top-full mt-1 w-48 bg-background rounded-md shadow-lg border border-border z-10">
{/* Mobile-only actions */}
{/* Overflow actions - shown when hidden from toolbar or on mobile */}
<button
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
className="w-full px-3 py-2.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2 sm:hidden"
className={cn("w-full px-3 py-2.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 5 ? "" : "sm:hidden")}
>
<Archive className="w-4 h-4" />
{t('archive')}
@@ -1334,7 +1521,7 @@ export function EmailViewer({
{(onMarkAsSpam || onUndoSpam) && (
<button
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); }}
className="w-full px-3 py-2.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2 sm:hidden"
className={cn("w-full px-3 py-2.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 4 ? "" : "sm:hidden")}
>
{isInJunkFolder ? (
<ShieldCheck className="h-4 w-4 text-green-600 dark:text-green-400" />
@@ -1344,9 +1531,9 @@ export function EmailViewer({
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
</button>
)}
{/* Move to folder submenu on mobile */}
{/* Move to folder submenu */}
{moveTree.length > 0 && onMoveToMailbox && (
<div className="sm:hidden">
<div className={cn(overflowCount >= 3 ? "" : "sm:hidden")}>
<div className="h-px bg-border my-1" />
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('move_to')}</div>
{(() => {
@@ -1383,9 +1570,9 @@ export function EmailViewer({
})()}
<div className="h-px bg-border my-1" />
</div>
)} {/* Tag submenu on mobile */}
)} {/* Tag submenu */}
{colorOptions.length > 0 && (
<div className="sm:hidden">
<div className={cn(overflowCount >= 2 ? "" : "sm:hidden")}>
<div className="h-px bg-border my-1" />
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
{colorOptions.map((option) => (
@@ -1416,7 +1603,7 @@ export function EmailViewer({
)}
<button
onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
className="w-full px-3 py-2.5 sm:py-2 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
className={cn("w-full px-3 py-2.5 sm:py-2 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 1 ? "" : "sm:hidden")}
>
<Printer className="w-4 h-4" />
{t('print')}
@@ -1519,38 +1706,38 @@ export function EmailViewer({
{toolbarPosition === 'below-subject' && (
<div className="bg-background border-b border-border">
<div className="px-2 sm:px-4 lg:px-6 py-1 sm:py-1.5">
<div className="flex items-center justify-between gap-0.5 sm:gap-2">
<div ref={toolbarRef} className="flex items-center justify-between gap-0.5 sm:gap-2">
{/* Left: Reply actions */}
<div className="flex items-center gap-0 sm:gap-0.5">
<Button
variant="ghost"
size="sm"
onClick={() => onReply?.()}
className="flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('tooltips.reply')}
>
<Reply className="w-4 h-4" />
<span className="text-[10px] leading-tight sm:text-sm">{t('reply')}</span>
<span className="hidden sm:inline text-sm">{t('reply')}</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={onReplyAll}
className="flex-col items-center gap-0.5 h-auto py-1.5 px-1.5 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0 sm:px-3"
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0 sm:px-3"
title={t('tooltips.reply_all')}
>
<ReplyAll className="w-4 h-4" />
<span className="text-[10px] leading-tight sm:text-sm">{t('reply_all')}</span>
<span className="hidden sm:inline text-sm">{t('reply_all')}</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={onForward}
className="flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('tooltips.forward')}
>
<Forward className="w-4 h-4" />
<span className="text-[10px] leading-tight sm:text-sm">{t('forward')}</span>
<span className="hidden sm:inline text-sm">{t('forward')}</span>
</Button>
</div>
@@ -1561,23 +1748,27 @@ export function EmailViewer({
<Loader2 className="w-4 h-4 animate-spin" />
</div>
)}
{/* Archive - hidden on mobile, available in More menu */}
{/* Archive - hidden on mobile, overflows to More menu */}
<Button
variant="ghost"
size="sm"
onClick={onArchive}
data-overflow-item
data-overflow-priority="1"
className="hidden sm:inline-flex h-8 gap-1.5"
title={t('tooltips.archive')}
>
<Archive className="w-4 h-4" />
<span className="hidden sm:inline text-sm">{t('archive')}</span>
</Button>
{/* Spam - hidden on mobile, available in More menu */}
{/* Spam - hidden on mobile, overflows to More menu */}
{(onMarkAsSpam || onUndoSpam) && (
<Button
variant="ghost"
size="sm"
onClick={isInJunkFolder ? onUndoSpam : onMarkAsSpam}
data-overflow-item
data-overflow-priority="2"
className={cn(
"hidden sm:inline-flex h-8 gap-1.5",
isInJunkFolder ? "hover:bg-green-50 dark:hover:bg-green-950/30" : "hover:bg-red-50 dark:hover:bg-red-950/30"
@@ -1591,9 +1782,9 @@ export function EmailViewer({
)}
</Button>
)}
{/* Move to folder - hidden on mobile, available in More menu */}
{/* Move to folder - hidden on mobile, overflows to More menu */}
{moveTree.length > 0 && onMoveToMailbox && (
<div ref={moveMenuRef} className="relative hidden sm:block">
<div ref={moveMenuRef} data-overflow-item data-overflow-priority="3" className="relative hidden sm:block">
<button
onClick={() => { setMoveMenuOpen(!moveMenuOpen); setMoreMenuOpen(false); setTagMenuOpen(false); }}
className="h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2"
@@ -1664,10 +1855,10 @@ export function EmailViewer({
<span className="text-[10px] leading-tight sm:hidden">{isStarred ? t('tooltips.unstar') : t('tooltips.star')}</span>
</Button>
<div className="w-px h-5 bg-border mx-0.5 hidden sm:block" />
{/* Tag Picker - click-based, hidden on mobile (available in More menu) */}
<div ref={tagMenuRef} className="relative hidden sm:block">
{/* Tag Picker + Divider - hidden on mobile, overflows to More menu */}
<div data-overflow-item data-overflow-priority="4" className="hidden sm:flex items-center">
<div className="w-px h-5 bg-border mx-0.5" />
<div ref={tagMenuRef} className="relative">
<button
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
className={cn(
@@ -1723,12 +1914,15 @@ export function EmailViewer({
</div>
)}
</div>
</div>
{/* Print - hidden on mobile, available in More menu */}
{/* Print - hidden on mobile, overflows to More menu */}
<Button
variant="ghost"
size="sm"
onClick={handlePrint}
data-overflow-item
data-overflow-priority="5"
className="hidden sm:inline-flex h-8 gap-1.5"
title={t('print')}
>
@@ -1748,12 +1942,12 @@ export function EmailViewer({
<MoreVertical className="w-4 h-4 text-muted-foreground" />
<span className="text-[10px] leading-tight sm:hidden">{t('more_actions')}</span>
</Button>
{moreMenuOpen && (
{moreMenuOpen && !isMobile && (
<div className="absolute right-0 top-full mt-1 w-48 bg-background rounded-md shadow-lg border border-border z-10">
{/* Mobile-only actions */}
{/* Overflow actions - shown when hidden from toolbar or on mobile */}
<button
onClick={() => { onArchive?.(); setMoreMenuOpen(false); }}
className="w-full px-3 py-2.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2 sm:hidden"
className={cn("w-full px-3 py-2.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 5 ? "" : "sm:hidden")}
>
<Archive className="w-4 h-4" />
{t('archive')}
@@ -1761,7 +1955,7 @@ export function EmailViewer({
{(onMarkAsSpam || onUndoSpam) && (
<button
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); }}
className="w-full px-3 py-2.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2 sm:hidden"
className={cn("w-full px-3 py-2.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 4 ? "" : "sm:hidden")}
>
{isInJunkFolder ? (
<ShieldCheck className="h-4 w-4 text-green-600 dark:text-green-400" />
@@ -1771,9 +1965,9 @@ export function EmailViewer({
{isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
</button>
)}
{/* Move to folder submenu on mobile */}
{/* Move to folder submenu */}
{moveTree.length > 0 && onMoveToMailbox && (
<div className="sm:hidden">
<div className={cn(overflowCount >= 3 ? "" : "sm:hidden")}>
<div className="h-px bg-border my-1" />
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('move_to')}</div>
{(() => {
@@ -1810,9 +2004,9 @@ export function EmailViewer({
})()}
<div className="h-px bg-border my-1" />
</div>
)} {/* Tag submenu on mobile */}
)} {/* Tag submenu */}
{colorOptions.length > 0 && (
<div className="sm:hidden">
<div className={cn(overflowCount >= 2 ? "" : "sm:hidden")}>
<div className="h-px bg-border my-1" />
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('tag')}</div>
{colorOptions.map((option) => (
@@ -1843,7 +2037,7 @@ export function EmailViewer({
)}
<button
onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
className="w-full px-3 py-2.5 sm:py-2 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
className={cn("w-full px-3 py-2.5 sm:py-2 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", overflowCount >= 1 ? "" : "sm:hidden")}
>
<Printer className="w-4 h-4" />
{t('print')}
@@ -2403,7 +2597,7 @@ export function EmailViewer({
)}
{/* Email Content Area */}
<div className="flex-1 overflow-auto bg-muted/30">
<div className={cn("flex-1 overflow-auto bg-muted/30", isMobile && "pb-16")}>
{/* Mobile/Tablet Sender Info - scrolls with content */}
<div className="lg:hidden bg-background border-b border-border px-4" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
@@ -2446,12 +2640,12 @@ export function EmailViewer({
}}
/>
)}
<span>·</span>
<span>·</span>
</>
)}
{email.to && email.to.length > 0 && (
<>
<span>â {t('recipient_to_prefix')}</span>
<span> {t('recipient_to_prefix')}</span>
{renderClickableRecipients(email.to, currentUserEmail, t, handleViewContactSidebar)}
</>
)}
@@ -2693,6 +2887,62 @@ export function EmailViewer({
)}
</div>
{/* Mobile bottom action bar */}
{isMobile && (
<nav className="fixed bottom-0 left-0 right-0 z-[50] bg-background border-t border-border sm:hidden">
<div className="flex items-center justify-around">
<button
onClick={onNavigatePrev}
disabled={!onNavigatePrev}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] transition-colors duration-150",
onNavigatePrev ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
)}
aria-label={t('tooltips.previous')}
>
<ChevronLeft className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('previous')}</span>
</button>
<button
onClick={() => onReply?.()}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.reply')}
>
<Reply className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('reply')}</span>
</button>
<button
onClick={onReplyAll}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.reply_all')}
>
<ReplyAll className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('reply_all')}</span>
</button>
<button
onClick={onForward}
className="flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] text-muted-foreground active:text-foreground transition-colors duration-150"
aria-label={t('tooltips.forward')}
>
<Forward className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('forward')}</span>
</button>
<button
onClick={onNavigateNext}
disabled={!onNavigateNext}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-3 min-w-[64px] min-h-[44px] transition-colors duration-150",
onNavigateNext ? "text-muted-foreground active:text-foreground" : "text-muted-foreground/30"
)}
aria-label={t('tooltips.next')}
>
<ChevronRight className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{t('next')}</span>
</button>
</div>
</nav>
)}
{/* Contact Detail Sidebar - desktop only */}
{contactSidebarEmail && !isMobileDevice && (
<ContactSidebarPanel
+5 -1
View File
@@ -175,6 +175,8 @@
"set_color": "Set tag",
"tag": "Tag",
"more_actions": "More actions",
"previous": "Prev",
"next": "Next",
"move_to": "Move to...",
"remove_color": "Remove tag",
"more_count": "+{count} more",
@@ -251,7 +253,9 @@
"delete": "Delete (# or Del)",
"star": "Star (s)",
"unstar": "Unstar (s)",
"compose": "Compose (c)"
"compose": "Compose (c)",
"previous": "Previous email",
"next": "Next email"
},
"spam": {
"button_title": "Report spam",