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}