From 6d701c4b066d14dee54d9b7a2173be415ae4530a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:09:39 +0100 Subject: [PATCH] feat: stop animations and pause animated images in mail when motion is reduced --- src/components/EmailBodyView.tsx | 19 ++- src/lib/__tests__/mail-motion.test.ts | 112 +++++++++++++++ src/lib/mail-motion.ts | 196 ++++++++++++++++++++++++++ 3 files changed, 324 insertions(+), 3 deletions(-) create mode 100644 src/lib/__tests__/mail-motion.test.ts create mode 100644 src/lib/mail-motion.ts diff --git a/src/components/EmailBodyView.tsx b/src/components/EmailBodyView.tsx index 78530d4d..bf6161b4 100644 --- a/src/components/EmailBodyView.tsx +++ b/src/components/EmailBodyView.tsx @@ -16,12 +16,13 @@ import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-s import { senderVerificationOf } from '../lib/email-headers'; import { plainTextReading } from '../lib/html-to-readable-text'; import { buildMailTextSizeScript, mailTextFactor } from '../lib/mail-reading'; +import { buildPauseAnimatedImagesScript, withReducedMotion } from '../lib/mail-motion'; import { useHasContacts } from '../lib/capabilities'; import { useSettingsStore } from '../stores/settings-store'; import { useContactsStore } from '../stores/contacts-store'; import { useLocaleStore } from '../stores/locale-store'; import { spacing, typography, type ThemePalette } from '../theme/tokens'; -import { useTypography } from '../theme/dynamic'; +import { useShouldAnimate, useTypography } from '../theme/dynamic'; import { useColors, useResolvedTheme } from '../theme/colors'; import { perfEndAfterPaint, perfLap } from '../lib/perf-marks'; import type { RootStackParamList } from '../navigation/types'; @@ -552,6 +553,10 @@ export default function EmailBodyView({ // scaled body style, so it follows whatever steps the text size setting has. const dyn = useTypography(); const textFactor = mailTextFactor(dyn.body.fontSize / typography.body.fontSize, PixelRatio.getFontScale()); + // Reduce motion (the system setting, or the app's animations switch) holds + // inside mail too: CSS animations stop and animated images pause behind a + // Play button (webmail 24a13cc1f, bb7869798). + const reduceMotion = !useShouldAnimate(); const trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails); const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded); const loadTrustedSendersBook = useContactsStore((s) => s.loadTrustedSendersBook); @@ -713,7 +718,14 @@ export default function EmailBodyView({ messageSpacing, plainTextFont, quoteLabels, textFactor, plainDarkColors, ]); - const source = React.useMemo(() => ({ html: prepared.html }), [prepared.html]); + const source = React.useMemo( + () => ({ html: reduceMotion && prepared.isHtml ? withReducedMotion(prepared.html) : prepared.html }), + [prepared.html, prepared.isHtml, reduceMotion], + ); + const pauseLabels = React.useMemo(() => ({ + play: t('email_viewer.play_animation', 'Play animation'), + pause: t('email_viewer.pause_animation', 'Pause animation'), + }), [t]); const showBanner = shouldBlock && prepared.blockedExternal; // Still cut off after the API's larger refetch (or the refetch failed). const showTruncated = !bodyOverride && hasTruncatedDisplayedBody(email); @@ -727,8 +739,9 @@ export default function EmailBodyView({ + (prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '') + (prepared.applyInversion ? DARK_REINVERT_SCRIPT : '') + (prepared.isHtml ? IMG_FIXUP_SCRIPT : '') + + (prepared.isHtml && reduceMotion ? buildPauseAnimatedImagesScript(pauseLabels) : '') + HEIGHT_REPORTER + SWIPE_DETECTOR + PINCH_ZOOM, - [prepared.isHtml, prepared.applyInversion, quoteLabels, textFactor], + [prepared.isHtml, prepared.applyInversion, quoteLabels, textFactor, reduceMotion, pauseLabels], ); // Performance harness: how much of opening a message is the WebView. diff --git a/src/lib/__tests__/mail-motion.test.ts b/src/lib/__tests__/mail-motion.test.ts new file mode 100644 index 00000000..13dad774 --- /dev/null +++ b/src/lib/__tests__/mail-motion.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { buildPauseAnimatedImagesScript, IS_ANIMATED_SOURCE, MAIL_REDUCED_MOTION_CSS, withReducedMotion } from '../mail-motion'; + +// The app follows the system's reduce motion, but mail kept its CSS +// animations and moving GIFs (webmail 24a13cc1f, bb7869798). + +const isAnimated = new Function(`${IS_ANIMATED_SOURCE}; return isAnimatedImageData;`)() as (b: Uint8Array) => boolean; + +function gif(frames: number): Uint8Array { + const head = [...'GIF89a'].map((ch) => ch.charCodeAt(0)); + const screen = [1, 0, 1, 0, 0, 0, 0]; // 1x1, no global colour table + const frame = [0x2c, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 1, 0, 0]; // descriptor, LZW size, one sub-block, end + const extension = [0x21, 0xf9, 4, 0, 0, 0, 0, 0]; // graphic control extension + const body: number[] = []; + for (let i = 0; i < frames; i++) body.push(...extension, ...frame); + return new Uint8Array([...head, ...screen, ...body, 0x3b]); +} + +function png(chunks: string[]): Uint8Array { + const out = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + for (const type of chunks) out.push(0, 0, 0, 0, ...[...type].map((ch) => ch.charCodeAt(0)), 0, 0, 0, 0); + return new Uint8Array(out); +} + +const base64 = (bytes: Uint8Array) => Buffer.from(bytes).toString('base64'); + +describe('reduce motion in mail', () => { + it('tells animated GIF, APNG and WebP from still images', () => { + expect(isAnimated(gif(2))).toBe(true); + expect(isAnimated(gif(1))).toBe(false); + expect(isAnimated(png(['IHDR', 'acTL', 'IDAT']))).toBe(true); + expect(isAnimated(png(['IHDR', 'IDAT']))).toBe(false); + const webp = (flags: number) => new Uint8Array([...'RIFF\0\0\0\0WEBPVP8X'].map((ch) => ch.charCodeAt(0)).concat([0, 0, 0, 0, flags, 0, 0, 0])); + expect(isAnimated(webp(0x02))).toBe(true); + expect(isAnimated(webp(0x00))).toBe(false); + }); + + it('stops CSS animations in the body', () => { + const html = withReducedMotion('

hi

'); + expect(html).toContain(``); + expect(MAIL_REDUCED_MOTION_CSS).toContain('animation-duration:0.01ms!important'); + }); + + it('swaps an animated image for a still frame with a Play button, and plays it on tap', () => { + const made: FakeEl[] = []; + let clickHandler: ((e: unknown) => void) | null = null; + class FakeEl { + attrs = new Map(); + children: FakeEl[] = []; + parentNode: FakeEl | null = null; + textContent = ''; + type = ''; + width = 0; + height = 0; + naturalWidth = 10; + complete = true; + style = { props: new Map(), width: '', setProperty(k: string, v: string) { this.props.set(k, v); }, removeProperty(k: string) { this.props.delete(k); } }; + constructor(public tagName: string) { made.push(this); } + get parentElement() { return this.parentNode; } + setAttribute(k: string, v: string) { this.attrs.set(k, v); } + getAttribute(k: string) { return this.attrs.get(k) ?? null; } + hasAttribute(k: string) { return this.attrs.has(k); } + appendChild(c: FakeEl) { c.parentNode?.children.splice(c.parentNode.children.indexOf(c), 1); c.parentNode = this; this.children.push(c); } + insertBefore(c: FakeEl, ref: FakeEl) { c.parentNode = this; this.children.splice(this.children.indexOf(ref), 0, c); } + getBoundingClientRect() { return { width: 10 }; } + getContext() { return { drawImage: () => undefined }; } + addEventListener() { /* loaded already */ } + querySelector(sel: string) { return this.children.find((c) => (sel.startsWith('canvas') ? c.tagName === 'CANVAS' : c.tagName === 'IMG')) ?? null; } + closest(sel: string) { return sel.startsWith('button') && this.tagName === 'BUTTON' ? this : null; } + } + const body = new FakeEl('BODY'); + const moving = new FakeEl('IMG'); + moving.setAttribute('src', `data:image/gif;base64,${base64(gif(3))}`); + const still = new FakeEl('IMG'); + still.setAttribute('src', `data:image/gif;base64,${base64(gif(1))}`); + body.appendChild(moving); + body.appendChild(still); + const document = { + body, + head: new FakeEl('HEAD'), + createElement: (tag: string) => new FakeEl(tag.toUpperCase()), + querySelectorAll: () => [moving, still], + addEventListener: (_: string, fn: (e: unknown) => void) => { clickHandler = fn; }, + }; + const atob = (s: string) => Buffer.from(s, 'base64').toString('binary'); + new Function('document', 'atob', 'URL', buildPauseAnimatedImagesScript({ play: 'Play animation', pause: 'Pause animation' }))(document, atob, URL); + + const wrap = body.children[0]; + expect(wrap.tagName).toBe('SPAN'); + const [button, canvas, img] = wrap.children; + expect(button.textContent).toBe('Play animation'); + expect(canvas.tagName).toBe('CANVAS'); + expect(img).toBe(moving); + expect(moving.style.props.get('display')).toBe('none'); + // The single-frame GIF is left alone. + expect(body.children[1]).toBe(still); + + const event = { target: button, preventDefault: () => undefined, stopImmediatePropagation: () => undefined }; + clickHandler!(event); + expect(button.textContent).toBe('Pause animation'); + expect(moving.style.props.has('display')).toBe(false); + }); + + it('is applied by the body view while motion is reduced', () => { + const view = fs.readFileSync(path.resolve(__dirname, '../../components/EmailBodyView.tsx'), 'utf8'); + expect(view).toMatch(/const reduceMotion = !useShouldAnimate\(\);/); + expect(view).toMatch(/reduceMotion && prepared\.isHtml \? withReducedMotion\(prepared\.html\)/); + expect(view).toMatch(/prepared\.isHtml && reduceMotion \? buildPauseAnimatedImagesScript\(pauseLabels\)/); + }); +}); diff --git a/src/lib/mail-motion.ts b/src/lib/mail-motion.ts new file mode 100644 index 00000000..7ffe3305 --- /dev/null +++ b/src/lib/mail-motion.ts @@ -0,0 +1,196 @@ +/** + * Reduce motion inside a message body (webmail lib/mail-reading.ts + * MAIL_REDUCED_MOTION_CSS, 24a13cc1f, and lib/animated-images.ts, + * bb7869798): CSS animations and transitions stop, and each animated GIF, + * WebP or APNG shows a still frame with a Play button. + * + * The page allows no script of its own, so the pause runs as a script the + * body view injects; the frame check is written out as a string because the + * app's JS engine (Hermes) can't hand a function's source to the page. + */ + +/** Stops CSS animations and transitions in a mail. */ +export const MAIL_REDUCED_MOTION_CSS = '*,*::before,*::after{animation-duration:0.01ms!important;animation-iteration-count:1!important;animation-delay:0s!important;transition-duration:0.01ms!important;transition-delay:0s!important;scroll-behavior:auto!important}'; + +/** Puts the reduce-motion style sheet into a built body document. */ +export function withReducedMotion(html: string): string { + const style = ``; + const at = html.lastIndexOf(''); + return at < 0 ? html + style : html.slice(0, at) + style + html.slice(at); +} + +/** + * `function isAnimatedImageData(bytes)`: whether image bytes hold more than + * one frame (GIF image descriptors, an APNG acTL chunk, the WebP animation + * flag). Shared by the page script and the tests. + */ +export const IS_ANIMATED_SOURCE = `function isAnimatedImageData(bytes) { + if (!bytes || bytes.length < 12) return false; + function ascii(start, length) { + var s = ''; + for (var k = start; k < start + length && k < bytes.length; k++) s += String.fromCharCode(bytes[k]); + return s; + } + var i; + if (ascii(0, 4) === 'GIF8') { + i = 13; + var flags = bytes[10]; + if (flags & 0x80) i += 3 * (1 << ((flags & 0x07) + 1)); + var frames = 0; + var skipSubBlocks = function () { + while (i < bytes.length && bytes[i] !== 0) i += bytes[i] + 1; + i += 1; + }; + while (i < bytes.length) { + var block = bytes[i]; + if (block === 0x2c) { + frames += 1; + if (frames > 1) return true; + var local = bytes[i + 9]; + i += 10; + if (local & 0x80) i += 3 * (1 << ((local & 0x07) + 1)); + i += 1; + skipSubBlocks(); + } else if (block === 0x21) { + i += 2; + skipSubBlocks(); + } else { + break; + } + } + return false; + } + if (bytes[0] === 0x89 && ascii(1, 3) === 'PNG') { + i = 8; + while (i + 8 <= bytes.length) { + var length = ((bytes[i] << 24) | (bytes[i + 1] << 16) | (bytes[i + 2] << 8) | bytes[i + 3]) >>> 0; + var type = ascii(i + 4, 4); + if (type === 'acTL') return true; + if (type === 'IDAT' || type === 'IEND') return false; + i += 12 + length; + } + return false; + } + if (ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') { + if (ascii(12, 4) === 'VP8X' && bytes.length > 20) return (bytes[20] & 0x02) !== 0; + return false; + } + return false; +}`; + +export interface PauseLabels { + play: string; + pause: string; +} + +const TOGGLE_ATTR = 'data-bulwark-animation'; +const STILL_ATTR = 'data-bulwark-still'; + +const PAUSE_STYLE = `[${TOGGLE_ATTR}-wrap]{position:relative;display:inline-block;max-width:100%;line-height:0}` + + `canvas[${STILL_ATTR}]{max-width:100%;height:auto}` + + `button[${TOGGLE_ATTR}]{position:absolute;top:6px;inset-inline-start:6px;z-index:1;min-width:32px;min-height:32px;padding:4px 10px;border:2px solid #fff;border-radius:999px;background:rgba(0,0,0,.75);color:#fff;font:600 13px/1.2 system-ui,sans-serif;animation:none!important;transition:none!important}`; + +/** + * Script that freezes the animated images of the body: each one is swapped + * for a canvas holding its first frame, with a Play button on top; Play shows + * the moving image again and the button becomes Pause. Inline images (data: + * URIs) are checked by their bytes; a remote image only by its name (.gif, + * .apng), since the page can't fetch it. Runs once per document. + */ +export function buildPauseAnimatedImagesScript(labels: PauseLabels): string { + const cfg = JSON.stringify({ play: labels.play, pause: labels.pause, style: PAUSE_STYLE }); + return ` +(function () { + if (document.__rnPauseImages || !document.body) return; + document.__rnPauseImages = true; + var C = ${cfg}; + var T = '${TOGGLE_ATTR}'; + var S = '${STILL_ATTR}'; + ${IS_ANIMATED_SOURCE} + function dataBytes(src) { + var m = /^data:[^,]*;base64,([\\s\\S]*)$/i.exec(src); + if (!m) return null; + try { + var bin = atob(m[1].replace(/\\s+/g, '')); + var out = new Uint8Array(bin.length); + for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } catch (e) { return null; } + } + function mayMove(src) { + if (/^data:/i.test(src)) return isAnimatedImageData(dataBytes(src)); + if (/^https?:/i.test(src)) { + try { return /\\.(gif|apng)$/i.test(new URL(src).pathname); } catch (e) { return false; } + } + return false; + } + function draw(img, canvas) { + var ctx = canvas.getContext && canvas.getContext('2d'); + if (!ctx) return false; + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + try { ctx.drawImage(img, 0, 0); } catch (e) { return false; } + return true; + } + function freeze(img) { + if (img.hasAttribute(S) || img.naturalWidth <= 1) return; + img.setAttribute(S, ''); + if (!mayMove(img.currentSrc || img.getAttribute('src') || '')) return; + var canvas = document.createElement('canvas'); + if (!draw(img, canvas)) return; + var width = img.getBoundingClientRect().width; + canvas.setAttribute(S, ''); + ['class', 'style'].forEach(function (a) { var v = img.getAttribute(a); if (v) canvas.setAttribute(a, v); }); + if (width > 0) canvas.style.width = width + 'px'; + var alt = (img.getAttribute('alt') || '').trim(); + if (alt) { canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', alt); } + else canvas.setAttribute('aria-hidden', 'true'); + var wrap = document.createElement('span'); + wrap.setAttribute(T + '-wrap', ''); + var button = document.createElement('button'); + button.type = 'button'; + button.setAttribute(T, 'paused'); + button.textContent = C.play; + img.parentNode && img.parentNode.insertBefore(wrap, img); + wrap.appendChild(button); + wrap.appendChild(canvas); + wrap.appendChild(img); + img.style.setProperty('display', 'none', 'important'); + } + var style = document.createElement('style'); + style.textContent = C.style; + (document.head || document.body).appendChild(style); + // Capture phase, so a button inside a linked image doesn't open the link. + document.addEventListener('click', function (event) { + var button = event.target && event.target.closest ? event.target.closest('button[' + T + ']') : null; + if (!button) return; + event.preventDefault(); + event.stopImmediatePropagation(); + var wrap = button.parentElement; + var canvas = wrap && wrap.querySelector('canvas[' + S + ']'); + var img = wrap && wrap.querySelector('img'); + if (!canvas || !img) return; + if (button.getAttribute(T) === 'paused') { + img.style.removeProperty('display'); + canvas.style.setProperty('display', 'none'); + button.setAttribute(T, 'playing'); + button.textContent = C.pause; + } else { + draw(img, canvas); + canvas.style.removeProperty('display'); + img.style.setProperty('display', 'none', 'important'); + button.setAttribute(T, 'paused'); + button.textContent = C.play; + } + }, true); + var imgs = document.querySelectorAll('img'); + for (var n = 0; n < imgs.length; n++) { + (function (img) { + if (img.complete) freeze(img); + else img.addEventListener('load', function () { freeze(img); }, { once: true }); + })(imgs[n]); + } +})(); +true; +`; +}