From 7b551fc3e0cc06e17bbc8e187adf1472f6877ad6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:51:04 +0100 Subject: [PATCH] feat: scale the mail body with the app text size and the system font size --- src/components/EmailBodyView.tsx | 20 +++++-- src/lib/__tests__/mail-reading.test.ts | 65 ++++++++++++++++++++++ src/lib/email-body-document.ts | 10 +++- src/lib/email-html.ts | 6 +- src/lib/mail-reading.ts | 76 ++++++++++++++++++++++++++ 5 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 src/lib/__tests__/mail-reading.test.ts create mode 100644 src/lib/mail-reading.ts diff --git a/src/components/EmailBodyView.tsx b/src/components/EmailBodyView.tsx index 688a61b8..1082e9d9 100644 --- a/src/components/EmailBodyView.tsx +++ b/src/components/EmailBodyView.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, StyleSheet, Linking, Platform, Text, Pressable, useWindowDimensions } from 'react-native'; +import { View, StyleSheet, Linking, Platform, Text, Pressable, PixelRatio, useWindowDimensions } from 'react-native'; import { WebView, type WebViewMessageEvent } from 'react-native-webview'; import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -15,11 +15,13 @@ import { fetchInlineImageDataUri } from '../lib/email-export'; import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders'; import { senderVerificationOf } from '../lib/email-headers'; import { plainTextReading } from '../lib/html-to-readable-text'; +import { buildMailTextSizeScript, mailTextFactor } from '../lib/mail-reading'; import { useHasContacts } from '../lib/capabilities'; import { useSettingsStore } from '../stores/settings-store'; import { useContactsStore } from '../stores/contacts-store'; import { useLocaleStore } from '../stores/locale-store'; import { spacing, typography, type ThemePalette } from '../theme/tokens'; +import { useTypography } from '../theme/dynamic'; import { useColors, useResolvedTheme } from '../theme/colors'; import { perfEndAfterPaint, perfLap } from '../lib/perf-marks'; import type { RootStackParamList } from '../navigation/types'; @@ -545,6 +547,11 @@ export default function EmailBodyView({ const plainTextFont = useSettingsStore((s) => s.plainTextFont); const messageSpacing = useSettingsStore((s) => s.messageSpacing); const plainTextMode = useSettingsStore((s) => s.plainTextMode); + // Body text follows the app's text size and the system font scale (webmail + // c07ba1a37); it was fixed at 14 px. The app's factor is read off its + // scaled body style, so it follows whatever steps the text size setting has. + const dyn = useTypography(); + const textFactor = mailTextFactor(dyn.body.fontSize / typography.body.fontSize, PixelRatio.getFontScale()); const trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails); const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded); const loadTrustedSendersBook = useContactsStore((s) => s.loadTrustedSendersBook); @@ -692,9 +699,10 @@ export default function EmailBodyView({ messageSpacing, plainTextFont, quoteLabels, + textFactor, }), [ jmapAccountId, email.id, rawHtml, text, email.preview, emptyLabel, shouldBlock, cidMap, renderAsDark, - messageSpacing, plainTextFont, quoteLabels, + messageSpacing, plainTextFont, quoteLabels, textFactor, ]); const source = React.useMemo(() => ({ html: prepared.html }), [prepared.html]); @@ -707,11 +715,12 @@ export default function EmailBodyView({ // input the CSS was built from, so the DOM pass and the stylesheet agree. const injectedJs = React.useMemo( () => - (prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '') + (prepared.isHtml ? buildMailTextSizeScript(textFactor) : '') + + (prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '') + (prepared.applyInversion ? DARK_REINVERT_SCRIPT : '') + (prepared.isHtml ? IMG_FIXUP_SCRIPT : '') + HEIGHT_REPORTER + SWIPE_DETECTOR + PINCH_ZOOM, - [prepared.isHtml, prepared.applyInversion, quoteLabels], + [prepared.isHtml, prepared.applyInversion, quoteLabels, textFactor], ); // Performance harness: how much of opening a message is the WebView. @@ -854,6 +863,9 @@ export default function EmailBodyView({ // Native Android zoom would scale the viewport inside the fixed-size // container; pinch zoom is implemented in the page instead. setBuiltInZoomControls={false} + // The system font size is part of textFactor; Android's WebView + // would scale the text by it a second time. + textZoom={100} showsVerticalScrollIndicator={false} showsHorizontalScrollIndicator={false} setSupportMultipleWindows={false} diff --git a/src/lib/__tests__/mail-reading.test.ts b/src/lib/__tests__/mail-reading.test.ts new file mode 100644 index 00000000..c7b187ab --- /dev/null +++ b/src/lib/__tests__/mail-reading.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { buildMailTextSizeScript, mailBaseFontPx, mailTextFactor } from '../mail-reading'; +import { buildBodyDocument } from '../email-body-document'; + +// The mail body was fixed at 14 px whatever the app's text size or the +// system font size said (webmail c07ba1a37 scales it). + +const base = { + key: 'k', emptyLabel: '-', blockRemoteImages: false, cidMap: {}, isDark: false, + messageSpacing: 'normal' as const, plainTextFont: 'sans' as const, quoteLabels: { show: 's', hide: 'h' }, +}; + +describe('mail text size', () => { + it('multiplies the app text size by the system font scale, within bounds', () => { + expect(mailTextFactor(1, 1)).toBe(1); + expect(mailTextFactor(1.14, 1.3)).toBe(1.48); + expect(mailTextFactor(1, 0)).toBe(1); + expect(mailTextFactor(2, 3)).toBe(3); + expect(mailBaseFontPx(1.5)).toBe(21); + }); + + it('sizes a plain-text body by the factor', () => { + expect(buildBodyDocument({ ...base, rawHtml: null, text: 'hello' }).html).toMatch(/font-size: 14px;/); + expect(buildBodyDocument({ ...base, rawHtml: null, text: 'hello', textFactor: 1.5 }).html).toMatch(/font-size: 21px;/); + }); + + it('scales every element of an HTML body once, line heights in px included', () => { + expect(buildMailTextSizeScript(1)).toBe(''); + const writes: Array<[string, string, string]> = []; + const attrs = new Set(); + const el = (tag: string, fontSize: string, lineHeight: string, inSvg = false) => ({ + tagName: tag, + computed: { fontSize, lineHeight }, + closest: (sel: string) => (sel === 'svg' && inSvg ? {} : null), + style: { setProperty: (prop: string, value: string) => writes.push([tag, prop, value]) }, + }); + const kids = [el('P', '16px', '24px'), el('SPAN', '12px', 'normal'), el('IMG', '14px', 'normal'), el('TEXT', '10px', 'normal', true)]; + const body = { + ...el('BODY', '14px', '22.4px'), + hasAttribute: (a: string) => attrs.has(a), + setAttribute: (a: string) => attrs.add(a), + querySelectorAll: () => kids, + }; + const window = { getComputedStyle: (e: { computed: unknown }) => e.computed }; + const run = () => new Function('document', 'window', buildMailTextSizeScript(1.5))({ body }, window); + run(); + expect(writes).toEqual([ + ['BODY', 'font-size', '21px'], ['BODY', 'line-height', '33.6px'], + ['P', 'font-size', '24px'], ['P', 'line-height', '36px'], + ['SPAN', 'font-size', '18px'], + ]); + // The body view injects its scripts again after load: no second pass. + run(); + expect(writes).toHaveLength(5); + }); + + it('is wired into the body view, with Android text zoom held at 100', () => { + const view = fs.readFileSync(path.resolve(__dirname, '../../components/EmailBodyView.tsx'), 'utf8'); + expect(view).toMatch(/mailTextFactor\(dyn\.body\.fontSize \/ typography\.body\.fontSize, PixelRatio\.getFontScale\(\)\)/); + expect(view).toMatch(/buildMailTextSizeScript\(textFactor\)/); + expect(view).toMatch(/textZoom=\{100\}/); + }); +}); diff --git a/src/lib/email-body-document.ts b/src/lib/email-body-document.ts index 995ab98f..8413c671 100644 --- a/src/lib/email-body-document.ts +++ b/src/lib/email-body-document.ts @@ -9,6 +9,7 @@ import { type MessageSpacing, type PlainTextFont, } from './email-html'; import { collapsePlainTextQuotes, type QuoteCollapseLabels } from './quote-collapse'; +import { mailBaseFontPx } from './mail-reading'; export interface BodyDocumentInput { /** The message (account and id); the sources below are compared as well. */ @@ -25,6 +26,11 @@ export interface BodyDocumentInput { messageSpacing: MessageSpacing; plainTextFont: PlainTextFont; quoteLabels: QuoteCollapseLabels; + /** + * Factor for the text size (mailTextFactor): the base size of a plain-text + * body. An HTML body is scaled by an injected pass instead. Default 1. + */ + textFactor?: number; } export interface BodyDocument { @@ -55,7 +61,7 @@ export function buildBodyDocument(input: BodyDocumentInput): BodyDocument { } const safe = collapsePlainTextQuotes(plainTextToSafeHtml(text), input.quoteLabels); return { - html: wrapPlainTextEmail(safe, { isDark, font: input.plainTextFont }), + html: wrapPlainTextEmail(safe, { isDark, font: input.plainTextFont, fontPx: mailBaseFontPx(input.textFactor ?? 1) }), applyInversion: false, hasNativeDark: false, blockedExternal: false, @@ -83,7 +89,7 @@ function sameSources(a: BodyDocumentInput, b: BodyDocumentInput): boolean { export function bodyDocument(input: BodyDocumentInput): BodyDocument { const key = JSON.stringify([ input.key, input.blockRemoteImages, input.isDark, input.messageSpacing, input.plainTextFont, - input.quoteLabels.show, input.quoteLabels.hide, input.emptyLabel, + input.quoteLabels.show, input.quoteLabels.hide, input.emptyLabel, input.textFactor ?? 1, ]); const hit = documents.get(key); if (hit && sameSources(hit.input, input)) { diff --git a/src/lib/email-html.ts b/src/lib/email-html.ts index 032f19c7..39a8577f 100644 --- a/src/lib/email-html.ts +++ b/src/lib/email-html.ts @@ -955,9 +955,9 @@ export type PlainTextFont = 'sans' | 'mono'; // (no filter inversion), and a dark-mode-appropriate link colour. export function wrapPlainTextEmail( innerHtml: string, - options: { isDark?: boolean; font?: PlainTextFont } = {}, + options: { isDark?: boolean; font?: PlainTextFont; fontPx?: number } = {}, ): string { - const { isDark = true, font = 'sans' } = options; + const { isDark = true, font = 'sans', fontPx = 14 } = options; const cleaned = stripDangerousTags(innerHtml); const bg = isDark ? '#0a0a0a' : '#ffffff'; @@ -976,7 +976,7 @@ body { margin: 0; padding: 16px; font-family: ${fontFamily}; - font-size: 14px; + font-size: ${fontPx}px; line-height: 1.6; color: ${fg}; white-space: pre-wrap; diff --git a/src/lib/mail-reading.ts b/src/lib/mail-reading.ts new file mode 100644 index 00000000..25db050c --- /dev/null +++ b/src/lib/mail-reading.ts @@ -0,0 +1,76 @@ +/** + * How the app's text size reaches a message body (webmail lib/mail-reading.ts, + * c07ba1a37). The body renders in a WebView the app's styles don't reach, so + * the size is carried in: as the base font size of a plain-text body, and as + * a pass over the computed styles of an HTML body once it has laid out. + */ + +/** 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; + +const MIN_FACTOR = 0.5; +const MAX_FACTOR = 3; + +/** + * The factor message text is drawn at: the app's text size times the + * system font scale (Android font size, iOS Dynamic Type). Android's WebView + * would apply the system scale on its own; the body view turns that off + * (`textZoom` 100) so both platforms scale the same way, once. + */ +export function mailTextFactor(appFactor: number, systemFontScale: number): number { + const app = appFactor > 0 ? appFactor : 1; + const system = systemFontScale > 0 ? systemFontScale : 1; + const factor = Math.round(app * system * 100) / 100; + return Math.min(MAX_FACTOR, Math.max(MIN_FACTOR, factor)); +} + +/** The base font size of a body at `factor`, in CSS px. */ +export function mailBaseFontPx(factor: number): number { + return Math.round(MAIL_BASE_FONT_PX * factor * 100) / 100; +} + +const DONE_ATTR = 'data-bulwark-text-size'; + +/** + * Script injected into an HTML body that scales its text by `factor`: reads + * every element's computed font size first and writes the scaled sizes after, + * pinning each element to its own px size so a child sized in em doesn't grow + * twice. A line height in px grows by the same ratio. Runs once per document + * (the body view injects its scripts again after load). 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. Empty at factor 1. + */ +export function buildMailTextSizeScript(factor: number): string { + if (!(factor > 0) || Math.abs(factor - 1) < 0.005) return ''; + return ` +(function () { + var F = ${JSON.stringify(factor)}; + var body = document.body; + if (!body || body.hasAttribute('${DONE_ATTR}')) return; + body.setAttribute('${DONE_ATTR}', ''); + var NO_TEXT = { IMG: 1, BR: 1, HR: 1, WBR: 1, PICTURE: 1, SOURCE: 1, VIDEO: 1, AUDIO: 1, CANVAS: 1, IFRAME: 1, OBJECT: 1, EMBED: 1, STYLE: 1, SCRIPT: 1, TEMPLATE: 1 }; + var els = [body]; + var all = body.querySelectorAll('*'); + for (var i = 0; i < all.length; i++) { + var el = all[i]; + if (NO_TEXT[el.tagName] || el.closest('svg')) continue; + els.push(el); + } + var sizes = []; + for (var j = 0; j < els.length; j++) { + var cs = window.getComputedStyle(els[j]); + var font = parseFloat(cs.fontSize); + if (!(font > 0)) continue; + var lh = /px$/.test(cs.lineHeight) ? parseFloat(cs.lineHeight) : null; + sizes.push([els[j], font * F, lh === null ? null : lh * F]); + } + for (var k = 0; k < sizes.length; k++) { + var s = sizes[k]; + s[0].style.setProperty('font-size', Math.round(s[1] * 100) / 100 + 'px', 'important'); + if (s[2] !== null) s[0].style.setProperty('line-height', Math.round(s[2] * 100) / 100 + 'px', 'important'); + } + if (window.__rnReport) setTimeout(window.__rnReport, 0); +})(); +true; +`; +}