feat: draw Mountain View's message rows like Gmail's

This commit is contained in:
Linus Rath
2026-10-12 01:01:09 +02:00
parent cf558e7e9f
commit 20b52a060f
7 changed files with 191 additions and 35 deletions
+25
View File
@@ -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] {
@@ -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']);
});
});
@@ -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<string>(), 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(
<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={onToggleExpand} onEmailSelect={onEmailSelect} />,
);
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();
});
});
+23 -4
View File
@@ -66,6 +66,22 @@ const ACTION_CONFIG: Record<HoverAction, {
},
};
/** The quick actions as shipped, before the user reorders them. */
const DEFAULT_HOVER_ACTIONS: readonly HoverAction[] = ['delete', 'star', 'markRead', 'archive'];
/** Gmail's row actions, in Gmail's order. */
const MOUNTAIN_VIEW_HOVER_ACTIONS: readonly HoverAction[] = ['archive', 'delete', 'markRead'];
/**
* The actions a Mountain View row shows on hover: Gmail's set while the user
* has not chosen their own, and never the star, which the row already leads
* with.
*/
export function mountainViewHoverActions(configured: readonly HoverAction[]): HoverAction[] {
const isDefault = configured.length === DEFAULT_HOVER_ACTIONS.length
&& configured.every((action, i) => 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),
)}
>
<DisplayIcon
className={cn(
"w-4 h-4",
mountainView ? "w-5 h-5" : "w-4 h-4",
actionId === "star" && isStarred && "fill-amber-400 text-amber-400",
)}
/>
+71 -30
View File
@@ -68,6 +68,16 @@ function RowChips({ email, loadAttachments, onOpenAttachment }: {
return leading ? <div className="mt-1.5 flex">{leading}</div> : 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<HTMLDivElement, SingleEmailItemProps>(
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<HTMLDivElement, SingleEmailItemProps>(
</div>
)}
<div
className={cn('px-3', isFocusedMailLayout ? 'flex items-center' : 'flex items-start', swipeEnabled && 'relative z-10 bg-inherit')}
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)', transform: swipeEnabled && offsetX ? `translateX(${offsetX}px)` : undefined, transition: swipeEnabled && offsetX === 0 ? 'transform 200ms ease-out' : undefined }}
className={cn('px-3', isFocusedMailLayout ? 'flex items-center' : 'flex items-start', mvRow && 'min-h-10', swipeEnabled && 'relative z-10 bg-inherit')}
style={{ gap: 'var(--density-item-gap)', paddingBlock: mvRow ? undefined : 'var(--density-item-py)', transform: swipeEnabled && offsetX ? `translateX(${offsetX}px)` : undefined, transition: swipeEnabled && offsetX === 0 ? 'transform 200ms ease-out' : undefined }}
>
{/* 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<HTMLDivElement, SingleEmailItemProps>(
)}
<span className={cn(
'w-32 shrink-0 truncate text-sm lg:w-40',
isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
isUnread ? (mvRow ? 'font-bold text-foreground' : 'font-semibold text-foreground') : 'font-medium text-foreground/80'
)}>
{sender?.name || (sender?.email && formatAddress(sender.email)) || 'Unknown'}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<div className={cn("flex min-w-0 flex-1 items-center text-sm", mvRow ? "gap-0" : "gap-2")}>
{tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
<span className={cn(TAG_GROUP_CLASS, mvRow && "me-2")}>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
@@ -578,13 +590,15 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)}
<span className={cn(
'min-w-0 truncate',
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
isUnread ? (mvRow ? 'font-bold text-foreground' : 'font-semibold text-foreground') : 'text-foreground/90'
)}>
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (email.subject || '(no subject)')}
</span>
{(previewSnippet || inlinePreview) && (
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">
{previewSnippet ? <> <SearchSnippetText snippet={previewSnippet} /></> : inlinePreview}
{/* Gmail runs the snippet on from the subject after a dash. */}
{mvRow && '\u00a0-\u00a0'}
{previewSnippet ? <> <SearchSnippetText snippet={previewSnippet} /></> : (mvRow ? trimmedPreview : inlinePreview)}
</span>
)}
</div>
@@ -592,9 +606,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
<div className="flex items-center gap-2.5 shrink-0">
{isPinned && <StatusIcon icon={Pin} label={tStatus('pinned')} className="w-3.5 h-3.5 text-primary" />}
{isStarred && !mvRow && <StatusIcon icon={Star} label={tStatus('starred')} className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{isAnswered && !isForwarded && <StatusIcon icon={Reply} label={tStatus('replied')} className="w-3.5 h-3.5 text-muted-foreground" />}
{isForwarded && !isAnswered && <StatusIcon icon={Forward} label={tStatus('forwarded')} className="w-3.5 h-3.5 text-muted-foreground" />}
{isAnswered && isForwarded && (
{isAnswered && !isForwarded && !mvRow && <StatusIcon icon={Reply} label={tStatus('replied')} className="w-3.5 h-3.5 text-muted-foreground" />}
{isForwarded && !isAnswered && !mvRow && <StatusIcon icon={Forward} label={tStatus('forwarded')} className="w-3.5 h-3.5 text-muted-foreground" />}
{isAnswered && isForwarded && !mvRow && (
<>
<StatusIcon icon={Reply} label={tStatus('replied')} className="w-3.5 h-3.5 text-muted-foreground" />
<StatusIcon icon={Forward} label={tStatus('forwarded')} className="w-3.5 h-3.5 text-muted-foreground" />
@@ -613,9 +627,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
) : (
<span className={cn(
'text-xs tabular-nums',
isUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
isUnread ? (mvRow ? 'text-foreground font-bold' : 'text-foreground font-semibold') : 'text-muted-foreground'
)}>
{formatDate(email.receivedAt)}
{formatDate(email.receivedAt, mvRow ? { listStyle: 'mountain-view' } : undefined)}
</span>
)}
</div>
@@ -754,7 +768,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{!email.isScheduled && (
<EmailHoverActions
email={email}
backgroundClassName={resolvedRowTint ? resolvedRowTint : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
backgroundClassName={resolvedRowTint ? resolvedRowTint : mvRow ? mvHoverBackground(isChecked, isUnread, selected) : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
@@ -953,6 +967,14 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
return;
}
// Mountain View opens the conversation in the reading pane, as Gmail
// does, rather than unfolding it inside the list.
if (mvRow) {
if (selectedEmailKeys.size > 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<HTMLDivElement, ThreadListItemPro
aria-current={isSelected ? 'true' : undefined}
data-unread={hasUnread ? 'true' : 'false'}
data-mv-mobile-row={mvMobileRow ? "" : undefined}
data-mv-row={mvRow && !rowTint ? "" : undefined}
data-checked={isChecked ? "" : undefined}
className={cn(
"relative group cursor-pointer select-none transition-shadow duration-200 overflow-hidden",
rowTint ? rowTint : (
@@ -1013,8 +1037,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
style={{ minHeight: isFocusedMailLayout ? undefined : 'var(--list-item-height)' }}
>
<div
className={cn('px-3', isFocusedMailLayout ? 'flex items-center' : 'flex items-start')}
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
className={cn('px-3', isFocusedMailLayout ? 'flex items-center' : 'flex items-start', mvRow && 'min-h-10')}
style={{ gap: 'var(--density-item-gap)', paddingBlock: mvRow ? undefined : 'var(--density-item-py)' }}
>
{/* 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<HTMLDivElement, ThreadListItemPro
<span className="sr-only">{threadAccountDescription}</span>
</span>
)}
{mvRow ? (
// Gmail puts the message count after the names, in grey.
<span data-mv-names="" className="flex w-32 shrink-0 items-baseline gap-1 text-sm lg:w-40">
<span className={cn('min-w-0 truncate', hasUnread ? 'font-bold text-foreground' : 'font-medium text-foreground/80')}>
{displayNames.join(', ')}
</span>
<span className="shrink-0 text-xs text-muted-foreground" title={t('messages_tooltip', { count: emailCount })}>
{emailCount}
</span>
</span>
) : (
<span className={cn(
// Matches SingleEmailItem: the sender column sets where
// every row's tags and subject begin, so the two have to
@@ -1121,26 +1156,32 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)}>
{displayNames.join(', ')}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm">
<span className={TAG_GROUP_CLASS}>
<ThreadCountPill
count={emailCount}
hasUnread={hasUnread}
title={t('messages_tooltip', { count: emailCount })}
/>
)}
<div className={cn("flex min-w-0 flex-1 items-center text-sm", mvRow ? "gap-0" : "gap-2")}>
{(!mvRow || tagIds.length > 0) && (
<span className={cn(TAG_GROUP_CLASS, mvRow && "me-2")}>
{!mvRow && (
<ThreadCountPill
count={emailCount}
hasUnread={hasUnread}
title={t('messages_tooltip', { count: emailCount })}
/>
)}
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
)}
<span className={cn(
'min-w-0 truncate',
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
hasUnread ? (mvRow ? 'font-bold text-foreground' : 'font-semibold text-foreground') : 'text-foreground/90'
)}>
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (latestEmail.subject || '(no subject)')}
</span>
{(previewSnippet || inlinePreview) && (
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">
{previewSnippet ? <> <SearchSnippetText snippet={previewSnippet} /></> : inlinePreview}
{mvRow && '\u00a0-\u00a0'}
{previewSnippet ? <> <SearchSnippetText snippet={previewSnippet} /></> : (mvRow ? trimmedPreview : inlinePreview)}
</span>
)}
</div>
@@ -1148,9 +1189,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
<div className="flex items-center gap-2.5 shrink-0">
{hasPinned && <StatusIcon icon={Pin} label={tStatus('pinned')} className="w-3.5 h-3.5 text-primary" />}
{hasStarred && !mvRow && <StatusIcon icon={Star} label={tStatus('starred')} className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{hasAnswered && !hasForwarded && <StatusIcon icon={Reply} label={tStatus('replied')} className="w-3.5 h-3.5 text-muted-foreground" />}
{hasForwarded && !hasAnswered && <StatusIcon icon={Forward} label={tStatus('forwarded')} className="w-3.5 h-3.5 text-muted-foreground" />}
{hasAnswered && hasForwarded && (
{hasAnswered && !hasForwarded && !mvRow && <StatusIcon icon={Reply} label={tStatus('replied')} className="w-3.5 h-3.5 text-muted-foreground" />}
{hasForwarded && !hasAnswered && !mvRow && <StatusIcon icon={Forward} label={tStatus('forwarded')} className="w-3.5 h-3.5 text-muted-foreground" />}
{hasAnswered && hasForwarded && !mvRow && (
<>
<StatusIcon icon={Reply} label={tStatus('replied')} className="w-3.5 h-3.5 text-muted-foreground" />
<StatusIcon icon={Forward} label={tStatus('forwarded')} className="w-3.5 h-3.5 text-muted-foreground" />
@@ -1169,9 +1210,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
) : (
<span className={cn(
'text-xs tabular-nums',
hasUnread ? 'text-foreground font-semibold' : 'text-muted-foreground'
hasUnread ? (mvRow ? 'text-foreground font-bold' : 'text-foreground font-semibold') : 'text-muted-foreground'
)}>
{formatDate(latestEmail.receivedAt)}
{formatDate(latestEmail.receivedAt, mvRow ? { listStyle: 'mountain-view' } : undefined)}
</span>
)}
</div>
@@ -1321,7 +1362,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{!latestEmail.isScheduled && (
<EmailHoverActions
email={latestEmail}
backgroundClassName={rowTint ? rowTint : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
backgroundClassName={rowTint ? rowTint : mvRow ? mvHoverBackground(isChecked, hasUnread, isSelected) : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
+14
View File
@@ -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'));
+10 -1
View File
@@ -78,6 +78,8 @@ function resolveDateLocale<T extends string | undefined>(
* 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<T extends string | undefined>(
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'`;