mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: give Mountain View's open message Gmail's toolbar, pager and header
This commit is contained in:
@@ -1106,6 +1106,23 @@ optgroup {
|
||||
0 1px 3px 1px rgb(60 64 67 / 0.15);
|
||||
}
|
||||
|
||||
/* The open message's toolbar: round 40px icon buttons in the muted colour,
|
||||
20px icons. Menus that drop from it keep their own rows. */
|
||||
[data-interface-layout="mountain-view"] [data-mv-viewer-toolbar] button:not([role="menu"] *):not([data-mv-pager] *) {
|
||||
height: 40px;
|
||||
min-width: 40px;
|
||||
padding-inline: 10px;
|
||||
border-radius: 9999px;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
[data-interface-layout="mountain-view"] [data-mv-viewer-toolbar] button:not([role="menu"] *):not([data-mv-pager] *):hover {
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
[data-interface-layout="mountain-view"] [data-mv-viewer-toolbar] button:not([role="menu"] *) > svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* The account popover: a wide card with a very round corner, rather
|
||||
than the narrow menu the rail and the sidebar switchers drop. */
|
||||
[data-interface-layout="mountain-view"] [data-mv-account-popover] {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { buildContactsPath, buildMailPath } from "@/lib/deep-links";
|
||||
import { useCopyLink } from "@/hooks/use-copy-link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { formatFileSize, cn, MailboxNode, formatDate, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { formatFileSize, cn, MailboxNode, formatDate, formatDateTime, formatRelativeAgo, generateUUID } from "@/lib/utils";
|
||||
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
|
||||
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
|
||||
import { emailDisplayDate } from "@/lib/email-date";
|
||||
@@ -155,6 +155,10 @@ interface EmailViewerProps {
|
||||
onBack?: () => void;
|
||||
onNavigateNext?: () => void;
|
||||
onNavigatePrev?: () => void;
|
||||
/** Mountain View: where the open message sits in the list, for the "1 of N" pager. */
|
||||
mvPosition?: { index: number; total: number };
|
||||
/** Mountain View: the folder the message is in, shown as a chip by the subject. */
|
||||
mvFolderName?: string;
|
||||
onShowShortcuts?: () => void;
|
||||
onEditDraft?: () => void;
|
||||
onCancelScheduledForEdit?: () => void;
|
||||
@@ -641,6 +645,60 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl
|
||||
return <>{children(drag)}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The time on a Mountain View sender line, followed by how long ago it was:
|
||||
* "12:16 (3 minutes ago)" today, "Oct 10, 2026, 20:33 (2 days ago)" before.
|
||||
*/
|
||||
function mvSenderDate(date: Date | string, timeFormat: '12h' | '24h'): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
const today = new Date().toDateString() === d.toDateString();
|
||||
const when = today
|
||||
? formatDateTime(d, timeFormat)
|
||||
: formatDateTime(d, timeFormat, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
const ago = formatRelativeAgo(d);
|
||||
return ago ? `${when} (${ago})` : when;
|
||||
}
|
||||
|
||||
/** Mountain View's "1 of 23" with newer / older arrows, at the end of the message toolbar. */
|
||||
function MvPager({
|
||||
position,
|
||||
onPrev,
|
||||
onNext,
|
||||
}: {
|
||||
position: { index: number; total: number };
|
||||
onPrev?: () => void;
|
||||
onNext?: () => void;
|
||||
}) {
|
||||
const t = useTranslations('email_viewer');
|
||||
return (
|
||||
<div className="ms-auto flex items-center gap-0.5 flex-shrink-0" data-mv-pager="">
|
||||
<span className="text-xs text-muted-foreground tabular-nums me-2 whitespace-nowrap">
|
||||
{t('mv_position', { index: position.index.toLocaleString(), total: position.total.toLocaleString() })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrev}
|
||||
disabled={!onPrev}
|
||||
className="grid place-items-center w-9 h-9 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent transition-colors"
|
||||
title={t('tooltips.previous')}
|
||||
aria-label={t('tooltips.previous')}
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={!onNext}
|
||||
className="grid place-items-center w-9 h-9 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent transition-colors"
|
||||
title={t('tooltips.next')}
|
||||
aria-label={t('tooltips.next')}
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSection({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
@@ -674,6 +732,8 @@ export function EmailViewer({
|
||||
onBack,
|
||||
onNavigateNext,
|
||||
onNavigatePrev,
|
||||
mvPosition,
|
||||
mvFolderName,
|
||||
onShowShortcuts,
|
||||
onEditDraft,
|
||||
onCancelScheduledForEdit,
|
||||
@@ -3077,7 +3137,7 @@ export function EmailViewer({
|
||||
onClick={onArchive}
|
||||
data-overflow-item
|
||||
data-overflow-priority="4"
|
||||
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={cn("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", mvViewer && "order-1")}
|
||||
title={t('tooltips.archive')}
|
||||
>
|
||||
<Archive className="w-4 h-4" />
|
||||
@@ -3088,7 +3148,7 @@ export function EmailViewer({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
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={cn("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", mvViewer && "order-3")}
|
||||
title={t('tooltips.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
@@ -3096,7 +3156,7 @@ export function EmailViewer({
|
||||
</Button>
|
||||
{/* Move to folder */}
|
||||
{moveTree.length > 0 && onMoveToMailbox && (
|
||||
<div ref={moveMenuRef} data-overflow-item data-overflow-priority="5" className="relative">
|
||||
<div ref={moveMenuRef} data-overflow-item data-overflow-priority="5" className={cn("relative", mvViewer && "order-6")}>
|
||||
<Button
|
||||
ref={moveButtonRef}
|
||||
variant="ghost"
|
||||
@@ -3157,8 +3217,8 @@ export function EmailViewer({
|
||||
</div>
|
||||
)}
|
||||
{/* Tag Picker - hidden on mobile, overflows to More menu */}
|
||||
<div data-overflow-item data-overflow-priority="6" className="hidden sm:flex items-center">
|
||||
<div className="w-px h-5 bg-border mx-0.5" />
|
||||
<div data-overflow-item data-overflow-priority="6" className={cn("hidden sm:flex items-center", mvViewer && "order-7")}>
|
||||
{!mvViewer && <div className="w-px h-5 bg-border mx-0.5" />}
|
||||
<div ref={tagMenuRef} className="relative">
|
||||
<button
|
||||
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
|
||||
@@ -3192,14 +3252,16 @@ export function EmailViewer({
|
||||
data-overflow-priority="7"
|
||||
className={cn(
|
||||
"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",
|
||||
isInJunkFolder ? "hover:bg-green-50 dark:hover:bg-green-950/30" : "hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
mvViewer
|
||||
? "order-2"
|
||||
: isInJunkFolder ? "hover:bg-green-50 dark:hover:bg-green-950/30" : "hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
)}
|
||||
title={isInJunkFolder ? t('spam.not_spam_title') : t('spam.button_title')}
|
||||
>
|
||||
{isInJunkFolder ? (
|
||||
<ShieldCheck className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<ShieldCheck className={cn("h-4 w-4", !mvViewer && "text-green-600 dark:text-green-400")} />
|
||||
) : (
|
||||
<ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||
<ShieldAlert className={cn("h-4 w-4", !mvViewer && "text-red-600 dark:text-red-400")} />
|
||||
)}
|
||||
{showToolbarLabels && <span className="text-[10px] leading-tight sm:text-sm">{isInJunkFolder ? t('not_spam_short') : t('spam_short')}</span>}
|
||||
</Button>
|
||||
@@ -3210,13 +3272,15 @@ export function EmailViewer({
|
||||
(the inactive one invisible) so the button keeps a fixed width when the
|
||||
email is auto-marked as read on open. Otherwise the width change re-runs
|
||||
the overflow calculation and the toolbar buttons jump around (#864). */}
|
||||
{/* Mountain View groups the actions as Gmail does, with a rule between groups. */}
|
||||
{mvViewer && <div className="order-4 w-px h-5 bg-border mx-1" aria-hidden />}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onMarkAsRead?.(email.id, isUnread)}
|
||||
data-overflow-item
|
||||
data-overflow-priority="8"
|
||||
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={cn("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", mvViewer && "order-5")}
|
||||
title={isUnread ? t('mark_read') : t('mark_unread')}
|
||||
>
|
||||
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
|
||||
@@ -3228,7 +3292,9 @@ export function EmailViewer({
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Print - hidden on mobile, overflows to More menu */}
|
||||
{/* Print - hidden on mobile, overflows to More menu. Mountain View
|
||||
has it on the subject line. */}
|
||||
{!mvViewer && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -3241,6 +3307,7 @@ export function EmailViewer({
|
||||
<Printer className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('print')}</span>}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* View source - hidden on mobile, overflows to More menu */}
|
||||
<Button
|
||||
@@ -3249,7 +3316,7 @@ export function EmailViewer({
|
||||
onClick={() => setShowSourceModal(true)}
|
||||
data-overflow-item
|
||||
data-overflow-priority="10"
|
||||
className="hidden sm:inline-flex h-8 gap-1.5"
|
||||
className={cn("hidden sm:inline-flex h-8 gap-1.5", mvViewer && "order-8")}
|
||||
title={t('view_source')}
|
||||
>
|
||||
<Code className="w-4 h-4" />
|
||||
@@ -3265,7 +3332,7 @@ export function EmailViewer({
|
||||
onClick={() => setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev)}
|
||||
data-overflow-item
|
||||
data-overflow-priority="11"
|
||||
className="hidden sm:inline-flex h-8 gap-1.5"
|
||||
className={cn("hidden sm:inline-flex h-8 gap-1.5", mvViewer && "order-8")}
|
||||
title={isDark ? t('view_in_light_mode') : t('view_in_dark_mode')}
|
||||
disabled={!effectiveEmailContent.isHtml}
|
||||
aria-disabled={!effectiveEmailContent.isHtml}
|
||||
@@ -3276,7 +3343,7 @@ export function EmailViewer({
|
||||
{/* Fullscreen toggle - hidden on mobile (already fullscreen there).
|
||||
Never overflows into the More menu: in fullscreen this button is
|
||||
the way back out, so it must stay visible. */}
|
||||
{onToggleFullscreen && (
|
||||
{onToggleFullscreen && !mvViewer && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -3290,7 +3357,7 @@ export function EmailViewer({
|
||||
)}
|
||||
|
||||
{/* More menu - click-based */}
|
||||
<div ref={moreMenuRef} className="relative">
|
||||
<div ref={moreMenuRef} className={cn("relative", mvViewer && "order-9")}>
|
||||
<Button
|
||||
ref={moreButtonRef}
|
||||
variant="ghost"
|
||||
@@ -3855,12 +3922,20 @@ export function EmailViewer({
|
||||
{/* === TOOLBAR (top position) === */}
|
||||
{toolbarPosition === 'top' && (
|
||||
<div className={cn(
|
||||
"bg-background border-b border-border",
|
||||
"bg-background",
|
||||
!mvViewer && "border-b border-border",
|
||||
"max-lg:sticky max-lg:top-0 max-lg:z-10"
|
||||
)}>
|
||||
<div className="px-2 sm:px-4 lg:px-6 h-14 flex items-center">
|
||||
<div ref={toolbarRef} className="flex items-center justify-between gap-0.5 sm:gap-2 w-full">
|
||||
<div className={cn("px-2 sm:px-4 lg:px-6 flex items-center", mvViewer ? "h-12 lg:ps-4" : "h-14")}>
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
data-mv-viewer-toolbar={mvViewer ? "" : undefined}
|
||||
className={cn("flex items-center gap-0.5 sm:gap-2 w-full", mvViewer ? "justify-start" : "justify-between")}
|
||||
>
|
||||
{renderToolbarItems(true)}
|
||||
{mvViewer && mvPosition && (
|
||||
<MvPager position={mvPosition} onPrev={onNavigatePrev} onNext={onNavigateNext} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3868,10 +3943,12 @@ export function EmailViewer({
|
||||
|
||||
{/* === SUBJECT BLOCK === */}
|
||||
<div className={cn(
|
||||
"bg-background border-b border-border",
|
||||
"bg-background",
|
||||
!mvViewer && "border-b border-border",
|
||||
toolbarPosition === 'below-subject' && "max-lg:sticky max-lg:top-0 max-lg:z-10"
|
||||
)}>
|
||||
<div className="px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||
{/* Mountain View lines the subject up with the sender's name below it. */}
|
||||
<div className={cn("px-4 lg:px-6", mvViewer && "lg:ps-[80px] pt-3 pb-1")} style={mvViewer ? undefined : { 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' && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
|
||||
@@ -3887,11 +3964,21 @@ export function EmailViewer({
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start gap-2">
|
||||
<h1 className="text-lg lg:text-xl font-bold text-foreground tracking-tight break-words min-w-0">
|
||||
<h1 className={cn(
|
||||
"text-foreground break-words min-w-0",
|
||||
mvViewer ? "text-[22px] leading-7 font-normal" : "text-lg lg:text-xl font-bold tracking-tight"
|
||||
)}>
|
||||
{email.subject || t('no_subject')}
|
||||
</h1>
|
||||
{/* Star inline with subject (top toolbar mode) */}
|
||||
{toolbarPosition === 'top' && (
|
||||
{/* Mountain View: the folder the message sits in, as a chip on the subject line. */}
|
||||
{mvViewer && mvFolderName && (
|
||||
<span className="self-center flex-shrink-0 rounded px-1.5 py-0.5 text-xs bg-muted text-muted-foreground" data-mv-folder-chip="">
|
||||
{mvFolderName}
|
||||
</span>
|
||||
)}
|
||||
{/* Star inline with subject (top toolbar mode). Mountain View
|
||||
has it on the sender's line instead. */}
|
||||
{toolbarPosition === 'top' && !mvViewer && (
|
||||
<button
|
||||
onClick={onToggleStar}
|
||||
className="flex-shrink-0 p-1 rounded hover:bg-muted transition-colors"
|
||||
@@ -3927,6 +4014,31 @@ export function EmailViewer({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Mountain View: print and full screen at the end of the subject line. */}
|
||||
{mvViewer && (
|
||||
<div className="hidden lg:flex items-center flex-shrink-0 -me-2" data-mv-subject-actions="">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrint}
|
||||
className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
title={t('print')}
|
||||
aria-label={t('print')}
|
||||
>
|
||||
<Printer className="w-5 h-5" />
|
||||
</button>
|
||||
{onToggleFullscreen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFullscreen}
|
||||
className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
title={isFullscreen ? t('exit_fullscreen') : t('fullscreen')}
|
||||
aria-label={isFullscreen ? t('exit_fullscreen') : t('fullscreen')}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="w-5 h-5" /> : <Maximize2 className="w-5 h-5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender.
|
||||
Mountain View puts it on the sender's row instead, and shows no size. */}
|
||||
<div className={cn("hidden flex-shrink-0 text-end", !mvViewer && "sm:block")}>
|
||||
@@ -3961,8 +4073,11 @@ export function EmailViewer({
|
||||
<div className="min-h-full flex flex-col">
|
||||
|
||||
{/* === SENDER INFO (Desktop) === */}
|
||||
<div className="hidden lg:block bg-background border-b border-border px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
|
||||
<div
|
||||
className={cn("hidden lg:block bg-background px-6", !mvViewer && "border-b border-border")}
|
||||
style={mvViewer ? { paddingBlock: '0.75rem 0.5rem' } : { paddingBlock: 'var(--density-header-py)' }}
|
||||
>
|
||||
<div className="flex items-start" style={{ gap: mvViewer ? '16px' : 'var(--density-item-gap)' }}>
|
||||
<button
|
||||
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
|
||||
className="cursor-pointer group flex-shrink-0"
|
||||
@@ -4010,8 +4125,31 @@ export function EmailViewer({
|
||||
/>
|
||||
)}
|
||||
{mvViewer && (
|
||||
<span className="ms-auto ps-2 text-xs text-muted-foreground whitespace-nowrap" data-mv-sender-date="">
|
||||
{formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
<span className="ms-auto ps-2 flex items-center gap-1 -my-2 -me-2" data-mv-sender-actions="">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap me-1" data-mv-sender-date="">
|
||||
{mvSenderDate(emailDisplayDate(email), timeFormat)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleStar}
|
||||
className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
title={isStarred ? t('tooltips.unstar') : t('tooltips.star')}
|
||||
aria-label={isStarred ? t('tooltips.unstar') : t('tooltips.star')}
|
||||
aria-pressed={!!isStarred}
|
||||
>
|
||||
<Star className={cn("w-5 h-5", isStarred && "fill-amber-400 text-amber-400")} />
|
||||
</button>
|
||||
{!isDraft && !isScheduled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onReply?.()}
|
||||
className="grid place-items-center w-10 h-10 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
title={t('tooltips.reply')}
|
||||
aria-label={t('tooltips.reply')}
|
||||
>
|
||||
<Reply className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -5275,6 +5413,8 @@ export function EmailViewer({
|
||||
{/* Email Body */}
|
||||
<div className={cn(
|
||||
"email-content-wrapper overflow-x-auto",
|
||||
// Mountain View indents the body under the sender's name, as Gmail does.
|
||||
mvViewer && "lg:ms-[60px]",
|
||||
!isDark && resolvedTheme === 'dark' ? "bg-white email-content-light" : "bg-background"
|
||||
)}
|
||||
style={isDark ? { backgroundColor: '#121212' } : undefined}>
|
||||
@@ -5321,7 +5461,7 @@ export function EmailViewer({
|
||||
{/* Mountain View: Reply / Reply all / Forward as buttons under the message. */}
|
||||
{mvReplyRow && !isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (
|
||||
<div
|
||||
className={cn("pb-6 pt-2", isMobile ? "grid grid-cols-3 gap-2 px-4" : "flex flex-wrap items-center gap-2 px-6")}
|
||||
className={cn("pb-6 pt-2", isMobile ? "grid grid-cols-3 gap-2 px-4" : "flex flex-wrap items-center gap-3 px-6 lg:ps-[80px] pt-6")}
|
||||
data-mv-reply-row=""
|
||||
>
|
||||
<Button variant="outline" size="sm" className={cn("rounded-full gap-2", isMobile ? "min-w-0 px-2 text-xs" : "px-5")} onClick={() => onReply?.()}>
|
||||
|
||||
@@ -4694,6 +4694,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
onBack={handleMobileBack}
|
||||
onNavigateNext={handleNavigateNext}
|
||||
onNavigatePrev={handleNavigatePrev}
|
||||
mvPosition={mvShell && selectedEmailIndex >= 0 ? {
|
||||
index: selectedEmailIndex + 1,
|
||||
total: Math.max(activeEmails.length, mailboxes.find((mb) => mb.id === selectedMailbox)?.totalEmails ?? 0),
|
||||
} : undefined}
|
||||
mvFolderName={mvShell && !isScheduledView && !isUnifiedView ? currentMailboxName : undefined}
|
||||
onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined}
|
||||
isFullscreen={fullscreenReading}
|
||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatRelativeAgo } from '../utils';
|
||||
|
||||
describe('formatRelativeAgo', () => {
|
||||
const now = new Date('2026-07-01T12:00:00Z');
|
||||
|
||||
it('says how long ago in the largest whole unit', () => {
|
||||
expect(formatRelativeAgo(new Date('2026-07-01T11:57:00Z'), now)).toBe('3 minutes ago');
|
||||
expect(formatRelativeAgo(new Date('2026-07-01T08:00:00Z'), now)).toBe('4 hours ago');
|
||||
expect(formatRelativeAgo(new Date('2026-06-29T12:00:00Z'), now)).toBe('2 days ago');
|
||||
});
|
||||
|
||||
it('says nothing once the message is a month old, or when the date is bad', () => {
|
||||
expect(formatRelativeAgo(new Date('2026-05-01T12:00:00Z'), now)).toBe('');
|
||||
expect(formatRelativeAgo('not a date', now)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -236,6 +236,27 @@ export function formatDateTime(
|
||||
return d.toLocaleString(effectiveLocale, localeOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* How long ago `date` was, in words ("3 minutes ago", "2 days ago"), for the
|
||||
* parenthesis Gmail puts after a message's time. Empty once it is a month
|
||||
* old, where the date alone says enough.
|
||||
*/
|
||||
export function formatRelativeAgo(date: Date | string, now: Date = new Date()): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
if (!d || Number.isNaN(d.getTime())) return '';
|
||||
const diff = Math.max(0, now.getTime() - d.getTime());
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (days >= 30) return '';
|
||||
const locale = getEffectiveLocale();
|
||||
const rtf = new Intl.RelativeTimeFormat(locale === 'en' ? 'en-US' : locale, { numeric: 'auto' });
|
||||
if (minutes < 1) return rtf.format(0, 'second');
|
||||
if (minutes < 60) return rtf.format(-minutes, 'minute');
|
||||
if (hours < 24) return rtf.format(-hours, 'hour');
|
||||
return rtf.format(-days, 'day');
|
||||
}
|
||||
|
||||
// Marketing emails pad the preheader with whitespace, format chars (soft
|
||||
// hyphens, zero-width chars, BOM, directional marks) and combining marks
|
||||
// (e.g. U+034F) to push real content past the preview window. Strip them all.
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "نسخ الرمز {code}",
|
||||
"copied": "تم نسخ الرمز",
|
||||
"copy_failed": "تعذّر نسخ الرمز"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} من {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "تصغير",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "Copia el codi {code}",
|
||||
"copied": "Codi copiat",
|
||||
"copy_failed": "No s'ha pogut copiar el codi"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} de {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimitza",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "Kopírovat kód {code}",
|
||||
"copied": "Kód zkopírován",
|
||||
"copy_failed": "Kód se nepodařilo zkopírovat"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} z {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimalizovat",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "Kopiér kode {code}",
|
||||
"copied": "Kode kopieret",
|
||||
"copy_failed": "Koden kunne ikke kopieres"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} af {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimer",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "Code {code} kopieren",
|
||||
"copied": "Code kopiert",
|
||||
"copy_failed": "Code konnte nicht kopiert werden"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} von {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimieren",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "Copy code {code}",
|
||||
"copied": "Code copied",
|
||||
"copy_failed": "Could not copy the code"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} of {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimize",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "Copiar código {code}",
|
||||
"copied": "Código copiado",
|
||||
"copy_failed": "No se pudo copiar el código"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} de {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimizar",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "کپی کد {code}",
|
||||
"copied": "کد کپی شد",
|
||||
"copy_failed": "کپی کد ممکن نشد"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} از {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "کوچک کردن",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "Copier le code {code}",
|
||||
"copied": "Code copié",
|
||||
"copy_failed": "Impossible de copier le code"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} sur {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Réduire",
|
||||
|
||||
@@ -575,7 +575,8 @@
|
||||
"copy": "העתקת הקוד {code}",
|
||||
"copied": "הקוד הועתק",
|
||||
"copy_failed": "לא ניתן להעתיק את הקוד"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} מתוך {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "מזעור",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "{code} kód másolása",
|
||||
"copied": "Kód másolva",
|
||||
"copy_failed": "Nem sikerült másolni a kódot"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} / {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Kis méret",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "Copia codice {code}",
|
||||
"copied": "Codice copiato",
|
||||
"copy_failed": "Impossibile copiare il codice"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} di {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Riduci a icona",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "コード {code} をコピー",
|
||||
"copied": "コードをコピーしました",
|
||||
"copy_failed": "コードをコピーできませんでした"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} / {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "最小化",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "코드 {code} 복사",
|
||||
"copied": "코드를 복사했습니다",
|
||||
"copy_failed": "코드를 복사할 수 없습니다"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} / {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "최소화",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "Kopēt kodu {code}",
|
||||
"copied": "Kods nokopēts",
|
||||
"copy_failed": "Neizdevās nokopēt kodu"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} no {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimizēt",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "{code} кодыг хуулах",
|
||||
"copied": "Код хуулагдлаа",
|
||||
"copy_failed": "Кодыг хуулж чадсангүй"
|
||||
}
|
||||
},
|
||||
"mv_position": "{total}-с {index}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Багасгах",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "Kopier kode {code}",
|
||||
"copied": "Kode kopiert",
|
||||
"copy_failed": "Kunne ikke kopiere koden"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} av {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimer",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "Code {code} kopiëren",
|
||||
"copied": "Code gekopieerd",
|
||||
"copy_failed": "Kan de code niet kopiëren"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} van {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimaliseren",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "Kopiuj kod {code}",
|
||||
"copied": "Skopiowano kod",
|
||||
"copy_failed": "Nie udało się skopiować kodu"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} z {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimalizuj",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "Copiar código {code}",
|
||||
"copied": "Código copiado",
|
||||
"copy_failed": "Não foi possível copiar o código"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} de {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimizar",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "Copiază codul {code}",
|
||||
"copied": "Cod copiat",
|
||||
"copy_failed": "Codul nu a putut fi copiat"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} din {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimizează",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "Скопировать код {code}",
|
||||
"copied": "Код скопирован",
|
||||
"copy_failed": "Не удалось скопировать код"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} из {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Свернуть",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "Kopírovať kód {code}",
|
||||
"copied": "Kód skopírovaný",
|
||||
"copy_failed": "Kód sa nepodarilo skopírovať"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} z {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Minimalizovať",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "{code} kodunu kopyala",
|
||||
"copied": "Kod kopyalandı",
|
||||
"copy_failed": "Kod kopyalanamadı"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} / {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Simge durumuna küçült",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "Скопіювати код {code}",
|
||||
"copied": "Код скопійовано",
|
||||
"copy_failed": "Не вдалося скопіювати код"
|
||||
}
|
||||
},
|
||||
"mv_position": "{index} з {total}"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "Згорнути",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "複製驗證碼 {code}",
|
||||
"copied": "驗證碼已複製",
|
||||
"copy_failed": "無法複製驗證碼"
|
||||
}
|
||||
},
|
||||
"mv_position": "第 {index} 封,共 {total} 封"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "最小化",
|
||||
|
||||
@@ -610,7 +610,8 @@
|
||||
"copy": "复制验证码 {code}",
|
||||
"copied": "验证码已复制",
|
||||
"copy_failed": "无法复制验证码"
|
||||
}
|
||||
},
|
||||
"mv_position": "第 {index} 封,共 {total} 封"
|
||||
},
|
||||
"email_composer": {
|
||||
"minimize": "最小化",
|
||||
|
||||
Reference in New Issue
Block a user