feat: collapse quoted reply text behind a "..." toggle #480
This commit is contained in:
@@ -7,6 +7,7 @@ import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilen
|
|||||||
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
||||||
import { EMAIL_IFRAME_SANITIZE_CONFIG, applyNewTabToAnchor, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
import { EMAIL_IFRAME_SANITIZE_CONFIG, applyNewTabToAnchor, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||||
|
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
|
||||||
import { withBasePath } from "@/lib/browser-navigation";
|
import { withBasePath } from "@/lib/browser-navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
@@ -1702,7 +1703,11 @@ export function EmailViewer({
|
|||||||
const textContent = email.bodyValues[email.textBody[0].partId].value;
|
const textContent = email.bodyValues[email.textBody[0].partId].value;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: plainTextToSafeHtml(textContent),
|
// Trailing ">"-quoted block collapses behind a <details> toggle (#480).
|
||||||
|
html: collapsePlainTextQuotes(plainTextToSafeHtml(textContent), {
|
||||||
|
show: t('show_quoted_text'),
|
||||||
|
hide: t('hide_quoted_text'),
|
||||||
|
}),
|
||||||
isHtml: false,
|
isHtml: false,
|
||||||
hasStyleTag: false,
|
hasStyleTag: false,
|
||||||
externalBlocked: false,
|
externalBlocked: false,
|
||||||
@@ -1742,6 +1747,11 @@ export function EmailViewer({
|
|||||||
|
|
||||||
// Override email content with S/MIME decrypted content when available
|
// Override email content with S/MIME decrypted content when available
|
||||||
const effectiveEmailContent = useMemo(() => {
|
const effectiveEmailContent = useMemo(() => {
|
||||||
|
const plainToHtml = (text: string) =>
|
||||||
|
collapsePlainTextQuotes(plainTextToSafeHtml(text), {
|
||||||
|
show: t('show_quoted_text'),
|
||||||
|
hide: t('hide_quoted_text'),
|
||||||
|
});
|
||||||
if (pluginRenderedHtml) {
|
if (pluginRenderedHtml) {
|
||||||
const htmlWithCidUrls = pluginRenderedHtml.replace(
|
const htmlWithCidUrls = pluginRenderedHtml.replace(
|
||||||
/\bcid:([^"'\s)]+)/gi,
|
/\bcid:([^"'\s)]+)/gi,
|
||||||
@@ -1753,7 +1763,7 @@ export function EmailViewer({
|
|||||||
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(pluginRenderedHtml), externalBlocked: false };
|
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(pluginRenderedHtml), externalBlocked: false };
|
||||||
}
|
}
|
||||||
if (pluginRenderedText) {
|
if (pluginRenderedText) {
|
||||||
return { html: plainTextToSafeHtml(pluginRenderedText), isHtml: false, hasStyleTag: false, externalBlocked: false };
|
return { html: plainToHtml(pluginRenderedText), isHtml: false, hasStyleTag: false, externalBlocked: false };
|
||||||
}
|
}
|
||||||
// TNEF (winmail.dat) extracted content
|
// TNEF (winmail.dat) extracted content
|
||||||
if (tnefHtml) {
|
if (tnefHtml) {
|
||||||
@@ -1761,7 +1771,7 @@ export function EmailViewer({
|
|||||||
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(tnefHtml), externalBlocked: false };
|
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(tnefHtml), externalBlocked: false };
|
||||||
}
|
}
|
||||||
if (tnefText) {
|
if (tnefText) {
|
||||||
return { html: plainTextToSafeHtml(tnefText), isHtml: false, hasStyleTag: false, externalBlocked: false };
|
return { html: plainToHtml(tnefText), isHtml: false, hasStyleTag: false, externalBlocked: false };
|
||||||
}
|
}
|
||||||
// Embedded message/rfc822 unwrapped content
|
// Embedded message/rfc822 unwrapped content
|
||||||
if (embeddedEmailHtml) {
|
if (embeddedEmailHtml) {
|
||||||
@@ -1769,10 +1779,10 @@ export function EmailViewer({
|
|||||||
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(embeddedEmailHtml), externalBlocked: false };
|
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(embeddedEmailHtml), externalBlocked: false };
|
||||||
}
|
}
|
||||||
if (embeddedEmailText) {
|
if (embeddedEmailText) {
|
||||||
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false, hasStyleTag: false, externalBlocked: false };
|
return { html: plainToHtml(embeddedEmailText), isHtml: false, hasStyleTag: false, externalBlocked: false };
|
||||||
}
|
}
|
||||||
return emailContent;
|
return emailContent;
|
||||||
}, [cidBlobUrls, emailContent, pluginRenderedHtml, pluginRenderedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
|
}, [cidBlobUrls, emailContent, pluginRenderedHtml, pluginRenderedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText, t]);
|
||||||
|
|
||||||
const resolveAttachmentName = useCallback(
|
const resolveAttachmentName = useCallback(
|
||||||
(attachment: EffectiveAttachment) => {
|
(attachment: EffectiveAttachment) => {
|
||||||
@@ -2244,6 +2254,14 @@ export function EmailViewer({
|
|||||||
if (initializedDocRef.current === doc) return;
|
if (initializedDocRef.current === doc) return;
|
||||||
initializedDocRef.current = doc;
|
initializedDocRef.current = doc;
|
||||||
{
|
{
|
||||||
|
// Collapse the quoted original of a reply behind a "•••" toggle
|
||||||
|
// (#480). Before the height wiring, so the initial measurement
|
||||||
|
// already reflects the collapsed body.
|
||||||
|
setupQuoteCollapse(doc, {
|
||||||
|
show: t('show_quoted_text'),
|
||||||
|
hide: t('hide_quoted_text'),
|
||||||
|
});
|
||||||
|
|
||||||
// Auto-resize iframe to fit content
|
// Auto-resize iframe to fit content
|
||||||
// Measure max(documentElement, body): a height:100% wrapper can leave
|
// Measure max(documentElement, body): a height:100% wrapper can leave
|
||||||
// documentElement.scrollHeight short while the real content lives in body.
|
// documentElement.scrollHeight short while the real content lives in body.
|
||||||
@@ -2415,7 +2433,7 @@ export function EmailViewer({
|
|||||||
} catch {
|
} catch {
|
||||||
// Cross-origin restrictions - iframe will still display content
|
// Cross-origin restrictions - iframe will still display content
|
||||||
}
|
}
|
||||||
}, [isDark, emailHasNativeDarkMode, email?.id]);
|
}, [isDark, emailHasNativeDarkMode, email?.id, t]);
|
||||||
|
|
||||||
// Wire up the iframe as soon as its sandboxed document has parsed, rather than
|
// Wire up the iframe as soon as its sandboxed document has parsed, rather than
|
||||||
// waiting for the iframe 'load' event. 'load' also waits on every subresource,
|
// waiting for the iframe 'load' event. 'load' also waits on every subresource,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import DOMPurify from "dompurify";
|
|||||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||||
|
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
|
||||||
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
@@ -424,7 +425,14 @@ function EmailCard({
|
|||||||
// Plain text fallback
|
// Plain text fallback
|
||||||
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
|
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
|
||||||
const text = email.bodyValues[email.textBody[0].partId].value;
|
const text = email.bodyValues[email.textBody[0].partId].value;
|
||||||
return { html: plainTextToSafeHtml(text, 'text-primary hover:underline'), isHtml: false };
|
return {
|
||||||
|
// Trailing ">"-quoted block collapses behind a <details> toggle (#480).
|
||||||
|
html: collapsePlainTextQuotes(plainTextToSafeHtml(text, 'text-primary hover:underline'), {
|
||||||
|
show: t('email_viewer.show_quoted_text'),
|
||||||
|
hide: t('email_viewer.hide_quoted_text'),
|
||||||
|
}),
|
||||||
|
isHtml: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,7 +446,7 @@ function EmailCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { html: "", isHtml: false };
|
return { html: "", isHtml: false };
|
||||||
}, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls]);
|
}, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls, t]);
|
||||||
|
|
||||||
// Render the sanitized HTML body inside a sandboxed iframe so a malicious
|
// Render the sanitized HTML body inside a sandboxed iframe so a malicious
|
||||||
// (or accidentally-bypassed) email cannot inject styles/scripts/forms into
|
// (or accidentally-bypassed) email cannot inject styles/scripts/forms into
|
||||||
@@ -469,6 +477,12 @@ function EmailCard({
|
|||||||
try {
|
try {
|
||||||
const doc = iframe.contentDocument;
|
const doc = iframe.contentDocument;
|
||||||
if (!doc?.body) return;
|
if (!doc?.body) return;
|
||||||
|
// Collapse the quoted original of a reply behind a "•••" toggle (#480),
|
||||||
|
// before the first resize so the height reflects the collapsed body.
|
||||||
|
setupQuoteCollapse(doc, {
|
||||||
|
show: t('email_viewer.show_quoted_text'),
|
||||||
|
hide: t('email_viewer.hide_quoted_text'),
|
||||||
|
});
|
||||||
const resize = () => {
|
const resize = () => {
|
||||||
iframe.style.height = doc.documentElement.scrollHeight + 'px';
|
iframe.style.height = doc.documentElement.scrollHeight + 'px';
|
||||||
};
|
};
|
||||||
@@ -482,7 +496,7 @@ function EmailCard({
|
|||||||
} catch {
|
} catch {
|
||||||
// contentDocument may be inaccessible under stricter sandboxes; ignore.
|
// contentDocument may be inaccessible under stricter sandboxes; ignore.
|
||||||
}
|
}
|
||||||
}, []);
|
}, [t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
import { setupQuoteCollapse, collapsePlainTextQuotes, QUOTE_TOGGLE_ATTR, QUOTE_COLLAPSED_ATTR } from '@/lib/quote-collapse';
|
||||||
|
import { plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from '@/lib/email-sanitization';
|
||||||
|
|
||||||
|
const labels = { show: 'Show quoted text', hide: 'Hide quoted text' };
|
||||||
|
|
||||||
|
const toggle = () => document.body.querySelector<HTMLButtonElement>(`[${QUOTE_TOGGLE_ATTR}]`);
|
||||||
|
|
||||||
|
describe('setupQuoteCollapse', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses a Gmail-style trailing quote behind a toggle', () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div dir="ltr">Thanks, sounds good!</div>
|
||||||
|
<div class="gmail_quote">
|
||||||
|
<div class="gmail_attr">On Mon, Jul 20, 2026 John wrote:</div>
|
||||||
|
<blockquote>Original message body</blockquote>
|
||||||
|
</div>`;
|
||||||
|
expect(setupQuoteCollapse(document, labels)).toBe(true);
|
||||||
|
|
||||||
|
const quote = document.querySelector<HTMLElement>('.gmail_quote')!;
|
||||||
|
expect(quote.style.display).toBe('none');
|
||||||
|
const btn = toggle()!;
|
||||||
|
expect(btn).toBeTruthy();
|
||||||
|
expect(btn.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
expect(btn.title).toBe(labels.show);
|
||||||
|
// Button sits directly before the quote it controls.
|
||||||
|
expect(btn.nextElementSibling).toBe(quote);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggle expands and re-collapses, restoring the original inline display', () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<p>reply text</p>
|
||||||
|
<div class="gmail_quote" style="display:flex">quoted</div>`;
|
||||||
|
setupQuoteCollapse(document, labels);
|
||||||
|
|
||||||
|
const quote = document.querySelector<HTMLElement>('.gmail_quote')!;
|
||||||
|
const btn = toggle()!;
|
||||||
|
btn.click();
|
||||||
|
expect(quote.style.display).toBe('flex');
|
||||||
|
expect(quote.hasAttribute(QUOTE_COLLAPSED_ATTR)).toBe(false);
|
||||||
|
expect(btn.getAttribute('aria-expanded')).toBe('true');
|
||||||
|
expect(btn.title).toBe(labels.hide);
|
||||||
|
|
||||||
|
btn.click();
|
||||||
|
expect(quote.style.display).toBe('none');
|
||||||
|
expect(btn.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
expect(btn.title).toBe(labels.show);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses an Apple Mail cite blockquote but keeps the attribution line visible', () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div>See you then.</div>
|
||||||
|
<div>On 20 Jul 2026, at 10:00, Jane wrote:</div>
|
||||||
|
<blockquote type="cite">the original</blockquote>`;
|
||||||
|
expect(setupQuoteCollapse(document, labels)).toBe(true);
|
||||||
|
|
||||||
|
const quote = document.querySelector<HTMLElement>('blockquote')!;
|
||||||
|
expect(quote.style.display).toBe('none');
|
||||||
|
expect(document.body.textContent).toContain('Jane wrote:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses Bulwark\'s own data-quoted-html reply island', () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<p>my answer</p>
|
||||||
|
<div>On Jul 20, John <j@example.com> wrote:</div>
|
||||||
|
<div data-quoted-html style="border-left:2px solid #c5c5c5;padding-left:12px;">original</div>`;
|
||||||
|
expect(setupQuoteCollapse(document, labels)).toBe(true);
|
||||||
|
expect(document.querySelector<HTMLElement>('[data-quoted-html]')!.style.display).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses everything after an Outlook divRplyFwdMsg separator', () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div>Top-posted answer</div>
|
||||||
|
<div id="divRplyFwdMsg"><b>From:</b> John<br><b>Sent:</b> Monday</div>
|
||||||
|
<div>quoted paragraph one</div>
|
||||||
|
<div>quoted paragraph two</div>`;
|
||||||
|
expect(setupQuoteCollapse(document, labels)).toBe(true);
|
||||||
|
|
||||||
|
const divs = [...document.querySelectorAll<HTMLElement>('body > div')];
|
||||||
|
expect(divs[0].style.display).toBe('');
|
||||||
|
expect(divs[1].style.display).toBe('none');
|
||||||
|
expect(divs[2].style.display).toBe('none');
|
||||||
|
expect(divs[3].style.display).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses from a Thunderbird moz-cite-prefix attribution onwards', () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<p>reply</p>
|
||||||
|
<div class="moz-cite-prefix">On 20.07.2026, Jane wrote:</div>
|
||||||
|
<blockquote type="cite">original</blockquote>`;
|
||||||
|
expect(setupQuoteCollapse(document, labels)).toBe(true);
|
||||||
|
expect(document.querySelector<HTMLElement>('.moz-cite-prefix')!.style.display).toBe('none');
|
||||||
|
expect(document.querySelector<HTMLElement>('blockquote')!.style.display).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not collapse when the quote is the entire message', () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div class="gmail_quote">forwarded content only</div>`;
|
||||||
|
expect(setupQuoteCollapse(document, labels)).toBe(false);
|
||||||
|
expect(toggle()).toBeNull();
|
||||||
|
expect(document.querySelector<HTMLElement>('.gmail_quote')!.style.display).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not collapse an interleaved reply (content after the quote container)', () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<p>first point</p>
|
||||||
|
<blockquote type="cite">their question</blockquote>
|
||||||
|
<p>my answer below the quote</p>`;
|
||||||
|
expect(setupQuoteCollapse(document, labels)).toBe(false);
|
||||||
|
expect(toggle()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores whitespace-only trailing content after the quote container', () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<p>reply</p>
|
||||||
|
<div class="gmail_quote">quoted</div>
|
||||||
|
<div><br></div>
|
||||||
|
${String.fromCharCode(160)}`;
|
||||||
|
expect(setupQuoteCollapse(document, labels)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent per document', () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<p>reply</p>
|
||||||
|
<div class="gmail_quote">quoted</div>`;
|
||||||
|
expect(setupQuoteCollapse(document, labels)).toBe(true);
|
||||||
|
expect(setupQuoteCollapse(document, labels)).toBe(false);
|
||||||
|
expect(document.querySelectorAll(`[${QUOTE_TOGGLE_ATTR}]`)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when no quote marker exists', () => {
|
||||||
|
document.body.innerHTML = `<p>plain email with content</p><p>and more content</p>`;
|
||||||
|
expect(setupQuoteCollapse(document, labels)).toBe(false);
|
||||||
|
expect(toggle()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses a bare blockquote preceded by an attribution line ending in a colon', () => {
|
||||||
|
// Shape of a Bulwark reply to a plain-text original (issue #480 follow-up):
|
||||||
|
// no type/class on the blockquote, attribution in the preceding <p>.
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<p>Hi Tracey,<br>thanks for reaching out.</p>
|
||||||
|
<p>On Mo., 18. Mai 2026, 20:27, Tracey Tiefisher wrote:<br></p>
|
||||||
|
<blockquote><p>Hi Linus,</p><blockquote><p>older nested quote</p></blockquote></blockquote>
|
||||||
|
<p></p>
|
||||||
|
</div>`;
|
||||||
|
expect(setupQuoteCollapse(document, labels)).toBe(true);
|
||||||
|
const outer = document.querySelector<HTMLElement>('blockquote')!;
|
||||||
|
expect(outer.style.display).toBe('none');
|
||||||
|
expect(document.body.textContent).toContain('Tracey Tiefisher wrote:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a bare blockquote alone when the preceding text is not an attribution', () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<p>Our newsletter quote of the day</p>
|
||||||
|
<blockquote>Stay hungry, stay foolish.</blockquote>`;
|
||||||
|
expect(setupQuoteCollapse(document, labels)).toBe(false);
|
||||||
|
expect(toggle()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses a nested wrapper case: separator inside a wrapper div hides content outside it too', () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div>answer</div>
|
||||||
|
<div><div id="appendonsend"></div><div>quoted intro</div></div>
|
||||||
|
<div>quoted rest</div>`;
|
||||||
|
expect(setupQuoteCollapse(document, labels)).toBe(true);
|
||||||
|
const last = document.body.lastElementChild as HTMLElement;
|
||||||
|
expect(last.textContent).toBe('quoted rest');
|
||||||
|
expect(last.style.display).toBe('none');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizer keeps the quote marker attribute', () => {
|
||||||
|
it('preserves data-quoted-html through sanitizeEmailHtml', () => {
|
||||||
|
const out = sanitizeEmailHtml('<p>hi</p><div data-quoted-html style="padding-left:12px">orig</div>');
|
||||||
|
expect(out).toContain('data-quoted-html');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('collapsePlainTextQuotes', () => {
|
||||||
|
const raw = [
|
||||||
|
'Hallo Linus,',
|
||||||
|
'',
|
||||||
|
'ich sehe eben, dass du das gefixt hast, thx!',
|
||||||
|
'',
|
||||||
|
'On Dienstag, 31. Maerz 2026 13:56 Richard Weinberger wrote:',
|
||||||
|
'> On Dienstag, 31. Maerz 2026 00:28 Richard Weinberger wrote:',
|
||||||
|
'> > Hi!',
|
||||||
|
'> ',
|
||||||
|
'> Here are some more issues:',
|
||||||
|
'> ',
|
||||||
|
'> - getClientIP() vulnerable to IP spoofing.',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'-- ',
|
||||||
|
'sigma star gmbh | Eduard-Bodem-Gasse 6, 6020 Innsbruck, AUT',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
it('wraps the trailing quote run in <details>, keeping reply, attribution and signature visible', () => {
|
||||||
|
const out = collapsePlainTextQuotes(plainTextToSafeHtml(raw), labels);
|
||||||
|
expect(out).toContain('<details>');
|
||||||
|
expect(out).toContain('•••');
|
||||||
|
expect(out).toContain(`title="${labels.show}"`);
|
||||||
|
// Quote run is inside the details, attribution and signature outside.
|
||||||
|
const details = out.slice(out.indexOf('<details>'), out.indexOf('</details>'));
|
||||||
|
expect(details).toContain('getClientIP()');
|
||||||
|
// The nested quoted attribution (00:28) collapses, the outer one (13:56) stays.
|
||||||
|
expect(details).toContain('00:28');
|
||||||
|
expect(details).not.toContain('13:56');
|
||||||
|
const outside = out.replace(details, '');
|
||||||
|
expect(outside).toContain('Hallo Linus,');
|
||||||
|
expect(outside).toContain('13:56 Richard Weinberger wrote:');
|
||||||
|
expect(outside).toContain('sigma star gmbh');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips through sanitizePlainTextRenderedHtml', () => {
|
||||||
|
const out = sanitizePlainTextRenderedHtml(collapsePlainTextQuotes(plainTextToSafeHtml(raw), labels));
|
||||||
|
expect(out).toContain('<details>');
|
||||||
|
expect(out).toContain('<summary');
|
||||||
|
expect(out).toContain(`title="${labels.show}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not collapse a bottom-posted reply (content after the quote run)', () => {
|
||||||
|
const text = 'Tom wrote:\n> what do you think?\n\nSounds good to me!';
|
||||||
|
const safe = plainTextToSafeHtml(text);
|
||||||
|
expect(collapsePlainTextQuotes(safe, labels)).toBe(safe);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not collapse when the quote is the whole message', () => {
|
||||||
|
const text = '> forwarded line one\n> forwarded line two';
|
||||||
|
const safe = plainTextToSafeHtml(text);
|
||||||
|
expect(collapsePlainTextQuotes(safe, labels)).toBe(safe);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves mail without quote lines untouched', () => {
|
||||||
|
const safe = plainTextToSafeHtml('just a normal message\nwith two lines');
|
||||||
|
expect(collapsePlainTextQuotes(safe, labels)).toBe(safe);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never treats a ">" mid-line as a quote', () => {
|
||||||
|
const safe = plainTextToSafeHtml('a -> b\nx > y comparison');
|
||||||
|
expect(collapsePlainTextQuotes(safe, labels)).toBe(safe);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,11 @@ import DOMPurify from 'dompurify';
|
|||||||
*/
|
*/
|
||||||
export const EMAIL_SANITIZE_CONFIG = {
|
export const EMAIL_SANITIZE_CONFIG = {
|
||||||
ADD_TAGS: [],
|
ADD_TAGS: [],
|
||||||
ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
|
// data-quoted-html is Bulwark's own reply-quote marker (see
|
||||||
|
// components/email/quoted-html.ts). Explicitly whitelisted despite
|
||||||
|
// ALLOW_DATA_ATTR:false so the viewer can detect and collapse the quoted
|
||||||
|
// original (lib/quote-collapse.ts); it's inert otherwise.
|
||||||
|
ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color', 'data-quoted-html'],
|
||||||
ALLOW_DATA_ATTR: false,
|
ALLOW_DATA_ATTR: false,
|
||||||
FORCE_BODY: true,
|
FORCE_BODY: true,
|
||||||
// Allow blob: URIs so authenticated inline images (CID) are not stripped.
|
// Allow blob: URIs so authenticated inline images (CID) are not stripped.
|
||||||
@@ -182,8 +186,10 @@ export function sanitizeI18nHtml(html: string): string {
|
|||||||
* future code path passes raw HTML in by mistake.
|
* future code path passes raw HTML in by mistake.
|
||||||
*/
|
*/
|
||||||
const PLAIN_TEXT_RENDERED_CONFIG = {
|
const PLAIN_TEXT_RENDERED_CONFIG = {
|
||||||
ALLOWED_TAGS: ['a', 'br', 'p', 'div', 'span'],
|
// details/summary/title carry the script-less quote-collapse toggle emitted
|
||||||
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style'],
|
// by collapsePlainTextQuotes (lib/quote-collapse.ts).
|
||||||
|
ALLOWED_TAGS: ['a', 'br', 'p', 'div', 'span', 'details', 'summary'],
|
||||||
|
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style', 'title'],
|
||||||
// DOMPurify URI-tests every attribute value not on its URI-safe list, so the
|
// DOMPurify URI-tests every attribute value not on its URI-safe list, so the
|
||||||
// strict ALLOWED_URI_REGEXP below would strip target="_blank" (and rel) —
|
// strict ALLOWED_URI_REGEXP below would strip target="_blank" (and rel) —
|
||||||
// "_blank" is not a URI. This branch renders into the main document rather
|
// "_blank" is not a URI. This branch renders into the main document rather
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* Quote collapsing for the email viewer (issue #480).
|
||||||
|
*
|
||||||
|
* When a reply carries the original message as a trailing quote, hide it by
|
||||||
|
* default and inject a Gmail-style "•••" pill that toggles it. Runs against
|
||||||
|
* the live iframe DOM from the parent frame AFTER sanitization, so the
|
||||||
|
* injected button and its click listener are not subject to the sanitizer or
|
||||||
|
* the iframe's script-blocking CSP (parent-attached DOM listeners still fire).
|
||||||
|
*
|
||||||
|
* Two kinds of markers:
|
||||||
|
* - container: an element that wraps the entire quoted original. Hiding the
|
||||||
|
* element hides the quote.
|
||||||
|
* - separator: a header/divider element ("From: …" block, attribution line)
|
||||||
|
* that the quoted original FOLLOWS as siblings. Everything from the marker
|
||||||
|
* to the end of the body is hidden.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Order matters only for readability - document order decides which marker
|
||||||
|
// wins when several are present (querySelector on the comma-joined list).
|
||||||
|
const CONTAINER_SELECTORS = [
|
||||||
|
// Bulwark's own reply/forward island (see components/email/quoted-html.ts).
|
||||||
|
// The attribute survives sanitization via an explicit ADD_ATTR entry.
|
||||||
|
'div[data-quoted-html]',
|
||||||
|
// Gmail: gmail_quote_container (2023+) wraps gmail_attr + gmail_quote.
|
||||||
|
'div.gmail_quote_container',
|
||||||
|
'div.gmail_quote',
|
||||||
|
// Apple Mail / Thunderbird quoted body.
|
||||||
|
'blockquote[type="cite"]',
|
||||||
|
'div.yahoo_quoted',
|
||||||
|
'blockquote.protonmail_quote',
|
||||||
|
'div.protonmail_quote',
|
||||||
|
];
|
||||||
|
|
||||||
|
const SEPARATOR_SELECTORS = [
|
||||||
|
// Outlook (desktop + OWA): "From:/Sent:/To:/Subject:" header block; the
|
||||||
|
// quoted message body follows as siblings.
|
||||||
|
'#divRplyFwdMsg',
|
||||||
|
'#appendonsend',
|
||||||
|
// Thunderbird attribution line ("On …, X wrote:"); the blockquote follows.
|
||||||
|
'div.moz-cite-prefix',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALL_SELECTORS = [...CONTAINER_SELECTORS, ...SEPARATOR_SELECTORS].join(',');
|
||||||
|
|
||||||
|
/** Marks the injected toggle button (also the idempotence guard). */
|
||||||
|
export const QUOTE_TOGGLE_ATTR = 'data-quote-toggle';
|
||||||
|
/** Marks elements hidden by the collapse; value stores the original inline display. */
|
||||||
|
export const QUOTE_COLLAPSED_ATTR = 'data-quote-collapsed';
|
||||||
|
|
||||||
|
export interface QuoteCollapseLabels {
|
||||||
|
/** Accessible label/tooltip while collapsed, e.g. "Show quoted text". */
|
||||||
|
show: string;
|
||||||
|
/** Accessible label/tooltip while expanded, e.g. "Hide quoted text". */
|
||||||
|
hide: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when any text node outside `marker` on the given side of it has visible content. */
|
||||||
|
function hasVisibleContent(body: HTMLElement, marker: Element, side: 'before' | 'after'): boolean {
|
||||||
|
const positionBit = side === 'before'
|
||||||
|
? Node.DOCUMENT_POSITION_PRECEDING
|
||||||
|
: Node.DOCUMENT_POSITION_FOLLOWING;
|
||||||
|
const doc = body.ownerDocument;
|
||||||
|
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
|
||||||
|
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||||
|
const rel = marker.compareDocumentPosition(node);
|
||||||
|
// Skip the marker's own subtree and anything not on the requested side.
|
||||||
|
if (rel & Node.DOCUMENT_POSITION_CONTAINED_BY) continue;
|
||||||
|
if (!(rel & positionBit)) continue;
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
if ((node.nodeValue || '').trim() !== '') return true;
|
||||||
|
} else if ((node as Element).tagName === 'IMG') {
|
||||||
|
// An image counts as content (image-only replies exist), but not the
|
||||||
|
// 1x1 placeholders left by blocked external images.
|
||||||
|
const img = node as HTMLImageElement;
|
||||||
|
if (!img.hasAttribute('data-blocked-src') && (img.style.display !== 'none')) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback for replies whose quote is a bare <blockquote> (no type/class -
|
||||||
|
* e.g. Bulwark's own reply to a plain-text original, and various mobile
|
||||||
|
* clients): an outermost blockquote directly preceded by an attribution line.
|
||||||
|
* Attribution is sniffed language-agnostically as the nearest preceding text
|
||||||
|
* ending with a colon ("On …, X wrote:", "Am … schrieb X:", "… a écrit :").
|
||||||
|
*/
|
||||||
|
function findAttributedBlockquote(body: HTMLElement): Element | null {
|
||||||
|
for (const bq of Array.from(body.querySelectorAll('blockquote'))) {
|
||||||
|
if (bq.parentElement?.closest('blockquote')) continue; // outermost only
|
||||||
|
if (precedingTextEndsWithColon(body, bq)) return bq;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the last non-blank text node before `el` ends with a colon. */
|
||||||
|
function precedingTextEndsWithColon(body: HTMLElement, el: Element): boolean {
|
||||||
|
const walker = body.ownerDocument.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
||||||
|
let last: string | null = null;
|
||||||
|
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||||
|
const rel = el.compareDocumentPosition(node);
|
||||||
|
if (rel & Node.DOCUMENT_POSITION_CONTAINED_BY) continue;
|
||||||
|
if (!(rel & Node.DOCUMENT_POSITION_PRECEDING)) break; // reached/passed el
|
||||||
|
const text = (node.nodeValue || '').trim();
|
||||||
|
if (text !== '') last = text;
|
||||||
|
}
|
||||||
|
return last !== null && last.endsWith(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hide an element, remembering its original inline display for restore. */
|
||||||
|
function hideElement(el: HTMLElement): void {
|
||||||
|
el.setAttribute(QUOTE_COLLAPSED_ATTR, el.style.display || '');
|
||||||
|
el.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The elements to hide for a separator marker: the marker itself plus
|
||||||
|
* everything after it in document order (climbing to body handles markers
|
||||||
|
* nested inside wrapper divs).
|
||||||
|
*/
|
||||||
|
function collectSeparatorRange(body: HTMLElement, marker: Element): HTMLElement[] {
|
||||||
|
const range: HTMLElement[] = [marker as HTMLElement];
|
||||||
|
let el: Element | null = marker;
|
||||||
|
while (el && el !== body) {
|
||||||
|
for (let sib = el.nextElementSibling; sib; sib = sib.nextElementSibling) {
|
||||||
|
range.push(sib as HTMLElement);
|
||||||
|
}
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect a trailing quoted original in `doc` and collapse it behind a "•••"
|
||||||
|
* toggle button. No-op (returns false) when no marker is found, when the
|
||||||
|
* quote IS the whole message (nothing visible before it - collapsing would
|
||||||
|
* render the mail as a lone button), or - for container markers - when
|
||||||
|
* visible content follows the quote (interleaved/bottom-posted reply, where
|
||||||
|
* hiding "the rest" would swallow real content).
|
||||||
|
*
|
||||||
|
* Idempotent per document. Returns true when a quote was collapsed.
|
||||||
|
*/
|
||||||
|
export function setupQuoteCollapse(doc: Document, labels: QuoteCollapseLabels): boolean {
|
||||||
|
const body = doc.body;
|
||||||
|
if (!body || body.querySelector(`[${QUOTE_TOGGLE_ATTR}]`)) return false;
|
||||||
|
|
||||||
|
const marker = body.querySelector(ALL_SELECTORS) ?? findAttributedBlockquote(body);
|
||||||
|
if (!marker) return false;
|
||||||
|
|
||||||
|
const isSeparator = SEPARATOR_SELECTORS.some((sel) => marker.matches(sel));
|
||||||
|
|
||||||
|
// Never collapse the entire message down to just the toggle.
|
||||||
|
if (!hasVisibleContent(body, marker, 'before')) return false;
|
||||||
|
// Interleaved reply: real content after the quote container - leave as is.
|
||||||
|
if (!isSeparator && hasVisibleContent(body, marker, 'after')) return false;
|
||||||
|
|
||||||
|
const hidden = isSeparator
|
||||||
|
? collectSeparatorRange(body, marker)
|
||||||
|
: [marker as HTMLElement];
|
||||||
|
|
||||||
|
const button = doc.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.setAttribute(QUOTE_TOGGLE_ATTR, '');
|
||||||
|
button.setAttribute('aria-expanded', 'false');
|
||||||
|
button.setAttribute('aria-label', labels.show);
|
||||||
|
button.title = labels.show;
|
||||||
|
button.textContent = '•••';
|
||||||
|
// Inline styles only: the iframe has no app CSS, and style-src allows
|
||||||
|
// inline. Neutral grays read fine after the dark-mode invert filter too.
|
||||||
|
button.style.cssText =
|
||||||
|
'display:inline-block;margin:12px 0 4px;padding:3px 12px;border:none;' +
|
||||||
|
'border-radius:999px;background:#e3e6ea;color:#3c4043;font-size:11px;' +
|
||||||
|
'line-height:1;letter-spacing:2px;cursor:pointer;font-family:inherit;';
|
||||||
|
button.addEventListener('mouseenter', () => { button.style.background = '#d4d8dd'; });
|
||||||
|
button.addEventListener('mouseleave', () => { button.style.background = '#e3e6ea'; });
|
||||||
|
|
||||||
|
marker.parentNode?.insertBefore(button, marker);
|
||||||
|
hidden.forEach(hideElement);
|
||||||
|
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const expanded = button.getAttribute('aria-expanded') === 'true';
|
||||||
|
hidden.forEach((el) => {
|
||||||
|
if (expanded) {
|
||||||
|
hideElement(el);
|
||||||
|
} else {
|
||||||
|
el.style.display = el.getAttribute(QUOTE_COLLAPSED_ATTR) || '';
|
||||||
|
el.removeAttribute(QUOTE_COLLAPSED_ATTR);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
button.setAttribute('aria-expanded', String(!expanded));
|
||||||
|
const label = expanded ? labels.show : labels.hide;
|
||||||
|
button.setAttribute('aria-label', label);
|
||||||
|
button.title = label;
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeAttr = (s: string) =>
|
||||||
|
s.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse the trailing ">"-quoted block of a plain-text email.
|
||||||
|
*
|
||||||
|
* Operates on the ESCAPED html produced by plainTextToSafeHtml (lines are
|
||||||
|
* plain text with entities plus <a> tags, still newline-separated), because
|
||||||
|
* the plain-text branch renders into the main DOM via dangerouslySetInnerHTML
|
||||||
|
* - there is no post-render hook to attach a JS toggle to. Instead the quote
|
||||||
|
* run is wrapped in native <details>/<summary>, which toggles without any
|
||||||
|
* script and survives sanitizePlainTextRenderedHtml (details/summary are
|
||||||
|
* whitelisted there for exactly this markup).
|
||||||
|
*
|
||||||
|
* Collapsed: the last run of ">"-prefixed lines (interior blank lines
|
||||||
|
* included). Left visible: everything before it (reply + attribution line),
|
||||||
|
* and a trailing "-- " signature block after it. No-op when real content
|
||||||
|
* follows the run (bottom-posted/interleaved reply), when there is no content
|
||||||
|
* before it, or when there are no quote lines at all.
|
||||||
|
*/
|
||||||
|
export function collapsePlainTextQuotes(safeHtml: string, labels: QuoteCollapseLabels): string {
|
||||||
|
const lines = safeHtml.split('\n');
|
||||||
|
const isQuote = (l: string) => /^\s*>/.test(l);
|
||||||
|
|
||||||
|
let end = -1;
|
||||||
|
for (let i = lines.length - 1; i >= 0; i--) {
|
||||||
|
if (isQuote(lines[i])) { end = i; break; }
|
||||||
|
}
|
||||||
|
if (end === -1) return safeHtml;
|
||||||
|
|
||||||
|
// Everything after the run must be blank or a "-- " signature block.
|
||||||
|
let inSignature = false;
|
||||||
|
for (let i = end + 1; i < lines.length; i++) {
|
||||||
|
if (inSignature || lines[i].trim() === '') continue;
|
||||||
|
if (/^--\s*$/.test(lines[i].trim())) { inSignature = true; continue; }
|
||||||
|
return safeHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk back to the start of the run; blank lines BETWEEN quote lines are
|
||||||
|
// part of it (start only ever lands on a quote line).
|
||||||
|
let start = end;
|
||||||
|
for (let i = end - 1; i >= 0; i--) {
|
||||||
|
if (isQuote(lines[i])) start = i;
|
||||||
|
else if (lines[i].trim() !== '') break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never collapse the entire message down to just the toggle.
|
||||||
|
if (!lines.slice(0, start).some((l) => l.trim() !== '')) return safeHtml;
|
||||||
|
|
||||||
|
const before = lines.slice(0, start).join('\n');
|
||||||
|
const quoted = lines.slice(start, end + 1).join('\n');
|
||||||
|
const after = lines.slice(end + 1).join('\n');
|
||||||
|
// Same pill look as the HTML-path toggle button; list-style:none hides the
|
||||||
|
// native disclosure triangle.
|
||||||
|
const summary =
|
||||||
|
`<summary title="${escapeAttr(labels.show)}" style="display:inline-block;` +
|
||||||
|
'list-style:none;margin:4px 0;padding:3px 12px;border-radius:999px;' +
|
||||||
|
'background:#e3e6ea;color:#3c4043;font-size:11px;line-height:1;' +
|
||||||
|
`letter-spacing:2px;cursor:pointer;">•••</summary>`;
|
||||||
|
return `${before}\n<details>${summary}${quoted}</details>${after ? '\n' + after : ''}`;
|
||||||
|
}
|
||||||
@@ -277,6 +277,8 @@
|
|||||||
"no_subject": "(بلا موضوع)",
|
"no_subject": "(بلا موضوع)",
|
||||||
"no_body_content": "(لا يوجد محتوى نصي متاح)",
|
"no_body_content": "(لا يوجد محتوى نصي متاح)",
|
||||||
"no_preview_available": "لا تتوفر معاينة",
|
"no_preview_available": "لا تتوفر معاينة",
|
||||||
|
"show_quoted_text": "إظهار النص المقتبس",
|
||||||
|
"hide_quoted_text": "إخفاء النص المقتبس",
|
||||||
"loading_email": "جارٍ تحميل الرسالة...",
|
"loading_email": "جارٍ تحميل الرسالة...",
|
||||||
"loading": "جارٍ التحميل...",
|
"loading": "جارٍ التحميل...",
|
||||||
"reply": "رد",
|
"reply": "رد",
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
"no_subject": "(Bez předmětu)",
|
"no_subject": "(Bez předmětu)",
|
||||||
"no_body_content": "(Žádný obsah těla)",
|
"no_body_content": "(Žádný obsah těla)",
|
||||||
"no_preview_available": "Náhled není k dispozici",
|
"no_preview_available": "Náhled není k dispozici",
|
||||||
|
"show_quoted_text": "Zobrazit citovaný text",
|
||||||
|
"hide_quoted_text": "Skrýt citovaný text",
|
||||||
"loading_email": "Načítání zprávy...",
|
"loading_email": "Načítání zprávy...",
|
||||||
"loading": "Načítání...",
|
"loading": "Načítání...",
|
||||||
"reply": "Odpovědět",
|
"reply": "Odpovědět",
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
"no_subject": "(Intet emne)",
|
"no_subject": "(Intet emne)",
|
||||||
"no_body_content": "(Intet indhold tilgængeligt)",
|
"no_body_content": "(Intet indhold tilgængeligt)",
|
||||||
"no_preview_available": "Forhåndsvisning ikke tilgængelig",
|
"no_preview_available": "Forhåndsvisning ikke tilgængelig",
|
||||||
|
"show_quoted_text": "Vis citeret tekst",
|
||||||
|
"hide_quoted_text": "Skjul citeret tekst",
|
||||||
"loading_email": "Indlæser e-mail...",
|
"loading_email": "Indlæser e-mail...",
|
||||||
"loading": "Indlæser...",
|
"loading": "Indlæser...",
|
||||||
"reply": "Svar",
|
"reply": "Svar",
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
"no_subject": "(Kein Betreff)",
|
"no_subject": "(Kein Betreff)",
|
||||||
"no_body_content": "(Kein Inhalt verfügbar)",
|
"no_body_content": "(Kein Inhalt verfügbar)",
|
||||||
"no_preview_available": "Keine Vorschau verfügbar",
|
"no_preview_available": "Keine Vorschau verfügbar",
|
||||||
|
"show_quoted_text": "Zitierten Text anzeigen",
|
||||||
|
"hide_quoted_text": "Zitierten Text ausblenden",
|
||||||
"loading_email": "E-Mail wird geladen...",
|
"loading_email": "E-Mail wird geladen...",
|
||||||
"loading": "Lädt...",
|
"loading": "Lädt...",
|
||||||
"reply": "Antworten",
|
"reply": "Antworten",
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
"no_subject": "(No Subject)",
|
"no_subject": "(No Subject)",
|
||||||
"no_body_content": "(No body content available)",
|
"no_body_content": "(No body content available)",
|
||||||
"no_preview_available": "No preview available",
|
"no_preview_available": "No preview available",
|
||||||
|
"show_quoted_text": "Show quoted text",
|
||||||
|
"hide_quoted_text": "Hide quoted text",
|
||||||
"loading_email": "Loading email...",
|
"loading_email": "Loading email...",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"reply": "Reply",
|
"reply": "Reply",
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
"no_subject": "(Sin Asunto)",
|
"no_subject": "(Sin Asunto)",
|
||||||
"no_body_content": "(Sin contenido disponible)",
|
"no_body_content": "(Sin contenido disponible)",
|
||||||
"no_preview_available": "Vista previa no disponible",
|
"no_preview_available": "Vista previa no disponible",
|
||||||
|
"show_quoted_text": "Mostrar texto citado",
|
||||||
|
"hide_quoted_text": "Ocultar texto citado",
|
||||||
"loading_email": "Cargando correo...",
|
"loading_email": "Cargando correo...",
|
||||||
"loading": "Cargando...",
|
"loading": "Cargando...",
|
||||||
"reply": "Responder",
|
"reply": "Responder",
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
"no_subject": "(بدون موضوع)",
|
"no_subject": "(بدون موضوع)",
|
||||||
"no_body_content": "(محتوایی موجود نیست)",
|
"no_body_content": "(محتوایی موجود نیست)",
|
||||||
"no_preview_available": "پیشنمایشی در دسترس نیست",
|
"no_preview_available": "پیشنمایشی در دسترس نیست",
|
||||||
|
"show_quoted_text": "نمایش متن نقلقولشده",
|
||||||
|
"hide_quoted_text": "پنهان کردن متن نقلقولشده",
|
||||||
"loading_email": "در حال بارگذاری ایمیل...",
|
"loading_email": "در حال بارگذاری ایمیل...",
|
||||||
"loading": "در حال بارگذاری...",
|
"loading": "در حال بارگذاری...",
|
||||||
"reply": "پاسخ",
|
"reply": "پاسخ",
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
"no_subject": "(Sans objet)",
|
"no_subject": "(Sans objet)",
|
||||||
"no_body_content": "(Aucun contenu disponible)",
|
"no_body_content": "(Aucun contenu disponible)",
|
||||||
"no_preview_available": "Aucun aperçu disponible",
|
"no_preview_available": "Aucun aperçu disponible",
|
||||||
|
"show_quoted_text": "Afficher le texte cité",
|
||||||
|
"hide_quoted_text": "Masquer le texte cité",
|
||||||
"loading_email": "Chargement de l'email...",
|
"loading_email": "Chargement de l'email...",
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
"reply": "Répondre",
|
"reply": "Répondre",
|
||||||
|
|||||||
@@ -478,6 +478,8 @@
|
|||||||
},
|
},
|
||||||
"no_body_content": "(אין תוכן גוף זמין)",
|
"no_body_content": "(אין תוכן גוף זמין)",
|
||||||
"no_preview_available": "אין תצוגה מקדימה זמינה",
|
"no_preview_available": "אין תצוגה מקדימה זמינה",
|
||||||
|
"show_quoted_text": "הצג טקסט מצוטט",
|
||||||
|
"hide_quoted_text": "הסתר טקסט מצוטט",
|
||||||
"unread": "סמן כלא נקרא",
|
"unread": "סמן כלא נקרא",
|
||||||
"read": "קרא",
|
"read": "קרא",
|
||||||
"spam_short": "דואר זבל",
|
"spam_short": "דואר זבל",
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
"no_subject": "(Nincs tárgy)",
|
"no_subject": "(Nincs tárgy)",
|
||||||
"no_body_content": "(Nincs elérhető tartalom)",
|
"no_body_content": "(Nincs elérhető tartalom)",
|
||||||
"no_preview_available": "Nincs előnézet",
|
"no_preview_available": "Nincs előnézet",
|
||||||
|
"show_quoted_text": "Idézett szöveg megjelenítése",
|
||||||
|
"hide_quoted_text": "Idézett szöveg elrejtése",
|
||||||
"loading_email": "E-mail betöltése...",
|
"loading_email": "E-mail betöltése...",
|
||||||
"loading": "Betöltés...",
|
"loading": "Betöltés...",
|
||||||
"reply": "Válasz",
|
"reply": "Válasz",
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
"no_subject": "(Nessun oggetto)",
|
"no_subject": "(Nessun oggetto)",
|
||||||
"no_body_content": "(Nessun contenuto disponibile)",
|
"no_body_content": "(Nessun contenuto disponibile)",
|
||||||
"no_preview_available": "Anteprima non disponibile",
|
"no_preview_available": "Anteprima non disponibile",
|
||||||
|
"show_quoted_text": "Mostra testo citato",
|
||||||
|
"hide_quoted_text": "Nascondi testo citato",
|
||||||
"loading_email": "Caricamento messaggio...",
|
"loading_email": "Caricamento messaggio...",
|
||||||
"loading": "Caricamento...",
|
"loading": "Caricamento...",
|
||||||
"reply": "Rispondi",
|
"reply": "Rispondi",
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
"no_subject": "(件名なし)",
|
"no_subject": "(件名なし)",
|
||||||
"no_body_content": "(本文がありません)",
|
"no_body_content": "(本文がありません)",
|
||||||
"no_preview_available": "プレビューは利用できません",
|
"no_preview_available": "プレビューは利用できません",
|
||||||
|
"show_quoted_text": "引用テキストを表示",
|
||||||
|
"hide_quoted_text": "引用テキストを非表示",
|
||||||
"loading_email": "メールを読み込み中...",
|
"loading_email": "メールを読み込み中...",
|
||||||
"loading": "読み込み中...",
|
"loading": "読み込み中...",
|
||||||
"reply": "返信",
|
"reply": "返信",
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
"no_subject": "(제목 없음)",
|
"no_subject": "(제목 없음)",
|
||||||
"no_body_content": "(본문 내용 없음)",
|
"no_body_content": "(본문 내용 없음)",
|
||||||
"no_preview_available": "미리 보기를 사용할 수 없습니다",
|
"no_preview_available": "미리 보기를 사용할 수 없습니다",
|
||||||
|
"show_quoted_text": "인용된 텍스트 표시",
|
||||||
|
"hide_quoted_text": "인용된 텍스트 숨기기",
|
||||||
"loading_email": "메일을 불러오는 중...",
|
"loading_email": "메일을 불러오는 중...",
|
||||||
"loading": "불러오는 중...",
|
"loading": "불러오는 중...",
|
||||||
"reply": "답장",
|
"reply": "답장",
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
"no_subject": "(nav temata)",
|
"no_subject": "(nav temata)",
|
||||||
"no_body_content": "(nav satura)",
|
"no_body_content": "(nav satura)",
|
||||||
"no_preview_available": "Priekšskatījums nav pieejams",
|
"no_preview_available": "Priekšskatījums nav pieejams",
|
||||||
|
"show_quoted_text": "Rādīt citēto tekstu",
|
||||||
|
"hide_quoted_text": "Paslēpt citēto tekstu",
|
||||||
"loading_email": "Ielādē vēstuli...",
|
"loading_email": "Ielādē vēstuli...",
|
||||||
"loading": "Ielādē...",
|
"loading": "Ielādē...",
|
||||||
"reply": "Atbildēt",
|
"reply": "Atbildēt",
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
"no_subject": "(Geen onderwerp)",
|
"no_subject": "(Geen onderwerp)",
|
||||||
"no_body_content": "(Geen inhoud beschikbaar)",
|
"no_body_content": "(Geen inhoud beschikbaar)",
|
||||||
"no_preview_available": "Geen voorbeeld beschikbaar",
|
"no_preview_available": "Geen voorbeeld beschikbaar",
|
||||||
|
"show_quoted_text": "Geciteerde tekst tonen",
|
||||||
|
"hide_quoted_text": "Geciteerde tekst verbergen",
|
||||||
"loading_email": "E-mail laden...",
|
"loading_email": "E-mail laden...",
|
||||||
"loading": "Laden...",
|
"loading": "Laden...",
|
||||||
"reply": "Beantwoorden",
|
"reply": "Beantwoorden",
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
"no_subject": "(Bez tematu)",
|
"no_subject": "(Bez tematu)",
|
||||||
"no_body_content": "(Brak treści)",
|
"no_body_content": "(Brak treści)",
|
||||||
"no_preview_available": "Podgląd niedostępny",
|
"no_preview_available": "Podgląd niedostępny",
|
||||||
|
"show_quoted_text": "Pokaż cytowany tekst",
|
||||||
|
"hide_quoted_text": "Ukryj cytowany tekst",
|
||||||
"loading_email": "Ładowanie wiadomości...",
|
"loading_email": "Ładowanie wiadomości...",
|
||||||
"loading": "Ładowanie...",
|
"loading": "Ładowanie...",
|
||||||
"reply": "Odpowiedz",
|
"reply": "Odpowiedz",
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
"no_subject": "(Sem Assunto)",
|
"no_subject": "(Sem Assunto)",
|
||||||
"no_body_content": "(Sem conteúdo disponível)",
|
"no_body_content": "(Sem conteúdo disponível)",
|
||||||
"no_preview_available": "Pré-visualização não disponível",
|
"no_preview_available": "Pré-visualização não disponível",
|
||||||
|
"show_quoted_text": "Mostrar texto citado",
|
||||||
|
"hide_quoted_text": "Ocultar texto citado",
|
||||||
"loading_email": "Carregando e-mail...",
|
"loading_email": "Carregando e-mail...",
|
||||||
"loading": "Carregando...",
|
"loading": "Carregando...",
|
||||||
"reply": "Responder",
|
"reply": "Responder",
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
"no_subject": "(Fără subiect)",
|
"no_subject": "(Fără subiect)",
|
||||||
"no_body_content": "(Nu există conținut disponibil)",
|
"no_body_content": "(Nu există conținut disponibil)",
|
||||||
"no_preview_available": "Nu este disponibilă previzualizarea",
|
"no_preview_available": "Nu este disponibilă previzualizarea",
|
||||||
|
"show_quoted_text": "Afișează textul citat",
|
||||||
|
"hide_quoted_text": "Ascunde textul citat",
|
||||||
"loading_email": "Se încarcă e-mailul...",
|
"loading_email": "Se încarcă e-mailul...",
|
||||||
"loading": "Se încarcă...",
|
"loading": "Se încarcă...",
|
||||||
"reply": "Răspunde",
|
"reply": "Răspunde",
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
"no_subject": "(Без темы)",
|
"no_subject": "(Без темы)",
|
||||||
"no_body_content": "(Содержимое отсутствует)",
|
"no_body_content": "(Содержимое отсутствует)",
|
||||||
"no_preview_available": "Предварительный просмотр недоступен",
|
"no_preview_available": "Предварительный просмотр недоступен",
|
||||||
|
"show_quoted_text": "Показать цитируемый текст",
|
||||||
|
"hide_quoted_text": "Скрыть цитируемый текст",
|
||||||
"loading_email": "Загрузка письма...",
|
"loading_email": "Загрузка письма...",
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
"reply": "Ответить",
|
"reply": "Ответить",
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
"no_subject": "(Bez predmetu)",
|
"no_subject": "(Bez predmetu)",
|
||||||
"no_body_content": "(Obsah správy nie je dostupný)",
|
"no_body_content": "(Obsah správy nie je dostupný)",
|
||||||
"no_preview_available": "Náhľad nie je dostupný",
|
"no_preview_available": "Náhľad nie je dostupný",
|
||||||
|
"show_quoted_text": "Zobraziť citovaný text",
|
||||||
|
"hide_quoted_text": "Skryť citovaný text",
|
||||||
"loading_email": "Načítavanie e-mailu...",
|
"loading_email": "Načítavanie e-mailu...",
|
||||||
"loading": "Načítavanie...",
|
"loading": "Načítavanie...",
|
||||||
"reply": "Odpovedať",
|
"reply": "Odpovedať",
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
"no_subject": "(Konu Yok)",
|
"no_subject": "(Konu Yok)",
|
||||||
"no_body_content": "(İçerik yok)",
|
"no_body_content": "(İçerik yok)",
|
||||||
"no_preview_available": "Önizleme kullanılamıyor",
|
"no_preview_available": "Önizleme kullanılamıyor",
|
||||||
|
"show_quoted_text": "Alıntılanan metni göster",
|
||||||
|
"hide_quoted_text": "Alıntılanan metni gizle",
|
||||||
"loading_email": "E-posta yükleniyor...",
|
"loading_email": "E-posta yükleniyor...",
|
||||||
"loading": "Yükleniyor...",
|
"loading": "Yükleniyor...",
|
||||||
"reply": "Yanıtla",
|
"reply": "Yanıtla",
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
"no_subject": "(без теми)",
|
"no_subject": "(без теми)",
|
||||||
"no_body_content": "(вміст відсутній)",
|
"no_body_content": "(вміст відсутній)",
|
||||||
"no_preview_available": "Попередній перегляд недоступний",
|
"no_preview_available": "Попередній перегляд недоступний",
|
||||||
|
"show_quoted_text": "Показати цитований текст",
|
||||||
|
"hide_quoted_text": "Приховати цитований текст",
|
||||||
"loading_email": "Завантаження електронної пошти...",
|
"loading_email": "Завантаження електронної пошти...",
|
||||||
"loading": "Завантаження...",
|
"loading": "Завантаження...",
|
||||||
"reply": "Відповісти",
|
"reply": "Відповісти",
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
"no_subject": "(无主题)",
|
"no_subject": "(无主题)",
|
||||||
"no_body_content": "(无正文内容)",
|
"no_body_content": "(无正文内容)",
|
||||||
"no_preview_available": "无可用预览",
|
"no_preview_available": "无可用预览",
|
||||||
|
"show_quoted_text": "显示引用文本",
|
||||||
|
"hide_quoted_text": "隐藏引用文本",
|
||||||
"loading_email": "正在加载邮件...",
|
"loading_email": "正在加载邮件...",
|
||||||
"loading": "正在加载…",
|
"loading": "正在加载…",
|
||||||
"reply": "回复",
|
"reply": "回复",
|
||||||
|
|||||||
Reference in New Issue
Block a user