mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: pause animated images in mail with a play button
This commit is contained in:
@@ -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: '<p>Look <img src="data:image/gif;base64,R0lGODlhAQABAAAAACw=" alt="x"></p>' }));
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<div className={className}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string | number[]>) =>
|
||||
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('<a href="https://ex.com"><img src="https://cdn.example/a.gif" alt="Dancing cat"></a>');
|
||||
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('<img src="https://cdn.example/photo.jpg" alt="Photo">');
|
||||
pauseAnimatedImages(doc, { play: 'Play', pause: 'Pause' });
|
||||
await flush();
|
||||
expect(doc.querySelector('canvas')).toBeNull();
|
||||
expect(doc.querySelector('button')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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<Uint8Array | null> {
|
||||
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<Uint8Array | null> = readBytes): Promise<boolean> {
|
||||
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<boolean> } = {},
|
||||
): () => 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<HTMLCanvasElement>(`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);
|
||||
};
|
||||
}
|
||||
@@ -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": "خارجي",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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í",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "خارجی",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "חיצוני",
|
||||
|
||||
@@ -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ő",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "外部",
|
||||
|
||||
@@ -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": "외부",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Гадаад",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Внешнее",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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": "ภายนอก",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Зовнішнє",
|
||||
|
||||
@@ -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": "外部",
|
||||
|
||||
@@ -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": "外部",
|
||||
|
||||
Reference in New Issue
Block a user