diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 07de2c88..1fff8b43 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -2,7 +2,7 @@ import { useTranslations } from "next-intl"; import { useCallback } from "react"; -import { formatDate } from "@/lib/utils"; +import { formatDate, stripInvisibleLeading } from "@/lib/utils"; import { Email } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; import { Avatar } from "@/components/ui/avatar"; @@ -51,7 +51,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; const isFocusedMailLayout = mailLayout === 'focus'; const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk; - const trimmedPreview = email.preview?.replace(/^\s+/, '') ?? ''; + const trimmedPreview = stripInvisibleLeading(email.preview ?? ''); const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; // Resolve color tags using keyword definitions from settings; unknown tags fall back to gray diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 9213ff16..bd73081a 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useCallback } from "react"; -import { formatDate } from "@/lib/utils"; +import { formatDate, stripInvisibleLeading } from "@/lib/utils"; import { Email, ThreadGroup } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; import { Avatar } from "@/components/ui/avatar"; @@ -71,7 +71,7 @@ const SingleEmailItem = React.forwardRef( const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined; const isChecked = selectedEmailIds.has(email.id); const isFocusedMailLayout = mailLayout === 'focus'; - const trimmedPreview = email.preview?.replace(/^\s+/, '') ?? ''; + const trimmedPreview = stripInvisibleLeading(email.preview ?? ''); const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; // Resolve color tags using keyword definitions; unknown tags fall back to gray @@ -367,7 +367,7 @@ export const ThreadListItem = React.forwardRef state.isMobile); const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; const isFocusedMailLayout = mailLayout === 'focus'; - const trimmedPreview = latestEmail.preview?.replace(/^\s+/, '') ?? ''; + const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? ''); const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore(); diff --git a/lib/utils.ts b/lib/utils.ts index 44193b1d..331e5f2b 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -78,14 +78,19 @@ export function formatDateTime( return d.toLocaleString(undefined, localeOptions); } -// Marketing emails often pad the preheader with invisible Unicode (combining -// grapheme joiners, soft hyphens, zero-width chars) alongside whitespace, to -// push real content out of the preview window. \s catches normal whitespace -// including figure space U+2007; we also strip the common invisible formatters. -const LEADING_INVISIBLE_RE = /^[\s\u00AD\u034F\u200B-\u200F\u2060-\u2064\uFEFF]+/; +// Marketing emails pad the preheader with whitespace, format chars (soft +// hyphens, zero-width chars, BOM, directional marks) and combining marks +// (e.g. U+034F) to push real content past the preview window. Strip them all. +// \p{Cf} = Format, \p{Mn} = combining marks; \s covers figure space, NBSP, etc. +const LEADING_INVISIBLE_RE = /^[\s\p{Cf}\p{Mn}]+/u; +// After stripping, a server-side truncation indicator like "..." may be all +// that's left. Treat that as no preview so callers can fall back. +const ONLY_PUNCTUATION_RE = /^[.\u2026\s]+$/; export function stripInvisibleLeading(text: string): string { - return text.replace(LEADING_INVISIBLE_RE, ''); + const stripped = text.replace(LEADING_INVISIBLE_RE, ''); + if (ONLY_PUNCTUATION_RE.test(stripped)) return ''; + return stripped; } export function truncateText(text: string, maxLength: number): string {