diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index e45dab33..dd59209e 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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
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: /]/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: /]/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: /]/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, diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 37552e93..275c73e5 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -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
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 (
document.body.querySelector(`[${QUOTE_TOGGLE_ATTR}]`); + +describe('setupQuoteCollapse', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + it('collapses a Gmail-style trailing quote behind a toggle', () => { + document.body.innerHTML = ` +
Thanks, sounds good!
+
+
On Mon, Jul 20, 2026 John wrote:
+
Original message body
+
`; + expect(setupQuoteCollapse(document, labels)).toBe(true); + + const quote = document.querySelector('.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 = ` +

reply text

+
quoted
`; + setupQuoteCollapse(document, labels); + + const quote = document.querySelector('.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 = ` +
See you then.
+
On 20 Jul 2026, at 10:00, Jane wrote:
+
the original
`; + expect(setupQuoteCollapse(document, labels)).toBe(true); + + const quote = document.querySelector('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 = ` +

my answer

+
On Jul 20, John <j@example.com> wrote:
+
original
`; + expect(setupQuoteCollapse(document, labels)).toBe(true); + expect(document.querySelector('[data-quoted-html]')!.style.display).toBe('none'); + }); + + it('collapses everything after an Outlook divRplyFwdMsg separator', () => { + document.body.innerHTML = ` +
Top-posted answer
+
From: John
Sent: Monday
+
quoted paragraph one
+
quoted paragraph two
`; + expect(setupQuoteCollapse(document, labels)).toBe(true); + + const divs = [...document.querySelectorAll('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 = ` +

reply

+
On 20.07.2026, Jane wrote:
+
original
`; + expect(setupQuoteCollapse(document, labels)).toBe(true); + expect(document.querySelector('.moz-cite-prefix')!.style.display).toBe('none'); + expect(document.querySelector('blockquote')!.style.display).toBe('none'); + }); + + it('does not collapse when the quote is the entire message', () => { + document.body.innerHTML = ` +
forwarded content only
`; + expect(setupQuoteCollapse(document, labels)).toBe(false); + expect(toggle()).toBeNull(); + expect(document.querySelector('.gmail_quote')!.style.display).toBe(''); + }); + + it('does not collapse an interleaved reply (content after the quote container)', () => { + document.body.innerHTML = ` +

first point

+
their question
+

my answer below the quote

`; + expect(setupQuoteCollapse(document, labels)).toBe(false); + expect(toggle()).toBeNull(); + }); + + it('ignores whitespace-only trailing content after the quote container', () => { + document.body.innerHTML = ` +

reply

+
quoted
+

+ ${String.fromCharCode(160)}`; + expect(setupQuoteCollapse(document, labels)).toBe(true); + }); + + it('is idempotent per document', () => { + document.body.innerHTML = ` +

reply

+
quoted
`; + 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 = `

plain email with content

and more content

`; + 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

. + document.body.innerHTML = ` +

+

Hi Tracey,
thanks for reaching out.

+

On Mo., 18. Mai 2026, 20:27, Tracey Tiefisher wrote:

+

Hi Linus,

older nested quote

+

+
`; + expect(setupQuoteCollapse(document, labels)).toBe(true); + const outer = document.querySelector('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 = ` +

Our newsletter quote of the day

+
Stay hungry, stay foolish.
`; + 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 = ` +
answer
+
quoted intro
+
quoted rest
`; + 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('

hi

orig
'); + 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
, keeping reply, attribution and signature visible', () => { + const out = collapsePlainTextQuotes(plainTextToSafeHtml(raw), labels); + expect(out).toContain('
'); + 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('
'), out.indexOf('
')); + 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('
'); + expect(out).toContain(' { + 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); + }); +}); diff --git a/lib/email-sanitization.ts b/lib/email-sanitization.ts index 3bcd75f2..06ac7faa 100644 --- a/lib/email-sanitization.ts +++ b/lib/email-sanitization.ts @@ -8,7 +8,11 @@ import DOMPurify from 'dompurify'; */ export const EMAIL_SANITIZE_CONFIG = { 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, FORCE_BODY: true, // 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. */ const PLAIN_TEXT_RENDERED_CONFIG = { - ALLOWED_TAGS: ['a', 'br', 'p', 'div', 'span'], - ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style'], + // details/summary/title carry the script-less quote-collapse toggle emitted + // 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 // strict ALLOWED_URI_REGEXP below would strip target="_blank" (and rel) — // "_blank" is not a URI. This branch renders into the main document rather diff --git a/lib/quote-collapse.ts b/lib/quote-collapse.ts new file mode 100644 index 00000000..1eab296c --- /dev/null +++ b/lib/quote-collapse.ts @@ -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
(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(/"-quoted block of a plain-text email. + * + * Operates on the ESCAPED html produced by plainTextToSafeHtml (lines are + * plain text with entities plus 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
/, 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 = + `•••`; + return `${before}\n
${summary}${quoted}
${after ? '\n' + after : ''}`; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 413e0859..79d811f5 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -277,6 +277,8 @@ "no_subject": "(بلا موضوع)", "no_body_content": "(لا يوجد محتوى نصي متاح)", "no_preview_available": "لا تتوفر معاينة", + "show_quoted_text": "إظهار النص المقتبس", + "hide_quoted_text": "إخفاء النص المقتبس", "loading_email": "جارٍ تحميل الرسالة...", "loading": "جارٍ التحميل...", "reply": "رد", diff --git a/locales/cs/common.json b/locales/cs/common.json index 7e328dff..8ab24325 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -277,6 +277,8 @@ "no_subject": "(Bez předmětu)", "no_body_content": "(Žádný obsah těla)", "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": "Načítání...", "reply": "Odpovědět", diff --git a/locales/da/common.json b/locales/da/common.json index 7588b86e..b19d5070 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -277,6 +277,8 @@ "no_subject": "(Intet emne)", "no_body_content": "(Intet indhold tilgængeligt)", "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": "Indlæser...", "reply": "Svar", diff --git a/locales/de/common.json b/locales/de/common.json index 29de840a..9b7eb9eb 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -277,6 +277,8 @@ "no_subject": "(Kein Betreff)", "no_body_content": "(Kein Inhalt 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": "Lädt...", "reply": "Antworten", diff --git a/locales/en/common.json b/locales/en/common.json index a5c080cc..6c1e4189 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -277,6 +277,8 @@ "no_subject": "(No Subject)", "no_body_content": "(No body content available)", "no_preview_available": "No preview available", + "show_quoted_text": "Show quoted text", + "hide_quoted_text": "Hide quoted text", "loading_email": "Loading email...", "loading": "Loading...", "reply": "Reply", diff --git a/locales/es/common.json b/locales/es/common.json index ef8fc6cb..13af4b30 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -277,6 +277,8 @@ "no_subject": "(Sin Asunto)", "no_body_content": "(Sin contenido 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": "Cargando...", "reply": "Responder", diff --git a/locales/fa/common.json b/locales/fa/common.json index 245455b7..fde49342 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -277,6 +277,8 @@ "no_subject": "(بدون موضوع)", "no_body_content": "(محتوایی موجود نیست)", "no_preview_available": "پیش‌نمایشی در دسترس نیست", + "show_quoted_text": "نمایش متن نقل‌قول‌شده", + "hide_quoted_text": "پنهان کردن متن نقل‌قول‌شده", "loading_email": "در حال بارگذاری ایمیل...", "loading": "در حال بارگذاری...", "reply": "پاسخ", diff --git a/locales/fr/common.json b/locales/fr/common.json index e7b362f4..afb52809 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -277,6 +277,8 @@ "no_subject": "(Sans objet)", "no_body_content": "(Aucun contenu 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": "Chargement...", "reply": "Répondre", diff --git a/locales/he/common.json b/locales/he/common.json index 44f14323..23fbbcda 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -478,6 +478,8 @@ }, "no_body_content": "(אין תוכן גוף זמין)", "no_preview_available": "אין תצוגה מקדימה זמינה", + "show_quoted_text": "הצג טקסט מצוטט", + "hide_quoted_text": "הסתר טקסט מצוטט", "unread": "סמן כלא נקרא", "read": "קרא", "spam_short": "דואר זבל", diff --git a/locales/hu/common.json b/locales/hu/common.json index 3c33e4cb..50866701 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -277,6 +277,8 @@ "no_subject": "(Nincs tárgy)", "no_body_content": "(Nincs elérhető tartalom)", "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": "Betöltés...", "reply": "Válasz", diff --git a/locales/it/common.json b/locales/it/common.json index 7ca798bf..606c6747 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -277,6 +277,8 @@ "no_subject": "(Nessun oggetto)", "no_body_content": "(Nessun contenuto disponibile)", "no_preview_available": "Anteprima non disponibile", + "show_quoted_text": "Mostra testo citato", + "hide_quoted_text": "Nascondi testo citato", "loading_email": "Caricamento messaggio...", "loading": "Caricamento...", "reply": "Rispondi", diff --git a/locales/ja/common.json b/locales/ja/common.json index 6db6ca29..b6256067 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -277,6 +277,8 @@ "no_subject": "(件名なし)", "no_body_content": "(本文がありません)", "no_preview_available": "プレビューは利用できません", + "show_quoted_text": "引用テキストを表示", + "hide_quoted_text": "引用テキストを非表示", "loading_email": "メールを読み込み中...", "loading": "読み込み中...", "reply": "返信", diff --git a/locales/ko/common.json b/locales/ko/common.json index f5f85af6..ec9edf2a 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -277,6 +277,8 @@ "no_subject": "(제목 없음)", "no_body_content": "(본문 내용 없음)", "no_preview_available": "미리 보기를 사용할 수 없습니다", + "show_quoted_text": "인용된 텍스트 표시", + "hide_quoted_text": "인용된 텍스트 숨기기", "loading_email": "메일을 불러오는 중...", "loading": "불러오는 중...", "reply": "답장", diff --git a/locales/lv/common.json b/locales/lv/common.json index 54f675b5..0bb11f70 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -277,6 +277,8 @@ "no_subject": "(nav temata)", "no_body_content": "(nav satura)", "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": "Ielādē...", "reply": "Atbildēt", diff --git a/locales/nl/common.json b/locales/nl/common.json index 8a740828..387f314c 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -277,6 +277,8 @@ "no_subject": "(Geen onderwerp)", "no_body_content": "(Geen inhoud 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": "Laden...", "reply": "Beantwoorden", diff --git a/locales/pl/common.json b/locales/pl/common.json index f92ee007..68df0fbb 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -277,6 +277,8 @@ "no_subject": "(Bez tematu)", "no_body_content": "(Brak treści)", "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": "Ładowanie...", "reply": "Odpowiedz", diff --git a/locales/pt/common.json b/locales/pt/common.json index e44bd2b7..4e3e7841 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -277,6 +277,8 @@ "no_subject": "(Sem Assunto)", "no_body_content": "(Sem conteúdo 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": "Carregando...", "reply": "Responder", diff --git a/locales/ro/common.json b/locales/ro/common.json index a480cb03..84a3ba28 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -277,6 +277,8 @@ "no_subject": "(Fără subiect)", "no_body_content": "(Nu există conținut disponibil)", "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": "Se încarcă...", "reply": "Răspunde", diff --git a/locales/ru/common.json b/locales/ru/common.json index 3ac4bdc1..be14c29e 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -277,6 +277,8 @@ "no_subject": "(Без темы)", "no_body_content": "(Содержимое отсутствует)", "no_preview_available": "Предварительный просмотр недоступен", + "show_quoted_text": "Показать цитируемый текст", + "hide_quoted_text": "Скрыть цитируемый текст", "loading_email": "Загрузка письма...", "loading": "Загрузка...", "reply": "Ответить", diff --git a/locales/sk/common.json b/locales/sk/common.json index 71c52464..01cbed03 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -277,6 +277,8 @@ "no_subject": "(Bez predmetu)", "no_body_content": "(Obsah správy 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": "Načítavanie...", "reply": "Odpovedať", diff --git a/locales/tr/common.json b/locales/tr/common.json index d090c750..2b76eb3c 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -277,6 +277,8 @@ "no_subject": "(Konu Yok)", "no_body_content": "(İçerik yok)", "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": "Yükleniyor...", "reply": "Yanıtla", diff --git a/locales/uk/common.json b/locales/uk/common.json index 88c33300..b66b3617 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -277,6 +277,8 @@ "no_subject": "(без теми)", "no_body_content": "(вміст відсутній)", "no_preview_available": "Попередній перегляд недоступний", + "show_quoted_text": "Показати цитований текст", + "hide_quoted_text": "Приховати цитований текст", "loading_email": "Завантаження електронної пошти...", "loading": "Завантаження...", "reply": "Відповісти", diff --git a/locales/zh/common.json b/locales/zh/common.json index 9e44de8e..2f0d05f1 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -277,6 +277,8 @@ "no_subject": "(无主题)", "no_body_content": "(无正文内容)", "no_preview_available": "无可用预览", + "show_quoted_text": "显示引用文本", + "hide_quoted_text": "隐藏引用文本", "loading_email": "正在加载邮件...", "loading": "正在加载…", "reply": "回复",