diff --git a/app/globals.css b/app/globals.css index 737d4b3f4..becfd7b55 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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] { diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 7b3e1618d..044eb6a55 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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 ( +
+ + {t('mv_position', { index: position.index.toLocaleString(), total: position.total.toLocaleString() })} + + + +
+ ); +} + function SidebarSection({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) { return (
@@ -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')} > @@ -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')} > @@ -3096,7 +3156,7 @@ export function EmailViewer({ {/* Move to folder */} {moveTree.length > 0 && onMoveToMailbox && ( -
+
@@ -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 &&
} - {/* 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 && ( + )} {/* View source - hidden on mobile, overflows to More menu */}
+ {/* Mountain View: print and full screen at the end of the subject line. */} + {mvViewer && ( +
+ + {onToggleFullscreen && ( + + )} +
+ )} {/* 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. */}
@@ -3961,8 +4073,11 @@ export function EmailViewer({
{/* === SENDER INFO (Desktop) === */} -
-
+
+
+ {!isDraft && !isScheduled && ( + + )} )}
@@ -5275,6 +5413,8 @@ export function EmailViewer({ {/* Email Body */}
@@ -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) && (