From d45c7996d2bbf9eacae13fdace59075e87c15024 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:43:35 +0200 Subject: [PATCH] fix: keep list previews and subjects on one line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previews keep the body's paragraph breaks, so a line-clamped row showed a dangling "…" line and rows came out at uneven heights, and a subject carrying CR/LF rendered over several lines in the list and the viewer. Fold every whitespace run to a single space before display with a new `singleLine` helper, in the mailbox and unified inbox rows, the viewer subject and the collapsed thread card preview. --- src/components/email/ThreadMessageCard.tsx | 3 ++- src/lib/__tests__/single-line.test.ts | 24 ++++++++++++++++++++++ src/lib/single-line.ts | 12 +++++++++++ src/screens/EmailListScreen.tsx | 5 +++-- src/screens/EmailThreadScreen.tsx | 3 ++- src/screens/UnifiedInboxScreen.tsx | 5 +++-- 6 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 src/lib/__tests__/single-line.test.ts create mode 100644 src/lib/single-line.ts diff --git a/src/components/email/ThreadMessageCard.tsx b/src/components/email/ThreadMessageCard.tsx index 4d9b7edc..979c589d 100644 --- a/src/components/email/ThreadMessageCard.tsx +++ b/src/components/email/ThreadMessageCard.tsx @@ -9,6 +9,7 @@ import { useLocaleStore } from '../../stores/locale-store'; import SenderAvatar from '../SenderAvatar'; import { MessageContent, type MessageContentProps } from './MessageContent'; import { emailDisplayDate, formatHeaderDate, formatHeaderTime } from '../../lib/email-date'; +import { singleLine } from '../../lib/single-line'; interface Props extends MessageContentProps { expanded: boolean; @@ -45,7 +46,7 @@ export function ThreadMessageCard({ expanded, onToggleExpanded, onReply, ...cont {email.hasAttachment && } {formatHeaderDate(date, locale)} {formatHeaderTime(date, timeFormat, locale)} - {email.preview || ''} + {singleLine(email.preview)} {starred && } diff --git a/src/lib/__tests__/single-line.test.ts b/src/lib/__tests__/single-line.test.ts new file mode 100644 index 00000000..3c949b7d --- /dev/null +++ b/src/lib/__tests__/single-line.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { singleLine } from '../single-line'; + +describe('singleLine', () => { + it('folds preview paragraph breaks into single spaces', () => { + expect(singleLine('Q3 revenue is up 12 %.\n\n…')).toBe('Q3 revenue is up 12 %. …'); + expect(singleLine('Hi team,\r\n\r\nthe numbers\tare in. See below.\n')).toBe('Hi team, the numbers are in. See below.'); + }); + + it('folds a subject carrying CR/LF onto one line', () => { + expect(singleLine('Invoice 1042\r\n for September')).toBe('Invoice 1042 for September'); + }); + + it('returns an empty string for missing or blank input', () => { + expect(singleLine(undefined)).toBe(''); + expect(singleLine(null)).toBe(''); + expect(singleLine(' \r\n\t ')).toBe(''); + }); + + it('keeps a lone no-break space but folds runs that contain one', () => { + expect(singleLine('12 %')).toBe('12 %'); + expect(singleLine('a \nb')).toBe('a b'); + }); +}); diff --git a/src/lib/single-line.ts b/src/lib/single-line.ts new file mode 100644 index 00000000..676b1834 --- /dev/null +++ b/src/lib/single-line.ts @@ -0,0 +1,12 @@ +/** + * Fold a server string onto one line for display: every run of whitespace + * (CR/LF, tabs, repeated spaces) becomes a single space and the ends are + * trimmed. Message previews keep the body's paragraph breaks and a subject + * can carry a stray CR/LF; on a line-clamped Text those left a dangling + * "…" line and uneven row heights, and an unclamped subject wrapped over + * several lines. A lone no-break space is kept so "12 %" doesn't split. + */ +export function singleLine(text: string | null | undefined): string { + if (!text) return ''; + return text.replace(/\s+/g, (run) => (run === ' ' ? run : ' ')).trim(); +} diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index cc3e2390..b3b8aedd 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -31,6 +31,7 @@ import { useContactsStore } from '../stores/contacts-store'; import { useOutboxStore } from '../stores/outbox-store'; import { getContactDisplayName } from '../lib/contact-utils'; import { formatListDate } from '../lib/date-format'; +import { singleLine } from '../lib/single-line'; import { findTrashMailbox, findArchiveMailbox, findJunkMailbox, mailboxesForSiblingOf, ownMailboxes, } from '../lib/mailbox-tree'; @@ -210,7 +211,7 @@ const EmailRow = React.memo(function EmailRow({ {/* Row 2: Subject + tag pills */} - {item.subject || '(no subject)'} + {singleLine(item.subject) || '(no subject)'} {tags.slice(0, 3).map((tag) => ( @@ -226,7 +227,7 @@ const EmailRow = React.memo(function EmailRow({ {/* Row 3: Preview - hidden in compact density modes regardless of toggle */} {showPreview && density.showPreview && ( - {item.preview} + {singleLine(item.preview)} )} diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 8ca6440c..2f31dee6 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -33,6 +33,7 @@ import { useSheetDrag } from '../lib/use-sheet-drag'; import { useLocaleStore } from '../stores/locale-store'; import { findTrashMailbox, mailboxesForSiblingOf } from '../lib/mailbox-tree'; import { pickEmailBody, plainTextBody } from '../lib/email-body'; +import { singleLine } from '../lib/single-line'; import { buildForwardAsAttachmentPayload } from '../lib/forward-as-attachment'; import type { Email, EmailAddress, Identity } from '../api/types'; import type { RootStackParamList } from '../navigation/types'; @@ -876,7 +877,7 @@ function EmailPane({ return ; } - const subject = email.subject || t('email_viewer.no_subject', '(No Subject)'); + const subject = singleLine(email.subject) || t('email_viewer.no_subject', '(No Subject)'); const conversation = threading && threadIds && threadIds.length > 1 ? threadIds.map((mid) => detailCache.get(mid)).filter((m): m is Email => !!m) : null; diff --git a/src/screens/UnifiedInboxScreen.tsx b/src/screens/UnifiedInboxScreen.tsx index 6576fd59..a74ce0a4 100644 --- a/src/screens/UnifiedInboxScreen.tsx +++ b/src/screens/UnifiedInboxScreen.tsx @@ -21,6 +21,7 @@ import { useAuthStore } from '../stores/auth-store'; import { useSettingsStore, type SwipeAction } from '../stores/settings-store'; import { useLocaleStore } from '../stores/locale-store'; import { formatListDate } from '../lib/date-format'; +import { singleLine } from '../lib/single-line'; import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm'; import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; @@ -319,7 +320,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) { {formatListDate(item.receivedAt, { dateFormat, timeFormat, locale, t })} - {item.subject || '(no subject)'} + {singleLine(item.subject) || '(no subject)'} {acc && accountIds.length > 1 && ( @@ -342,7 +343,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) { )} {item.preview ? ( - {item.preview} + {singleLine(item.preview)} ) : null}