fix: show the preview on its own line again at the regular density

This commit is contained in:
Linus Rath
2026-11-04 14:59:26 +01:00
parent f7ffdcdf6a
commit d278bd5a77
5 changed files with 25 additions and 73 deletions
@@ -54,38 +54,24 @@ beforeEach(() => {
useEmailStore.setState({ selectedEmailKeys: new Set<string>(), selectedMailbox: 'inbox' });
});
// mail-15: the default density puts the preview after the subject, on the
// subject's line, so about ten rows fit at 1366×768 (they were about 96 px,
// seven on screen); comfortable keeps two preview lines, and a phone keeps
// the preview under the subject.
// mail-15: the default density shows one preview line, so more rows fit;
// comfortable keeps two.
describe('list row preview lines', () => {
const onSubjectLine = () => {
const preview = screen.getByText(PREVIEW, { exact: false });
return preview.closest('[data-row-preview]') !== null;
};
it.each([
['regular', 'line-clamp-1'],
['comfortable', 'line-clamp-2'],
] as const)('a %s row clamps its preview with %s', (density, clamp) => {
useSettingsStore.setState({ density });
renderRow([makeEmail()]);
expect(screen.getByText(PREVIEW).className.split(/\s+/)).toContain(clamp);
});
it.each([
['message', [makeEmail()]],
['conversation', [makeEmail({ id: 'a' }), makeEmail({ id: 'b', receivedAt: '2024-01-16T10:00:00Z' })]],
] as const)('a regular %s row shows its preview on the subject line', (_kind, emails) => {
useSettingsStore.setState({ density: 'regular' });
renderRow([...emails]);
expect(onSubjectLine()).toBe(true);
expect(document.querySelector('p.line-clamp-1')).toBeNull();
});
it('a comfortable row keeps two preview lines of its own', () => {
useSettingsStore.setState({ density: 'comfortable' });
renderRow([makeEmail()]);
expect(onSubjectLine()).toBe(false);
expect(screen.getByText(PREVIEW).className.split(/\s+/)).toContain('line-clamp-2');
});
it('a regular row on a phone keeps the preview under the subject', () => {
useUIStore.setState({ isMobile: true });
useSettingsStore.setState({ density: 'regular' });
renderRow([makeEmail()]);
expect(onSubjectLine()).toBe(false);
expect(screen.getByText(PREVIEW).className.split(/\s+/)).toContain('line-clamp-1');
['regular', 'line-clamp-1'],
['comfortable', 'line-clamp-2'],
] as const)('a %s conversation row clamps its preview with %s', (density, clamp) => {
useSettingsStore.setState({ density });
renderRow([makeEmail({ id: 'a' }), makeEmail({ id: 'b', receivedAt: '2024-01-16T10:00:00Z' })]);
expect(screen.getByText(PREVIEW).className.split(/\s+/)).toContain(clamp);
});
});
@@ -93,9 +93,7 @@ describe('message list rows under the bottom reading pane (#262)', () => {
it('keeps stacked rows in the split layout', () => {
useSettingsStore.setState({ mailLayout: 'split' });
const { container } = renderEmails([makeEmail()]);
// Stacked: a preview line of its own, or the preview after the subject
// on the subject's line at the regular density (mail-15).
expect(container.querySelector('p, [data-row-preview]')).not.toBeNull();
expect(container.querySelector('p')).not.toBeNull();
});
describe('as columns', () => {
+3 -7
View File
@@ -39,7 +39,7 @@ import { useOpenEmailInWindow } from "@/hooks/use-popouts-available";
import { useIsDesktop } from "@/hooks/use-media-query";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useSettingsStore } from "@/stores/settings-store";
import { useListRowShape, showsPreviewOnSubjectLine } from "@/hooks/use-list-row-shape";
import { useListRowShape } from "@/hooks/use-list-row-shape";
import { MailListRowsSkeleton, type MailRowShape } from "@/components/boot-skeleton/mail-skeleton";
import type { Density } from "@/lib/density";
import { useUIStore } from "@/stores/ui-store";
@@ -492,11 +492,7 @@ export function EmailList({
// up after a jump down (scrollbar drag, End). So guess per row.
const latest = item?.kind === 'row' ? item.thread.latestEmail : undefined;
let size = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
if (showsPreviewOnSubjectLine({ showPreview, density, isMobile, mountainView: mountainViewList })) {
// The preview follows the subject on its line, and the subject line
// drops its margin (mail-15).
size -= 4;
} else if (showPreview && density !== 'extra-compact') {
if (showPreview && density !== 'extra-compact') {
// A mail without a preview draws a one-line "No preview available",
// a line (23px) shorter than a real one. Only comfortable rows show a
// second preview line (mail-15).
@@ -507,7 +503,7 @@ export function EmailList({
const hasCode = !!latest && showVerificationCodes && !!listVerificationCode(latest);
if (latest && (hasCode || (onOpenAttachment && listRowShowsChips(latest, loadAttachments)))) size += 28;
return size;
}, [density, isFocusedMailLayout, showPreview, showVerificationCodes, items, onOpenAttachment, loadAttachments, isMobile, mountainViewList]);
}, [density, isFocusedMailLayout, showPreview, showVerificationCodes, items, onOpenAttachment, loadAttachments]);
// Stable per list, so the virtualizer does not rebuild every row's
// measurement on each scroll render.
+5 -17
View File
@@ -17,7 +17,7 @@ import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, Messa
import type { AppIcon } from "@/components/icons";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useShallow } from "zustand/react/shallow";
import { useListRowShape, LIST_COLUMN_CLASS, showsPreviewOnSubjectLine } from "@/hooks/use-list-row-shape";
import { useListRowShape, LIST_COLUMN_CLASS } from "@/hooks/use-list-row-shape";
import { SizeCell } from "./list-columns";
import { accountTintKey, generateAvatarColor } from "@/lib/account-utils";
import { useUIStore } from "@/stores/ui-store";
@@ -499,7 +499,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
// together.
const mvRow = interfaceLayout === 'mountain-view' && oneLineRow;
const mvMobileRow = interfaceLayout === 'mountain-view' && isMobile;
const previewOnSubjectLine = showsPreviewOnSubjectLine({ showPreview, density, isMobile, mountainView: interfaceLayout === 'mountain-view' });
const trimmedPreview = cleanPreview(email.preview);
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
// Search hits carry server snippets with the matched terms marked; they
@@ -973,7 +972,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
</div>
<div className={cn("flex min-w-0 items-center gap-1.5", !previewOnSubjectLine && "mb-1")}>
<div className="mb-1 flex min-w-0 items-center gap-1.5">
{tagPlacement === 'subject' && tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
@@ -988,16 +987,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
: "font-normal text-foreground/90"
)}>
<bdi>{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (email.subject || tNoSubject)}</bdi>
{previewOnSubjectLine && (
<span data-row-preview="" className="font-normal text-muted-foreground">
{' – '}<span dir="auto">{previewSnippet ? <SearchSnippetText snippet={previewSnippet} /> : (trimmedPreview || t('no_preview_available'))}</span>
</span>
)}
</span>
<PluginRowBadges email={email} />
</div>
{showPreview && !previewOnSubjectLine && density !== 'extra-compact' && density !== 'compact' && (
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
<div className={cn("flex min-w-0 items-center gap-1", !mvMobileRow && "contents")}>
<p dir="auto" className={cn(
"text-sm leading-relaxed",
@@ -1099,7 +1093,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
// together.
const mvRow = interfaceLayout === 'mountain-view' && oneLineRow;
const mvMobileRow = interfaceLayout === 'mountain-view' && isMobile;
const previewOnSubjectLine = showsPreviewOnSubjectLine({ showPreview, density, isMobile, mountainView: interfaceLayout === 'mountain-view' });
const trimmedPreview = cleanPreview(latestEmail.preview);
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
// In a search the matched mail need not be the thread's latest one: show
@@ -1767,7 +1760,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div>
</div>
<div className={cn("flex min-w-0 items-center gap-1.5", !previewOnSubjectLine && "mb-1")}>
<div className="mb-1 flex min-w-0 items-center gap-1.5">
{tagPlacement === 'subject' && tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
@@ -1782,16 +1775,11 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
: "font-normal text-foreground/90"
)}>
<bdi>{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (latestEmail.subject || tNoSubject)}</bdi>
{previewOnSubjectLine && (
<span data-row-preview="" className="font-normal text-muted-foreground">
{' – '}<span dir="auto">{previewSnippet ? <SearchSnippetText snippet={previewSnippet} /> : (trimmedPreview || tEmailViewer('no_preview_available'))}</span>
</span>
)}
</span>
<PluginRowBadges email={latestEmail} />
</div>
{showPreview && !previewOnSubjectLine && density !== 'extra-compact' && density !== 'compact' && (
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
<div className={cn("flex min-w-0 items-center gap-1", !mvMobileRow && "contents")}>
<p dir="auto" className={cn(
"text-sm leading-relaxed",
-16
View File
@@ -36,19 +36,3 @@ export const LIST_COLUMN_CLASS = {
date: 'w-28 shrink-0 text-end',
size: 'w-16 shrink-0 text-end',
} as const;
/**
* Whether a stacked row puts its preview after the subject, on the subject's
* line, instead of on a line of its own (mail-15). The regular density does
* on a computer, which brings a row from about 96 px to about 68 px, so a
* 1366×768 screen lists about ten. Comfortable keeps two preview lines, and
* phones keep the preview under the subject, where the line is too short.
*/
export function showsPreviewOnSubjectLine(options: {
showPreview: boolean;
density: string;
isMobile: boolean;
mountainView: boolean;
}): boolean {
return options.showPreview && options.density === 'regular' && !options.isMobile && !options.mountainView;
}