mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
feat: recolour HTML mail for dark mode instead of inverting it
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;
|
||||
@@ -569,8 +467,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
|
||||
@@ -730,18 +628,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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -37,8 +37,8 @@ export interface BodyDocumentInput {
|
||||
|
||||
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;
|
||||
@@ -69,7 +69,7 @@ export function buildBodyDocument(input: BodyDocumentInput): BodyDocument {
|
||||
fontPx: mailBaseFontPx(input.textFactor ?? 1),
|
||||
darkColors: input.plainDarkColors,
|
||||
}),
|
||||
applyInversion: false,
|
||||
recolorDark: false,
|
||||
hasNativeDark: false,
|
||||
blockedExternal: false,
|
||||
isHtml: false,
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
}
|
||||
+24
-57
@@ -21,6 +21,8 @@
|
||||
// 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';
|
||||
|
||||
const FORBIDDEN_TAGS = [
|
||||
'script', 'iframe', 'object', 'embed', 'form',
|
||||
'input', 'button', 'textarea', 'select', 'option',
|
||||
@@ -675,9 +677,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 +733,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 +817,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 +859,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 +877,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 +896,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 +904,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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user