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}