mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +00:00
feat: scale the mail body with the app text size and the system font size
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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 { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
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 { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders';
|
||||||
import { senderVerificationOf } from '../lib/email-headers';
|
import { senderVerificationOf } from '../lib/email-headers';
|
||||||
import { plainTextReading } from '../lib/html-to-readable-text';
|
import { plainTextReading } from '../lib/html-to-readable-text';
|
||||||
|
import { buildMailTextSizeScript, mailTextFactor } from '../lib/mail-reading';
|
||||||
import { useHasContacts } from '../lib/capabilities';
|
import { useHasContacts } from '../lib/capabilities';
|
||||||
import { useSettingsStore } from '../stores/settings-store';
|
import { useSettingsStore } from '../stores/settings-store';
|
||||||
import { useContactsStore } from '../stores/contacts-store';
|
import { useContactsStore } from '../stores/contacts-store';
|
||||||
import { useLocaleStore } from '../stores/locale-store';
|
import { useLocaleStore } from '../stores/locale-store';
|
||||||
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
||||||
|
import { useTypography } from '../theme/dynamic';
|
||||||
import { useColors, useResolvedTheme } from '../theme/colors';
|
import { useColors, useResolvedTheme } from '../theme/colors';
|
||||||
import { perfEndAfterPaint, perfLap } from '../lib/perf-marks';
|
import { perfEndAfterPaint, perfLap } from '../lib/perf-marks';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
@@ -545,6 +547,11 @@ export default function EmailBodyView({
|
|||||||
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
|
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
|
||||||
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
|
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
|
||||||
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
|
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 trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails);
|
||||||
const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded);
|
const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded);
|
||||||
const loadTrustedSendersBook = useContactsStore((s) => s.loadTrustedSendersBook);
|
const loadTrustedSendersBook = useContactsStore((s) => s.loadTrustedSendersBook);
|
||||||
@@ -692,9 +699,10 @@ export default function EmailBodyView({
|
|||||||
messageSpacing,
|
messageSpacing,
|
||||||
plainTextFont,
|
plainTextFont,
|
||||||
quoteLabels,
|
quoteLabels,
|
||||||
|
textFactor,
|
||||||
}), [
|
}), [
|
||||||
jmapAccountId, email.id, rawHtml, text, email.preview, emptyLabel, shouldBlock, cidMap, renderAsDark,
|
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]);
|
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.
|
// input the CSS was built from, so the DOM pass and the stylesheet agree.
|
||||||
const injectedJs = React.useMemo(
|
const injectedJs = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
(prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '')
|
(prepared.isHtml ? buildMailTextSizeScript(textFactor) : '')
|
||||||
|
+ (prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '')
|
||||||
+ (prepared.applyInversion ? DARK_REINVERT_SCRIPT : '')
|
+ (prepared.applyInversion ? DARK_REINVERT_SCRIPT : '')
|
||||||
+ (prepared.isHtml ? IMG_FIXUP_SCRIPT : '')
|
+ (prepared.isHtml ? IMG_FIXUP_SCRIPT : '')
|
||||||
+ HEIGHT_REPORTER + SWIPE_DETECTOR + PINCH_ZOOM,
|
+ 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.
|
// 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
|
// Native Android zoom would scale the viewport inside the fixed-size
|
||||||
// container; pinch zoom is implemented in the page instead.
|
// container; pinch zoom is implemented in the page instead.
|
||||||
setBuiltInZoomControls={false}
|
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}
|
showsVerticalScrollIndicator={false}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
setSupportMultipleWindows={false}
|
setSupportMultipleWindows={false}
|
||||||
|
|||||||
@@ -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<string>();
|
||||||
|
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\}/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type MessageSpacing, type PlainTextFont,
|
type MessageSpacing, type PlainTextFont,
|
||||||
} from './email-html';
|
} from './email-html';
|
||||||
import { collapsePlainTextQuotes, type QuoteCollapseLabels } from './quote-collapse';
|
import { collapsePlainTextQuotes, type QuoteCollapseLabels } from './quote-collapse';
|
||||||
|
import { mailBaseFontPx } from './mail-reading';
|
||||||
|
|
||||||
export interface BodyDocumentInput {
|
export interface BodyDocumentInput {
|
||||||
/** The message (account and id); the sources below are compared as well. */
|
/** The message (account and id); the sources below are compared as well. */
|
||||||
@@ -25,6 +26,11 @@ export interface BodyDocumentInput {
|
|||||||
messageSpacing: MessageSpacing;
|
messageSpacing: MessageSpacing;
|
||||||
plainTextFont: PlainTextFont;
|
plainTextFont: PlainTextFont;
|
||||||
quoteLabels: QuoteCollapseLabels;
|
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 {
|
export interface BodyDocument {
|
||||||
@@ -55,7 +61,7 @@ export function buildBodyDocument(input: BodyDocumentInput): BodyDocument {
|
|||||||
}
|
}
|
||||||
const safe = collapsePlainTextQuotes(plainTextToSafeHtml(text), input.quoteLabels);
|
const safe = collapsePlainTextQuotes(plainTextToSafeHtml(text), input.quoteLabels);
|
||||||
return {
|
return {
|
||||||
html: wrapPlainTextEmail(safe, { isDark, font: input.plainTextFont }),
|
html: wrapPlainTextEmail(safe, { isDark, font: input.plainTextFont, fontPx: mailBaseFontPx(input.textFactor ?? 1) }),
|
||||||
applyInversion: false,
|
applyInversion: false,
|
||||||
hasNativeDark: false,
|
hasNativeDark: false,
|
||||||
blockedExternal: false,
|
blockedExternal: false,
|
||||||
@@ -83,7 +89,7 @@ function sameSources(a: BodyDocumentInput, b: BodyDocumentInput): boolean {
|
|||||||
export function bodyDocument(input: BodyDocumentInput): BodyDocument {
|
export function bodyDocument(input: BodyDocumentInput): BodyDocument {
|
||||||
const key = JSON.stringify([
|
const key = JSON.stringify([
|
||||||
input.key, input.blockRemoteImages, input.isDark, input.messageSpacing, input.plainTextFont,
|
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);
|
const hit = documents.get(key);
|
||||||
if (hit && sameSources(hit.input, input)) {
|
if (hit && sameSources(hit.input, input)) {
|
||||||
|
|||||||
@@ -955,9 +955,9 @@ export type PlainTextFont = 'sans' | 'mono';
|
|||||||
// (no filter inversion), and a dark-mode-appropriate link colour.
|
// (no filter inversion), and a dark-mode-appropriate link colour.
|
||||||
export function wrapPlainTextEmail(
|
export function wrapPlainTextEmail(
|
||||||
innerHtml: string,
|
innerHtml: string,
|
||||||
options: { isDark?: boolean; font?: PlainTextFont } = {},
|
options: { isDark?: boolean; font?: PlainTextFont; fontPx?: number } = {},
|
||||||
): string {
|
): string {
|
||||||
const { isDark = true, font = 'sans' } = options;
|
const { isDark = true, font = 'sans', fontPx = 14 } = options;
|
||||||
const cleaned = stripDangerousTags(innerHtml);
|
const cleaned = stripDangerousTags(innerHtml);
|
||||||
|
|
||||||
const bg = isDark ? '#0a0a0a' : '#ffffff';
|
const bg = isDark ? '#0a0a0a' : '#ffffff';
|
||||||
@@ -976,7 +976,7 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
font-family: ${fontFamily};
|
font-family: ${fontFamily};
|
||||||
font-size: 14px;
|
font-size: ${fontPx}px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: ${fg};
|
color: ${fg};
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user