From 1b4cecd2a50d2ec9e6b01989bd6af18d004c2432 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 06:45:10 +0100
Subject: [PATCH] fix: show one preview line in regular list rows, two in
comfortable ones
The skeleton test counted two preview lines per regular row; regular rows now draw one, so it expects one there and two for comfortable rows.
---
app/boot-skeleton.css | 2 +
.../__tests__/mail-skeleton.test.tsx | 7 +-
components/boot-skeleton/mail-skeleton.tsx | 11 ++-
.../__tests__/list-row-preview-lines.test.tsx | 77 +++++++++++++++++++
components/email/email-list.tsx | 5 +-
components/email/thread-list-item.tsx | 4 +-
6 files changed, 99 insertions(+), 7 deletions(-)
create mode 100644 components/email/__tests__/list-row-preview-lines.test.tsx
diff --git a/app/boot-skeleton.css b/app/boot-skeleton.css
index b33ec0f2e..3de963440 100644
--- a/app/boot-skeleton.css
+++ b/app/boot-skeleton.css
@@ -362,6 +362,8 @@
@custom-variant boot-dense (&:where([data-boot-density="compact"] *, [data-boot-density="extra-compact"] *));
/* Message previews turned off (showPreview). */
@custom-variant boot-no-preview (&:where([data-boot-preview="off"] *));
+/* The preview is one line except in comfortable rows. */
+@custom-variant boot-one-preview-line (&:where(:root:not([data-boot-density="comfortable"]) *));
/* The default interface layout, the opposite of mv: (both can't be told apart by order). */
@custom-variant boot-std (&:where(:root:not([data-interface-layout="mountain-view"]) *));
/* Mountain View's side panel shows only with calendar or contacts. */
diff --git a/components/boot-skeleton/__tests__/mail-skeleton.test.tsx b/components/boot-skeleton/__tests__/mail-skeleton.test.tsx
index 784665b8c..05734374d 100644
--- a/components/boot-skeleton/__tests__/mail-skeleton.test.tsx
+++ b/components/boot-skeleton/__tests__/mail-skeleton.test.tsx
@@ -30,14 +30,16 @@ describe('mail skeleton', () => {
expect(pane.querySelector('.lg\\:mv\\:hidden.border-b')).toBeNull();
});
+ // Regular rows have a one-line preview, comfortable ones two (mail-15).
it.each([
['extra-compact', 0, 0],
['compact', 16, 0],
['regular', 16, 16],
- ] as const)('draws %s stacked rows with %i avatars and %i previews', (density, avatars, previews) => {
+ ['comfortable', 16, 32],
+ ] as const)('draws %s stacked rows with %i avatars and %i preview lines', (density, avatars, previewLines) => {
const rows = html();
expect(rows.querySelectorAll('.w-10.h-10').length).toBe(avatars);
- expect(rows.querySelectorAll('.leading-relaxed').length).toBe(previews * 2);
+ expect(rows.querySelectorAll('.leading-relaxed').length).toBe(previewLines);
});
it('leaves the preview out when previews are turned off', () => {
@@ -49,6 +51,7 @@ describe('mail skeleton', () => {
const rows = html();
expect(rows.querySelector('.boot-density-xc\\:hidden')).not.toBeNull();
expect(rows.querySelector('.boot-dense\\:hidden')).not.toBeNull();
+ expect(rows.querySelector('.boot-one-preview-line\\:hidden')).not.toBeNull();
expect(rows.querySelector('.md\\:mv\\:block')).not.toBeNull();
});
});
diff --git a/components/boot-skeleton/mail-skeleton.tsx b/components/boot-skeleton/mail-skeleton.tsx
index a13165166..0fbbe323f 100644
--- a/components/boot-skeleton/mail-skeleton.tsx
+++ b/components/boot-skeleton/mail-skeleton.tsx
@@ -181,6 +181,12 @@ function previewClass(o: RowOptions): string | null {
return o.preview === false || o.density === "compact" || o.density === "extra-compact" ? null : "";
}
+/** Classes for the preview's second line, which only comfortable rows have (mail-15). */
+function secondPreviewLineClass(o: RowOptions): string | null {
+ if (o.density === undefined) return "boot-one-preview-line:hidden";
+ return o.density === "comfortable" ? "" : null;
+}
+
/*
* The rows below are drawn dozens of times, so they use the short classes in
* app/boot-skeleton.css (mail section) for what every row repeats: sk-mrow
@@ -202,6 +208,7 @@ function L({ className, w }: { className?: string; w: string }) {
function StackedRow({ i, o }: { i: number; o: RowOptions }) {
const avatar = avatarClass(o);
const preview = previewClass(o);
+ const secondLine = secondPreviewLineClass(o);
return (
@@ -220,7 +227,9 @@ function StackedRow({ i, o }: { i: number; o: RowOptions }) {
{/* Mountain View's phone rows keep the preview to one line, beside a 32px star. */}
-
+ {secondLine !== null && (
+
+ )}
)}
diff --git a/components/email/__tests__/list-row-preview-lines.test.tsx b/components/email/__tests__/list-row-preview-lines.test.tsx
new file mode 100644
index 000000000..d1686f78b
--- /dev/null
+++ b/components/email/__tests__/list-row-preview-lines.test.tsx
@@ -0,0 +1,77 @@
+import { render, screen } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { ThreadListItem } from '../thread-list-item';
+import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
+import { useEmailStore } from '@/stores/email-store';
+import { useUIStore } from '@/stores/ui-store';
+import { groupEmailsByThread } from '@/lib/thread-utils';
+import type { Email } from '@/lib/jmap/types';
+
+vi.mock('@/hooks/use-email-drag', () => ({
+ useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
+}));
+
+vi.mock('@/stores/auth-store', () => {
+ const state = { identities: [], username: null, client: null, activeAccountId: null, getClientForAccount: () => undefined };
+ const useAuthStore = Object.assign(
+ (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state),
+ { getState: () => state },
+ );
+ return { useAuthStore };
+});
+
+const PREVIEW = 'Hi Robin, thanks for the quick reply. I went through the points again.';
+
+const makeEmail = (overrides: Partial
= {}): Email => ({
+ id: 'email-1',
+ threadId: 'thread-1',
+ mailboxIds: { inbox: true },
+ keywords: { $seen: true },
+ size: 1000,
+ receivedAt: '2024-01-15T10:00:00Z',
+ from: [{ name: 'Alice', email: 'alice@example.com' }],
+ subject: 'Test Subject',
+ preview: PREVIEW,
+ hasAttachment: false,
+ ...overrides,
+});
+
+function renderRow(emails: Email[]) {
+ const [thread] = groupEmailsByThread(emails);
+ return render(
+ {}} onEmailSelect={() => {}} />,
+ );
+}
+
+beforeEach(() => {
+ useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
+ useSettingsStore.setState({
+ emailKeywords: [...DEFAULT_KEYWORDS],
+ showPreview: true,
+ mailLayout: 'split',
+ interfaceLayout: 'default',
+ });
+ useEmailStore.setState({ selectedEmailKeys: new Set(), selectedMailbox: 'inbox' });
+});
+
+// mail-15: the default density shows one preview line, so more rows fit;
+// comfortable keeps two.
+describe('list row preview lines', () => {
+ 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([
+ ['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);
+ });
+});
diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx
index a744becf7..e77c65df3 100644
--- a/components/email/email-list.tsx
+++ b/components/email/email-list.tsx
@@ -463,9 +463,10 @@ export function EmailList({
let size = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
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.
+ // a line (23px) shorter than a real one. Only comfortable rows show a
+ // second preview line (mail-15).
const emptyPreview = !!latest && !cleanPreview(latest.preview) && !latest.searchSnippet?.preview;
- size += emptyPreview ? 36 - 23 : 36;
+ size += emptyPreview || density !== 'comfortable' ? 36 - 23 : 36;
}
// The chip row (attachments, verification code): a 22px chip plus 6px margin.
const hasCode = !!latest && showVerificationCodes && !!listVerificationCode(latest);
diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx
index e86b199e1..cabf2d89c 100644
--- a/components/email/thread-list-item.tsx
+++ b/components/email/thread-list-item.tsx
@@ -975,7 +975,7 @@ const SingleEmailItem = React.forwardRef(