mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 00:06:39 +00:00
Merge branch 'cu-readx' into catch-up-2026-11
This commit is contained in:
@@ -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')}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user