Merge branch 'cu-readx' into catch-up-2026-11

This commit is contained in:
Linus Rath
2026-10-31 14:59:37 +01:00
41 changed files with 3880 additions and 219 deletions
+16 -111
View File
@@ -6,6 +6,7 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Image as ImageIcon, ShieldCheck } from './icons';
import type { Email } from '../api/types';
import { extractCidRefs, isUserLinkNavigation } from '../lib/email-html';
import { buildEmailDarkModeScript } from '../lib/email-dark-mode';
import { hasTruncatedDisplayedBody, pickEmailBody, selectRenderableHtml } from '../lib/email-body';
import { buildQuoteCollapseScript } from '../lib/quote-collapse';
import { bodyDocument } from '../lib/email-body-document';
@@ -67,109 +68,6 @@ interface EmailBodyViewProps {
onShowFormatted?: () => void;
}
// Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The
// body's `filter: invert(1)` flips colored emoji (yellow smiley -> blue, red
// heart -> cyan) and stylesheet-defined background images, which the static CSS
// attribute selectors can't reach. This walks the DOM once and wraps/re-inverts
// those so they keep their original colors. Only injected when inversion is on.
const DARK_REINVERT_SCRIPT = `
(function () {
if (document.__rnDarkInvertDone) return;
var doc = document;
var win = window;
if (!doc.body) return;
document.__rnDarkInvertDone = true;
// Re-invert elements with stylesheet-defined background images (CSS attribute
// selectors only catch inline styles, not rules that survive in <style>).
doc.body.querySelectorAll('*').forEach(function (el) {
// Skip elements already handled by CSS attribute selectors.
if (el.style.backgroundImage || el.style.background ||
el.hasAttribute('background') || el.hasAttribute('bgcolor')) return;
// Skip leaf media elements (already re-inverted by CSS).
var tag = el.tagName;
if (['IMG','VIDEO','SVG','CANVAS','OBJECT','EMBED'].indexOf(tag) !== -1) return;
var computed = win.getComputedStyle(el);
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
// Re-invert so the background image returns to its original
// appearance. Matches the CSS background-image rules: re-invert even
// when media is nested, then cancel the per-image re-invert on that
// media so it isn't left double-inverted.
el.style.filter = 'invert(1) hue-rotate(180deg)';
el.querySelectorAll('img, video, svg, canvas, object, embed, input[type="image"]')
.forEach(function (m) { m.style.filter = 'none'; });
}
});
// Re-invert emoji glyphs so they keep their original colors. Wrap each emoji
// run in a span that re-inverts. Only act when the ancestor invert depth is
// odd - emojis inside a double-inverted bgcolor container already render at
// their original colors.
var emojiRe;
try {
emojiRe = new RegExp('\\\\p{RGI_Emoji}', 'gv');
} catch (e) {
emojiRe = /\\p{Extended_Pictographic}(?:\\uFE0F)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F)?)*/gu;
}
var emojiTestRe = /\\p{Extended_Pictographic}/u;
var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEXTAREA: 1, IFRAME: 1 };
function isOddInvertDepth(start) {
var count = 0;
var n = start;
while (n) {
if (n === doc.body) { count++; break; }
var cs = win.getComputedStyle(n);
if (cs.filter && cs.filter.indexOf('invert') !== -1) count++;
n = n.parentElement;
}
return count % 2 === 1;
}
var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
acceptNode: function (node) {
var p = node.parentElement;
while (p) {
if (SKIP_TAGS[p.tagName]) return NodeFilter.FILTER_REJECT;
p = p.parentElement;
}
return emojiTestRe.test(node.nodeValue || '')
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
}
});
var emojiTextNodes = [];
var cur;
while ((cur = walker.nextNode())) emojiTextNodes.push(cur);
emojiTextNodes.forEach(function (textNode) {
var parent = textNode.parentElement;
if (!parent || !isOddInvertDepth(parent)) return;
var text = textNode.nodeValue || '';
emojiRe.lastIndex = 0;
var frag = doc.createDocumentFragment();
var lastIndex = 0;
var m;
while ((m = emojiRe.exec(text)) !== null) {
if (m.index > lastIndex) {
frag.appendChild(doc.createTextNode(text.slice(lastIndex, m.index)));
}
var span = doc.createElement('span');
span.style.cssText = 'filter:invert(1) hue-rotate(180deg)';
span.textContent = m[0];
frag.appendChild(span);
lastIndex = m.index + m[0].length;
}
if (lastIndex === 0) return;
if (lastIndex < text.length) {
frag.appendChild(doc.createTextNode(text.slice(lastIndex)));
}
parent.replaceChild(frag, textNode);
});
})();
`;
// Image fix-ups, mirroring the webmail's post-render passes:
// - collapse empty <td>/<div> wrappers left behind by blocked images so a
// blocked newsletter doesn't show rows of empty cells;
@@ -546,6 +444,9 @@ export default function EmailBodyView({
const syncTrustedSenders = isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts);
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
// The reading font reaches a plain-text body ("Same as above"), as in the
// webmail; HTML mail keeps the sender's fonts.
const appFont = useSettingsStore((s) => s.appFont);
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
@@ -569,8 +470,8 @@ export default function EmailBodyView({
React.useEffect(() => {
if (syncTrustedSenders && !trustedSendersLoaded) void loadTrustedSendersBook(false);
}, [syncTrustedSenders, trustedSendersLoaded, loadTrustedSendersBook]);
// Most marketing email is authored against a white background, so dark-mode
// inversion can wreck logos/banners. With this flag the user opts to render
// Most marketing email is authored against a white background, and some
// people prefer it as sent. With this flag the user opts to render
// emails on a light surface even while the rest of the app is dark; the
// per-message toggle in the More sheet overrides both.
const renderAsDark = themeOverride
@@ -713,9 +614,10 @@ export default function EmailBodyView({
quoteLabels,
textFactor,
plainDarkColors,
appFont,
}), [
jmapAccountId, email.id, rawHtml, text, email.preview, emptyLabel, shouldBlock, cidMap, renderAsDark,
messageSpacing, plainTextFont, quoteLabels, textFactor, plainDarkColors,
messageSpacing, plainTextFont, quoteLabels, textFactor, plainDarkColors, appFont,
]);
const source = React.useMemo(
@@ -730,18 +632,21 @@ export default function EmailBodyView({
// Still cut off after the API's larger refetch (or the refetch failed).
const showTruncated = !bodyOverride && hasTruncatedDisplayedBody(email);
// Inversion is applied for HTML bodies in dark mode unless the email ships
// its own dark-mode CSS - decided once by prepareEmailHtml on the same
// input the CSS was built from, so the DOM pass and the stylesheet agree.
// An HTML body shown dark has its colours rewritten after layout (webmail
// applyEmailDarkMode): decided once by prepareEmailHtml, whose head hides
// the body until this pass ran. It comes after the quote toggle and the
// Play buttons exist, so those are converted too, as in the webmail. Only
// with remote content allowed may a remote image be fetched again to look
// at its pixels.
const injectedJs = React.useMemo(
() =>
(prepared.isHtml ? buildMailTextSizeScript(textFactor) : '')
+ (prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '')
+ (prepared.applyInversion ? DARK_REINVERT_SCRIPT : '')
+ (prepared.isHtml ? IMG_FIXUP_SCRIPT : '')
+ (prepared.isHtml && reduceMotion ? buildPauseAnimatedImagesScript(pauseLabels) : '')
+ (prepared.recolorDark ? buildEmailDarkModeScript({ remoteContentAllowed: !shouldBlock }) : '')
+ HEIGHT_REPORTER + SWIPE_DETECTOR + PINCH_ZOOM,
[prepared.isHtml, prepared.applyInversion, quoteLabels, textFactor, reduceMotion, pauseLabels],
[prepared.isHtml, prepared.recolorDark, shouldBlock, quoteLabels, textFactor, reduceMotion, pauseLabels],
);
// Performance harness: how much of opening a message is the WebView.
@@ -1,10 +1,11 @@
import React from 'react';
import { View } from 'react-native';
import { Platform, View } from 'react-native';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { spacing } from '../../theme/tokens';
import { useLocaleStore } from '../../stores/locale-store';
import { FONT_SIZES, useSettingsStore, type FontSize } from '../../stores/settings-store';
import { APP_FONTS, FONT_SIZES, useSettingsStore, type AppFont, type FontSize } from '../../stores/settings-store';
import { uiFontsBundled } from '../../theme/reading-fonts';
import { toast } from '../../stores/toast-store';
import {
ACCESSIBILITY_PRESET_IDS,
@@ -15,6 +16,14 @@ import {
} from '../../lib/accessibility-presets';
import { TOAST_DURATION_CHOICES } from '../../lib/toast-duration';
// Font names are names: the same in every language (webmail
// accessibility-settings.tsx).
const FONT_NAMES: Record<Exclude<AppFont, 'default'>, string> = {
atkinson: 'Atkinson Hyperlegible',
lexend: 'Lexend',
opendyslexic: 'OpenDyslexic',
};
// Percentages read the same in every language the app has.
const TEXT_SIZE_LABELS: Record<FontSize, string> = {
small: '87.5%',
@@ -41,6 +50,11 @@ function presetCopy(id: AccessibilityPresetId, t: Translate): { label: string; d
label: t('settings.accessibility.presets.low-vision.label', 'Low vision'),
description: t('settings.accessibility.presets.low-vision.description_mobile', 'Text at 150% and labels under the toolbar icons.'),
};
case 'dyslexia':
return {
label: t('settings.accessibility.presets.dyslexia.label', 'Dyslexia'),
description: t('settings.accessibility.presets.dyslexia.description_mobile', 'Atkinson Hyperlegible as the font, also for plain-text messages.'),
};
case 'motion':
return {
label: t('settings.accessibility.presets.motion.label', 'Motion and seizure safe'),
@@ -61,6 +75,9 @@ export function AccessibilitySettings() {
const hydrate = useSettingsStore((s) => s.hydrate);
const update = useSettingsStore((s) => s.updateSetting);
const fontSize = useSettingsStore((s) => s.fontSize);
const appFont = useSettingsStore((s) => s.appFont);
const colorBlindPalette = useSettingsStore((s) => s.colorBlindPalette);
const plainLanguage = useSettingsStore((s) => s.plainLanguage);
const animationsEnabled = useSettingsStore((s) => s.animationsEnabled);
const toastDuration = useSettingsStore((s) => s.toastDuration);
const sendConfirmation = useSettingsStore((s) => s.sendConfirmation);
@@ -130,7 +147,6 @@ export function AccessibilitySettings() {
<SettingItem
label={t('settings.accessibility.text_size.label', 'Text size')}
description={t('settings.accessibility.text_size.description_mobile', "Scales the app's text on top of your phone's text size. 100% is your phone's size.")}
noBorder
>
<Select
value={fontSize}
@@ -138,6 +154,46 @@ export function AccessibilitySettings() {
options={FONT_SIZES.map((size) => ({ value: size, label: TEXT_SIZE_LABELS[size] }))}
/>
</SettingItem>
<SettingItem
label={t('settings.accessibility.font.label', 'Font')}
description={uiFontsBundled(Platform.OS)
? t('settings.accessibility.font.description', 'Atkinson Hyperlegible and Lexend are designed to be easy to tell apart; OpenDyslexic helps some people with dyslexia. They cover Latin script.')
: t('settings.accessibility.font.description_ios', 'Atkinson Hyperlegible and Lexend are designed to be easy to tell apart; OpenDyslexic helps some people with dyslexia. On this device the font is used for plain-text messages.')}
>
<Select
value={appFont}
onChange={(v) => update('appFont', v as AppFont)}
options={APP_FONTS.map((font) => ({
value: font,
label: font === 'default' ? t('settings.accessibility.font.default', 'Theme font') : FONT_NAMES[font],
}))}
/>
</SettingItem>
<SettingItem
label={t('settings.accessibility.plain_language.label', 'Plain language')}
description={t('settings.accessibility.plain_language.description_mobile', 'Simpler wording for settings and error messages. Available in English and German.')}
noBorder
>
<ToggleSwitch
checked={plainLanguage}
onChange={(v) => update('plainLanguage', v)}
accessibilityLabel={t('settings.accessibility.plain_language.label', 'Plain language')}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t('settings.accessibility.sections.contrast', 'Colour and contrast')}>
<SettingItem
label={t('settings.accessibility.cvd_palette.label', 'Colour-blind-safe colours')}
description={t('settings.accessibility.cvd_palette.description_mobile', 'Uses tag and calendar colours that stay distinct with red-green or blue-yellow colour blindness.')}
noBorder
>
<ToggleSwitch
checked={colorBlindPalette}
onChange={(v) => update('colorBlindPalette', v)}
accessibilityLabel={t('settings.accessibility.cvd_palette.label', 'Colour-blind-safe colours')}
/>
</SettingItem>
</SettingsSection>
<SettingsSection title={t('settings.accessibility.sections.motion', 'Motion')}>
+6 -4
View File
@@ -113,7 +113,7 @@ export function KeywordSettings() {
/>
);
}
const palette = c.tags[kw.color];
const palette = (c.tagsAsPicked ?? c.tags)[kw.color];
return (
<View key={kw.id} style={styles.kwRow}>
<View style={[styles.kwDot, { backgroundColor: palette.dot }]} />
@@ -178,7 +178,7 @@ export function KeywordSettings() {
</Text>
{scan.found.map((kw) => (
<View key={kw.id} style={styles.kwRow}>
<View style={[styles.kwDot, { backgroundColor: c.tags[kw.color]?.dot ?? c.textMuted }]} />
<View style={[styles.kwDot, { backgroundColor: (c.tagsAsPicked ?? c.tags)[kw.color]?.dot ?? c.textMuted }]} />
<View style={{ flex: 1 }}>
<Text style={styles.kwLabel}>{kw.label}</Text>
<Text style={styles.kwId}>{kw.keyword} · {kw.count}</Text>
@@ -251,7 +251,7 @@ function SharedTagsSection() {
) : (
tags.map((kw) => (
<View key={kw.id} style={styles.kwRow}>
<View style={[styles.kwDot, { backgroundColor: c.tags[kw.color]?.dot ?? c.textMuted }]} />
<View style={[styles.kwDot, { backgroundColor: (c.tagsAsPicked ?? c.tags)[kw.color]?.dot ?? c.textMuted }]} />
<Text style={styles.kwLabel}>{kw.label}</Text>
<Text style={styles.kwId}>$label:{kw.id}</Text>
</View>
@@ -313,7 +313,9 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
<Text style={styles.formLabel}>{t('settings.keywords.color_field', "Color")}</Text>
<View style={styles.palette}>
{PALETTE_KEYS.map((key) => {
const p = c.tags[key];
// The colours as picked, also with colour-blind-safe colours on
// (webmail keyword-settings.tsx draws KEYWORD_PALETTE).
const p = (c.tagsAsPicked ?? c.tags)[key];
return (
<Pressable
key={key}
+129
View File
@@ -0,0 +1,129 @@
// Plain language (Settings > Accessibility, webmail i18n/plain-language.ts and
// lib/__tests__/plain-language-strings.test.ts): simpler English and German
// strings laid over the catalog while the setting is on.
import { describe, it, expect, vi } from 'vitest';
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
vi.mock('expo-localization', () => ({ getLocales: () => [{ languageTag: 'en-US', languageCode: 'en' }] }));
// The setup file's stub plus I18nManager, which the locale store uses.
vi.mock('react-native', () => ({
Platform: { OS: 'android', Version: 33, select: <T,>(spec: { default?: T; android?: T }) => spec.android ?? spec.default },
NativeModules: {},
NativeEventEmitter: class { addListener() { return { remove: () => undefined }; } removeAllListeners() {} },
Linking: { openURL: async () => undefined },
Appearance: { getColorScheme: () => 'dark', addChangeListener: () => ({ remove: () => undefined }) },
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => undefined }) },
I18nManager: { isRTL: false, allowRTL: () => {}, forceRTL: () => {} },
}));
import { hasPlainLanguage, translate, SUPPORTED_LOCALES } from '../index';
import { followPlainLanguage, useLocaleStore } from '../../stores/locale-store';
import { mergeWithDefaults } from '../../stores/settings-store';
const LOCALES = join(__dirname, '..', '..', '..', 'locales');
type Tree = Record<string, unknown>;
function leaves(node: unknown, prefix = ''): Array<[string, unknown]> {
if (node && typeof node === 'object' && !Array.isArray(node)) {
return Object.entries(node as Tree).flatMap(([key, value]) => leaves(value, prefix ? `${prefix}.${key}` : key));
}
return [[prefix, node]];
}
const load = (locale: string, file: string): Tree => JSON.parse(readFileSync(join(LOCALES, locale, file), 'utf8'));
// Argument names a message uses: `{name}` and `{count, plural, …}`; the word
// in a plural branch (`one {item}`) is text.
function argumentsOf(message: string): string[] {
const names = new Set<string>();
for (const m of message.matchAll(/(?<!\b(?:zero|one|two|few|many|other)\s*|=\d+\s*)\{\s*(\w+)\s*[,}]/g)) {
names.add(m[1]);
}
return [...names].sort();
}
const plainLocales = readdirSync(LOCALES).filter((l) => existsSync(join(LOCALES, l, 'plain.json')));
describe('plain-language strings', () => {
it('exist for English and German only, each with a loader', () => {
expect(plainLocales.sort()).toEqual(['de', 'en']);
expect(SUPPORTED_LOCALES.map((l) => l.code).filter(hasPlainLanguage).sort()).toEqual(['de', 'en']);
});
for (const locale of ['en', 'de']) {
describe(locale, () => {
const plain = leaves(load(locale, 'plain.json'));
const common = new Map(leaves(load(locale, 'common.json')));
it('has strings', () => {
expect(plain.length).toBeGreaterThan(100);
});
it('only overrides keys that exist in common.json', () => {
expect(plain.map(([key]) => key).filter((key) => !common.has(key))).toEqual([]);
});
it('only holds non-empty strings with the placeholders of the string they replace', () => {
const bad = plain
.filter(([key, value]) => typeof value !== 'string' || !value.trim()
|| JSON.stringify(argumentsOf(value)) !== JSON.stringify(argumentsOf(String(common.get(key)))))
.map(([key]) => key);
expect(bad).toEqual([]);
});
});
}
});
describe('translate with plain language', () => {
const key = 'login.error.invalid_credentials';
it('uses the plain string where there is one', () => {
expect(translate('en', key)).toBe('Invalid email or password. Please check your credentials and try again.');
expect(translate('en', key, undefined, undefined, true)).toBe('The email address or password is wrong. Check both and try again.');
const de = translate('de', key, undefined, undefined, true);
expect(de).not.toBe(translate('de', key));
expect(de).toBe((load('de', 'plain.json').login as Tree & { error: Tree }).error.invalid_credentials);
});
it('keeps the normal string where there is no plain one, and other languages as they are', () => {
expect(translate('en', 'settings.tabs.account', undefined, undefined, true)).toBe(translate('en', 'settings.tabs.account'));
expect(translate('fr', key, undefined, undefined, true)).toBe(translate('fr', key));
});
});
describe('the locale store', () => {
const key = 'login.error.invalid_credentials';
it('hands out a new t that speaks plainly while the setting is on', () => {
useLocaleStore.getState().setOverride('en');
const normal = useLocaleStore.getState().t;
useLocaleStore.getState().setPlainLanguage(true);
const plain = useLocaleStore.getState().t;
expect(plain).not.toBe(normal);
expect(plain(key)).toBe('The email address or password is wrong. Check both and try again.');
// Changing the language keeps the plain wording.
useLocaleStore.getState().setOverride('de');
expect(useLocaleStore.getState().t(key)).toBe(translate('de', key, undefined, undefined, true));
useLocaleStore.getState().setPlainLanguage(false);
useLocaleStore.getState().setOverride('en');
expect(useLocaleStore.getState().t(key)).toBe(translate('en', key));
});
it('follows the settings store switch', () => {
let state = { plainLanguage: false };
const listeners: Array<(s: typeof state) => void> = [];
followPlainLanguage({ getState: () => state, subscribe: (l) => listeners.push(l) });
expect(useLocaleStore.getState().plainLanguage).toBe(false);
state = { plainLanguage: true };
listeners.forEach((l) => l(state));
expect(useLocaleStore.getState().plainLanguage).toBe(true);
state = { plainLanguage: false };
listeners.forEach((l) => l(state));
expect(useLocaleStore.getState().plainLanguage).toBe(false);
});
it('is off by default and kept when stored', () => {
expect(mergeWithDefaults({}).plainLanguage).toBe(false);
expect(mergeWithDefaults({ plainLanguage: true }).plainLanguage).toBe(true);
});
});
+36 -5
View File
@@ -144,16 +144,44 @@ const OVERLAY_LOADERS: Record<LocaleCode, () => Dictionary> = {
'zh-TW': () => require('../../locales/rn/zh-TW.json'),
};
const dictionaries: Partial<Record<LocaleCode, Dictionary>> = {};
// Plain language (Settings > Accessibility, webmail i18n/plain-language.ts):
// simpler wording for settings and error messages. Only English and German
// have it; ../../locales/<code>/plain.json holds just the keys that get a
// simpler version, under the same paths as common.json, and anything it
// leaves out keeps the normal string. Every other language is unchanged
// when the setting is on.
const PLAIN_LOADERS: Partial<Record<LocaleCode, () => Dictionary>> = {
en: () => require('../../locales/en/plain.json'),
de: () => require('../../locales/de/plain.json'),
};
/** The merged catalog (vendored + RN overlay) for a locale, loaded on first use. */
export function getDictionary(locale: LocaleCode): Dictionary {
/** Whether a language has plain-language strings. */
export function hasPlainLanguage(locale: string): boolean {
return Object.prototype.hasOwnProperty.call(PLAIN_LOADERS, locale);
}
const dictionaries: Partial<Record<LocaleCode, Dictionary>> = {};
const plainDictionaries: Partial<Record<LocaleCode, Dictionary>> = {};
/**
* The merged catalog (vendored + RN overlay) for a locale, loaded on first
* use. With `plain`, the plain-language strings win over it where the
* language has them.
*/
export function getDictionary(locale: LocaleCode, plain = false): Dictionary {
let dict = dictionaries[locale];
if (!dict) {
dict = deepMerge(CATALOG_LOADERS[locale](), OVERLAY_LOADERS[locale]());
dictionaries[locale] = dict;
}
return dict;
const loadPlain = plain ? PLAIN_LOADERS[locale] : undefined;
if (!loadPlain) return dict;
let plainDict = plainDictionaries[locale];
if (!plainDict) {
plainDict = deepMerge(dict, loadPlain());
plainDictionaries[locale] = plainDict;
}
return plainDict;
}
/** Which catalogs have been loaded so far (for tests). */
@@ -250,8 +278,11 @@ export function translate(
key: string,
fallback?: string,
params?: MessageParams,
plain = false,
): string {
const message = lookup(getDictionary(locale), key)
// A key the language lacks falls back to normal English, as in the
// webmail (plain strings are laid over the language's own catalog only).
const message = lookup(getDictionary(locale, plain), key)
?? lookup(getDictionary('en'), key)
?? fallback
?? key;
+89
View File
@@ -0,0 +1,89 @@
// Colour-blind-safe colours (webmail lib/cvd-palette.ts and its tests,
// cf4d02b49), and how the app's theme draws tags with the setting on.
import { describe, it, expect } from 'vitest';
import { CVD_COLORS, cvdColor, cvdHue, cvdTagPalette } from '../cvd-palette';
import { resolvePalette } from '../../theme/colors';
import { DARK_COLORS, LIGHT_COLORS } from '../../theme/tokens';
import { mergeWithDefaults } from '../../stores/settings-store';
describe('cvdHue', () => {
it('maps the default calendar and tag colours onto separate palette colours', () => {
expect(cvdHue('#ef4444')).toBe('vermillion');
expect(cvdHue('#f97316')).toBe('orange');
expect(cvdHue('#eab308')).toBe('yellow');
expect(cvdHue('#22c55e')).toBe('bluishGreen');
expect(cvdHue('#06b6d4')).toBe('skyBlue');
expect(cvdHue('#3b82f6')).toBe('blue');
expect(cvdHue('#a855f7')).toBe('reddishPurple');
});
it('keeps red and green apart, the pair red-green colour blindness merges', () => {
expect(cvdHue('#dc2626')).not.toBe(cvdHue('#16a34a'));
expect(cvdHue('#b91c1c')).toBe('vermillion');
expect(cvdHue('#15803d')).toBe('bluishGreen');
});
it('reads tag palette names, with their light and dark shades', () => {
expect(cvdHue('red')).toBe('vermillion');
expect(cvdHue('teal-light')).toBe('skyBlue');
expect(cvdHue('indigo-dark')).toBe('blue');
expect(cvdHue('gray')).toBe('grey');
});
it('reads short hex, rgb() and hsl()', () => {
expect(cvdHue('#f00')).toBe('vermillion');
expect(cvdHue('rgb(0, 128, 0)')).toBe('bluishGreen');
expect(cvdHue('hsl(220, 80%, 50%)')).toBe('blue');
});
it('turns greys grey and gives up on what it cannot read', () => {
expect(cvdHue('#777777')).toBe('grey');
expect(cvdHue('var(--color-primary)')).toBeNull();
expect(cvdHue('')).toBeNull();
expect(cvdHue(undefined)).toBeNull();
});
});
describe('cvdColor', () => {
it('draws a colour as it is while the setting is off', () => {
expect(cvdColor('#3b82f6', false)).toBe('#3b82f6');
});
it('draws the palette colour while it is on, and leaves unreadable colours alone', () => {
expect(cvdColor('#3b82f6', true)).toBe(CVD_COLORS.blue);
expect(cvdColor('var(--color-primary)', true)).toBe('var(--color-primary)');
});
});
describe('tags drawn colour-blind-safe', () => {
it('draws the dot in the palette colour, the fill as its tint, the label in the text colour', () => {
const tags = cvdTagPalette(DARK_COLORS.tags, '#fafafa');
expect(tags.red).toEqual({ dot: CVD_COLORS.vermillion, bg: 'rgba(213, 94, 0, 0.15)', text: '#fafafa' });
expect(tags.green.dot).toBe(CVD_COLORS.bluishGreen);
expect(tags.red.dot).not.toBe(tags.green.dot);
expect(tags.gray.dot).toBe(CVD_COLORS.grey);
});
it('switches every c.tags colour in the theme and keeps the picked ones for the picker', () => {
const plain = resolvePalette('dark', null);
expect(plain).toBe(DARK_COLORS);
expect(plain.tags.red.dot).toBe('#ef4444');
const safe = resolvePalette('dark', null, true);
expect(safe.tags.red.dot).toBe(CVD_COLORS.vermillion);
expect(safe.tags.red.text).toBe(DARK_COLORS.text);
expect(safe.tagsAsPicked).toBe(DARK_COLORS.tags);
// The rest of the theme is untouched, and the palette object is stable.
expect(safe.primary).toBe(DARK_COLORS.primary);
expect(resolvePalette('dark', null, true)).toBe(safe);
const light = resolvePalette('light', 'builtin-roundcube-elastic', true);
expect(light.tags.blue.dot).toBe(CVD_COLORS.blue);
expect(light.tagsAsPicked).toBe(LIGHT_COLORS.tags);
});
it('is off by default and kept when stored', () => {
expect(mergeWithDefaults({}).colorBlindPalette).toBe(false);
expect(mergeWithDefaults({ colorBlindPalette: true }).colorBlindPalette).toBe(true);
});
});
@@ -50,6 +50,15 @@ describe('bodyDocument', () => {
expect(bodyDocument(input({ cidMap: { a: 'data:image/png;base64,AA' } }))).not.toBe(first);
});
it('draws a plain-text body in the reading font and rebuilds when it changes', () => {
const text = input({ rawHtml: null, text: 'Hello' });
const plain = bodyDocument(text);
const lexend = bodyDocument({ ...text, appFont: 'lexend' });
expect(lexend).not.toBe(plain);
expect(lexend.html).toContain('font-family: "Lexend"');
expect(plain.html).not.toContain('Lexend');
});
it('keeps messages apart by key', () => {
const a = bodyDocument(input({ key: '|e1' }));
const b = bodyDocument(input({ key: 'group|e1' }));
+332
View File
@@ -0,0 +1,332 @@
// The dark-mode recolour pass (webmail lib/email-dark-mode.ts). The pass is
// JavaScript source injected into the mail WebView; these tests evaluate that
// same source. The colour cases are the webmail's
// (lib/__tests__/email-dark-mode.test.ts). The app has no DOM library, so the
// DOM pass runs on a small fake document that answers getComputedStyle the
// way a browser does for the parts the pass reads (inline styles win, colour
// inherits, everything else has its initial value).
import { describe, it, expect } from 'vitest';
import {
DARK_PENDING_CLASS,
EMAIL_DARK_MODE_LIB,
buildEmailDarkModeScript,
emailColorScheme,
resolveColorSchemeMedia,
type EmailDarkModeLib,
type Rgba,
} from '../email-dark-mode';
const DM = new Function(`return ${EMAIL_DARK_MODE_LIB}`)()() as EmailDarkModeLib;
const c = (value: string): Rgba => DM.parseComputedColor(value)!;
const lightness = ({ r, g, b }: Rgba) => (r + g + b) / 3;
describe('darkenBackground', () => {
it('turns white into a dark canvas', () => {
expect(lightness(DM.darkenBackground(c('rgb(255, 255, 255)')))).toBeLessThan(40);
});
it('keeps a light grey canvas lighter than a white card on it', () => {
const card = DM.darkenBackground(c('rgb(255, 255, 255)'));
const canvas = DM.darkenBackground(c('rgb(244, 244, 244)'));
expect(lightness(canvas)).toBeGreaterThan(lightness(card));
});
it('keeps the hue of a light tint', () => {
const out = DM.darkenBackground(c('rgb(214, 233, 248)'));
expect(lightness(out)).toBeLessThan(80);
expect(out.b).toBeGreaterThan(out.r);
});
it('leaves vivid brand colours and dark fills as sent', () => {
const red = c('rgb(227, 6, 19)');
const green = c('rgb(115, 215, 0)');
const navy = c('rgb(0, 32, 91)');
expect(DM.darkenBackground(red)).toBe(red);
expect(DM.darkenBackground(green)).toBe(green);
expect(DM.darkenBackground(navy)).toBe(navy);
});
it('keeps alpha', () => {
expect(DM.darkenBackground(c('rgba(255, 255, 255, 0.5)')).a).toBe(0.5);
});
});
describe('ensureContrast', () => {
const dark = c('rgb(20, 20, 20)');
it('keeps text that already reads', () => {
const white = c('rgb(255, 255, 255)');
expect(DM.ensureContrast(white, dark, 4.5)).toBe(white);
});
it('lifts dark text above 4.5:1 on a dark background', () => {
const out = DM.ensureContrast(c('rgb(51, 51, 51)'), dark, 4.5);
expect(DM.contrastRatio(out, dark)).toBeGreaterThanOrEqual(4.5);
});
it('keeps the order of text greys', () => {
const black = DM.ensureContrast(c('rgb(0, 0, 0)'), dark, 4.5);
const grey = DM.ensureContrast(c('rgb(102, 102, 102)'), dark, 4.5);
expect(lightness(black)).toBeGreaterThan(lightness(grey));
});
it('keeps a red link red', () => {
const out = DM.ensureContrast(c('rgb(200, 0, 0)'), dark, 4.5);
expect(out.r).toBeGreaterThan(out.g + 60);
});
});
describe('parseComputedColor / formatColor', () => {
it('reads the forms getComputedStyle returns', () => {
expect(c('rgb(1, 2, 3)')).toEqual({ r: 1, g: 2, b: 3, a: 1 });
expect(c('rgba(1, 2, 3, 0.5)')).toEqual({ r: 1, g: 2, b: 3, a: 0.5 });
expect(c('rgb(1 2 3 / 50%)')).toEqual({ r: 1, g: 2, b: 3, a: 0.5 });
expect(DM.parseComputedColor('transparent')).toBeNull();
});
it('writes rgb() when opaque and rgba() otherwise', () => {
expect(DM.formatColor({ r: 1.4, g: 2, b: 3, a: 1 })).toBe('rgb(1, 2, 3)');
expect(DM.formatColor({ r: 1, g: 2, b: 3, a: 0.25 })).toBe('rgba(1, 2, 3, 0.25)');
});
});
describe('resolveColorSchemeMedia', () => {
const html = '<style>@media (prefers-color-scheme: dark) { .a { color: white } } @media (prefers-color-scheme:light) { .b {} }</style><p>(prefers-color-scheme: dark)</p>';
it('turns the dark query on and the light one off in dark mode', () => {
const out = resolveColorSchemeMedia(html, true);
expect(out).toContain('@media (min-width: 0px) { .a');
expect(out).toContain('@media (max-width: 0.001px) { .b');
});
it('turns the dark query off in light mode', () => {
const out = resolveColorSchemeMedia(html, false);
expect(out).toContain('@media (max-width: 0.001px) { .a');
expect(out).toContain('@media (min-width: 0px) { .b');
});
it('leaves text outside <style> alone', () => {
expect(resolveColorSchemeMedia(html, true)).toContain('<p>(prefers-color-scheme: dark)</p>');
});
});
describe('emailColorScheme', () => {
it('is dark only for a mail with its own dark styles shown dark', () => {
expect(emailColorScheme(true, true)).toBe('dark');
expect(emailColorScheme(true, false)).toBe('light');
expect(emailColorScheme(false, true)).toBe('light');
});
});
// ─── A fake rendered document ─────────────────────────────────────────────
type Css = Partial<Record<'color' | 'backgroundColor' | 'backgroundImage' | 'border', string>>;
class FakeStyle {
private props = new Map<string, { value: string; priority: string }>();
setProperty(name: string, value: string, priority = '') { this.props.set(name, { value, priority }); }
getPropertyValue(name: string) { return this.props.get(name)?.value ?? ''; }
getPropertyPriority(name: string) { return this.props.get(name)?.priority ?? ''; }
}
class FakeElement {
parentElement: FakeElement | null = null;
children: FakeElement[] = [];
style = new FakeStyle();
private classes = new Set<string>();
classList = {
remove: (name: string) => { this.classes.delete(name); },
contains: (name: string) => this.classes.has(name),
add: (name: string) => { this.classes.add(name); },
};
constructor(public tagName: string, public css: Css = {}, public id = '') {}
append(...kids: FakeElement[]) {
for (const kid of kids) {
kid.parentElement = this;
this.children.push(kid);
}
return this;
}
descendants(): FakeElement[] {
return this.children.flatMap((kid) => [kid, ...kid.descendants()]);
}
querySelectorAll(selector: string): FakeElement[] {
const all = this.descendants();
return selector === '*' ? all : all.filter((el) => el.tagName.toLowerCase() === selector);
}
closest(selector: string): FakeElement | null {
for (let n: FakeElement | null = this; n; n = n.parentElement) {
if (n.tagName.toLowerCase() === selector) return n;
}
return null;
}
}
/** What the browser would report: the pass's inline writes win, colour inherits. */
function computed(el: FakeElement) {
const own = (prop: string, css: string | undefined) => el.style.getPropertyValue(prop) || css;
const color = (): string => {
for (let n: FakeElement | null = el; n; n = n.parentElement) {
const v = n.style.getPropertyValue('color') || n.css.color;
if (v) return v;
}
return 'rgb(0, 0, 0)';
};
const border = el.css.border;
return {
color: color(),
backgroundColor: own('background-color', el.css.backgroundColor) ?? 'rgba(0, 0, 0, 0)',
backgroundImage: own('background-image', el.css.backgroundImage) ?? 'none',
getPropertyValue(name: string) {
const side = /^border-(top|right|bottom|left)-(style|width|color)$/.exec(name);
if (!side) return '';
if (side[2] === 'color') return el.style.getPropertyValue(name) || border || '';
if (side[2] === 'style') return border ? 'solid' : 'none';
return border ? '1px' : '0px';
},
};
}
function hex(value: string): string {
const h = value.replace('#', '');
const full = h.length === 3 ? h.split('').map((d) => d + d).join('') : h;
const [r, g, b] = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16));
return `rgb(${r}, ${g}, ${b})`;
}
/**
* A document in the state the head CSS leaves it in: pending class on
* <html>, our dark canvas on <html> and <body>, light fallback text.
*/
function makeDoc(content: FakeElement[], opts: { htmlBg?: string; bodyBg?: string } = {}) {
const html = new FakeElement('HTML', { backgroundColor: opts.htmlBg ?? 'rgb(20, 20, 20)' });
const body = new FakeElement('BODY', { backgroundColor: opts.bodyBg ?? 'rgb(20, 20, 20)', color: 'rgb(230, 230, 230)' });
html.classList.add(DARK_PENDING_CLASS);
html.append(body);
body.append(...content);
const doc = {
documentElement: html,
body,
defaultView: { getComputedStyle: computed },
byId: (id: string) => html.descendants().find((e) => e.id === id)!,
};
return doc;
}
const run = (doc: ReturnType<typeof makeDoc>) => DM.apply(doc as unknown as Document);
const writtenBg = (el: FakeElement) => {
expect(el.style.getPropertyPriority('background-color')).toBe('important');
return c(el.style.getPropertyValue('background-color'));
};
const el = (tag: string, css: Css = {}, id = '', ...kids: FakeElement[]) => new FakeElement(tag, css, id).append(...kids);
describe('the DOM pass', () => {
it('darkens a white cell', () => {
const doc = makeDoc([el('TABLE', {}, '', el('TR', {}, '', el('TD', { backgroundColor: hex('#fff') }, 'a')))]);
run(doc);
expect(lightness(writtenBg(doc.byId('a')))).toBeLessThan(40);
});
it('makes dark text on a darkened cell readable', () => {
const doc = makeDoc([el('DIV', { backgroundColor: hex('#fff') }, 'd', el('P', { color: hex('#222') }, 'p'))]);
run(doc);
const p = doc.byId('p');
expect(p.style.getPropertyPriority('color')).toBe('important');
const bg = writtenBg(doc.byId('d'));
expect(DM.contrastRatio(c(p.style.getPropertyValue('color')), bg)).toBeGreaterThan(4.5);
});
it('keeps a brand button and its label as sent', () => {
const doc = makeDoc([el('A', { backgroundColor: hex('#e30613'), color: hex('#ffffff') }, 'btn')]);
run(doc);
const btn = doc.byId('btn');
expect(btn.style.getPropertyPriority('background-color')).toBe('');
expect(btn.style.getPropertyPriority('color')).toBe('');
});
it('leaves text on a background image as sent', () => {
const doc = makeDoc([
el('DIV', { backgroundImage: 'url("data:image/png;base64,AAAA")', backgroundColor: hex('#ffffff') }, 'photo',
el('P', { color: hex('#111') }, 'p')),
]);
run(doc);
expect(doc.byId('p').style.getPropertyPriority('color')).toBe('');
expect(doc.byId('photo').style.getPropertyPriority('background-color')).toBe('');
});
it('converts a cell whose remote background the blocker stripped', () => {
const doc = makeDoc([
el('TD', { backgroundImage: 'url("")', backgroundColor: hex('#ffffff') }, 'hero', el('P', { color: hex('#111') }, 'p')),
]);
run(doc);
expect(lightness(writtenBg(doc.byId('hero')))).toBeLessThan(40);
expect(doc.byId('p').style.getPropertyPriority('color')).toBe('important');
});
it('keeps a vivid cell as sent', () => {
const doc = makeDoc([el('TD', { backgroundColor: hex('#e30613'), color: hex('#ffffff') }, 'x')]);
run(doc);
expect(doc.byId('x').style.getPropertyValue('background-color')).toBe('');
});
it('darkens a light border with the backgrounds', () => {
const doc = makeDoc([el('DIV', { backgroundColor: hex('#ffffff'), border: hex('#dddddd') }, 'card')]);
run(doc);
const card = doc.byId('card');
expect(card.style.getPropertyPriority('border-top-color')).toBe('important');
expect(lightness(c(card.style.getPropertyValue('border-top-color')))).toBeLessThan(110);
});
it('leaves the dark canvas behind a mail without colours alone', () => {
const doc = makeDoc([el('P', {}, 'p')]);
run(doc);
expect(doc.body.style.getPropertyValue('background-color')).toBe('');
expect(doc.documentElement.style.getPropertyValue('background-color')).toBe('');
// Our light fallback text already reads on it.
expect(doc.byId('p').style.getPropertyValue('color')).toBe('');
});
it('un-hides the body', () => {
const doc = makeDoc([el('P')]);
run(doc);
expect(doc.documentElement.classList.contains(DARK_PENDING_CLASS)).toBe(false);
});
// Newsletters (Lieferando) paint the canvas through `html { background }`.
it('darkens a canvas the mail paints on <html>', () => {
const doc = makeDoc([el('P')], { htmlBg: hex('#ffffff'), bodyBg: hex('#ffffff') });
run(doc);
expect(lightness(writtenBg(doc.documentElement))).toBeLessThan(40);
expect(lightness(writtenBg(doc.body))).toBeLessThan(40);
});
});
describe('buildEmailDarkModeScript', () => {
const inject = (script: string, doc: ReturnType<typeof makeDoc>) => {
const win: Record<string, unknown> = {};
new Function('document', 'window', script)(doc, win);
};
it('runs the pass on the page once, however often the view injects it', () => {
const doc = makeDoc([el('TD', { backgroundColor: hex('#fff') }, 'a')]);
const script = buildEmailDarkModeScript({ remoteContentAllowed: false });
inject(script, doc);
const first = doc.byId('a').style.getPropertyValue('background-color');
expect(lightness(c(first))).toBeLessThan(40);
// A second pass would read the darkened colour back and could shift it.
doc.byId('a').style.setProperty('background-color', 'rgb(255, 255, 255)');
inject(script, doc);
expect(doc.byId('a').style.getPropertyValue('background-color')).toBe('rgb(255, 255, 255)');
});
it('shows the body even when the pass throws', () => {
const doc = makeDoc([el('P')]);
(doc.defaultView as { getComputedStyle: unknown }).getComputedStyle = () => { throw new Error('boom'); };
inject(buildEmailDarkModeScript(), doc);
expect(doc.documentElement.classList.contains(DARK_PENDING_CLASS)).toBe(false);
});
});
+46 -12
View File
@@ -254,21 +254,39 @@ describe('prepareEmailHtml', () => {
expect(prepareEmailHtml('<p>x</p>').html).not.toMatch(/<script/i);
});
it('evaluates native dark mode once and reports the inversion decision', () => {
const native = prepareEmailHtml('<style>@media (prefers-color-scheme: dark){body{color:#fff}}</style><p>x</p>', { isDark: true });
expect(native.hasNativeDark).toBe(true);
expect(native.applyInversion).toBe(false);
expect(native.html).toContain('color-scheme: light dark');
expect(native.html).not.toContain('invert(1)');
// Dark mode rewrites the mail's colours after layout (lib/email-dark-mode)
// instead of inverting the body with a filter, as the webmail does since
// 7401ea665: the head only sets fallback colours and hides the body until
// the pass ran.
it('recolours a mail shown dark instead of inverting it', () => {
const plain = prepareEmailHtml('<p>x</p>', { isDark: true });
expect(plain.applyInversion).toBe(true);
expect(plain.html).toContain('body { filter: invert(1) hue-rotate(180deg)');
// Current webmail revision: background-image containers re-invert without the :has guard.
expect(plain.html).toMatch(/\[style\*="background-image"\],\s*\[background\]\s*\{\s*filter: invert\(1\)/);
expect(plain.recolorDark).toBe(true);
expect(plain.html).not.toContain('invert(1)');
expect(plain.html).toContain('<html class="bulwark-dark-pending"');
expect(plain.html).toContain('body { color: #e6e6e6; background: #141414; }');
expect(plain.html).toContain('animation: bulwark-dark-reveal 0s 1.5s forwards');
const light = prepareEmailHtml('<p>x</p>', { isDark: false });
expect(light.applyInversion).toBe(false);
expect(light.recolorDark).toBe(false);
expect(light.html).not.toContain('bulwark-dark-pending');
expect(light.html).not.toContain('#141414');
});
it('recolours a mail with its own dark styles too and pins them to the app theme', () => {
const mail = '<style>@media (prefers-color-scheme: dark){body{color:#fff}} @media (prefers-color-scheme: light){p{color:#000}}</style><p>x</p>';
const dark = prepareEmailHtml(mail, { isDark: true });
expect(dark.hasNativeDark).toBe(true);
expect(dark.recolorDark).toBe(true);
expect(dark.html).toContain('color-scheme: dark');
expect(dark.html).toContain('@media (min-width: 0px){body{color:#fff}}');
expect(dark.html).toContain('@media (max-width: 0.001px){p{color:#000}}');
// A light app (or "Always view emails in light mode") on a dark phone
// must not get the mail's dark variant.
const light = prepareEmailHtml(mail, { isDark: false });
expect(light.html).toContain('color-scheme: light');
expect(light.html).not.toContain('light dark');
expect(light.html).toContain('@media (max-width: 0.001px){body{color:#fff}}');
});
it('reports blocked external content', () => {
@@ -370,6 +388,22 @@ describe('wrapPlainTextEmail', () => {
it('lets the content pick its text direction', () => {
expect(wrapPlainTextEmail('مرحبا بالعالم.')).toContain('<body dir="auto">');
});
// Settings > Accessibility > Font: a plain body sits in the app document in
// the webmail, so it is drawn in the reading font ("Same as above").
it('draws the body in the reading font, which the page loads from data:', () => {
const html = wrapPlainTextEmail('x', { font: 'sans', readingFont: 'opendyslexic' });
expect(html).toContain('font-family: "OpenDyslexic", -apple-system');
expect(html).toMatch(/@font-face \{ font-family: "OpenDyslexic";[^}]*src: url\("data:font\/woff2;base64,/);
expect(html).toContain('font-src data:');
});
it('keeps monospace and the theme font as they were', () => {
const mono = wrapPlainTextEmail('x', { font: 'mono', readingFont: 'opendyslexic' });
expect(mono).toContain('font-family: ui-monospace');
expect(mono).not.toContain('OpenDyslexic');
expect(wrapPlainTextEmail('x', { font: 'sans', readingFont: 'default' })).not.toContain('@font-face');
});
});
describe('hasNativeDarkMode', () => {
+11 -4
View File
@@ -6,14 +6,15 @@
//
// The phone takes the presets whose settings it has: screen reader (no
// conversation grouping), low vision (150% text, labels under the toolbar
// icons) and motion (no animations). The webmail's others need settings the
// app doesn't have (reading fonts, spacing, focus outlines, hover, sounds).
// icons), dyslexia (the reading font, also for plain-text mail) and motion
// (no animations). The webmail's others need settings the app doesn't have
// (spacing, focus outlines, hover, sounds).
import type { PersistedSettings } from '../stores/settings-store';
export type AccessibilityPresetId = 'screen-reader' | 'low-vision' | 'motion';
export type AccessibilityPresetId = 'screen-reader' | 'low-vision' | 'dyslexia' | 'motion';
export const ACCESSIBILITY_PRESET_IDS: readonly AccessibilityPresetId[] = ['screen-reader', 'low-vision', 'motion'];
export const ACCESSIBILITY_PRESET_IDS: readonly AccessibilityPresetId[] = ['screen-reader', 'low-vision', 'dyslexia', 'motion'];
type PresetValues = Partial<PersistedSettings>;
@@ -27,6 +28,12 @@ export const ACCESSIBILITY_PRESETS: Record<AccessibilityPresetId, PresetValues>
fontSize: 'xx-large',
showToolbarLabels: true,
},
// The webmail's font part (its spacing, reading width and mail style
// options have no phone counterpart yet).
dyslexia: {
appFont: 'atkinson',
plainTextFont: 'sans',
},
motion: {
animationsEnabled: false,
},
+135
View File
@@ -0,0 +1,135 @@
/**
* The colour-blind-safe palette behind Settings > Accessibility >
* "Colour-blind-safe colours" (colorBlindPalette). A port of the webmail's
* lib/cvd-palette.ts (cf4d02b49).
*
* Tags and calendars keep the colours the user picked; this only changes how
* they are drawn. Every colour is moved to the nearest of the Okabe-Ito
* colours by hue: eight colours chosen to stay apart for protanopia,
* deuteranopia and tritanopia (Okabe & Ito, "Color Universal Design", 2008).
* Two picked colours of a similar hue (blue and indigo) can land on the same
* one; the names next to them still tell them apart.
*/
export const CVD_COLORS = {
vermillion: '#D55E00',
orange: '#E69F00',
yellow: '#F0E442',
bluishGreen: '#009E73',
skyBlue: '#56B4E9',
blue: '#0072B2',
reddishPurple: '#CC79A7',
grey: '#999999',
} as const;
export type CvdHue = keyof typeof CVD_COLORS;
/** The hues of the tag palette (theme/tokens TAGS), as Tailwind's 500 shades. */
const PALETTE_HUES: Record<string, string> = {
red: '#ef4444',
orange: '#f97316',
amber: '#f59e0b',
yellow: '#eab308',
lime: '#84cc16',
green: '#22c55e',
teal: '#14b8a6',
cyan: '#06b6d4',
blue: '#3b82f6',
indigo: '#6366f1',
purple: '#a855f7',
pink: '#ec4899',
gray: '#6b7280',
};
/** Hue (0-360) and saturation (0-1) of a colour, or null when it can't be read. */
function hueAndSaturation(color: string): { hue: number; saturation: number } | null {
let r: number, g: number, b: number;
const value = color.trim().toLowerCase();
const hex = /^#([0-9a-f]{3,8})$/.exec(value);
if (hex) {
let digits = hex[1];
if (digits.length === 3 || digits.length === 4) digits = digits.slice(0, 3).split('').map((d) => d + d).join('');
if (digits.length !== 6 && digits.length !== 8) return null;
r = parseInt(digits.slice(0, 2), 16);
g = parseInt(digits.slice(2, 4), 16);
b = parseInt(digits.slice(4, 6), 16);
} else {
const rgb = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/.exec(value);
const hsl = /^hsla?\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%[\s,]+([\d.]+)%/.exec(value);
if (rgb) {
[r, g, b] = [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
} else if (hsl) {
return { hue: Number(hsl[1]) % 360, saturation: Number(hsl[2]) / 100 };
} else {
return null;
}
}
const [rn, gn, bn] = [r / 255, g / 255, b / 255];
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const delta = max - min;
const lightness = (max + min) / 2;
const saturation = delta === 0 ? 0 : delta / (1 - Math.abs(2 * lightness - 1));
let hue = 0;
if (delta !== 0) {
if (max === rn) hue = ((gn - bn) / delta) % 6;
else if (max === gn) hue = (bn - rn) / delta + 2;
else hue = (rn - gn) / delta + 4;
hue *= 60;
if (hue < 0) hue += 360;
}
return { hue, saturation };
}
/**
* The palette colour for `color`: a CSS colour (#hex, rgb(), hsl()) or a
* tag palette name ("red", "teal-light"). Null when it can't be read.
*/
export function cvdHue(color: string | null | undefined): CvdHue | null {
if (!color) return null;
const paletteName = color.replace(/-(light|dark)$/, '');
const parsed = hueAndSaturation(PALETTE_HUES[paletteName] ?? color);
if (!parsed) return null;
const { hue, saturation } = parsed;
if (saturation < 0.15) return 'grey';
if (hue < 18 || hue >= 345) return 'vermillion';
if (hue < 44) return 'orange';
if (hue < 70) return 'yellow';
if (hue < 165) return 'bluishGreen';
if (hue < 200) return 'skyBlue';
if (hue < 255) return 'blue';
return 'reddishPurple';
}
/**
* The colour to draw `color` in: itself, or its colour-blind-safe
* replacement when `enabled`. A colour it can't read is drawn as it is.
*/
export function cvdColor(color: string, enabled: boolean): string {
if (!enabled) return color;
const hue = cvdHue(color);
return hue ? CVD_COLORS[hue] : color;
}
/** `#RRGGBB` at `alpha`, as rgba() (React Native has no 8-digit-hex tint helper here). */
function withAlpha(hex: string, alpha: number): string {
const n = parseInt(hex.slice(1), 16);
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
}
export interface TagSwatch { dot: string; bg: string; text: string }
/**
* A tag palette drawn colour-blind-safe, the way the webmail draws tags with
* the setting on (CVD_CLASSES): the dot in the palette colour, the fill as a
* 15 % tint of it, and the label in the text colour, since several of these
* colours are too light to read as text.
*/
export function cvdTagPalette<K extends string>(tags: Record<K, TagSwatch>, textColor: string): Record<K, TagSwatch> {
const out = {} as Record<K, TagSwatch>;
for (const key of Object.keys(tags) as K[]) {
const hex = CVD_COLORS[cvdHue(key) ?? cvdHue(tags[key].dot) ?? 'grey'];
out[key] = { dot: hex, bg: withAlpha(hex, hex === CVD_COLORS.yellow ? 0.2 : 0.15), text: textColor };
}
return out;
}
+8 -4
View File
@@ -10,6 +10,7 @@ import {
} from './email-html';
import { collapsePlainTextQuotes, type QuoteCollapseLabels } from './quote-collapse';
import { mailBaseFontPx } from './mail-reading';
import type { AppFont } from '../theme/reading-fonts';
export interface BodyDocumentInput {
/** The message (account and id); the sources below are compared as well. */
@@ -33,12 +34,14 @@ export interface BodyDocumentInput {
textFactor?: number;
/** The app theme's colours for a plain-text body in dark mode. */
plainDarkColors?: PlainTextColors;
/** The reading font (Settings > Accessibility > Font) for a plain-text body. */
appFont?: AppFont;
}
export interface BodyDocument {
html: string;
/** Inversion CSS is in effect; the DOM re-invert pass should run. */
applyInversion: boolean;
/** Shown dark: the recolour pass (lib/email-dark-mode) has to run. */
recolorDark: boolean;
hasNativeDark: boolean;
/** At least one external resource was neutralised. */
blockedExternal: boolean;
@@ -68,8 +71,9 @@ export function buildBodyDocument(input: BodyDocumentInput): BodyDocument {
font: input.plainTextFont,
fontPx: mailBaseFontPx(input.textFactor ?? 1),
darkColors: input.plainDarkColors,
readingFont: input.appFont,
}),
applyInversion: false,
recolorDark: false,
hasNativeDark: false,
blockedExternal: false,
isHtml: false,
@@ -97,7 +101,7 @@ 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.textFactor ?? 1,
input.plainDarkColors ?? null,
input.plainDarkColors ?? null, input.appFont ?? 'default',
]);
const hit = documents.get(key);
if (hit && sameSources(hit.input, input)) {
+544
View File
@@ -0,0 +1,544 @@
/**
* Dark mode for HTML mail bodies (webmail lib/email-dark-mode.ts, 7401ea665,
* 6e5e07107). Instead of inverting the whole body with a CSS filter (which
* turns brand red into pink, flips photos that then need re-inverting and
* emoji that need wrapping), the rendered document is read back through
* getComputedStyle and its colours are rewritten in place:
*
* - Light backgrounds are darkened with their hue kept. Vivid colours (brand
* buttons, banners) and backgrounds that are already dark stay as sent.
* - Text and borders only change when they lack contrast against the new
* background; they are then lightened (or darkened) in their own hue.
* - Images are never touched. An element painted with a background image
* keeps its colours, and so does the text drawn straight on it.
*
* Computed styles only exist inside the WebView, so the pass runs there as an
* injected script. Hermes keeps no function source, so the pass can't be
* stringified from TypeScript: it lives below as JavaScript source
* (EMAIL_DARK_MODE_LIB), and the tests evaluate that same source, so what is
* tested is what the WebView runs.
*/
/** Canvas the converted mail sits on (white maps to this). */
export const EMAIL_DARK_BACKGROUND = '#141414';
const DARK_TEXT = '#e6e6e6';
const DARK_LINK = '#8ab4f8';
/** Class on <html> that keeps the body hidden until the pass has run. */
export const DARK_PENDING_CLASS = 'bulwark-dark-pending';
/**
* `color-scheme` for the document root. A mail with its own dark styles gets
* `dark`, so its `prefers-color-scheme: dark` rules apply whenever the app
* shows it dark - not only when the phone is dark too.
*/
export function emailColorScheme(isDark: boolean, nativeDarkMode: boolean): 'light' | 'dark' {
return isDark && nativeDarkMode ? 'dark' : 'light';
}
const ALWAYS = '(min-width: 0px)';
const NEVER = '(max-width: 0.001px)';
/**
* Pins the mail's `prefers-color-scheme` media queries to the theme the app
* shows it in. Inside the WebView they would follow the phone, not the app
* (or the per-message light/dark switch). Only <style> blocks are rewritten.
*/
export function resolveColorSchemeMedia(html: string, isDark: boolean): string {
if (!/prefers-color-scheme/i.test(html)) return html;
return html.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi, (_m, open: string, css: string, close: string) =>
open + css.replace(/\(\s*prefers-color-scheme\s*:\s*(dark|light)\s*\)/gi, (_q, scheme: string) =>
(scheme.toLowerCase() === 'dark') === isDark ? ALWAYS : NEVER) + close);
}
/**
* Stylesheet for the document head in dark mode. Sets the fallback colours
* for mails that set none, and hides the body until the pass ran so the light
* original never flashes. The reveal animation is a failsafe: the body shows
* after a moment even if the pass never runs.
*/
export function emailDarkModeCss(): string {
return `
html { background: ${EMAIL_DARK_BACKGROUND}; }
body { color: ${DARK_TEXT}; background: ${EMAIL_DARK_BACKGROUND}; }
a { color: ${DARK_LINK}; }
@keyframes bulwark-dark-reveal { to { visibility: visible; } }
html.${DARK_PENDING_CLASS} { background-color: ${EMAIL_DARK_BACKGROUND} !important; }
html.${DARK_PENDING_CLASS} body { visibility: hidden; animation: bulwark-dark-reveal 0s 1.5s forwards; }
`;
}
/** An sRGB colour with alpha, as the pass reads it from computed styles. */
export interface Rgba { r: number; g: number; b: number; a: number }
export interface EmailDarkModeOptions {
/**
* The user allowed remote content for this message. Only then may a remote
* image be fetched a second time (with CORS) to look at its pixels.
*/
remoteContentAllowed?: boolean;
}
/** What EMAIL_DARK_MODE_LIB evaluates to. */
export interface EmailDarkModeLib {
parseComputedColor(value: string): Rgba | null;
formatColor(color: Rgba): string;
contrastRatio(x: Rgba, y: Rgba): number;
darkenBackground(color: Rgba): Rgba;
ensureContrast(color: Rgba, bg: Rgba, minRatio: number): Rgba;
hasImage(backgroundImage: string): boolean;
apply(doc: Document, options?: EmailDarkModeOptions): void;
}
/**
* The pass as JavaScript source: an expression that evaluates to a factory
* returning {@link EmailDarkModeLib}. A line-for-line port of the webmail's
* lib/email-dark-mode.ts (colour maths in OKLab/OKLCH, WCAG contrast, the
* read-then-write DOM pass and the image plates); keep the two in step.
* No template literals or `${` inside: the source is a raw string.
*/
export const EMAIL_DARK_MODE_LIB = String.raw`(function () {
'use strict';
// Colour maths (sRGB <-> OKLab/OKLCH, WCAG contrast)
function parseComputedColor(value) {
var m = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,\/]\s*([\d.]+%?))?\s*\)$/i.exec(value || '');
if (!m) return null;
var a = 1;
if (m[4] !== undefined && m[4] !== '') {
a = m[4].charAt(m[4].length - 1) === '%' ? parseFloat(m[4]) / 100 : parseFloat(m[4]);
}
return { r: +m[1], g: +m[2], b: +m[3], a: a };
}
function formatColor(c) {
var R = Math.round(c.r), G = Math.round(c.g), B = Math.round(c.b);
return c.a >= 1
? 'rgb(' + R + ', ' + G + ', ' + B + ')'
: 'rgba(' + R + ', ' + G + ', ' + B + ', ' + (+c.a.toFixed(3)) + ')';
}
function toLinear(v) {
var c = v / 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
function fromLinear(v) {
var c = v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
return c * 255;
}
function toLch(c) {
var lr = toLinear(c.r), lg = toLinear(c.g), lb = toLinear(c.b);
var l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
var m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
var s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
var L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
var A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
var B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
return { l: L, c: Math.hypot(A, B), h: Math.atan2(B, A) };
}
function lchToLinear(lch) {
var A = lch.c * Math.cos(lch.h), B = lch.c * Math.sin(lch.h), L = lch.l;
var l = Math.pow(L + 0.3963377774 * A + 0.2158037573 * B, 3);
var m = Math.pow(L - 0.1055613458 * A - 0.0638541728 * B, 3);
var s = Math.pow(L - 0.0894841775 * A - 1.291485548 * B, 3);
return [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
];
}
function outOfGamut(lin) {
for (var i = 0; i < 3; i++) if (lin[i] < -1e-4 || lin[i] > 1 + 1e-4) return true;
return false;
}
// OKLCH back to sRGB, pulling chroma in until the colour fits the gamut.
function fromLch(lch, a) {
var c = lch.c;
var lin = lchToLinear({ l: lch.l, c: c, h: lch.h });
for (var i = 0; i < 24 && outOfGamut(lin); i++) {
c *= 0.9;
lin = lchToLinear({ l: lch.l, c: c, h: lch.h });
}
var out = lin.map(function (v) {
return Math.min(255, Math.max(0, fromLinear(Math.min(1, Math.max(0, v)))));
});
return { r: out[0], g: out[1], b: out[2], a: a };
}
function luminance(c) {
return 0.2126 * toLinear(c.r) + 0.7152 * toLinear(c.g) + 0.0722 * toLinear(c.b);
}
function contrastRatio(x, y) {
var lx = luminance(x), ly = luminance(y);
return (Math.max(lx, ly) + 0.05) / (Math.min(lx, ly) + 0.05);
}
function sameColor(x, y) {
return Math.round(x.r) === Math.round(y.r) && Math.round(x.g) === Math.round(y.g)
&& Math.round(x.b) === Math.round(y.b) && x.a === y.a;
}
// Paints a translucent colour over an opaque one.
function composite(top, under) {
var a = top.a;
return {
r: top.r * a + under.r * (1 - a),
g: top.g * a + under.g * (1 - a),
b: top.b * a + under.b * (1 - a),
a: 1
};
}
// Lightness white lands on, and the bottom of the "already dark" range.
var BG_WHITE_L = 0.2;
var BG_DARK_L = 0.45;
// Chroma from which a background counts as a deliberate, vivid colour.
var VIVID_CHROMA = 0.1;
// Dark counterpart of a background colour. Light colours are mirrored into
// the dark range (white -> the dark canvas, light grey -> a little lighter,
// so the mail's card/canvas layering survives), mid greys move down to meet
// that range, and vivid or already-dark colours are left alone.
function darkenBackground(color) {
if (color.a === 0) return color;
var lch = toLch(color);
if (lch.l <= BG_DARK_L) return color;
if (lch.c >= VIVID_CHROMA) return color;
var l;
if (lch.l >= 0.7) {
// 1.0 -> 0.2, 0.7 -> 0.38
l = BG_WHITE_L + (1 - lch.l) * 0.6;
} else {
// 0.45 (kept) ... 0.7 (-> 0.38): fold mid greys into the dark range.
l = BG_DARK_L - ((lch.l - BG_DARK_L) / (0.7 - BG_DARK_L)) * (BG_DARK_L - 0.38);
}
return fromLch({ l: l, c: lch.c, h: lch.h }, color.a);
}
// Returns color if it already reads against bg, otherwise a colour of the
// same hue moved away from the background's lightness until it reaches
// minRatio. Dark text on a dark background is mirrored first (black -> near
// white, dark grey -> light grey), which keeps the sender's hierarchy of
// text greys instead of flattening them all to one value.
function ensureContrast(color, bg, minRatio) {
if (color.a === 0) return color;
var opaque = color.a < 1 ? composite(color, bg) : color;
if (contrastRatio(opaque, bg) >= minRatio) return color;
var lch = toLch(opaque);
var bgL = toLch(bg).l;
var lighten = bgL < 0.6;
var l = lighten ? Math.max(lch.l, 0.96 - lch.l * 0.5) : Math.min(lch.l, 0.3);
var out = fromLch({ l: l, c: lch.c, h: lch.h }, 1);
for (var i = 0; i < 40 && contrastRatio(out, bg) < minRatio; i++) {
l = lighten ? Math.min(1, l + 0.02) : Math.max(0, l - 0.02);
out = fromLch({ l: l, c: lch.c, h: lch.h }, 1);
if (l === 0 || l === 1) break;
}
return out;
}
// Rewrites every rgb()/rgba() in a computed gradient.
function mapGradientColors(value, map) {
return value.replace(/rgba?\([^)]*\)/gi, function (m) {
var c = parseComputedColor(m);
return c ? formatColor(map(c)) : m;
});
}
// The DOM pass
var BORDER_SIDES = ['top', 'right', 'bottom', 'left'];
var WHITE = { r: 255, g: 255, b: 255, a: 1 };
var SKIP_TAGS = {
IMG: 1, VIDEO: 1, CANVAS: 1, PICTURE: 1, SOURCE: 1, svg: 1, SVG: 1, STYLE: 1, SCRIPT: 1,
BR: 1, WBR: 1, HEAD: 1, TITLE: 1, META: 1, LINK: 1
};
// True for a background-image that actually paints a picture.
function hasImage(backgroundImage) {
if (!backgroundImage || backgroundImage === 'none') return false;
// The blocker rewrites remote urls to url() (computed as url("")); those
// paint nothing.
return /url\(/i.test(backgroundImage.replace(/url\(\s*(?:""|''|)\s*\)/gi, ''));
}
// Lifts a kept dark fill that stood out from its light surroundings (a
// near-black button on a white card) but now sinks into the darkened
// surface around it.
function keepSeparation(next, original, parent) {
if (next !== original || original.a < 0.9) return next;
if (contrastRatio(original, parent.original) < 3) return next;
var target = 1.8;
if (contrastRatio(next, parent.bg) >= target) return next;
var lch = toLch(next);
var parentL = toLch(parent.bg).l;
var l = Math.max(lch.l, parentL);
var out = next;
for (var i = 0; i < 25 && contrastRatio(out, parent.bg) < target; i++) {
l += 0.02;
out = fromLch({ l: l, c: lch.c, h: lch.h }, next.a);
}
return out;
}
// Looks at an image's pixels: does it have see-through areas, and would
// what it draws there disappear against bg? 'unknown' when the pixels can't
// be read (a cross-origin image without CORS).
function judgeImagePixels(source, doc, bg) {
var w = source.naturalWidth, h = source.naturalHeight;
if (!w || !h) return 'fine';
var scale = Math.min(1, 48 / Math.max(w, h));
var cw = Math.max(1, Math.round(w * scale)), ch = Math.max(1, Math.round(h * scale));
var canvas = doc.createElement('canvas');
canvas.width = cw;
canvas.height = ch;
var ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return 'unknown';
var data;
try {
ctx.drawImage(source, 0, 0, cw, ch);
data = ctx.getImageData(0, 0, cw, ch).data;
} catch (e) {
return 'unknown';
}
var clear = 0, drawn = 0, hidden = 0;
for (var i = 0; i < data.length; i += 4) {
var a = data[i + 3] / 255;
if (a < 0.5) { clear++; continue; }
drawn++;
var px = composite({ r: data[i], g: data[i + 1], b: data[i + 2], a: a }, bg);
if (contrastRatio(px, bg) < 2) hidden++;
}
var total = data.length / 4;
// Opaque images cover any plate; mostly-readable artwork doesn't need one.
if (clear / total < 0.02 || drawn === 0) return 'fine';
return hidden / drawn > 0.35 ? 'plate' : 'fine';
}
function whenLoaded(img) {
if (img.complete) return Promise.resolve(img.naturalWidth > 0);
return new Promise(function (resolve) {
img.addEventListener('load', function () { resolve(true); }, { once: true });
img.addEventListener('error', function () { resolve(false); }, { once: true });
});
}
// Loads a second copy of a remote image with CORS so its pixels can be read.
function loadCorsCopy(img, doc) {
var src = img.currentSrc || img.src;
if (!/^https?:/i.test(src)) return Promise.resolve(null);
var copy = doc.createElement('img');
copy.crossOrigin = 'anonymous';
copy.referrerPolicy = 'no-referrer';
var loaded = new Promise(function (resolve) {
var timer = setTimeout(function () { resolve(null); }, 8000);
copy.onload = function () { clearTimeout(timer); resolve(copy); };
copy.onerror = function () { clearTimeout(timer); resolve(null); };
});
copy.src = src;
return loaded;
}
function plateIfNeeded(img, surface, options) {
return whenLoaded(img).then(function (ok) {
if (!ok) return;
var doc = img.ownerDocument;
// Spacers and tracking pixels.
if (img.naturalWidth <= 2 && img.naturalHeight <= 2) return;
var verdict = judgeImagePixels(img, doc, surface.bg);
var next = verdict === 'unknown' && options.remoteContentAllowed
? loadCorsCopy(img, doc).then(function (copy) {
return copy ? judgeImagePixels(copy, doc, surface.bg) : verdict;
})
: Promise.resolve(verdict);
return next.then(function (v) {
// Pixels we can't read get the plate: a light box around a logo is
// better than a logo that isn't there.
if (v === 'fine' || !img.isConnected) return;
img.style.setProperty('background-color', formatColor(surface.original), 'important');
});
}).catch(function () {});
}
function each(list, fn) {
Array.prototype.forEach.call(list, fn);
}
// Converts a rendered mail document to dark colours in place. Call once,
// after the document has laid out; image plates are decided once each
// image has loaded, after this returns.
function apply(doc, options) {
options = options || {};
var win = doc.defaultView;
var body = doc.body;
try {
if (!win || !body) return;
// Un-pin the canvas to read the mail's own; nothing paints before the
// writes below, so the light original never shows.
var html = doc.documentElement;
html.classList.remove('bulwark-dark-pending');
var ours = { r: 20, g: 20, b: 20, a: 1 };
var canvas = parseComputedColor(win.getComputedStyle(html).backgroundColor);
// A mail that paints <html> itself gets that canvas darkened like any
// other fill; left alone it shows wherever the body doesn't cover it.
var mailCanvas = canvas && canvas.a === 1 && !sameColor(canvas, ours) ? canvas : null;
var base = mailCanvas ? darkenBackground(mailCanvas) : ours;
// Read everything first, then write: interleaving would force a style
// recalc per element.
var elements = [body];
each(body.querySelectorAll('*'), function (el) {
if (!SKIP_TAGS[el.tagName] && !el.closest('svg')) elements.push(el);
});
var snaps = elements.map(function (el) {
var cs = win.getComputedStyle(el);
return {
el: el,
color: parseComputedColor(cs.color),
bg: parseComputedColor(cs.backgroundColor),
bgImage: cs.backgroundImage,
borders: BORDER_SIDES.map(function (side) {
return cs.getPropertyValue('border-' + side + '-style') !== 'none'
&& parseFloat(cs.getPropertyValue('border-' + side + '-width')) > 0
? parseComputedColor(cs.getPropertyValue('border-' + side + '-color'))
: null;
})
};
});
// The original canvas is the mail's own or white: that is what the
// light view paints.
var root = { bg: base, original: mailCanvas || WHITE, onImage: false };
var surfaces = new Map();
var surfaceOf = function (el) {
for (var n = el; n; n = n.parentElement) {
var s = surfaces.get(n);
if (s) return s;
}
return root;
};
var writes = [];
if (mailCanvas && base !== mailCanvas) {
writes.push(function () { html.style.setProperty('background-color', formatColor(base), 'important'); });
}
snaps.forEach(function (snap) {
var el = snap.el;
var parent = el === body ? root : surfaceOf(el.parentElement);
var surface = parent;
if (hasImage(snap.bgImage)) {
// Picture background: keep the element's own fill (it shows
// wherever the picture is transparent or fails to load) and its
// text as sent.
var fill = snap.bg && snap.bg.a > 0 ? composite(snap.bg, parent.bg) : parent.bg;
surface = { bg: fill, original: fill, onImage: true };
} else {
if (snap.bgImage && snap.bgImage !== 'none' && !parent.onImage) {
var gradient = mapGradientColors(snap.bgImage, darkenBackground);
if (gradient !== snap.bgImage) {
writes.push(function () { el.style.setProperty('background-image', gradient, 'important'); });
}
}
// A translucent box over a picture still shows the picture, so it
// keeps its colours; an opaque one is a surface of its own.
if (el === body && snap.bg && sameColor(snap.bg, ours)) {
// Our own dark canvas (emailDarkModeCss), not a fill the sender
// chose: in the light view this is the white page.
} else if (snap.bg && snap.bg.a > 0 && !(parent.onImage && snap.bg.a < 0.9)) {
var original = snap.bg;
// The body has no surroundings to stand out from.
var nextBg = parent.onImage || el === body
? darkenBackground(original)
: keepSeparation(darkenBackground(original), original, parent);
surface = {
bg: composite(nextBg, parent.bg),
original: composite(original, parent.original),
onImage: false
};
if (nextBg !== original) {
writes.push(function () { el.style.setProperty('background-color', formatColor(nextBg), 'important'); });
}
}
}
surfaces.set(el, surface);
if (surface.onImage) return;
if (snap.color) {
var nextColor = ensureContrast(snap.color, surface.bg, 4.5);
if (nextColor !== snap.color) {
writes.push(function () { el.style.setProperty('color', formatColor(nextColor), 'important'); });
}
}
snap.borders.forEach(function (border, i) {
if (!border || border.a === 0) return;
// Borders sit between the element and its parent; a light rule on a
// light mail darkens with the backgrounds, a dark one is lifted
// just enough to stay visible.
var darkened = darkenBackground(border);
var nextBorder = darkened !== border ? darkened : ensureContrast(border, parent.bg, 1.6);
if (nextBorder !== border) {
writes.push(function () {
el.style.setProperty('border-' + BORDER_SIDES[i] + '-color', formatColor(nextBorder), 'important');
});
}
});
});
// Images keep their pixels, but a transparent one (a black logo, a dark
// icon) was drawn for the light background it sat on and can vanish on
// the darkened one. Those get that original background back behind them.
var plates = [];
each(body.querySelectorAll('img'), function (img) {
var surface = surfaceOf(img.parentElement);
if (surface.onImage || surface.bg === surface.original) return;
if (toLch(surface.original).l < 0.7 || contrastRatio(surface.original, surface.bg) < 3) return;
plates.push({ img: img, surface: surface });
});
writes.forEach(function (write) { write(); });
plates.forEach(function (p) { plateIfNeeded(p.img, p.surface, options); });
} finally {
doc.documentElement.classList.remove('bulwark-dark-pending');
}
}
return {
parseComputedColor: parseComputedColor,
formatColor: formatColor,
contrastRatio: contrastRatio,
darkenBackground: darkenBackground,
ensureContrast: ensureContrast,
hasImage: hasImage,
apply: apply
};
})`;
/**
* The script the body view injects into an HTML mail shown dark: runs the
* pass once per document (the view injects its scripts again after load) and
* asks for a fresh height report.
*/
export function buildEmailDarkModeScript(options: EmailDarkModeOptions = {}): string {
const opts = JSON.stringify({ remoteContentAllowed: !!options.remoteContentAllowed });
return `
(function () {
if (document.__rnDarkRecolorDone || !document.body) return;
document.__rnDarkRecolorDone = true;
try {
(${EMAIL_DARK_MODE_LIB})().apply(document, ${opts});
} catch (e) {
document.documentElement.classList.remove('${DARK_PENDING_CLASS}');
}
if (window.__rnReport) setTimeout(window.__rnReport, 0);
})();
true;
`;
}
+41 -61
View File
@@ -21,6 +21,9 @@
// class-based colours (native #46/#49). When external content is blocked the
// sheet is scrubbed of url()/@import/@font-face references instead.
import { DARK_PENDING_CLASS, emailColorScheme, emailDarkModeCss, resolveColorSchemeMedia } from './email-dark-mode';
import { mailFontStack, readingFontFaceCss, type AppFont } from '../theme/reading-fonts';
const FORBIDDEN_TAGS = [
'script', 'iframe', 'object', 'embed', 'form',
'input', 'button', 'textarea', 'select', 'option',
@@ -675,9 +678,9 @@ export function hasRemoteContent(html: string): boolean {
// ─── Styles ──────────────────────────────────────────────────────────────
// Base styles for HTML emails - mirrors the webmail iframe body.
// Emails are authored for light mode; we render them true-to-life and apply a
// filter inversion trick for dark mode (unless the email has native dark
// support via @media (prefers-color-scheme: dark)).
// Emails are authored for light mode; they are rendered true-to-life, and in
// dark mode their colours are rewritten after layout by the injected pass in
// ./email-dark-mode.
function baseStyles(bodyPadding: string): string {
return `
html { background: #ffffff; height: auto !important; }
@@ -731,46 +734,6 @@ body { line-height: 1.15; }
p.MsoNormal, li.MsoNormal, div.MsoNormal { margin: 0 0 6px; }
`;
// Applied when the host is in dark mode and the email does NOT have native
// dark-mode CSS. Matches the webmail's darkModeCSS verbatim.
//
// Background-COLOR containers (bgcolor, background: shorthand) are re-inverted
// so their fill flips to a dark tone, but skipped when they wrap media: the
// inner image already gets its own re-invert, and a filter on the container
// would double-invert it. Nested colour containers must NOT stack another
// invert either - each filter toggles the inversion, so an odd number of
// stacked filters lands back on light-on-light; the descendant rule disables
// filter on a colour container nested inside another.
//
// Background-IMAGE containers are different: a real photo and whatever is
// composited over it was designed by the sender to read together, so the
// whole subtree should return to its original light-mode appearance - even
// when it wraps media. Re-invert those unconditionally (no :has guard) and
// cancel the per-image re-invert for media inside them.
const DARK_INVERSION = `
html { background: #121212; }
body { filter: invert(1) hue-rotate(180deg); background: #ededed; }
img, video, svg, canvas, object, embed, input[type="image"] {
filter: invert(1) hue-rotate(180deg);
}
[style*="background:"]:not(:has(img, video, svg, canvas, object, embed)),
[bgcolor]:not(:has(img, video, svg, canvas, object, embed)) {
filter: invert(1) hue-rotate(180deg);
}
:where([style*="background:"], [bgcolor])
:where([style*="background:"], [bgcolor]):not(:has(img, video, svg, canvas, object, embed)) {
filter: none !important;
}
[style*="background-image"],
[background] {
filter: invert(1) hue-rotate(180deg);
}
[style*="background-image"] :where(img, video, svg, canvas, object, embed, input[type="image"]),
[background] :where(img, video, svg, canvas, object, embed, input[type="image"]) {
filter: none;
}
`;
// Matches the webmail's `emailHasNativeDarkMode` detector: true if the HTML
// contains a `@media (prefers-color-scheme: dark)` rule.
export function hasNativeDarkMode(html: string): boolean {
@@ -855,16 +818,16 @@ export interface WrapOptions {
/** Neutralise external resources and switch to the strict CSP. */
blockRemoteImages?: boolean;
cidMap?: Record<string, string>;
/** When true, the host renders in dark mode; inversion is applied unless
* the email already has native dark-mode CSS. */
/** When true, the host renders in dark mode: the head gets the dark
* fallback colours and the body stays hidden until the recolour pass ran. */
isDark?: boolean;
messageSpacing?: MessageSpacing;
}
export interface PreparedEmailHtml {
html: string;
/** Inversion CSS is in effect; the DOM re-invert pass should run. */
applyInversion: boolean;
/** Shown dark: the recolour pass (./email-dark-mode) has to run. */
recolorDark: boolean;
/** The email ships its own `prefers-color-scheme: dark` rules. */
hasNativeDark: boolean;
/** At least one external resource was neutralised. */
@@ -897,10 +860,10 @@ function buildCsp(strict: boolean): string {
}
/**
* Turn a raw HTML body into a complete, sandboxed document. `hasNativeDark`
* and `applyInversion` are evaluated once on the sanitised input and returned
* so the caller injects the matching DOM pass (a mismatch between the CSS
* decision and the script decision was native #49).
* Turn a raw HTML body into a complete, sandboxed document. `recolorDark` is
* returned so the caller injects the recolour pass exactly when the head
* hides the body for it (a mismatch between the CSS decision and the script
* decision was native #49).
*/
export function prepareEmailHtml(innerHtml: string, options: WrapOptions = {}): PreparedEmailHtml {
const { blockRemoteImages = false, cidMap, isDark = true, messageSpacing = 'auto' } = options;
@@ -915,9 +878,14 @@ export function prepareEmailHtml(innerHtml: string, options: WrapOptions = {}):
}
const hasNativeDark = hasNativeDarkMode(processed);
const applyInversion = isDark && !hasNativeDark;
const colorScheme = isDark && hasNativeDark ? 'light dark' : 'light';
const darkCss = applyInversion ? DARK_INVERSION : '';
// The recolour pass runs on every mail shown dark, also one with its own
// dark styles: those are pinned to the app's theme below, and the pass
// leaves colours that are already dark or readable alone (webmail
// email-viewer.tsx recolorDark).
const recolorDark = isDark;
const colorScheme = emailColorScheme(isDark, hasNativeDark);
const darkCss = recolorDark ? emailDarkModeCss() : '';
const rootClass = recolorDark ? ` class="${DARK_PENDING_CLASS}"` : '';
const word = isWordHtml(processed);
const hasStyleTag = /<style\b/i.test(processed);
@@ -929,7 +897,7 @@ export function prepareEmailHtml(innerHtml: string, options: WrapOptions = {}):
const bodyDir = declaresOwnDirection(processed) ? '' : ' dir="auto"';
const html = `<!doctype html>
<html style="color-scheme: ${colorScheme};">
<html${rootClass} style="color-scheme: ${colorScheme};">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
@@ -937,10 +905,10 @@ export function prepareEmailHtml(innerHtml: string, options: WrapOptions = {}):
<meta name="referrer" content="no-referrer">
<style>${baseStyles(bodyPadding)}${word ? WORD_HTML_CSS : ''}${darkCss}</style>
</head>
<body${bodyDir}>${processed}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body>
<body${bodyDir}>${resolveColorSchemeMedia(processed, isDark)}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body>
</html>`;
return { html, applyInversion, hasNativeDark, blockedExternal };
return { html, recolorDark, hasNativeDark, blockedExternal };
}
export function wrapEmailHtml(innerHtml: string, options: WrapOptions = {}): string {
@@ -968,10 +936,22 @@ function cssColor(value: string, fallback: string): string {
export function wrapPlainTextEmail(
innerHtml: string,
options: { isDark?: boolean; font?: PlainTextFont; fontPx?: number; darkColors?: PlainTextColors } = {},
options: {
isDark?: boolean;
font?: PlainTextFont;
fontPx?: number;
darkColors?: PlainTextColors;
/**
* The app's reading font (Settings > Accessibility > Font). "Same as
* above" (sans) draws the body in it, as the webmail does for a plain
* body in the app document; monospace stays monospace.
*/
readingFont?: AppFont;
} = {},
): string {
const { isDark = true, font = 'sans', fontPx = 14, darkColors } = options;
const { isDark = true, font = 'sans', fontPx = 14, darkColors, readingFont } = options;
const cleaned = stripDangerousTags(innerHtml);
const reading = font === 'sans' ? readingFont : undefined;
// In dark mode the body takes the app theme's ground, text and link colours
// when they are given (a built-in dark theme isn't #0a0a0a); light mode
@@ -981,9 +961,9 @@ export function wrapPlainTextEmail(
const link = isDark ? cssColor(darkColors?.link ?? '', '#60a5fa') : '#1a73e8';
const fontFamily = font === 'mono'
? 'ui-monospace, Menlo, Consolas, "SF Mono", monospace'
: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
: mailFontStack(reading);
const styles = `
const styles = `${readingFontFaceCss(reading)}
html, body { background: ${bg}; }
/* Pinch-zoomed content overflows sideways; it is panned via the injected
transform, never via native horizontal scrolling. */
+18
View File
@@ -213,6 +213,24 @@
"version": "",
"license": "MIT",
"url": "https://tabler.io/icons"
},
{
"name": "Atkinson Hyperlegible Next",
"version": "",
"license": "OFL-1.1",
"url": "https://github.com/googlefonts/atkinson-hyperlegible-next"
},
{
"name": "Lexend",
"version": "",
"license": "OFL-1.1",
"url": "https://github.com/googlefonts/lexend"
},
{
"name": "OpenDyslexic",
"version": "",
"license": "OFL-1.1",
"url": "https://opendyslexic.org"
}
]
}
+2 -2
View File
@@ -57,8 +57,8 @@ export const QUOTE_COLLAPSED_ATTR = 'data-quote-collapsed';
/**
* The in-page implementation of `setupQuoteCollapse`, as a self-contained
* script for `injectedJavaScript`. Idempotent per document. Uses inline
* styles only (style-src allows inline; neutral greys read fine after the
* dark-mode invert filter too).
* styles only (style-src allows inline; the dark-mode recolour pass runs
* after it and converts the toggle like the rest of the mail).
*/
export function buildQuoteCollapseScript(labels: QuoteCollapseLabels): string {
const cfg = JSON.stringify({
+5 -1
View File
@@ -63,8 +63,12 @@ export const SETTINGS_SEARCH_PATHS: Record<SettingsTabId, string[]> = {
accessibility: [
'settings.accessibility.presets.screen-reader',
'settings.accessibility.presets.low-vision',
'settings.accessibility.presets.dyslexia',
'settings.accessibility.presets.motion',
'settings.accessibility.text_size',
'settings.accessibility.font',
'settings.accessibility.plain_language',
'settings.accessibility.cvd_palette',
'settings.accessibility.reduce_motion',
'settings.accessibility.toast_duration',
'settings.accessibility.confirm_send',
@@ -267,7 +271,7 @@ export const SETTINGS_SEARCH_KEYWORDS: Record<SettingsTabId, string> = {
recovery: 'recover restore undelete deleted trash emptied lost messages',
language: 'locale region date time format translation',
notifications: 'alert push badge unifiedpush relay device reminder',
accessibility: 'a11y screen reader talkback voiceover low vision text size font bigger larger motion animation preset undo timeout duration confirm',
accessibility: 'a11y screen reader talkback voiceover low vision text size font bigger larger motion animation preset undo timeout duration confirm dyslexia dyslexic typeface atkinson lexend opendyslexic colour color blind blindness palette plain simple easy language wording',
appearance: 'theme dark light font size text color animation density',
layout: 'swipe gesture quick actions toolbar order sort unified folder count avatar',
reading: 'mark read preview thread conversation archive delete attachment open monospace mono font plain text receipt',
+40 -4
View File
@@ -24,8 +24,15 @@ interface LocaleState {
* next launch, so the language pane shows a "restart to apply" hint.
*/
directionChangePending: boolean;
/**
* Plain language (Settings > Accessibility): simpler wording where the
* language has it (English, German). Mirrors the settings store's
* plainLanguage, which is where the user's choice is kept.
*/
plainLanguage: boolean;
hydrate: () => Promise<void>;
setOverride: (locale: LocaleCode | null) => void;
setPlainLanguage: (on: boolean) => void;
t: TranslateFn;
}
@@ -51,8 +58,8 @@ function applyLayoutDirection(locale: LocaleCode): boolean {
// A new `t` per locale: most components select only `t`, and a function
// that never changed identity left them in the device language after the
// saved one loaded on a cold start (tab labels, the folder title).
function translatorFor(locale: LocaleCode): TranslateFn {
return (key, fallback, params) => translate(locale, key, fallback, params);
function translatorFor(locale: LocaleCode, plain = false): TranslateFn {
return (key, fallback, params) => translate(locale, key, fallback, params, plain);
}
export const useLocaleStore = create<LocaleState>((set, get) => ({
@@ -60,6 +67,7 @@ export const useLocaleStore = create<LocaleState>((set, get) => ({
override: null,
hydrated: false,
directionChangePending: false,
plainLanguage: false,
hydrate: async () => {
if (get().hydrated) return;
@@ -69,7 +77,7 @@ export const useLocaleStore = create<LocaleState>((set, get) => ({
const override = stored && isSupportedLocale(stored) ? stored : null;
const locale = override ?? detectDeviceLocale();
const directionChangePending = applyLayoutDirection(locale);
set({ override, locale, hydrated: true, directionChangePending, t: translatorFor(locale) });
set({ override, locale, hydrated: true, directionChangePending, t: translatorFor(locale, get().plainLanguage) });
} catch {
set({ hydrated: true });
}
@@ -78,13 +86,41 @@ export const useLocaleStore = create<LocaleState>((set, get) => ({
setOverride: (override) => {
const locale = override ?? detectDeviceLocale();
const directionChangePending = applyLayoutDirection(locale);
set({ override, locale, directionChangePending, t: translatorFor(locale) });
set({ override, locale, directionChangePending, t: translatorFor(locale, get().plainLanguage) });
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ override })).catch(() => {});
},
// A new `t` here too, so every component that selects it re-renders.
setPlainLanguage: (on) => {
if (get().plainLanguage === on) return;
set({ plainLanguage: on, t: translatorFor(get().locale, on) });
},
t: translatorFor(detectDeviceLocale()),
}));
interface PlainLanguageSource {
getState: () => { plainLanguage?: boolean };
subscribe: (listener: (state: { plainLanguage?: boolean }) => void) => unknown;
}
/** Keeps the wording in step with the settings store's Plain language switch. */
export function followPlainLanguage(settings: PlainLanguageSource): void {
useLocaleStore.getState().setPlainLanguage(!!settings.getState().plainLanguage);
settings.subscribe((state) => {
useLocaleStore.getState().setPlainLanguage(!!state.plainLanguage);
});
}
// Required, not imported: the settings store loads the API client, which
// tests of this store's users mock piecemeal; without it (a test) the
// wording stays normal.
try {
followPlainLanguage((require('./settings-store') as typeof import('./settings-store')).useSettingsStore);
} catch {
// No settings store: normal wording.
}
// Non-hook accessor for code outside React (background tasks, stores).
export function t(key: string, fallback?: string, params?: MessageParams): string {
return useLocaleStore.getState().t(key, fallback, params);
+20
View File
@@ -17,6 +17,9 @@ export type MailAttachmentAction = 'preview' | 'download';
export type AttachmentPosition = 'beside-sender' | 'below-header';
export type PlainTextFont = 'sans' | 'mono';
export type MessageSpacing = 'auto' | 'always' | 'edge';
// a11y-reading: the webmail's reading fonts (lib/accessibility-settings.ts AppFont).
export type AppFont = 'default' | 'atkinson' | 'lexend' | 'opendyslexic';
export const APP_FONTS: readonly AppFont[] = ['default', 'atkinson', 'lexend', 'opendyslexic'];
export type ReadReceiptResponse = 'ask' | 'always' | 'never';
export type PostExportAction = 'keep' | 'archive' | 'trash';
export type SwipeAction =
@@ -159,6 +162,17 @@ export interface PersistedSettings {
emailAlwaysLightMode: boolean;
activeThemeId: string | null;
// a11y-reading: Settings > Accessibility (webmail lib/accessibility-
// settings.ts, same keys for export/import). The reading font for the
// app's text and plain-text mail (theme/reading-fonts).
appFont: AppFont;
// Tag and calendar colours drawn in the colour-blind-safe palette
// (lib/cvd-palette, theme/colors).
colorBlindPalette: boolean;
// Simpler wording in English and German (locales/<code>/plain.json,
// i18n/index, stores/locale-store).
plainLanguage: boolean;
// Composing
autoSelectReplyIdentity: boolean;
replyIdentityMatch: ReplyIdentityMatch;
@@ -359,6 +373,10 @@ const DEFAULT_PERSISTED: PersistedSettings = {
animationsEnabled: true,
emailAlwaysLightMode: false,
activeThemeId: null,
// a11y-reading:
appFont: 'default',
colorBlindPalette: false,
plainLanguage: false,
autoSelectReplyIdentity: false,
replyIdentityMatch: 'domain',
@@ -617,6 +635,8 @@ const VALIDATORS: Partial<Record<keyof PersistedSettings, (v: unknown) => boolea
// Levels are sanitized by the consumer (lib/message-list-order sanitizeSortLevels).
messageListOrder: (v) => Array.isArray(v),
fontSize: oneOf(FONT_SIZES),
// a11y-reading:
appFont: oneOf(APP_FONTS),
density: oneOf(['extra-compact', 'compact', 'regular', 'comfortable']),
attachmentReminderKeywords: stringArray,
// Same set the webmail accepts (stores/settings-store.ts importSettings).
+141
View File
@@ -0,0 +1,141 @@
// Reading fonts (Settings > Accessibility > Font, webmail
// lib/accessibility-settings.ts AppFont, app/accessibility.css): the names,
// the bundled Android files, the @font-face rules a mail body gets, and the
// wrapper that puts the font on the app's Text.
import { describe, it, expect, vi } from 'vitest';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
// The wrapper subscribes with useSyncExternalStore; outside a React render
// the test reads the snapshot directly.
vi.mock('react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useSyncExternalStore: (_subscribe: unknown, getSnapshot: () => unknown) => getSnapshot(),
}));
import {
appFontAssetFamily,
appFontFamilyName,
mailFontStack,
readingFontFaceCss,
uiFontFamily,
uiFontsBundled,
} from '../reading-fonts';
import {
getUiFontFamily,
setUiFontFamily,
withFontFamily,
wrapModuleDefaults,
wrapWithReadingFont,
} from '../reading-font-text';
import { APP_FONTS, mergeWithDefaults } from '../../stores/settings-store';
import { presetChanges } from '../../lib/accessibility-presets';
const ASSETS = join(__dirname, '..', '..', '..', 'android', 'app', 'src', 'main', 'assets', 'fonts');
describe('reading fonts', () => {
it('offers the webmail fonts under its names and keys', () => {
expect(APP_FONTS).toEqual(['default', 'atkinson', 'lexend', 'opendyslexic']);
expect(appFontFamilyName('atkinson')).toBe('Atkinson Hyperlegible Next');
expect(appFontFamilyName('lexend')).toBe('Lexend');
expect(appFontFamilyName('opendyslexic')).toBe('OpenDyslexic');
expect(appFontFamilyName('default')).toBeNull();
});
it('bundles a regular and a bold file on Android for every font', () => {
for (const font of APP_FONTS) {
const family = appFontAssetFamily(font);
if (font === 'default') {
expect(family).toBeNull();
continue;
}
expect(existsSync(join(ASSETS, `${family}.ttf`))).toBe(true);
expect(existsSync(join(ASSETS, `${family}_bold.ttf`))).toBe(true);
}
expect(existsSync(join(ASSETS, 'OFL.txt'))).toBe(true);
});
it('names a family for the app text only where the fonts are bundled', () => {
expect(uiFontsBundled('android')).toBe(true);
expect(uiFontFamily('opendyslexic', 'android')).toBe('OpenDyslexic');
expect(uiFontFamily('default', 'android')).toBeNull();
// iOS reports an unknown family as an error.
expect(uiFontFamily('opendyslexic', 'ios')).toBeNull();
});
it('puts the font first in a mail body and keeps the system stack behind it', () => {
expect(mailFontStack('lexend')).toMatch(/^"Lexend", -apple-system, /);
expect(mailFontStack('default')).toMatch(/^-apple-system, /);
});
it('gives a mail document the font as data: woff2 with the webmail unicode ranges', () => {
const css = readingFontFaceCss('atkinson');
const faces = css.split('\n');
expect(faces).toHaveLength(2);
for (const face of faces) {
expect(face).toContain('font-family: "Atkinson Hyperlegible Next";');
expect(face).toContain('font-weight: 200 800;');
// "wOF2", the woff2 signature, in base64.
expect(face).toMatch(/src: url\("data:font\/woff2;base64,d09GMg[A-Za-z0-9+/=]+"\) format\("woff2"\);/);
expect(face).toMatch(/unicode-range: U\+/);
}
expect(readingFontFaceCss('opendyslexic')).toContain('font-family: "OpenDyslexic";');
expect(readingFontFaceCss('default')).toBe('');
});
it('keeps a stored font and drops one the app does not know', () => {
expect(mergeWithDefaults({ appFont: 'lexend' }).appFont).toBe('lexend');
expect(mergeWithDefaults({ appFont: 'comic-sans' as never }).appFont).toBe('default');
});
it('has a dyslexia preset that picks the readable font for the app and plain-text mail', () => {
expect(presetChanges('dyslexia', { appFont: 'default', plainTextFont: 'mono' })).toEqual([
{ key: 'appFont', from: 'default', to: 'atkinson' },
{ key: 'plainTextFont', from: 'mono', to: 'sans' },
]);
});
});
describe('the reading font on the app text', () => {
it('puts the family under the caller style, which still wins', () => {
const own = { color: 'red' };
expect(withFontFamily({ style: own }, 'Lexend')).toEqual({ style: [{ fontFamily: 'Lexend' }, own] });
expect(withFontFamily({}, 'Lexend')).toEqual({ style: { fontFamily: 'Lexend' } });
const props = { style: own };
expect(withFontFamily(props, null)).toBe(props);
});
it('renders the original component with the current family', () => {
const seen: unknown[] = [];
const Original = Object.assign((props: Record<string, unknown>) => { seen.push(props); return null; }, {
displayName: 'TextInput',
State: { currentlyFocusedInput: () => null },
});
const Wrapped = wrapWithReadingFont(Original);
// Statics such as TextInput.State stay reachable.
expect((Wrapped as unknown as typeof Original).State).toBe(Original.State);
expect(Wrapped.displayName).toBe('TextInput');
setUiFontFamily(null);
Wrapped({ style: { color: 'red' }, children: 'x' });
expect(seen.pop()).toEqual({ style: { color: 'red' }, children: 'x' });
setUiFontFamily('OpenDyslexic');
expect(getUiFontFamily()).toBe('OpenDyslexic');
Wrapped({ style: { color: 'red' }, children: 'x' });
expect(seen.pop()).toEqual({ style: [{ fontFamily: 'OpenDyslexic' }, { color: 'red' }], children: 'x' });
setUiFontFamily(null);
});
it('wraps each module default once and leaves non-functions alone', () => {
const text = { default: () => null };
const memo = { default: { $$typeof: Symbol.for('react.memo') } };
const original = text.default;
expect(wrapModuleDefaults([text, memo])).toBe(1);
expect(text.default).not.toBe(original);
const wrapped = text.default;
expect(wrapModuleDefaults([text])).toBe(0);
expect(text.default).toBe(wrapped);
expect(memo.default).toEqual({ $$typeof: Symbol.for('react.memo') });
});
});
+28 -6
View File
@@ -1,8 +1,9 @@
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useColorScheme } from 'react-native';
import { useSettingsStore } from '../stores/settings-store';
import { LIGHT_COLORS, DARK_COLORS, type ThemePalette } from './tokens';
import { getBuiltinTheme } from './builtin-themes';
import { cvdColor, cvdTagPalette } from '../lib/cvd-palette';
export type { ThemePalette };
@@ -12,15 +13,24 @@ const paletteCache = new Map<string, ThemePalette>();
* Base light/dark palette with the active built-in theme's overrides applied
* (Settings → Themes). Memoised per (scheme, theme) pair so `useColors()`
* keeps returning a stable object for `useMemo(() => makeStyles(c), [c])`.
*
* With "Colour-blind-safe colours" (Settings → Accessibility, webmail
* colorBlindPalette) the tag colours are drawn in that palette
* (lib/cvd-palette): every place that draws a tag from `c.tags` follows,
* as the webmail's tag formatter does. The picked colours stay in
* `tagsAsPicked` for the colour picker and tag settings.
*/
export function resolvePalette(scheme: 'light' | 'dark', themeId: string | null): ThemePalette {
export function resolvePalette(scheme: 'light' | 'dark', themeId: string | null, colorBlind = false): ThemePalette {
const base = scheme === 'light' ? LIGHT_COLORS : DARK_COLORS;
const theme = getBuiltinTheme(themeId);
if (!theme) return base;
const key = `${scheme}:${theme.id}`;
if (!theme && !colorBlind) return base;
const key = `${scheme}:${theme?.id ?? ''}:${colorBlind ? 'cvd' : ''}`;
const cached = paletteCache.get(key);
if (cached) return cached;
const merged: ThemePalette = { ...base, ...(scheme === 'light' ? theme.light : theme.dark) };
let merged: ThemePalette = theme ? { ...base, ...(scheme === 'light' ? theme.light : theme.dark) } : base;
if (colorBlind) {
merged = { ...merged, tags: cvdTagPalette(merged.tags, merged.text), tagsAsPicked: merged.tags };
}
paletteCache.set(key, merged);
return merged;
}
@@ -36,12 +46,24 @@ export function resolvePalette(scheme: 'light' | 'dark', themeId: string | null)
export function useColors(): ThemePalette {
const themePref = useSettingsStore((s) => s.theme);
const activeThemeId = useSettingsStore((s) => s.activeThemeId);
const colorBlind = useSettingsStore((s) => s.colorBlindPalette);
const systemScheme = useColorScheme();
const resolved =
themePref === 'system'
? systemScheme === 'light' ? 'light' : 'dark'
: themePref;
return useMemo(() => resolvePalette(resolved, activeThemeId), [resolved, activeThemeId]);
return useMemo(() => resolvePalette(resolved, activeThemeId, colorBlind), [resolved, activeThemeId, colorBlind]);
}
/**
* Draws a colour the user picked (a calendar's, a tag's hex) in the
* colour-blind-safe palette while that setting is on, and as it is otherwise
* (webmail hooks/use-cvd-color.ts). For colours that don't come from
* `c.tags`, such as calendar colours from the server.
*/
export function useCvdColor(): (color: string) => string {
const enabled = useSettingsStore((s) => s.colorBlindPalette);
return useCallback((color: string) => cvdColor(color, enabled), [enabled]);
}
/**
+22
View File
@@ -0,0 +1,22 @@
// Installs the reading font for the app's text (./reading-font-text). Imported
// first thing by index.ts, before any screen module copies `Text` out of
// react-native. Android only: iOS has no bundled reading fonts yet
// (./reading-fonts). The settings store is required after the wrapping, so
// nothing it loads can copy the plain Text first.
/* eslint-disable @typescript-eslint/no-require-imports */
import { Platform } from 'react-native';
import { setUiFontFamily, wrapModuleDefaults } from './reading-font-text';
import { uiFontFamily, uiFontsBundled } from './reading-fonts';
if (uiFontsBundled(Platform.OS)) {
wrapModuleDefaults([
require('react-native/Libraries/Text/Text'),
require('react-native/Libraries/Components/TextInput/TextInput'),
]);
const { useSettingsStore } = require('../stores/settings-store') as typeof import('../stores/settings-store');
setUiFontFamily(uiFontFamily(useSettingsStore.getState().appFont, Platform.OS));
useSettingsStore.subscribe((state, prev) => {
if (state.appFont !== prev.appFont) setUiFontFamily(uiFontFamily(state.appFont, Platform.OS));
});
}
+88
View File
@@ -0,0 +1,88 @@
// The reading font for the app's own text (Settings > Accessibility > Font).
//
// The webmail sets one CSS variable on <html> and every piece of text follows
// (app/accessibility.css data-app-font). React Native has no inherited font:
// each Text takes its family from its own style, the app's styles name none,
// and Text.defaultProps is gone on the new architecture. So the app's Text
// and TextInput components are wrapped once at startup: the wrapper puts the
// chosen family under the caller's own style (an explicit fontFamily still
// wins) and re-renders when the setting changes.
//
// Wrapping happens before any screen module loads (index.ts imports
// ./reading-font-install first): with Metro's import support every module
// copies `Text` out of react-native when it is evaluated, so a module loaded
// earlier keeps the plain component. The wrapper stays installed for the
// whole run, also while the theme font is chosen; swapping the component
// later would remount every text field.
import { useSyncExternalStore } from 'react';
type Props = { style?: unknown } & Record<string, unknown>;
type FunctionComponent = ((props: Props) => unknown) & { displayName?: string };
let family: string | null = null;
const listeners = new Set<() => void>();
const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => { listeners.delete(listener); };
};
/** The family the app's text is drawn in right now, or null for the theme font. */
export function getUiFontFamily(): string | null {
return family;
}
/** Changes the family; every wrapped Text re-renders. */
export function setUiFontFamily(next: string | null): void {
if (next === family) return;
family = next;
for (const listener of [...listeners]) listener();
}
/** `props` with the family under the caller's own style; unchanged for null. */
export function withFontFamily<P extends { style?: unknown }>(props: P, fontFamily: string | null): P {
if (!fontFamily) return props;
return { ...props, style: props.style == null ? { fontFamily } : [{ fontFamily }, props.style] };
}
const WRAPPED = Symbol.for('bulwark.readingFont');
/**
* A component that renders `Original` with the reading font. `Original` is
* called as a function, not rendered as a child, so no extra component is
* added per text: its hooks run as part of the wrapper, always in the same
* order. Statics (TextInput.State) and the display name are kept.
*/
export function wrapWithReadingFont(Original: FunctionComponent): FunctionComponent {
const Wrapped = function ReadingFontText(props: Props) {
const current = useSyncExternalStore(subscribe, getUiFontFamily, getUiFontFamily);
return Original(withFontFamily(props, current));
} as FunctionComponent;
Object.assign(Wrapped, Original);
Wrapped.displayName = Original.displayName ?? Original.name;
(Wrapped as unknown as Record<symbol, unknown>)[WRAPPED] = true;
return Wrapped;
}
/**
* Wraps the default export of each module (react-native's Text and
* TextInput) in place. Only plain function components are wrapped; anything
* else (a future memo or forwardRef object) is left alone. Returns how many
* were wrapped; a second call wraps nothing.
*/
export function wrapModuleDefaults(modules: Array<{ default?: unknown }>): number {
let wrapped = 0;
for (const mod of modules) {
const component = mod?.default;
if (typeof component !== 'function') continue;
if ((component as unknown as Record<symbol, unknown>)[WRAPPED]) continue;
try {
mod.default = wrapWithReadingFont(component as FunctionComponent);
wrapped++;
} catch {
// A read-only export: leave the component as it is.
}
}
return wrapped;
}
File diff suppressed because one or more lines are too long
+97
View File
@@ -0,0 +1,97 @@
// The reading fonts on Settings > Accessibility > Font (webmail
// lib/accessibility-settings.ts AppFont, app/accessibility.css,
// lib/mail-reading.ts appFontFamilyName): Atkinson Hyperlegible Next, Lexend
// and OpenDyslexic. As in the webmail the font is the app's text font, and a
// plain-text mail body (which the webmail draws in the app document) uses it
// too; HTML mail keeps the sender's fonts.
//
// The app's text: Android finds the fonts in android/app/src/main/assets/
// fonts by family name (scripts/build-reading-fonts.py), and the Text patch in
// ./reading-font-text applies the family. iOS needs them registered in the
// app bundle (expo-font's config plugin), which the app doesn't have yet, so
// there the setting only reaches mail bodies.
//
// A mail body renders in a sandboxed WebView, which gets the woff2 files as
// data: URIs (./reading-fonts-data: a few hundred KB of base64 string
// constants, which Hermes keeps in the bytecode's string table rather than
// parsing them at startup).
import type { AppFont } from '../stores/settings-store';
import { ATKINSON, LEXEND, OPENDYSLEXIC, type ReadingFontFace } from './reading-fonts-data';
export type { AppFont };
/** The CSS family name a font is bundled under (webmail appFontFamilyName), or null for the default. */
export function appFontFamilyName(font: AppFont | null | undefined): string | null {
switch (font) {
case 'atkinson': return 'Atkinson Hyperlegible Next';
case 'lexend': return 'Lexend';
case 'opendyslexic': return 'OpenDyslexic';
default: return null;
}
}
/** The Android asset family (assets/fonts/<name>.ttf and <name>_bold.ttf). */
export function appFontAssetFamily(font: AppFont | null | undefined): string | null {
switch (font) {
case 'atkinson': return 'AtkinsonHyperlegibleNext';
case 'lexend': return 'Lexend';
case 'opendyslexic': return 'OpenDyslexic';
default: return null;
}
}
/** Whether the platform has the fonts for the app's own text. */
export function uiFontsBundled(platform: string): boolean {
return platform === 'android';
}
/**
* The fontFamily the app's text is drawn in: null for the theme font, and
* where the fonts aren't bundled (an unknown family is an error on iOS).
*/
export function uiFontFamily(font: AppFont | null | undefined, platform: string): string | null {
return uiFontsBundled(platform) ? appFontAssetFamily(font) : null;
}
/** The mail body's own font stack (lib/email-html). */
export const MAIL_SYSTEM_FONT_STACK = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
/**
* font-family for a mail body: the reading font first (it covers Latin
* script; other scripts fall through to the system stack, as in the webmail).
*/
export function mailFontStack(font: AppFont | null | undefined): string {
const family = appFontFamilyName(font);
return family ? `"${family}", ${MAIL_SYSTEM_FONT_STACK}` : MAIL_SYSTEM_FONT_STACK;
}
function faceFor(font: AppFont): ReadingFontFace | null {
switch (font) {
case 'atkinson': return ATKINSON;
case 'lexend': return LEXEND;
case 'opendyslexic': return OPENDYSLEXIC;
default: return null;
}
}
/**
* The @font-face rules a mail document needs for the font, with the fonts as
* data: URIs (the page's CSP allows font-src data: in every mode). Empty for
* the default font.
*/
export function readingFontFaceCss(font: AppFont | null | undefined): string {
if (!font || font === 'default') return '';
const face = faceFor(font);
if (!face) return '';
return face.subsets.map((subset) => [
'@font-face {',
`font-family: "${face.family}";`,
'font-style: normal;',
'font-display: swap;',
`font-weight: ${face.weight};`,
`src: url("data:font/woff2;base64,${subset.woff2}") format("woff2");`,
subset.unicodeRange ? `unicode-range: ${subset.unicodeRange};` : '',
'}',
].filter(Boolean).join(' ')).join('\n');
}
+6
View File
@@ -74,6 +74,12 @@ export interface ThemePalette {
// Tags
tags: TagColors;
/**
* The tag colours as the user picks them, set while tags are drawn in the
* colour-blind-safe palette (theme/colors): the colour picker and the tag
* settings show the colours themselves, as in the webmail.
*/
tagsAsPicked?: TagColors;
// Navigation
navActive: string;