From bb786979812cc3e80fdbfa4d31de07acae794ab2 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Thu, 29 Oct 2026 16:07:25 +0100
Subject: [PATCH] feat: pause animated images in mail with a play button
---
.../email/__tests__/viewer-reading.test.tsx | 20 ++
components/email/email-viewer.tsx | 6 +
components/email/thread-conversation-view.tsx | 7 +-
hooks/use-mail-reading.ts | 8 +-
lib/__tests__/animated-images.test.ts | 108 +++++++++
lib/animated-images.ts | 218 ++++++++++++++++++
locales/ar/common.json | 4 +-
locales/ca/common.json | 4 +-
locales/cs/common.json | 4 +-
locales/da/common.json | 4 +-
locales/de/common.json | 4 +-
locales/en/common.json | 4 +-
locales/es/common.json | 4 +-
locales/et/common.json | 4 +-
locales/fa/common.json | 4 +-
locales/fr/common.json | 4 +-
locales/he/common.json | 4 +-
locales/hu/common.json | 4 +-
locales/it/common.json | 4 +-
locales/ja/common.json | 4 +-
locales/ko/common.json | 4 +-
locales/lv/common.json | 4 +-
locales/mn/common.json | 4 +-
locales/nb/common.json | 4 +-
locales/nl/common.json | 4 +-
locales/pl/common.json | 4 +-
locales/pt/common.json | 4 +-
locales/ro/common.json | 4 +-
locales/ru/common.json | 4 +-
locales/sk/common.json | 4 +-
locales/th/common.json | 4 +-
locales/tr/common.json | 4 +-
locales/uk/common.json | 4 +-
locales/zh-TW/common.json | 4 +-
locales/zh/common.json | 4 +-
35 files changed, 452 insertions(+), 31 deletions(-)
create mode 100644 lib/__tests__/animated-images.test.ts
create mode 100644 lib/animated-images.ts
diff --git a/components/email/__tests__/viewer-reading.test.tsx b/components/email/__tests__/viewer-reading.test.tsx
index a5fe58fcd..6beb7d355 100644
--- a/components/email/__tests__/viewer-reading.test.tsx
+++ b/components/email/__tests__/viewer-reading.test.tsx
@@ -254,3 +254,23 @@ describe('HTML mail as plain text', () => {
expect(container.querySelector('iframe')).not.toBeNull();
});
});
+
+describe('pausing animated images', () => {
+ const frameKey = async () => {
+ const { container } = await show(message({ html: '
diff --git a/hooks/use-mail-reading.ts b/hooks/use-mail-reading.ts
index 642d1063f..530edab7e 100644
--- a/hooks/use-mail-reading.ts
+++ b/hooks/use-mail-reading.ts
@@ -31,6 +31,8 @@ export interface MailReading {
forcedColors: boolean;
/** Reduce motion, resolved: stop animations inside the mail too. */
reduceMotion: boolean;
+ /** Show animated images as a still frame with a Play button. */
+ pauseImages: boolean;
/** The sender's fonts, sizes and colours give way to the app's. */
overrideStyles: boolean;
/** Reading width "comfortable": lines of about 80 characters. */
@@ -76,6 +78,7 @@ export function useMailReading(): MailReading {
underlineLinks: state.underlineLinks,
readingWidth: state.readingWidth,
mailFitWidth: state.mailFitWidth,
+ pauseAnimatedImages: state.pauseAnimatedImages,
})));
// The theme's colours are read from the page, so a theme change re-reads them.
const theme = useThemeStore((state) => `${state.resolvedTheme}/${state.activeThemeId ?? ''}`);
@@ -87,6 +90,8 @@ export function useMailReading(): MailReading {
const textFactor = mailTextFactor(s.mailTextScale, s.fontSize);
const minFontSize = s.mailMinFontSize || 0;
const reduceMotion = resolveSystemToggle(s.reduceMotion, systemReducedMotion);
+ // "System" follows reduce motion, which itself may follow the OS.
+ const pauseImages = resolveSystemToggle(s.pauseAnimatedImages, reduceMotion);
const overrideStyles = mailOverrideActive(s.mailOverrideStyles, s.contrast, systemMoreContrast);
let override: MailOverride | null = null;
let fontFaces = { css: '', sources: [] as string[] };
@@ -111,11 +116,12 @@ export function useMailReading(): MailReading {
fitWidth: s.mailFitWidth,
forcedColors,
reduceMotion,
+ pauseImages,
overrideStyles,
plainFontSize: (basePx: number) => plainBodyFontSize(basePx, s.mailTextScale, s.fontSize, minFontSize),
frameCss: mailFrameCss({ reduceMotion, underlineLinks: s.underlineLinks, comfortableWidth, override, fontFaceCss: fontFaces.css }),
fontSources: fontFaces.sources,
- frameKey: `${textFactor}/${minFontSize}${overrideStyles ? '/o' : ''}${s.mailFitWidth ? '/f' : ''}`,
+ frameKey: `${textFactor}/${minFontSize}${overrideStyles ? '/o' : ''}${s.mailFitWidth ? '/f' : ''}${pauseImages ? '/p' : ''}`,
};
// `theme` only marks that the page's colours changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
diff --git a/lib/__tests__/animated-images.test.ts b/lib/__tests__/animated-images.test.ts
new file mode 100644
index 000000000..4b2848e9d
--- /dev/null
+++ b/lib/__tests__/animated-images.test.ts
@@ -0,0 +1,108 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { imageIsAnimated, isAnimatedImageData, pauseAnimatedImages, remoteImageMayBeAnimated } from '@/lib/animated-images';
+
+const bytes = (...parts: Array
) =>
+ new Uint8Array(parts.flatMap((p) => (typeof p === 'string' ? [...p].map((c) => c.charCodeAt(0)) : p)));
+
+/** A minimal GIF with `frames` 1x1 frames and no colour tables. */
+function gif(frames: number): Uint8Array {
+ const frame = [
+ 0x21, 0xf9, 0x04, 0x00, 0x0a, 0x00, 0x00, 0x00, // graphic control extension
+ 0x2c, 0, 0, 0, 0, 1, 0, 1, 0, 0x00, // image descriptor, no local table
+ 0x02, 0x02, 0x44, 0x01, 0x00, // LZW data
+ ];
+ return bytes('GIF89a', [1, 0, 1, 0, 0x00, 0, 0], ...Array.from({ length: frames }, () => frame), [0x3b]);
+}
+
+describe('isAnimatedImageData', () => {
+ it('tells an animated GIF from a still one', () => {
+ expect(isAnimatedImageData(gif(1))).toBe(false);
+ expect(isAnimatedImageData(gif(3))).toBe(true);
+ });
+
+ it('finds the APNG animation chunk before the image data', () => {
+ const chunk = (type: string, length = 0) => [0, 0, 0, length, ...[...type].map((c) => c.charCodeAt(0)), ...new Array(length).fill(0), 0, 0, 0, 0];
+ const png = (...chunks: number[][]) => bytes([0x89], 'PNG', [0x0d, 0x0a, 0x1a, 0x0a], ...chunks);
+ expect(isAnimatedImageData(png(chunk('IHDR', 13), chunk('acTL', 8), chunk('IDAT', 2)))).toBe(true);
+ expect(isAnimatedImageData(png(chunk('IHDR', 13), chunk('IDAT', 2), chunk('IEND')))).toBe(false);
+ });
+
+ it('reads the WebP animation flag', () => {
+ const webp = (flags: number) => bytes('RIFF', [0, 0, 0, 0], 'WEBP', 'VP8X', [10, 0, 0, 0, flags, 0, 0, 0]);
+ expect(isAnimatedImageData(webp(0x02))).toBe(true);
+ expect(isAnimatedImageData(webp(0x10))).toBe(false);
+ });
+
+ it('is false for anything else', () => {
+ expect(isAnimatedImageData(bytes([0xff, 0xd8, 0xff, 0xe0], 'JFIF______'))).toBe(false);
+ });
+});
+
+describe('imageIsAnimated', () => {
+ it('reads the bytes of inline images', async () => {
+ expect(await imageIsAnimated('blob:x', async () => gif(2))).toBe(true);
+ expect(await imageIsAnimated('data:image/gif;base64,AA', async () => gif(1))).toBe(false);
+ });
+ it('goes by the name for remote images', async () => {
+ expect(remoteImageMayBeAnimated('https://cdn.example/promo.GIF?x=1')).toBe(true);
+ expect(await imageIsAnimated('https://cdn.example/photo.jpg', async () => gif(2))).toBe(false);
+ });
+});
+
+describe('pauseAnimatedImages', () => {
+ const drawImage = vi.fn();
+ beforeEach(() => drawImage.mockReset());
+ afterEach(() => vi.restoreAllMocks());
+
+ function frameDoc(html: string): Document {
+ const frame = document.createElement('iframe');
+ document.body.appendChild(frame);
+ const doc = frame.contentDocument!;
+ // jsdom has no canvas; the frame's own realm needs the stub.
+ const win = doc.defaultView as unknown as typeof window;
+ vi.spyOn(win.HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ drawImage } as never);
+ doc.body.innerHTML = html;
+ doc.querySelectorAll('img').forEach((img) => {
+ Object.defineProperty(img, 'complete', { value: true });
+ Object.defineProperty(img, 'naturalWidth', { value: 40 });
+ Object.defineProperty(img, 'naturalHeight', { value: 20 });
+ });
+ return doc;
+ }
+ const flush = () => new Promise((r) => setTimeout(r, 0));
+
+ it('shows a still frame with a Play button, then Pause while it plays', async () => {
+ const doc = frameDoc('
');
+ const linkClicks = vi.fn();
+ pauseAnimatedImages(doc, { play: 'Play animation', pause: 'Pause animation' });
+ // The reader's link handler, registered after.
+ doc.addEventListener('click', linkClicks, true);
+ await flush();
+
+ const img = doc.querySelector('img')!;
+ const canvas = doc.querySelector('canvas')!;
+ const button = doc.querySelector('button')!;
+ expect(img.style.display).toBe('none');
+ expect(canvas.getAttribute('aria-label')).toBe('Dancing cat');
+ expect(drawImage).toHaveBeenCalledWith(img, 0, 0);
+ expect(button.textContent).toBe('Play animation');
+
+ button.click();
+ expect(img.style.display).toBe('');
+ expect(canvas.style.display).toBe('none');
+ expect(button.textContent).toBe('Pause animation');
+ expect(linkClicks).not.toHaveBeenCalled();
+
+ button.click();
+ expect(img.style.display).toBe('none');
+ expect(button.textContent).toBe('Play animation');
+ });
+
+ it('leaves still images alone', async () => {
+ const doc = frameDoc('
');
+ pauseAnimatedImages(doc, { play: 'Play', pause: 'Pause' });
+ await flush();
+ expect(doc.querySelector('canvas')).toBeNull();
+ expect(doc.querySelector('button')).toBeNull();
+ });
+});
diff --git a/lib/animated-images.ts b/lib/animated-images.ts
new file mode 100644
index 000000000..6a4c32a6d
--- /dev/null
+++ b/lib/animated-images.ts
@@ -0,0 +1,218 @@
+/**
+ * Pause animated images in a message (Settings > Accessibility > "Pause
+ * animated images"): each animated GIF, WebP or APNG shows a still frame
+ * with a Play button, and Pause once it plays.
+ *
+ * The message frame is sandboxed without scripts, so all of this runs from
+ * the parent document: the still frame is drawn onto a canvas that takes the
+ * image's place, and the button's listener lives in our own window.
+ */
+
+/** Whether image bytes hold more than one frame. */
+export function isAnimatedImageData(bytes: Uint8Array): boolean {
+ if (bytes.length < 12) return false;
+ const ascii = (start: number, length: number) => String.fromCharCode(...bytes.subarray(start, start + length));
+
+ // GIF: count the image descriptors, skipping extension and data blocks.
+ if (ascii(0, 4) === 'GIF8') {
+ let i = 13;
+ const flags = bytes[10];
+ if (flags & 0x80) i += 3 * (1 << ((flags & 0x07) + 1));
+ let frames = 0;
+ const skipSubBlocks = () => {
+ while (i < bytes.length && bytes[i] !== 0) i += bytes[i] + 1;
+ i += 1;
+ };
+ while (i < bytes.length) {
+ const block = bytes[i];
+ if (block === 0x2c) {
+ frames += 1;
+ if (frames > 1) return true;
+ const local = bytes[i + 9];
+ i += 10;
+ if (local & 0x80) i += 3 * (1 << ((local & 0x07) + 1));
+ i += 1; // LZW minimum code size
+ skipSubBlocks();
+ } else if (block === 0x21) {
+ i += 2;
+ skipSubBlocks();
+ } else {
+ break; // 0x3b trailer, or garbage
+ }
+ }
+ return false;
+ }
+
+ // PNG: an acTL chunk before the image data makes it an APNG.
+ if (bytes[0] === 0x89 && ascii(1, 3) === 'PNG') {
+ let i = 8;
+ while (i + 8 <= bytes.length) {
+ const length = ((bytes[i] << 24) | (bytes[i + 1] << 16) | (bytes[i + 2] << 8) | bytes[i + 3]) >>> 0;
+ const type = ascii(i + 4, 4);
+ if (type === 'acTL') return true;
+ if (type === 'IDAT' || type === 'IEND') return false;
+ i += 12 + length;
+ }
+ return false;
+ }
+
+ // WebP: the extended header's animation flag, or an ANIM chunk.
+ 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;
+}
+
+/**
+ * For an image whose bytes we can't read (a remote URL: the app's own CSP
+ * doesn't let it fetch other hosts), whether its name says it may move.
+ * Animated WebP is rare enough that a .webp is left playing.
+ */
+export function remoteImageMayBeAnimated(src: string): boolean {
+ try {
+ return /\.(gif|apng)$/i.test(new URL(src).pathname);
+ } catch {
+ return false;
+ }
+}
+
+async function readBytes(src: string): Promise {
+ try {
+ const response = await fetch(src);
+ if (!response.ok) return null;
+ return new Uint8Array(await response.arrayBuffer());
+ } catch {
+ return null;
+ }
+}
+
+/** Whether to pause this (loaded) image. */
+export async function imageIsAnimated(src: string, read: (src: string) => Promise = readBytes): Promise {
+ if (!src) return false;
+ if (/^(data|blob):/i.test(src)) {
+ const bytes = await read(src);
+ return bytes ? isAnimatedImageData(bytes) : false;
+ }
+ return /^https?:/i.test(src) && remoteImageMayBeAnimated(src);
+}
+
+export interface PauseLabels {
+ play: string;
+ pause: string;
+}
+
+const TOGGLE_ATTR = 'data-bulwark-animation';
+const STILL_ATTR = 'data-bulwark-still';
+
+const 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;cursor:pointer;animation:none!important;transition:none!important}
+button[${TOGGLE_ATTR}]:focus-visible{outline:3px solid #1d4ed8;outline-offset:2px}`;
+
+/**
+ * Freezes the animated images of a rendered message. Each one is swapped for
+ * a canvas holding its current (first) frame, with a Play button on top;
+ * Play shows the moving image again and the button becomes Pause, which draws
+ * the frame it stopped at. Returns a cleanup that drops the listener.
+ */
+export function pauseAnimatedImages(
+ doc: Document,
+ labels: PauseLabels,
+ options: { isAnimated?: (src: string) => Promise } = {},
+): () => void {
+ const isAnimated = options.isAnimated ?? ((src: string) => imageIsAnimated(src));
+ let disposed = false;
+
+ if (!doc.head.querySelector(`style[${TOGGLE_ATTR}]`)) {
+ const style = doc.createElement('style');
+ style.setAttribute(TOGGLE_ATTR, '');
+ style.textContent = STYLE;
+ doc.head.appendChild(style);
+ }
+
+ const draw = (img: HTMLImageElement, canvas: HTMLCanvasElement): boolean => {
+ const context = canvas.getContext('2d');
+ if (!context) return false;
+ canvas.width = img.naturalWidth;
+ canvas.height = img.naturalHeight;
+ try {
+ context.drawImage(img, 0, 0);
+ } catch {
+ return false;
+ }
+ return true;
+ };
+
+ const freeze = (img: HTMLImageElement) => {
+ if (disposed || img.hasAttribute(STILL_ATTR) || img.naturalWidth <= 1) return;
+ img.setAttribute(STILL_ATTR, '');
+ void isAnimated(img.currentSrc || img.getAttribute('src') || '').then((animated) => {
+ if (!animated || disposed || !img.isConnected) return;
+ const canvas = doc.createElement('canvas');
+ if (!draw(img, canvas)) return;
+ const width = img.getBoundingClientRect().width;
+ canvas.setAttribute(STILL_ATTR, '');
+ for (const attr of ['class', 'style']) {
+ const value = img.getAttribute(attr);
+ if (value) canvas.setAttribute(attr, value);
+ }
+ if (width > 0) canvas.style.width = `${width}px`;
+ const alt = img.getAttribute('alt')?.trim();
+ if (alt) {
+ canvas.setAttribute('role', 'img');
+ canvas.setAttribute('aria-label', alt);
+ } else {
+ canvas.setAttribute('aria-hidden', 'true');
+ }
+
+ const wrap = doc.createElement('span');
+ wrap.setAttribute(`${TOGGLE_ATTR}-wrap`, '');
+ const button = doc.createElement('button');
+ button.type = 'button';
+ button.setAttribute(TOGGLE_ATTR, 'paused');
+ button.textContent = labels.play;
+
+ img.parentNode?.insertBefore(wrap, img);
+ wrap.append(button, canvas, img);
+ img.style.setProperty('display', 'none', 'important');
+ });
+ };
+
+ // One listener on the document, in the capture phase and ahead of the
+ // reader's link handling: a button inside a linked image must not also
+ // open the link.
+ const onClick = (event: Event) => {
+ const button = (event.target as Element | null)?.closest?.(`button[${TOGGLE_ATTR}]`) as HTMLButtonElement | null;
+ if (!button) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ const wrap = button.parentElement;
+ const canvas = wrap?.querySelector(`canvas[${STILL_ATTR}]`);
+ const img = wrap?.querySelector('img');
+ if (!canvas || !img) return;
+ if (button.getAttribute(TOGGLE_ATTR) === 'paused') {
+ img.style.removeProperty('display');
+ canvas.style.setProperty('display', 'none');
+ button.setAttribute(TOGGLE_ATTR, 'playing');
+ button.textContent = labels.pause;
+ } else {
+ draw(img, canvas);
+ canvas.style.removeProperty('display');
+ img.style.setProperty('display', 'none', 'important');
+ button.setAttribute(TOGGLE_ATTR, 'paused');
+ button.textContent = labels.play;
+ }
+ };
+ doc.addEventListener('click', onClick, true);
+
+ doc.querySelectorAll('img').forEach((img) => {
+ if (img.complete) freeze(img);
+ else img.addEventListener('load', () => freeze(img), { once: true });
+ });
+
+ return () => {
+ disposed = true;
+ doc.removeEventListener('click', onClick, true);
+ };
+}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 26e098c50..e3462c888 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -774,7 +774,9 @@
"preview_named": "معاينة {name}",
"show_as_plain_text": "العرض كنص عادي",
"show_formatted": "العرض منسقًا",
- "shown_as_plain_text": "تُعرض هذه الرسالة كنص عادي."
+ "shown_as_plain_text": "تُعرض هذه الرسالة كنص عادي.",
+ "play_animation": "تشغيل الرسوم المتحركة",
+ "pause_animation": "إيقاف الرسوم المتحركة مؤقتًا"
},
"external_mail": {
"sender_label": "خارجي",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 71e2c4ac2..24e2a9e7f 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -774,7 +774,9 @@
"preview_named": "Previsualitza {name}",
"show_as_plain_text": "Mostra com a text sense format",
"show_formatted": "Mostra amb format",
- "shown_as_plain_text": "Aquest missatge es mostra com a text sense format."
+ "shown_as_plain_text": "Aquest missatge es mostra com a text sense format.",
+ "play_animation": "Reprodueix l'animació",
+ "pause_animation": "Posa en pausa l'animació"
},
"external_mail": {
"sender_label": "Extern",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index e5f9143ce..44cefda80 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -774,7 +774,9 @@
"preview_named": "Náhled {name}",
"show_as_plain_text": "Zobrazit jako prostý text",
"show_formatted": "Zobrazit formátovaně",
- "shown_as_plain_text": "Tato zpráva se zobrazuje jako prostý text."
+ "shown_as_plain_text": "Tato zpráva se zobrazuje jako prostý text.",
+ "play_animation": "Přehrát animaci",
+ "pause_animation": "Pozastavit animaci"
},
"external_mail": {
"sender_label": "Externí",
diff --git a/locales/da/common.json b/locales/da/common.json
index a83a1ddef..58e8f5855 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -774,7 +774,9 @@
"preview_named": "Forhåndsvis {name}",
"show_as_plain_text": "Vis som almindelig tekst",
"show_formatted": "Vis formateret",
- "shown_as_plain_text": "Denne besked vises som almindelig tekst."
+ "shown_as_plain_text": "Denne besked vises som almindelig tekst.",
+ "play_animation": "Afspil animation",
+ "pause_animation": "Sæt animation på pause"
},
"external_mail": {
"sender_label": "Ekstern",
diff --git a/locales/de/common.json b/locales/de/common.json
index 55aa25b2c..2c7f01b4c 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -774,7 +774,9 @@
"preview_named": "Vorschau von {name}",
"show_as_plain_text": "Als reinen Text anzeigen",
"show_formatted": "Formatiert anzeigen",
- "shown_as_plain_text": "Diese Nachricht wird als reiner Text angezeigt."
+ "shown_as_plain_text": "Diese Nachricht wird als reiner Text angezeigt.",
+ "play_animation": "Animation abspielen",
+ "pause_animation": "Animation anhalten"
},
"external_mail": {
"sender_label": "Extern",
diff --git a/locales/en/common.json b/locales/en/common.json
index d8671bc2e..19abfbce1 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -774,7 +774,9 @@
"preview_named": "Preview {name}",
"show_as_plain_text": "Show as plain text",
"show_formatted": "Show formatted",
- "shown_as_plain_text": "This message is shown as plain text."
+ "shown_as_plain_text": "This message is shown as plain text.",
+ "play_animation": "Play animation",
+ "pause_animation": "Pause animation"
},
"external_mail": {
"sender_label": "External",
diff --git a/locales/es/common.json b/locales/es/common.json
index 838d5e771..a3c91c828 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -774,7 +774,9 @@
"preview_named": "Vista previa de {name}",
"show_as_plain_text": "Mostrar como texto sin formato",
"show_formatted": "Mostrar con formato",
- "shown_as_plain_text": "Este mensaje se muestra como texto sin formato."
+ "shown_as_plain_text": "Este mensaje se muestra como texto sin formato.",
+ "play_animation": "Reproducir animación",
+ "pause_animation": "Pausar animación"
},
"external_mail": {
"sender_label": "Externo",
diff --git a/locales/et/common.json b/locales/et/common.json
index 25923d1d6..47f0e2cbd 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -774,7 +774,9 @@
"preview_named": "Eelvaade: {name}",
"show_as_plain_text": "Näita lihttekstina",
"show_formatted": "Näita vormindatult",
- "shown_as_plain_text": "Seda kirja näidatakse lihttekstina."
+ "shown_as_plain_text": "Seda kirja näidatakse lihttekstina.",
+ "play_animation": "Esita animatsioon",
+ "pause_animation": "Peata animatsioon"
},
"external_mail": {
"sender_label": "Väline",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index a2cc55608..22b4bb04b 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -774,7 +774,9 @@
"preview_named": "پیشنمایش {name}",
"show_as_plain_text": "نمایش بهصورت متن ساده",
"show_formatted": "نمایش قالببندیشده",
- "shown_as_plain_text": "این پیام بهصورت متن ساده نمایش داده میشود."
+ "shown_as_plain_text": "این پیام بهصورت متن ساده نمایش داده میشود.",
+ "play_animation": "پخش پویانمایی",
+ "pause_animation": "توقف پویانمایی"
},
"external_mail": {
"sender_label": "خارجی",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index b3c89d2f5..a830e2033 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -774,7 +774,9 @@
"preview_named": "Aperçu de {name}",
"show_as_plain_text": "Afficher en texte brut",
"show_formatted": "Afficher la mise en forme",
- "shown_as_plain_text": "Ce message est affiché en texte brut."
+ "shown_as_plain_text": "Ce message est affiché en texte brut.",
+ "play_animation": "Lire l'animation",
+ "pause_animation": "Mettre l'animation en pause"
},
"external_mail": {
"sender_label": "Externe",
diff --git a/locales/he/common.json b/locales/he/common.json
index 8c4d7dc9a..eb7bf7511 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -739,7 +739,9 @@
"preview_named": "תצוגה מקדימה של {name}",
"show_as_plain_text": "הצגה כטקסט פשוט",
"show_formatted": "הצגה מעוצבת",
- "shown_as_plain_text": "הודעה זו מוצגת כטקסט פשוט."
+ "shown_as_plain_text": "הודעה זו מוצגת כטקסט פשוט.",
+ "play_animation": "הפעלת האנימציה",
+ "pause_animation": "השהיית האנימציה"
},
"external_mail": {
"sender_label": "חיצוני",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 1b434be96..df60dfc91 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -774,7 +774,9 @@
"preview_named": "{name} előnézete",
"show_as_plain_text": "Megjelenítés egyszerű szövegként",
"show_formatted": "Megjelenítés formázva",
- "shown_as_plain_text": "Ez az üzenet egyszerű szövegként jelenik meg."
+ "shown_as_plain_text": "Ez az üzenet egyszerű szövegként jelenik meg.",
+ "play_animation": "Animáció lejátszása",
+ "pause_animation": "Animáció szüneteltetése"
},
"external_mail": {
"sender_label": "Külső",
diff --git a/locales/it/common.json b/locales/it/common.json
index 53bb47ccf..6bc565645 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -774,7 +774,9 @@
"preview_named": "Anteprima di {name}",
"show_as_plain_text": "Mostra come testo semplice",
"show_formatted": "Mostra formattato",
- "shown_as_plain_text": "Questo messaggio è mostrato come testo semplice."
+ "shown_as_plain_text": "Questo messaggio è mostrato come testo semplice.",
+ "play_animation": "Riproduci animazione",
+ "pause_animation": "Metti in pausa l'animazione"
},
"external_mail": {
"sender_label": "Esterno",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index bf508cccf..e6ded79bd 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -774,7 +774,9 @@
"preview_named": "{name} をプレビュー",
"show_as_plain_text": "プレーンテキストで表示",
"show_formatted": "書式付きで表示",
- "shown_as_plain_text": "このメッセージはプレーンテキストで表示されています。"
+ "shown_as_plain_text": "このメッセージはプレーンテキストで表示されています。",
+ "play_animation": "アニメーションを再生",
+ "pause_animation": "アニメーションを一時停止"
},
"external_mail": {
"sender_label": "外部",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 44291ec0c..28af1e9f7 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -774,7 +774,9 @@
"preview_named": "{name} 미리보기",
"show_as_plain_text": "일반 텍스트로 보기",
"show_formatted": "서식 있게 보기",
- "shown_as_plain_text": "이 메시지는 일반 텍스트로 표시됩니다."
+ "shown_as_plain_text": "이 메시지는 일반 텍스트로 표시됩니다.",
+ "play_animation": "애니메이션 재생",
+ "pause_animation": "애니메이션 일시 중지"
},
"external_mail": {
"sender_label": "외부",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index d965a3c76..9a4b0f79b 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -774,7 +774,9 @@
"preview_named": "Priekšskatīt {name}",
"show_as_plain_text": "Rādīt kā vienkāršu tekstu",
"show_formatted": "Rādīt formatētu",
- "shown_as_plain_text": "Šis ziņojums tiek rādīts kā vienkāršs teksts."
+ "shown_as_plain_text": "Šis ziņojums tiek rādīts kā vienkāršs teksts.",
+ "play_animation": "Atskaņot animāciju",
+ "pause_animation": "Pauzēt animāciju"
},
"external_mail": {
"sender_label": "Ārējs",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 91ec0b51c..0f9f4b714 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -774,7 +774,9 @@
"preview_named": "{name}-г урьдчилан харах",
"show_as_plain_text": "Энгийн текстээр харуулах",
"show_formatted": "Форматтайгаар харуулах",
- "shown_as_plain_text": "Энэ захидлыг энгийн текстээр харуулж байна."
+ "shown_as_plain_text": "Энэ захидлыг энгийн текстээр харуулж байна.",
+ "play_animation": "Хөдөлгөөнт зургийг тоглуулах",
+ "pause_animation": "Хөдөлгөөнт зургийг түр зогсоох"
},
"external_mail": {
"sender_label": "Гадаад",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 51adb3a3c..9f0a4c431 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -774,7 +774,9 @@
"preview_named": "Forhåndsvis {name}",
"show_as_plain_text": "Vis som ren tekst",
"show_formatted": "Vis formatert",
- "shown_as_plain_text": "Denne meldingen vises som ren tekst."
+ "shown_as_plain_text": "Denne meldingen vises som ren tekst.",
+ "play_animation": "Spill av animasjon",
+ "pause_animation": "Sett animasjon på pause"
},
"external_mail": {
"sender_label": "Ekstern",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index fb217b3ce..9c5966c06 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -774,7 +774,9 @@
"preview_named": "Voorbeeld van {name}",
"show_as_plain_text": "Als platte tekst weergeven",
"show_formatted": "Opgemaakt weergeven",
- "shown_as_plain_text": "Dit bericht wordt als platte tekst weergegeven."
+ "shown_as_plain_text": "Dit bericht wordt als platte tekst weergegeven.",
+ "play_animation": "Animatie afspelen",
+ "pause_animation": "Animatie pauzeren"
},
"external_mail": {
"sender_label": "Extern",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index a9de2ba9d..832368ed3 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -774,7 +774,9 @@
"preview_named": "Podgląd {name}",
"show_as_plain_text": "Pokaż jako zwykły tekst",
"show_formatted": "Pokaż z formatowaniem",
- "shown_as_plain_text": "Ta wiadomość jest wyświetlana jako zwykły tekst."
+ "shown_as_plain_text": "Ta wiadomość jest wyświetlana jako zwykły tekst.",
+ "play_animation": "Odtwórz animację",
+ "pause_animation": "Wstrzymaj animację"
},
"external_mail": {
"sender_label": "Zewnętrzny",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index d5cee6ef1..fddbf1e28 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -774,7 +774,9 @@
"preview_named": "Pré-visualizar {name}",
"show_as_plain_text": "Mostrar como texto simples",
"show_formatted": "Mostrar formatado",
- "shown_as_plain_text": "Esta mensagem é mostrada como texto simples."
+ "shown_as_plain_text": "Esta mensagem é mostrada como texto simples.",
+ "play_animation": "Reproduzir animação",
+ "pause_animation": "Pausar animação"
},
"external_mail": {
"sender_label": "Externo",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index edff183c6..7f4c715ef 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -774,7 +774,9 @@
"preview_named": "Previzualizați {name}",
"show_as_plain_text": "Afișați ca text simplu",
"show_formatted": "Afișați formatat",
- "shown_as_plain_text": "Acest mesaj este afișat ca text simplu."
+ "shown_as_plain_text": "Acest mesaj este afișat ca text simplu.",
+ "play_animation": "Redați animația",
+ "pause_animation": "Întrerupeți animația"
},
"external_mail": {
"sender_label": "Extern",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 56986a2ae..01ee6d4f8 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -774,7 +774,9 @@
"preview_named": "Предпросмотр {name}",
"show_as_plain_text": "Показать как обычный текст",
"show_formatted": "Показать с форматированием",
- "shown_as_plain_text": "Это письмо показано как обычный текст."
+ "shown_as_plain_text": "Это письмо показано как обычный текст.",
+ "play_animation": "Воспроизвести анимацию",
+ "pause_animation": "Приостановить анимацию"
},
"external_mail": {
"sender_label": "Внешнее",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 56d6efd19..471f893fc 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -774,7 +774,9 @@
"preview_named": "Náhľad {name}",
"show_as_plain_text": "Zobraziť ako obyčajný text",
"show_formatted": "Zobraziť formátovane",
- "shown_as_plain_text": "Táto správa sa zobrazuje ako obyčajný text."
+ "shown_as_plain_text": "Táto správa sa zobrazuje ako obyčajný text.",
+ "play_animation": "Prehrať animáciu",
+ "pause_animation": "Pozastaviť animáciu"
},
"external_mail": {
"sender_label": "Externé",
diff --git a/locales/th/common.json b/locales/th/common.json
index e583cf5d3..4a69d689d 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -1704,7 +1704,9 @@
"preview_named": "ดูตัวอย่าง {name}",
"show_as_plain_text": "แสดงเป็นข้อความธรรมดา",
"show_formatted": "แสดงแบบมีรูปแบบ",
- "shown_as_plain_text": "ข้อความนี้แสดงเป็นข้อความธรรมดา"
+ "shown_as_plain_text": "ข้อความนี้แสดงเป็นข้อความธรรมดา",
+ "play_animation": "เล่นภาพเคลื่อนไหว",
+ "pause_animation": "หยุดภาพเคลื่อนไหวชั่วคราว"
},
"external_mail": {
"sender_label": "ภายนอก",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 19bab7e77..d98643637 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -774,7 +774,9 @@
"preview_named": "{name} önizlemesi",
"show_as_plain_text": "Düz metin olarak göster",
"show_formatted": "Biçimli göster",
- "shown_as_plain_text": "Bu ileti düz metin olarak gösteriliyor."
+ "shown_as_plain_text": "Bu ileti düz metin olarak gösteriliyor.",
+ "play_animation": "Animasyonu oynat",
+ "pause_animation": "Animasyonu duraklat"
},
"external_mail": {
"sender_label": "Harici",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 4709ea53d..bdd249b15 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -774,7 +774,9 @@
"preview_named": "Переглянути {name}",
"show_as_plain_text": "Показати як звичайний текст",
"show_formatted": "Показати з форматуванням",
- "shown_as_plain_text": "Цей лист показано як звичайний текст."
+ "shown_as_plain_text": "Цей лист показано як звичайний текст.",
+ "play_animation": "Відтворити анімацію",
+ "pause_animation": "Призупинити анімацію"
},
"external_mail": {
"sender_label": "Зовнішнє",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index dcb44252c..453c404e7 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -774,7 +774,9 @@
"preview_named": "預覽 {name}",
"show_as_plain_text": "以純文字顯示",
"show_formatted": "以格式顯示",
- "shown_as_plain_text": "此郵件以純文字顯示。"
+ "shown_as_plain_text": "此郵件以純文字顯示。",
+ "play_animation": "播放動畫",
+ "pause_animation": "暫停動畫"
},
"external_mail": {
"sender_label": "外部",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 7f4ad82ac..146a286db 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -774,7 +774,9 @@
"preview_named": "预览 {name}",
"show_as_plain_text": "以纯文本显示",
"show_formatted": "以格式显示",
- "shown_as_plain_text": "此邮件以纯文本显示。"
+ "shown_as_plain_text": "此邮件以纯文本显示。",
+ "play_animation": "播放动画",
+ "pause_animation": "暂停动画"
},
"external_mail": {
"sender_label": "外部",