diff --git a/src/components/EmailBodyView.tsx b/src/components/EmailBodyView.tsx index bf6161b4..da8ebdc2 100644 --- a/src/components/EmailBodyView.tsx +++ b/src/components/EmailBodyView.tsx @@ -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

(prefers-color-scheme: dark)

'; + + 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

x

', { 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('

x

', { 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('x

', { 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 = '

x

'; + 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', () => { diff --git a/src/lib/email-body-document.ts b/src/lib/email-body-document.ts index 68b8d5fa..c01cd088 100644 --- a/src/lib/email-body-document.ts +++ b/src/lib/email-body-document.ts @@ -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, diff --git a/src/lib/email-dark-mode.ts b/src/lib/email-dark-mode.ts new file mode 100644 index 00000000..37a48f31 --- /dev/null +++ b/src/lib/email-dark-mode.ts @@ -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 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 -${processed} +${resolveColorSchemeMedia(processed, isDark)} `; - return { html, applyInversion, hasNativeDark, blockedExternal }; + return { html, recolorDark, hasNativeDark, blockedExternal }; } export function wrapEmailHtml(innerHtml: string, options: WrapOptions = {}): string { diff --git a/src/lib/quote-collapse.ts b/src/lib/quote-collapse.ts index 7343b6dc..b75ed648 100644 --- a/src/lib/quote-collapse.ts +++ b/src/lib/quote-collapse.ts @@ -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({