feat: collapse quoted reply text behind a "..." toggle #480

This commit is contained in:
Linus Rath
2026-07-22 19:17:23 +02:00
parent 813185e58d
commit b7c8cd999e
28 changed files with 603 additions and 12 deletions
+24 -6
View File
@@ -7,6 +7,7 @@ import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilen
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 { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { withBasePath } from "@/lib/browser-navigation";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
@@ -1702,7 +1703,11 @@ export function EmailViewer({
const textContent = email.bodyValues[email.textBody[0].partId].value;
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,
hasStyleTag: false,
externalBlocked: false,
@@ -1742,6 +1747,11 @@ export function EmailViewer({
// Override email content with S/MIME decrypted content when available
const effectiveEmailContent = useMemo(() => {
const plainToHtml = (text: string) =>
collapsePlainTextQuotes(plainTextToSafeHtml(text), {
show: t('show_quoted_text'),
hide: t('hide_quoted_text'),
});
if (pluginRenderedHtml) {
const htmlWithCidUrls = pluginRenderedHtml.replace(
/\bcid:([^"'\s)]+)/gi,
@@ -1753,7 +1763,7 @@ export function EmailViewer({
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(pluginRenderedHtml), externalBlocked: false };
}
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
if (tnefHtml) {
@@ -1761,7 +1771,7 @@ export function EmailViewer({
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(tnefHtml), externalBlocked: false };
}
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
if (embeddedEmailHtml) {
@@ -1769,10 +1779,10 @@ export function EmailViewer({
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(embeddedEmailHtml), externalBlocked: false };
}
if (embeddedEmailText) {
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false, hasStyleTag: false, externalBlocked: false };
return { html: plainToHtml(embeddedEmailText), isHtml: false, hasStyleTag: false, externalBlocked: false };
}
return emailContent;
}, [cidBlobUrls, emailContent, pluginRenderedHtml, pluginRenderedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
}, [cidBlobUrls, emailContent, pluginRenderedHtml, pluginRenderedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText, t]);
const resolveAttachmentName = useCallback(
(attachment: EffectiveAttachment) => {
@@ -2244,6 +2254,14 @@ export function EmailViewer({
if (initializedDocRef.current === doc) return;
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
// Measure max(documentElement, body): a height:100% wrapper can leave
// documentElement.scrollHeight short while the real content lives in body.
@@ -2415,7 +2433,7 @@ export function EmailViewer({
} catch {
// 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
// waiting for the iframe 'load' event. 'load' also waits on every subresource,
+17 -3
View File
@@ -5,6 +5,7 @@ import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
import { useThemeStore } from "@/stores/theme-store";
import { Avatar } from "@/components/ui/avatar";
@@ -424,7 +425,14 @@ function EmailCard({
// Plain text fallback
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
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 };
}, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls]);
}, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls, t]);
// Render the sanitized HTML body inside a sandboxed iframe so a malicious
// (or accidentally-bypassed) email cannot inject styles/scripts/forms into
@@ -469,6 +477,12 @@ function EmailCard({
try {
const doc = iframe.contentDocument;
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 = () => {
iframe.style.height = doc.documentElement.scrollHeight + 'px';
};
@@ -482,7 +496,7 @@ function EmailCard({
} catch {
// contentDocument may be inaccessible under stricter sandboxes; ignore.
}
}, []);
}, [t]);
return (
<div className={cn(