mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
fix: drop a leading style sheet and padding from message previews (bulwarkmail/webmail#1136)
This commit is contained in:
@@ -10,6 +10,7 @@ import SenderAvatar from '../SenderAvatar';
|
||||
import { MessageContent, type MessageContentProps } from './MessageContent';
|
||||
import { emailDisplayDate, formatHeaderDate, formatHeaderTime } from '../../lib/email-date';
|
||||
import { singleLine } from '../../lib/single-line';
|
||||
import { cleanPreview } from '../../lib/preview-text';
|
||||
import { useSenderWarning } from './use-sender-warning';
|
||||
|
||||
interface Props extends MessageContentProps {
|
||||
@@ -58,7 +59,7 @@ export function ThreadMessageCard({
|
||||
{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}>{singleLine(email.preview)}</Text>
|
||||
<Text style={styles.collapsedPreview} numberOfLines={1}>{singleLine(cleanPreview(email.preview))}</Text>
|
||||
</View>
|
||||
{starred && <Star size={14} color={c.starred} fill={c.starred} />}
|
||||
</Pressable>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { cleanPreview, stripEmphasisMarkers, stripLeadingCss } from '../preview-text';
|
||||
|
||||
// Copied from the webmail (lib/__tests__/preview-leading-css.test.ts), plus
|
||||
// the check that the list rows use it (#1136).
|
||||
|
||||
// Some servers build a message preview from its HTML without skipping the
|
||||
// <style> element, so a marketing mail's preview starts with its style sheet.
|
||||
describe('stripLeadingCss', () => {
|
||||
it('drops a leading media query and keeps the text after it', () => {
|
||||
const preview = '@media screen and (min-width:600px){.hide{display:none!important;overflow:hidden!important}.show{display:block!important}} Your parcel is on its way';
|
||||
expect(stripLeadingCss(preview)).toBe('Your parcel is on its way');
|
||||
});
|
||||
|
||||
it('drops several leading rules in a row', () => {
|
||||
expect(stripLeadingCss('.a{color:red} #b p{margin:0} td, th {padding:0}Hello')).toBe('Hello');
|
||||
});
|
||||
|
||||
it('drops a leading @import that ends without a block', () => {
|
||||
expect(stripLeadingCss("@import url('x.css'); Weekly digest")).toBe('Weekly digest');
|
||||
});
|
||||
|
||||
it('empties a preview that ends inside the style sheet', () => {
|
||||
expect(stripLeadingCss('@media screen and (min-width:600px){.hide{display:none!important;max-height:0!import')).toBe('');
|
||||
});
|
||||
|
||||
it('leaves ordinary text alone, braces and all', () => {
|
||||
for (const text of [
|
||||
'Hi Luca, the meeting is at {time} tomorrow',
|
||||
'#1234 has been shipped',
|
||||
'@Luca can you check the figures?',
|
||||
'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('\u034f \u200b.x{color:red} Real text')).toBe('Real text');
|
||||
expect(cleanPreview('.x{color:red} ...')).toBe('');
|
||||
expect(cleanPreview(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// The preview comes from the text/plain part, which marks bold with asterisks
|
||||
// where the HTML part uses <strong> (#1260).
|
||||
describe('stripEmphasisMarkers', () => {
|
||||
it('drops emphasis markers around words', () => {
|
||||
expect(cleanPreview('*@Claude* mentioned you: please review')).toBe('@Claude mentioned you: please review');
|
||||
expect(stripEmphasisMarkers('This is **really** important.')).toBe('This is really important.');
|
||||
expect(stripEmphasisMarkers('See _the docs_ and __this__, (*now*)')).toBe('See the docs and this, (now)');
|
||||
});
|
||||
|
||||
it('leaves asterisks and underscores inside words or between spaces alone', () => {
|
||||
for (const text of [
|
||||
'2 * 3 * 4 = 24',
|
||||
'a*b*c and snake_case_name',
|
||||
'rating: * * *',
|
||||
'my_var_ is set',
|
||||
'footnote* and *',
|
||||
'***',
|
||||
'__init__.py changed',
|
||||
]) expect(stripEmphasisMarkers(text)).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('list rows', () => {
|
||||
it('show the cleaned preview', () => {
|
||||
const read = (p: string) => fs.readFileSync(path.resolve(__dirname, '../..', p), 'utf8');
|
||||
expect(read('screens/EmailListScreen.tsx')).toMatch(/singleLine\(cleanPreview\(item\.preview\)\)/);
|
||||
expect(read('screens/UnifiedInboxScreen.tsx')).toMatch(/singleLine\(cleanPreview\(item\.preview\)\)/);
|
||||
expect(read('components/email/ThreadMessageCard.tsx')).toMatch(/singleLine\(cleanPreview\(email\.preview\)\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// Message previews ready to show (webmail lib/utils.ts cleanPreview, #1136,
|
||||
// #1260): some servers build the preview from an HTML body without skipping
|
||||
// its <style>, so a marketing mail's preview began with CSS; preheaders pad
|
||||
// with invisible characters; text/plain emphasis markers ("*bold*") show.
|
||||
// Copied from the webmail as it is.
|
||||
|
||||
// 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 {
|
||||
const stripped = text.replace(LEADING_INVISIBLE_RE, '');
|
||||
if (ONLY_PUNCTUATION_RE.test(stripped)) return '';
|
||||
return stripped;
|
||||
}
|
||||
|
||||
// A style sheet at the start of the text: an at-rule, or a selector opening a
|
||||
// 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;
|
||||
// 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.
|
||||
*
|
||||
* Some servers build the preview from an HTML body without skipping its
|
||||
* <style> element, so a marketing mail's preview can begin with
|
||||
* "@media screen and (min-width:600px){.hide{display:none!important;…" and
|
||||
* never reach the text. Whole leading rules are removed; a preview that ends
|
||||
* inside one was nothing but CSS and becomes empty, so callers fall back to
|
||||
* their "no preview" text.
|
||||
*/
|
||||
export function stripLeadingCss(text: string): string {
|
||||
let rest = text.trimStart();
|
||||
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) && /^@(?:import|charset)\b/.test(rest)) {
|
||||
rest = rest.slice(semicolon + 1).trimStart();
|
||||
continue;
|
||||
}
|
||||
// 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++) {
|
||||
if (rest[i] === '{') depth++;
|
||||
else if (rest[i] === '}' && --depth === 0) { end = i; break; }
|
||||
}
|
||||
if (end < 0) return rest.startsWith('@') || DECLARATIONS_RE.test(rest.slice(brace + 1)) ? '' : rest;
|
||||
// A rule's block holds declarations ("color: red") or further rules; a
|
||||
// brace in prose ("at {time} tomorrow") holds neither and ends the strip.
|
||||
if (!rest.startsWith('@') && !DECLARATIONS_RE.test(rest.slice(brace + 1, end))) return rest;
|
||||
rest = rest.slice(end + 1).trimStart();
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
|
||||
// Plain-text emphasis: "*bold*", "**bold**", "_italic_", "__bold__". The
|
||||
// markers sit at word edges, so "2 * 3 * 4", "a*b*c", "snake_case_name" and
|
||||
// "__init__.py" are left alone.
|
||||
const EMPHASIS_RE = /(^|[\s([{"'])(\*\*|__|\*|_)([^\s*_]|[^\s*_].*?[^\s])\2(?=$|[\s)\]}"']|[.,;:!?](?!\w))/g;
|
||||
|
||||
/**
|
||||
* Drops plain-text emphasis markers. The server builds the preview from the
|
||||
* text/plain part, where senders like GitHub write "*@user* mentioned you"
|
||||
* while the HTML part shows it in bold.
|
||||
*/
|
||||
export function stripEmphasisMarkers(text: string): string {
|
||||
return text.replace(EMPHASIS_RE, '$1$3');
|
||||
}
|
||||
|
||||
/** A message preview ready to show: no leading padding, style sheet or emphasis markers. */
|
||||
export function cleanPreview(text: string | null | undefined): string {
|
||||
return stripEmphasisMarkers(stripInvisibleLeading(stripLeadingCss(stripInvisibleLeading(text ?? ''))));
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import { toast } from '../stores/toast-store';
|
||||
import { getContactDisplayName } from '../lib/contact-utils';
|
||||
import { formatListDate } from '../lib/date-format';
|
||||
import { singleLine } from '../lib/single-line';
|
||||
import { cleanPreview } from '../lib/preview-text';
|
||||
import {
|
||||
findTrashMailbox, findArchiveMailbox, findJunkMailbox, mailboxesForSiblingOf, ownMailboxes,
|
||||
} from '../lib/mailbox-tree';
|
||||
@@ -304,7 +305,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}>
|
||||
{singleLine(item.preview)}
|
||||
{singleLine(cleanPreview(item.preview))}
|
||||
</Text>
|
||||
)}
|
||||
{verificationCode && <VerificationCodeChip code={verificationCode} disabled={selectionMode} inList />}
|
||||
|
||||
@@ -23,6 +23,7 @@ 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 { cleanPreview } from '../lib/preview-text';
|
||||
import { prefetchMessage, rememberRows } from '../lib/email-detail-cache';
|
||||
import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm';
|
||||
import { confirmMailRemoval } from '../lib/mail-removal-confirm';
|
||||
@@ -396,7 +397,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
||||
</View>
|
||||
{/* Same settings as the folder list: preview on/off, density, text size. */}
|
||||
{item.preview && showPreview && density.showPreview ? (
|
||||
<Text style={[styles.preview, dyn.body]} numberOfLines={2}>{singleLine(item.preview)}</Text>
|
||||
<Text style={[styles.preview, dyn.body]} numberOfLines={2}>{singleLine(cleanPreview(item.preview))}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
Reference in New Issue
Block a user