diff --git a/app/globals.css b/app/globals.css index 389035287..737d4b3f4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1081,6 +1081,31 @@ optgroup { font-weight: 500; } +/* Message rows: read mail sits on a faint tint and unread on the plain card, + a ticked row takes the selection colour, and the row under the pointer + lifts on a shadow instead of changing colour. */ +[data-interface-layout="mountain-view"] [data-mv-row] { + background-color: var(--mv-row-read); + box-shadow: none; +} +[data-interface-layout="mountain-view"] [data-mv-row][data-unread="true"] { + background-color: var(--color-background); +} +[data-interface-layout="mountain-view"] [data-mv-row][data-checked] { + background-color: var(--mv-nav-selected); +} +[data-interface-layout="mountain-view"] [data-mv-row][aria-current="true"] { + background-color: var(--color-accent); +} +[data-interface-layout="mountain-view"] [data-mv-row]:hover { + z-index: 1; + box-shadow: + inset 1px 0 0 var(--color-border), + inset -1px 0 0 var(--color-border), + 0 1px 2px 0 rgb(60 64 67 / 0.3), + 0 1px 3px 1px rgb(60 64 67 / 0.15); +} + /* 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/__tests__/email-hover-actions-mountain-view.test.ts b/components/email/__tests__/email-hover-actions-mountain-view.test.ts new file mode 100644 index 000000000..114c43759 --- /dev/null +++ b/components/email/__tests__/email-hover-actions-mountain-view.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest'; +import { mountainViewHoverActions } from '../email-hover-actions'; + +describe('mountainViewHoverActions', () => { + it("uses Gmail's actions in Gmail's order while the defaults are untouched", () => { + expect(mountainViewHoverActions(['delete', 'star', 'markRead', 'archive'])).toEqual(['archive', 'delete', 'markRead']); + }); + + it("keeps the user's own choice, minus the star the row already has", () => { + expect(mountainViewHoverActions(['star', 'tag', 'delete'])).toEqual(['tag', 'delete']); + }); +}); diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx index aabbd2415..df972f2eb 100644 --- a/components/email/__tests__/thread-list-item.test.tsx +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -503,3 +503,39 @@ describe('ThreadListItem selection in a merged view', () => { expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0); }); }); + +describe('ThreadListItem Mountain View desktop row', () => { + beforeEach(() => { + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: true, + mailLayout: 'focus', + interfaceLayout: 'mountain-view', + } as never); + useEmailStore.setState({ selectedEmailKeys: new Set(), selectedMailbox: 'inbox' }); + }); + + it('runs the snippet on from the subject after a dash', () => { + const { container } = renderRow(makeEmail({ preview: 'See you there' })); + expect(container.textContent).toContain('Test Subject - See you there'); + }); + + it('counts a thread after the names instead of in a pill, and opens it without unfolding', () => { + const onEmailSelect = vi.fn(); + const onToggleExpand = vi.fn(); + const emails = [ + makeEmail({ id: 'a', receivedAt: '2024-01-15T10:00:00Z' }), + makeEmail({ id: 'b', receivedAt: '2024-01-15T11:00:00Z', from: [{ name: 'Bob', email: 'bob@example.com' }] }), + ]; + const [thread] = groupEmailsByThread(emails); + const { container } = render( + , + ); + + const names = container.querySelector('[data-mv-names]'); + expect(names?.textContent).toMatch(/2$/); + fireEvent.click(screen.getByText('Test Subject')); + expect(onEmailSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'b' })); + expect(onToggleExpand).not.toHaveBeenCalled(); + }); +}); diff --git a/components/email/email-hover-actions.tsx b/components/email/email-hover-actions.tsx index 3d29a0a0f..6b1e835fe 100644 --- a/components/email/email-hover-actions.tsx +++ b/components/email/email-hover-actions.tsx @@ -66,6 +66,22 @@ const ACTION_CONFIG: Record action === DEFAULT_HOVER_ACTIONS[i]); + return (isDefault ? MOUNTAIN_VIEW_HOVER_ACTIONS : configured).filter((action) => action !== 'star'); +} + const CORNER_CLASSES = { 'top-right': 'top-1 right-1', 'top-left': 'top-1 left-1', @@ -90,7 +106,9 @@ export function EmailHoverActions({ onUndoSpam, spamApplicable = true, }: EmailHoverActionsProps) { - const hoverActions = useSettingsStore((state) => state.hoverActions); + const configuredHoverActions = useSettingsStore((state) => state.hoverActions); + const mountainView = useSettingsStore((state) => state.interfaceLayout === 'mountain-view'); + const hoverActions = mountainView ? mountainViewHoverActions(configuredHoverActions) : configuredHoverActions; const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode); const hoverActionsCorner = useSettingsStore((state) => state.hoverActionsCorner); const t = useTranslations("settings.email_behavior.hover_actions"); @@ -219,13 +237,14 @@ export function EmailHoverActions({ aria-haspopup={actionId === "tag" ? "menu" : undefined} aria-expanded={actionId === "tag" ? tagPickerPos !== null : undefined} className={cn( - "p-1.5 rounded-md transition-colors duration-100 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10", - className, + "transition-colors duration-100 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10", + // Gmail's are round 40px targets that darken rather than change hue. + mountainView ? "p-2 rounded-full hover:text-foreground" : cn("p-1.5 rounded-md", className), )} > diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 7d5f27873..45e73924a 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -68,6 +68,16 @@ function RowChips({ email, loadAttachments, onOpenAttachment }: { return leading ?
{leading}
: null; } +/** + * The fill behind Mountain View's hover actions, which has to match the row + * they sit on (see the [data-mv-row] rules in globals.css). + */ +function mvHoverBackground(checked: boolean, unread: boolean, selected: boolean): string { + if (selected) return "bg-accent"; + if (checked) return "bg-[var(--mv-nav-selected)]"; + return unread ? "bg-background" : "bg-[var(--mv-row-read)]"; +} + /** * Mountain View's leading controls: a checkbox and a star, both always * visible and both acting on the row without opening it. They take the slot @@ -444,6 +454,8 @@ const SingleEmailItem = React.forwardRef( data-unread={isUnread ? 'true' : 'false'} data-starred={email.keywords?.$flagged ? 'true' : 'false'} data-mv-mobile-row={mvMobileRow ? "" : undefined} + data-mv-row={mvRow && !resolvedRowTint ? "" : undefined} + data-checked={isChecked ? "" : undefined} aria-current={selected ? 'true' : undefined} className={cn( "relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden", @@ -490,8 +502,8 @@ const SingleEmailItem = React.forwardRef( )}
{/* Checkbox - only for extra-compact density (no avatar) while in selection mode */} {!mvRow && density === 'extra-compact' && selectedEmailKeys.size > 0 && ( @@ -564,13 +576,13 @@ const SingleEmailItem = React.forwardRef( )} {sender?.name || (sender?.email && formatAddress(sender.email)) || 'Unknown'} -
+
{tagIds.length > 0 && ( - + {tagIds.map((id) => ( ))} @@ -578,13 +590,15 @@ const SingleEmailItem = React.forwardRef( )} {subjectSnippet ? : (email.subject || '(no subject)')} {(previewSnippet || inlinePreview) && ( - {previewSnippet ? <> : inlinePreview} + {/* Gmail runs the snippet on from the subject after a dash. */} + {mvRow && '\u00a0-\u00a0'} + {previewSnippet ? <> : (mvRow ? trimmedPreview : inlinePreview)} )}
@@ -592,9 +606,9 @@ const SingleEmailItem = React.forwardRef(
{isPinned && } {isStarred && !mvRow && } - {isAnswered && !isForwarded && } - {isForwarded && !isAnswered && } - {isAnswered && isForwarded && ( + {isAnswered && !isForwarded && !mvRow && } + {isForwarded && !isAnswered && !mvRow && } + {isAnswered && isForwarded && !mvRow && ( <> @@ -613,9 +627,9 @@ const SingleEmailItem = React.forwardRef( ) : ( - {formatDate(email.receivedAt)} + {formatDate(email.receivedAt, mvRow ? { listStyle: 'mountain-view' } : undefined)} )}
@@ -754,7 +768,7 @@ const SingleEmailItem = React.forwardRef( {!email.isScheduled && ( 0) clearSelection(); + onEmailSelect(latestEmail); + return; + } + const target = e.target as HTMLElement; if (target.closest('[data-expand-toggle]')) { onToggleExpand(); @@ -982,6 +1004,8 @@ export const ThreadListItem = React.forwardRef
{/* Checkbox for thread selection - only for extra-compact density (no avatar) while in selection mode */} {!mvRow && density === 'extra-compact' && selectedEmailKeys.size > 0 && ( @@ -1112,6 +1136,17 @@ export const ThreadListItem = React.forwardRef{threadAccountDescription} )} + {mvRow ? ( + // Gmail puts the message count after the names, in grey. + + + {displayNames.join(', ')} + + + {emailCount} + + + ) : ( {displayNames.join(', ')} -
- - + )} +
+ {(!mvRow || tagIds.length > 0) && ( + + {!mvRow && ( + + )} {tagIds.map((id) => ( ))} + )} {subjectSnippet ? : (latestEmail.subject || '(no subject)')} {(previewSnippet || inlinePreview) && ( - {previewSnippet ? <> : inlinePreview} + {mvRow && '\u00a0-\u00a0'} + {previewSnippet ? <> : (mvRow ? trimmedPreview : inlinePreview)} )}
@@ -1148,9 +1189,9 @@ export const ThreadListItem = React.forwardRef {hasPinned && } {hasStarred && !mvRow && } - {hasAnswered && !hasForwarded && } - {hasForwarded && !hasAnswered && } - {hasAnswered && hasForwarded && ( + {hasAnswered && !hasForwarded && !mvRow && } + {hasForwarded && !hasAnswered && !mvRow && } + {hasAnswered && hasForwarded && !mvRow && ( <> @@ -1169,9 +1210,9 @@ export const ThreadListItem = React.forwardRef - {formatDate(latestEmail.receivedAt)} + {formatDate(latestEmail.receivedAt, mvRow ? { listStyle: 'mountain-view' } : undefined)}
)}
@@ -1321,7 +1362,7 @@ export const ThreadListItem = React.forwardRef onToggleStar(latestEmail) : undefined} onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} onDelete={onDelete ? () => onDelete(latestEmail) : undefined} diff --git a/lib/__tests__/timezone.test.ts b/lib/__tests__/timezone.test.ts index 47bae312e..05026a6ed 100644 --- a/lib/__tests__/timezone.test.ts +++ b/lib/__tests__/timezone.test.ts @@ -191,6 +191,20 @@ describe('mail timestamp formatting honours the override', () => { expect(formatDate(undefined as unknown as string)).toBe(''); }); + it("formatDate uses Gmail's buckets for Mountain View lists", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-01T16:00:00Z')); + useSettingsStore.setState({ dateLocale: 'en-US', timeFormat: '12h' }); + const mv = { listStyle: 'mountain-view' } as const; + + expect(formatDate(new Date('2026-07-01T14:05:00Z'), mv)).toBe('02:05 PM'); + // Two days ago is a date, not a weekday, and this year leaves the year out. + expect(formatDate(new Date('2026-06-29T14:05:00Z'), mv)).toBe('Jun 29'); + expect(formatDate(new Date('2025-10-10T14:05:00Z'), mv)).toBe('10/10/25'); + // Without it the default buckets stay. + expect(formatDate(new Date('2026-06-29T14:05:00Z'))).toBe('Mon 02:05 PM'); + }); + it('formatDate overrides preview other options without touching the store', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-07-01T16:00:00Z')); diff --git a/lib/utils.ts b/lib/utils.ts index 0b7e4fdb6..3d5fcbfff 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -78,6 +78,8 @@ function resolveDateLocale( * today → time only ("15:31" or "3:31 PM") * last 7 days → short weekday+time ("Fr 15:31", "Fri 3:31 PM") * older → full locale date ("28.04.2026", "04/28/2026") + * With `listStyle: 'mountain-view'` the buckets are Gmail's instead: + * today → time, this year → "Oct 10", older → "10/10/25". * - `relative` — legacy en-US relative format ("1h ago", "2d ago"). * - `full` — always the full locale date+time. * @@ -97,7 +99,7 @@ function resolveDateLocale( export function formatDate( date: Date | string, // Settings preview: render as if these were picked, without saving them. - overrides?: { dateFormat?: DateFormat; dateLocale?: DateLocale }, + overrides?: { dateFormat?: DateFormat; dateLocale?: DateLocale; listStyle?: 'mountain-view' }, ): string { const d = typeof date === "string" ? new Date(date) : date; // An unparsable receivedAt (e.g. a 5-digit year) makes Intl throw @@ -167,6 +169,13 @@ export function formatDate( dWall.day === nowWall.day; if (isSameDay) return timeStr; + if (overrides?.listStyle === 'mountain-view') { + if (dWall.year === nowWall.year) { + return d.toLocaleDateString(uiLocale, { timeZone, month: "short", day: "numeric" }); + } + return d.toLocaleDateString(numericLocale, { timeZone, year: "2-digit", month: "numeric", day: "numeric" }); + } + const daysAgo = Math.floor((now.getTime() - d.getTime()) / 86400000); if (daysAgo < 7) { // German Intl outputs "Fr." with a trailing dot for `weekday: 'short'`;