mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
feat: draw Mountain View's message rows like Gmail's
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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'`;
|
||||
|
||||
Reference in New Issue
Block a user