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 { 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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user