fix: keep list previews and subjects on one line

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.
This commit is contained in:
Linus Rath
2026-09-24 18:50:55 +02:00
parent 476780553c
commit d45c7996d2
6 changed files with 46 additions and 6 deletions
+2 -1
View File
@@ -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 && <Paperclip size={12} color={c.textMuted} />}
<Text style={styles.collapsedDate}>{formatHeaderDate(date, locale)} {formatHeaderTime(date, timeFormat, locale)}</Text>
</View>
<Text style={styles.collapsedPreview} numberOfLines={1}>{email.preview || ''}</Text>
<Text style={styles.collapsedPreview} numberOfLines={1}>{singleLine(email.preview)}</Text>
</View>
{starred && <Star size={14} color={c.starred} fill={c.starred} />}
</Pressable>
+24
View File
@@ -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');
});
});
+12
View File
@@ -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();
}
+3 -2
View File
@@ -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 */}
<View style={styles.subjectRow}>
<Text style={[styles.emailSubject, dyn.body, unread && styles.textBold]} numberOfLines={1}>
{item.subject || '(no subject)'}
{singleLine(item.subject) || '(no subject)'}
</Text>
{tags.slice(0, 3).map((tag) => (
<View key={tag.id} style={[styles.tagPill, { backgroundColor: tag.bg }]}>
@@ -226,7 +227,7 @@ const EmailRow = React.memo(function EmailRow({
{/* Row 3: Preview - hidden in compact density modes regardless of toggle */}
{showPreview && density.showPreview && (
<Text style={[styles.emailPreview, dyn.body]} numberOfLines={2}>
{item.preview}
{singleLine(item.preview)}
</Text>
)}
</View>
+2 -1
View File
@@ -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 <EmailPaneSkeleton styles={styles} />;
}
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;
+3 -2
View File
@@ -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) {
<Text style={styles.time}>{formatListDate(item.receivedAt, { dateFormat, timeFormat, locale, t })}</Text>
</View>
<Text style={[styles.subject, unread && styles.bold]} numberOfLines={1}>
{item.subject || '(no subject)'}
{singleLine(item.subject) || '(no subject)'}
</Text>
<View style={styles.line}>
{acc && accountIds.length > 1 && (
@@ -342,7 +343,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
)}
</View>
{item.preview ? (
<Text style={styles.preview} numberOfLines={1}>{item.preview}</Text>
<Text style={styles.preview} numberOfLines={1}>{singleLine(item.preview)}</Text>
) : null}
</View>
</Pressable>