mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
feat: stop animations and pause animated images in mail when motion is reduced
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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('<html><body><p>hi</p></body></html>');
|
||||
expect(html).toContain(`<style data-bulwark-reduced-motion>${MAIL_REDUCED_MOTION_CSS}</style></body>`);
|
||||
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<string, string>();
|
||||
children: FakeEl[] = [];
|
||||
parentNode: FakeEl | null = null;
|
||||
textContent = '';
|
||||
type = '';
|
||||
width = 0;
|
||||
height = 0;
|
||||
naturalWidth = 10;
|
||||
complete = true;
|
||||
style = { props: new Map<string, string>(), 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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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 = `<style data-bulwark-reduced-motion>${MAIL_REDUCED_MOTION_CSS}</style>`;
|
||||
const at = html.lastIndexOf('</body>');
|
||||
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;
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user