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: '

Look x

' })); + return /data-reading="([^"]*)"/.exec(container.querySelector('iframe')!.getAttribute('srcdoc')!)?.[1]; + }; + + it('follows reduce motion by default', async () => { + expect(await frameKey()).not.toContain('/p'); + media.matches.add('(prefers-reduced-motion: reduce)'); + expect(await frameKey()).toContain('/p'); + }); + + it('can be turned on or off on its own', async () => { + useSettingsStore.setState({ pauseAnimatedImages: 'on' } as never); + expect(await frameKey()).toContain('/p'); + useSettingsStore.setState({ pauseAnimatedImages: 'off', reduceMotion: 'on' } as never); + expect(await frameKey()).not.toContain('/p'); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 128b113ad..b1500691b 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -8,6 +8,7 @@ import { TRANSPARENT_BLOCKED_PIXEL, applyNewTabToAnchor, emailIframeCsp, escapeH import { getRenderableHtmlBody } from "@/lib/email-body-selection"; import { mailLanguage } from "@/lib/mail-language"; import { htmlToReadableText } from "@/lib/html-to-readable-text"; +import { pauseAnimatedImages } from "@/lib/animated-images"; import { applyMailTextSize, mailFitOptions } from "@/lib/mail-reading"; import { useMailReading } from "@/hooks/use-mail-reading"; import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility"; @@ -3443,6 +3444,11 @@ export function EmailViewer({ unjustify: readingRef.current.overrideStyles, sizesFromCss: readingRef.current.overrideStyles, }); + // Animated images as a still frame with a Play button. Before the + // link wiring below: its click listener has to see the button first. + if (readingRef.current.pauseImages) { + pauseAnimatedImages(doc, { play: t('play_animation'), pause: t('pause_animation') }); + } // Before the height wiring and after the quote toggle exists, so the // toggle is converted too. Reads the mail's computed colours and diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 9f711f50e..f3ed998d9 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -17,6 +17,7 @@ import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collaps import { fitEmailBodyWidth } from "@/lib/email-fit-width"; import { mailLanguage } from "@/lib/mail-language"; import { htmlToReadableText } from "@/lib/html-to-readable-text"; +import { pauseAnimatedImages } from "@/lib/animated-images"; import { applyMailTextSize, mailFitOptions } from "@/lib/mail-reading"; import { useMailReading } from "@/hooks/use-mail-reading"; import { DARK_PENDING_CLASS, applyEmailDarkMode, emailDarkModeCss } from "@/lib/email-dark-mode"; @@ -857,6 +858,10 @@ export function ConversationEmailBody({ unjustify: reading.overrideStyles, sizesFromCss: reading.overrideStyles, }); + // Before the link handling: a Play button inside a link must not open it. + if (reading.pauseImages) { + pauseAnimatedImages(doc, { play: t('email_viewer.play_animation'), pause: t('email_viewer.pause_animation') }); + } if (isDark) applyEmailDarkMode(doc, { remoteContentAllowed: allowExternal }); const resize = () => { // Fit desktop-width mail to the screen before measuring: the transform @@ -902,7 +907,7 @@ export function ConversationEmailBody({ } catch { // contentDocument may be inaccessible under stricter sandboxes; ignore. } - }, [t, isDark, allowExternal, email.id, selectionMenu.openRef, reading.textFactor, reading.minFontSize, reading.overrideStyles, reading.fitWidth]); + }, [t, isDark, allowExternal, email.id, selectionMenu.openRef, reading.textFactor, reading.minFontSize, reading.overrideStyles, reading.fitWidth, reading.pauseImages]); return (
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('Dancing cat'); + 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('Photo'); + 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": "外部",