mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user