fix: keep previews that only start like a style sheet #1136

The leading-CSS strip emptied or cut ordinary previews:
"@media team, the brief is attached", "@Page can you check?" and
"@import the CSV ..." became empty, and "Reminder: your appointment
{date: 2026-10-01} is confirmed" became "is confirmed".

At-rule names now match in lower case only, only `@import` and
`@charset` end at a semicolon, an at-rule word with no block after it
is prose, and a selector colon must not be followed by a space.

The conversation view's collapsed cards, contact activity, the pro
folder tab and the list's row-height estimate now use `cleanPreview`
too, so they agree with the list rows. The test's invisible characters
are written as escapes.
This commit is contained in:
Linus Rath
2026-10-01 11:02:26 +02:00
parent 1a186f8962
commit 6bce3323a9
6 changed files with 32 additions and 14 deletions
+3 -2
View File
@@ -8,6 +8,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { Avatar } from "@/components/ui/avatar";
import { cleanPreview } from "@/lib/utils";
import { Section } from "./contact-detail";
import type { ContactCard, Email, CalendarEvent } from "@/lib/jmap/types";
@@ -244,9 +245,9 @@ export function ContactActivity({ contact }: ContactActivityProps) {
<div className="text-sm truncate">
{email.subject || t("no_subject")}
</div>
{email.preview && (
{cleanPreview(email.preview) && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{email.preview}
{cleanPreview(email.preview)}
</p>
)}
</div>
+2 -2
View File
@@ -7,7 +7,7 @@ import type { LoadListAttachments } from "@/lib/list-attachments";
import { listRowShowsChips } from "./attachment-chips";
import { listVerificationCode } from "@/lib/verification-code";
import { EmailContextMenu } from "./email-context-menu";
import { cn } from "@/lib/utils";
import { cn, cleanPreview } from "@/lib/utils";
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck } from "@/components/icons";
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
@@ -246,7 +246,7 @@ export function EmailList({
if (showPreview && density !== 'extra-compact') {
// A mail without a preview draws a one-line "No preview available",
// a line (23px) shorter than a real one.
const emptyPreview = !!latest && !latest.preview?.trim() && !latest.searchSnippet?.preview;
const emptyPreview = !!latest && !cleanPreview(latest.preview) && !latest.searchSnippet?.preview;
size += emptyPreview ? 36 - 23 : 36;
}
// The chip row (attachments, verification code): a 22px chip plus 6px margin.
@@ -14,7 +14,7 @@ import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDa
import { useThemeStore } from "@/stores/theme-store";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { formatDate, formatFileSize, cn } from "@/lib/utils";
import { formatDate, formatFileSize, cn, cleanPreview } from "@/lib/utils";
import {
ArrowLeft,
ChevronDown,
@@ -568,7 +568,7 @@ function EmailCard({
</div>
{!isExpanded && density !== 'extra-compact' && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{email.preview || t('email_viewer.no_preview_available')}
{cleanPreview(email.preview) || t('email_viewer.no_preview_available')}
</p>
)}
</div>
+3 -3
View File
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Folder, Loader2, Paperclip, RefreshCw, Star } from "@/components/icons";
import { cn, formatDate } from "@/lib/utils";
import { cn, cleanPreview, formatDate } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { EmailViewer } from "@/components/email/email-viewer";
import { ProEmailView } from "@/components/pro/pro-email-tab-body";
@@ -240,9 +240,9 @@ export function ProFolderTabBody({ tabId, data }: ProFolderTabBodyProps) {
<Star className="h-3.5 w-3.5 flex-shrink-0 fill-yellow-400 text-yellow-400" aria-hidden="true" />
)}
</div>
{email.preview && (
{cleanPreview(email.preview) && (
<div className="mt-0.5 truncate text-xs text-muted-foreground">
{email.preview}
{cleanPreview(email.preview)}
</div>
)}
</div>
+11 -1
View File
@@ -29,14 +29,24 @@ describe('stripLeadingCss', () => {
'Order {A-17} confirmed',
'Price {EUR}: 10 per box',
'Meeting at {room',
'@media team, the brief is attached',
'@Page can you check?',
'@import the CSV, then send it back',
'Reminder: your appointment {date: 2026-10-01} is confirmed',
]) expect(stripLeadingCss(text)).toBe(text);
});
});
describe('cleanPreview', () => {
it('strips padding and a style sheet together, and treats nothing left as no preview', () => {
expect(cleanPreview('͏ ​.x{color:red} Real text')).toBe('Real text');
expect(cleanPreview('\u034f \u200b.x{color:red} Real text')).toBe('Real text');
expect(cleanPreview('.x{color:red} ...')).toBe('');
expect(cleanPreview(null)).toBe('');
});
});
describe('stripLeadingCss pseudo-classes', () => {
it('still drops a rule whose selector has a pseudo-class', () => {
expect(stripLeadingCss('a:hover, a:focus{color:red} Hello')).toBe('Hello');
});
});
+11 -4
View File
@@ -242,7 +242,12 @@ export function stripInvisibleLeading(text: string): string {
// rule. Plain prose does not start like this - "@" or "#" followed by a word
// and then, before any sentence ends, a "{".
const DECLARATIONS_RE = /^\s*(?:[-\w]+\s*:|[.#@a-z*][^{}]*\{)/i;
const LEADING_CSS_RE = /^(?:@(?:media|font-face|import|supports|keyframes|charset|page)\b|[.#]?[a-z*][\w\-.#:,>~+*\s[\]="']*\{)/i;
// At-rule names are matched case-sensitively: style sheets write them in lower
// case, while "@Page …" at the start of a preview is a mention. A selector's
// colon is a pseudo-class ("a:hover"), never followed by a space as in
// "Reminder: your appointment {date: …}".
const AT_RULE_RE = /^@(?:media|font-face|import|supports|keyframes|charset|page)\b/;
const SELECTOR_RE = /^[.#]?[a-z*](?:[\w\-.#,>~+*\s[\]="']|:(?!\s))*\{/i;
/**
* Drops a style sheet from the start of a text preview.
@@ -256,15 +261,17 @@ const LEADING_CSS_RE = /^(?:@(?:media|font-face|import|supports|keyframes|charse
*/
export function stripLeadingCss(text: string): string {
let rest = text.trimStart();
for (let guard = 0; guard < 100 && LEADING_CSS_RE.test(rest); guard++) {
for (let guard = 0; guard < 100 && (AT_RULE_RE.test(rest) || SELECTOR_RE.test(rest)); guard++) {
const brace = rest.indexOf('{');
const semicolon = rest.indexOf(';');
// `@import url(...);` and `@charset "x";` end without a block.
if (semicolon >= 0 && (brace < 0 || semicolon < brace) && rest.startsWith('@')) {
if (semicolon >= 0 && (brace < 0 || semicolon < brace) && /^@(?:import|charset)\b/.test(rest)) {
rest = rest.slice(semicolon + 1).trimStart();
continue;
}
if (brace < 0) return '';
// An at-rule word with no block after it ("@media team, …", "@page 3 …")
// starts prose, not a style sheet.
if (brace < 0) return rest;
let depth = 0;
let end = -1;
for (let i = brace; i < rest.length; i++) {