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(