From c07ba1a370d702ae4524aefbc22c2e4921dc9e69 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:33:42 +0100 Subject: [PATCH] feat: show message bodies at the chosen text size and enlarge text below the minimum --- .../__tests__/conversation-reading.test.tsx | 9 ++ .../email/__tests__/viewer-reading.test.tsx | 23 ++++ components/email/email-viewer.tsx | 20 ++- components/email/thread-conversation-view.tsx | 15 ++- hooks/use-mail-reading.ts | 42 ++++++ lib/__tests__/mail-reading.test.ts | 78 +++++++++++ lib/mail-reading.ts | 126 ++++++++++++++++++ 7 files changed, 304 insertions(+), 9 deletions(-) create mode 100644 hooks/use-mail-reading.ts create mode 100644 lib/__tests__/mail-reading.test.ts create mode 100644 lib/mail-reading.ts diff --git a/components/email/__tests__/conversation-reading.test.tsx b/components/email/__tests__/conversation-reading.test.tsx index df175663b..04b6ce18e 100644 --- a/components/email/__tests__/conversation-reading.test.tsx +++ b/components/email/__tests__/conversation-reading.test.tsx @@ -70,3 +70,12 @@ describe('conversation controls name their target', () => { expect(screen.getByRole('button', { name: /^email_viewer\.(download|preview)_named \{"name":"report\.pdf"\}$/ })).toBeInTheDocument(); }); }); + +describe('conversation message text size', () => { + it('plain text follows the mail text size instead of a fixed 13px', () => { + useSettingsStore.setState({ mailTextScale: 2 } as never); + show([mail('a', { htmlBody: [], textBody: [{ partId: '1', type: 'text/plain' }], bodyValues: { '1': { value: 'just text' } } })]); + const body = screen.getByText('just text').closest('[data-plugin-source="email-body"]') as HTMLElement; + expect(body.style.fontSize).toMatch(/0\.8125rem \* 2|1\.625rem/); + }); +}); diff --git a/components/email/__tests__/viewer-reading.test.tsx b/components/email/__tests__/viewer-reading.test.tsx index 434c20a93..b65ff5e21 100644 --- a/components/email/__tests__/viewer-reading.test.tsx +++ b/components/email/__tests__/viewer-reading.test.tsx @@ -105,3 +105,26 @@ describe('attachment actions', () => { expect(screen.getAllByRole('button', { name: 'download_named {"name":"report.pdf"}' }).length).toBeGreaterThan(0); }); }); + +describe('message text size', () => { + it('plain text follows the mail text size and its minimum instead of a fixed 14px', async () => { + useSettingsStore.setState({ mailTextScale: 1.5, mailMinFontSize: 16 } as never); + await show(message({ text: 'Plain words here' })); + const body = screen.getByText('Plain words here').closest('.email-content-text') as HTMLElement; + // jsdom folds the calc(). + expect(body.style.fontSize).toMatch(/^max\(.*(0\.875rem \* 1\.5|1\.3125rem).*, 16px\)$/); + }); + + it('plain text follows the app text size by default', async () => { + useSettingsStore.setState({ fontSize: 'xx-large' } as never); + await show(message({ text: 'Plain words here' })); + const body = screen.getByText('Plain words here').closest('.email-content-text') as HTMLElement; + expect(body.style.fontSize).toBe('0.875rem'); + }); + + it('rebuilds the HTML frame for a new size', async () => { + useSettingsStore.setState({ fontSize: 'xx-large' } as never); + const { container } = await show(message({ html: '
Some formatted text
' })); + expect(container.querySelector('iframe')!.getAttribute('srcdoc')).toContain('data-reading="1.5/0"'); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 711440a5e..cdf182581 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -7,6 +7,8 @@ import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; import { TRANSPARENT_BLOCKED_PIXEL, applyNewTabToAnchor, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { getRenderableHtmlBody } from "@/lib/email-body-selection"; import { mailLanguage } from "@/lib/mail-language"; +import { applyMailTextSize } from "@/lib/mail-reading"; +import { useMailReading } from "@/hooks/use-mail-reading"; import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; import { DARK_PENDING_CLASS, applyEmailDarkMode, emailColorScheme, emailDarkModeCss, emailHasNativeDarkMode as htmlHasNativeDarkMode, resolveColorSchemeMedia } from "@/lib/email-dark-mode"; @@ -3149,6 +3151,12 @@ export function EmailViewer({ }, [effectiveEmailContent.html, effectiveEmailContent.isHtml]); const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode); + // Text size, minimum size and the other reading settings for the body. A + // ref for the iframe setup, which runs from observers wired once per + // document; the srcDoc changes with frameKey, so a new setting rebuilds it. + const reading = useMailReading(); + const readingRef = useRef(reading); + readingRef.current = reading; const [emailViewDarkOverride, setEmailViewDarkOverride] = useStatetiny
'); + expect(applyMailTextSize(doc, { factor: 1, minPx: 0 })).toBe(false); + expect(doc.querySelector('p')!.style.fontSize).toBe('10px'); + }); + + it('enlarges small text to the minimum and pins the rest', () => { + const doc = mailDoc('a
'); + doc.body.style.fontSize = '14px'; + applyMailTextSize(doc, { factor: 1.5, minPx: 0 }); + const p = doc.querySelector('p')!; + expect(p.style.getPropertyValue('font-size')).toBe('24px'); + expect(p.style.getPropertyValue('line-height')).toBe('30px'); + expect(doc.body.style.getPropertyValue('font-size')).toBe('21px'); + expect(applyMailTextSize(doc, { factor: 1.5, minPx: 0 })).toBe(false); + expect(p.style.getPropertyValue('font-size')).toBe('24px'); + }); +}); diff --git a/lib/mail-reading.ts b/lib/mail-reading.ts new file mode 100644 index 000000000..6f1147fc0 --- /dev/null +++ b/lib/mail-reading.ts @@ -0,0 +1,126 @@ +/** + * How the reading settings on Settings > Accessibility reach a message body. + * + * HTML mail renders in a sandboxed iframe that the app's stylesheet doesn't + * reach, so the text size, spacing, colours and motion settings have to be + * carried into that document: as CSS appended after the mail's own (so it + * wins the cascade) and, for text size, as a pass over the computed styles + * after the document parsed. Plain-text bodies sit in the app document and + * only need their own font size. + */ + +import { FONT_SIZE_SCALE, type FontSize } from '@/lib/accessibility-settings'; + +/** Size of body text in a message, in CSS px at 100 %: what mail used to be fixed at. */ +export const MAIL_BASE_FONT_PX = 14; + +/** + * The factor message text is drawn at: the mail text size, or the app's text + * size when that is set to follow it (0). + */ +export function mailTextFactor(mailTextScale: number, fontSize: FontSize): number { + if (mailTextScale > 0) return mailTextScale; + return FONT_SIZE_SCALE[fontSize] ?? 1; +} + +/** + * CSS font size for a plain-text body in the app document. Its rem already + * carries the app's text size, so only the part the mail size adds on top of + * that is multiplied in. The minimum, when set, is a floor in px. + */ +export function plainBodyFontSize(basePx: number, mailTextScale: number, fontSize: FontSize, minPx: number): string { + const appFactor = FONT_SIZE_SCALE[fontSize] ?? 1; + const extra = Math.round((mailTextFactor(mailTextScale, fontSize) / appFactor) * 10000) / 10000; + const rem = Math.round((basePx / 16) * 10000) / 10000; + const size = extra === 1 ? `${rem}rem` : `calc(${rem}rem * ${extra})`; + return minPx > 0 ? `max(${size}, ${minPx}px)` : size; +} + +/** + * One element's text size after scaling by `factor` and raising it to the + * minimum. A line height in px grows by the same ratio, so the enlarged text + * doesn't overlap the next line; "normal" (null) needs nothing. + */ +export function scaledTextSize( + fontPx: number, + lineHeightPx: number | null, + { factor, minPx }: { factor: number; minPx: number }, +): { font: number; lineHeight: number | null } { + if (!(fontPx > 0)) return { font: fontPx, lineHeight: lineHeightPx }; + const font = Math.max(fontPx * factor, minPx || 0); + const ratio = font / fontPx; + return { font, lineHeight: lineHeightPx === null ? null : lineHeightPx * ratio }; +} + +/** Elements that draw no text of their own. */ +const NO_TEXT = new Set(['IMG', 'BR', 'HR', 'WBR', 'PICTURE', 'SOURCE', 'VIDEO', 'AUDIO', 'CANVAS', 'IFRAME', 'OBJECT', 'EMBED', 'STYLE', 'SCRIPT', 'TEMPLATE']); + +export interface MailTextSizeOptions { + /** Multiplier on every text size in the mail. */ + factor: number; + /** Smallest text size, in CSS px; 0 = no minimum. */ + minPx: number; + /** Turn justified text into start-aligned text. */ + unjustify?: boolean; + /** Leave sizes alone (the override stylesheet sets them); only unjustify. */ + sizesFromCss?: boolean; +} + +const DONE_ATTR = 'data-bulwark-text-size'; + +/** + * Scales the text of a rendered mail document and raises text below the + * minimum, by reading every element's computed font size and writing the new + * one back inline. Reads all elements first and writes after, so the pass + * costs one style recalculation. Once one element changes, every element is + * pinned to its own px size: a child sized in em would otherwise grow a second + * time with its parent. Runs once per document; returns whether it changed + * anything. + * + * Text size works on the text rather than zooming the whole mail, so a + * fixed-width newsletter keeps its width and its text wraps inside it instead + * of pushing the layout off the side of the pane. + */ +export function applyMailTextSize(doc: Document, options: MailTextSizeOptions): boolean { + const body = doc.body; + const view = doc.defaultView; + if (!body || !view || body.hasAttribute(DONE_ATTR)) return false; + const factor = options.sizesFromCss ? 1 : options.factor; + const minPx = options.sizesFromCss ? 0 : options.minPx; + if (factor === 1 && !(minPx > 0) && !options.unjustify) return false; + body.setAttribute(DONE_ATTR, ''); + + const elements: HTMLElement[] = [body]; + body.querySelectorAll