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(`