From 9e008960435ea79c26a5706242f6891220693176 Mon Sep 17 00:00:00 2001 From: pcatapan Date: Thu, 29 Oct 2026 11:00:53 +0100 Subject: [PATCH] feat(composer): collapse quoted reply history behind an ellipsis --- app/globals.css | 5 + .../composer-reply-signature.test.tsx | 34 ++++ .../__tests__/reply-quote-collapse.test.ts | 170 ++++++++++++++++++ components/email/email-composer.tsx | 13 +- components/email/reply-quote-collapse.ts | 151 ++++++++++++++++ components/email/rich-text-editor.tsx | 3 + 6 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 components/email/__tests__/reply-quote-collapse.test.ts create mode 100644 components/email/reply-quote-collapse.ts diff --git a/app/globals.css b/app/globals.css index fb908a2db..4dd7d9d64 100644 --- a/app/globals.css +++ b/app/globals.css @@ -780,6 +780,11 @@ optgroup { outline: none; } +/* View-only quote decorations must survive a node view refreshing its inline styles. */ +.tiptap [data-reply-quote-collapsed] { + display: none !important; +} + .tiptap p { margin: 0.25rem 0; } diff --git a/components/email/__tests__/composer-reply-signature.test.tsx b/components/email/__tests__/composer-reply-signature.test.tsx index 2cb02c410..9205d4c35 100644 --- a/components/email/__tests__/composer-reply-signature.test.tsx +++ b/components/email/__tests__/composer-reply-signature.test.tsx @@ -316,3 +316,37 @@ describe('reply signature above the quote (#842)', () => { expect(sent.body).toMatch(/Original text[\s\S]*Sales team$/); }); }); + + +describe('reply quote attribution markers', () => { + afterEach(() => { + useSettingsStore.setState({ plainTextMode: false, signaturePosition: 'below_quote' }); + }); + + it.each(['reply', 'replyAll'] as const)('marks the HTML original in %s mode', (mode) => { + render(); + const html = screen.getByTestId('rich-text-editor').getAttribute('data-html')!; + expect(html).toContain('data-reply-quote-start="true"'); + expect(html).toContain('Original text'); + }); + + it('marks the attribution of a plain-text original composed in rich-text mode', () => { + render(); + const html = screen.getByTestId('rich-text-editor').getAttribute('data-html')!; + expect(html).toContain('data-reply-quote-start="true"'); + expect(html).toContain(' { + render(); + const html = screen.getByTestId('rich-text-editor').getAttribute('data-html')!; + expect(html).not.toContain('data-reply-quote-start'); + expect(html).toContain('Original text'); + }); + + it('preserves a simple plugin attribution', () => { + render(From: Bob

', quoteWrapInBlockquote: false }} />); + const html = screen.getByTestId('rich-text-editor').getAttribute('data-html')!; + expect(html).toContain('

From: Bob

'); + }); +}); diff --git a/components/email/__tests__/reply-quote-collapse.test.ts b/components/email/__tests__/reply-quote-collapse.test.ts new file mode 100644 index 000000000..86c22986c --- /dev/null +++ b/components/email/__tests__/reply-quote-collapse.test.ts @@ -0,0 +1,170 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { Editor } from '@tiptap/core'; +import StarterKit from '@tiptap/starter-kit'; +import Paragraph from '@tiptap/extension-paragraph'; +import { trailingParagraphBreakRule } from '../paragraph-breaks'; +import { QuotedHtml, buildQuotedHtmlBlock, serializeEditorContent } from '../quoted-html'; +import { SignatureBlock, buildSignatureBlock } from '../signature-block'; +import { ReplyQuoteCollapse, markReplyQuoteHeader } from '../reply-quote-collapse'; + +const HEADER = markReplyQuoteHeader('
On Monday, Bob wrote:
'); +const ORIGINAL = '

Original message

Earlier reply
'; +const QUOTE = buildQuotedHtmlBlock(ORIGINAL); +const ReplyParagraph = Paragraph.extend({ + parseHTML() { + return [...(this.parent?.() ?? []), trailingParagraphBreakRule]; + }, +}); +let editor: Editor | undefined; + +function mount(html = `

My response

${HEADER}${QUOTE}`) { + editor = new Editor({ + extensions: [StarterKit.configure({ paragraph: false }), ReplyParagraph, QuotedHtml, SignatureBlock, ReplyQuoteCollapse], + content: html, + }); + // Settle StarterKit's trailing paragraph before comparing message data. + editor.view.dispatch(editor.state.tr); + document.body.appendChild(editor.view.dom); + return editor; +} +function toggle() { + return editor!.view.dom.querySelector('[data-reply-quote-toggle]')!; +} +function quote() { + return editor!.view.dom.querySelector('.quoted-html-island, blockquote')!; +} +afterEach(() => { + editor?.destroy(); + editor = undefined; + document.body.innerHTML = ''; +}); + +describe('reply quote visibility', () => { + it('starts collapsed, hides attribution too, and toggles without changing serialized content', () => { + const e = mount(); + const saved = serializeEditorContent(e); + expect(toggle()).toHaveAttribute('aria-expanded', 'false'); + expect(toggle()).toHaveAccessibleName('Show quoted text'); + expect(quote()).toHaveAttribute('data-reply-quote-collapsed', 'true'); + expect(e.view.dom.querySelector('[data-reply-quote-start]')).toHaveAttribute('data-reply-quote-collapsed', 'true'); + expect(saved).toContain('On Monday, Bob wrote:'); + expect(saved).toContain(ORIGINAL); + expect(saved).not.toContain('data-reply-quote-toggle'); + expect(saved).not.toContain('display: none'); + expect(saved).not.toContain('data-reply-quote-collapsed'); + toggle().click(); + expect(toggle()).toHaveAttribute('aria-expanded', 'true'); + expect(quote()).not.toHaveAttribute('data-reply-quote-collapsed'); + expect(serializeEditorContent(e)).toBe(saved); + toggle().click(); + expect(toggle()).toHaveAttribute('aria-expanded', 'false'); + expect(serializeEditorContent(e)).toBe(saved); + }); + + it('round-trips a collapsed quote through a native draft without dropping inline images', () => { + const e = mount(); + const saved = serializeEditorContent(e); + e.commands.setContent(saved); + expect(toggle()).toHaveAttribute('aria-expanded', 'false'); + expect(serializeEditorContent(e)).toBe(saved); + expect(saved).toContain('src="cid:logo"'); + }); + + it('collapses a plain-text original in rich-text mode and leaves an authored blockquote visible', () => { + mount(`

Response

My own quotation
${HEADER}
Original plain-text message
`); + const quotes = editor!.view.dom.querySelectorAll('blockquote'); + expect(quotes[0]).not.toHaveAttribute('data-reply-quote-collapsed'); + expect(quotes[1]).toHaveAttribute('data-reply-quote-collapsed', 'true'); + }); + + it.each(['above', 'below'])('keeps the signature visible %s the quoted original', (position) => { + const signature = buildSignatureBlock('My signature'); + const e = mount(`

Response

${position === 'above' ? signature : ''}${HEADER}${QUOTE}${position === 'below' ? signature : ''}`); + const signatureNode = e.view.dom.querySelector('[data-signature-html]'); + expect(serializeEditorContent(e)).toContain('My signature'); + // Every hidden top-level block belongs to the attribution/original pair. + const hidden = e.view.dom.querySelectorAll('[data-reply-quote-collapsed]'); + expect(hidden).toHaveLength(2); + expect(signatureNode?.style.display ?? '').toBe(''); + }); + + it('keeps edited quote content when collapsed and serialized', async () => { + const e = mount(); + toggle().click(); + const inner = quote().shadowRoot!.firstElementChild as HTMLElement; + inner.querySelector('p')!.textContent = 'Edited original'; + inner.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + await new Promise((resolve) => requestAnimationFrame(resolve)); + toggle().click(); + expect(serializeEditorContent(e)).toContain('Edited original'); + expect(serializeEditorContent(e)).toContain('src="cid:logo"'); + }); + + it('keeps the quote hidden when its node view updates while collapsed', () => { + const e = mount(); + let quotePos = 0; + e.state.doc.forEach((node, pos) => { if (node.type.name === 'quotedHtml') quotePos = pos; }); + const node = e.state.doc.nodeAt(quotePos)!; + e.view.dispatch(e.state.tr.setNodeMarkup(quotePos, undefined, { ...node.attrs, html: '

Updated original

' })); + expect(toggle()).toHaveAttribute('aria-expanded', 'false'); + expect(quote()).toHaveAttribute('data-reply-quote-collapsed', 'true'); + expect(serializeEditorContent(e)).toContain('Updated original'); + }); + + it('does not add visibility toggles to Undo history', () => { + const e = mount(); + e.commands.insertContentAt(1, 'Added '); + toggle().click(); + toggle().click(); + e.commands.undo(); + expect(serializeEditorContent(e)).not.toContain('Added '); + expect(serializeEditorContent(e)).toContain(ORIGINAL); + e.commands.redo(); + expect(serializeEditorContent(e)).toContain('Added '); + }); + + it('reveals a quote reached with a text selection and moves the caret out when collapsed', () => { + const e = mount(); + const start = Number(toggle().dataset.replyQuoteToggle); + e.commands.setTextSelection(start + 1); + expect(toggle()).toHaveAttribute('aria-expanded', 'true'); + toggle().click(); + expect(toggle()).toHaveAttribute('aria-expanded', 'false'); + expect(e.state.selection.to).toBeLessThanOrEqual(start); + }); + + it('retains keyboard focus on the toggle when expanding or collapsing', () => { + mount(); + toggle().focus(); + toggle().click(); + expect(toggle()).toHaveFocus(); + toggle().click(); + expect(toggle()).toHaveFocus(); + }); + + it('preserves expansion when the authored response changes before the quote', () => { + const e = mount(); + toggle().click(); + e.commands.insertContentAt(1, 'More text. '); + expect(toggle()).toHaveAttribute('aria-expanded', 'true'); + expect(quote()).not.toHaveAttribute('data-reply-quote-collapsed'); + }); + + it.each([ + `

Forwarded message

${QUOTE}`, + '

Old draft

Unknown attribution
', + `${HEADER}

New authored paragraph

Authored quote
`, + `${HEADER}
`, + ])('leaves unmarked, ambiguous or empty quote content visible', (html) => { + mount(html); + expect(toggle()).toBeNull(); + expect(editor!.view.dom.querySelector('[data-reply-quote-collapsed]')).toBeNull(); + }); + + it('preserves an unrecognized plugin header and recognizes a simple plugin paragraph', () => { + const unknown = '

Custom header

'; + expect(markReplyQuoteHeader(unknown)).toBe(unknown); + mount(`

Response

${markReplyQuoteHeader('

From: Bob

')}${QUOTE}`); + expect(toggle()).toHaveAttribute('aria-expanded', 'false'); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 75f1129d3..51f6d0347 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -56,6 +56,7 @@ import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay, sanitizeEmailHt import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; import { isFilePreviewable } from "@/lib/file-preview"; import { isEditableEventTarget } from "@/lib/keyboard"; +import { markReplyQuoteHeader } from "@/components/email/reply-quote-collapse"; import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html"; import { containsEmbeddedSignature } from "@/components/email/signature-block"; import { @@ -729,6 +730,10 @@ export function EmailComposer({ const { externalContentPolicy, isSenderTrusted } = useSettingsStore.getState(); const quoteRemoteAllowed = externalContentPolicy === 'allow' || (!!from?.email && isSenderTrusted(from.email)); + const replyHeader = (html: string) => mode === 'reply' || mode === 'replyAll' + ? markReplyQuoteHeader(html) + : html; + // Plugin override (resolved at composer open via onBuildQuoteHeader). if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { if (replyTo.htmlBody) { @@ -739,7 +744,7 @@ export function EmailComposer({ rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody)), { remoteAllowed: quoteRemoteAllowed, showQuoteBorder: replyTo.quoteWrapInBlockquote !== false }, ); - return `${prefix}${signatureBlock}
${replyTo.quoteHeaderHtml}${island}`; + return `${prefix}${signatureBlock}
${replyHeader(replyTo.quoteHeaderHtml)}${island}`; } const escaped = replyTo.body ? replyTo.body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
') @@ -748,7 +753,7 @@ export function EmailComposer({ const bodyHtml = wrap ? `
${escaped}
` : escaped; - return `${prefix}${signatureBlock}
${replyTo.quoteHeaderHtml}${bodyHtml}`; + return `${prefix}${signatureBlock}
${replyHeader(replyTo.quoteHeaderHtml)}${bodyHtml}`; } // Build quoted content as HTML @@ -766,7 +771,7 @@ export function EmailComposer({ rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody)), { remoteAllowed: quoteRemoteAllowed }, ); - return `${prefix}${signatureBlock}
${quoteHeader}
${island}`; + return `${prefix}${signatureBlock}
${replyHeader(`
${quoteHeader}
`)}${island}`; } if (replyTo.body) { @@ -774,7 +779,7 @@ export function EmailComposer({ if (mode === 'forward') { return `${prefix}${signatureBlock}

${tQuote('forwarded_separator')}
${tQuote('from_label')}: ${escapeHtml(fromStrFull)}
${tQuote('date_label')}: ${escapeHtml(date)}
${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}

${escapedOriginal}`; } else if (mode === 'reply' || mode === 'replyAll') { - return `${prefix}${signatureBlock}

${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}
${escapedOriginal}
`; + return `${prefix}${signatureBlock}

${replyHeader(`

${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}

`)}
${escapedOriginal}
`; } } return prefix; diff --git a/components/email/reply-quote-collapse.ts b/components/email/reply-quote-collapse.ts new file mode 100644 index 000000000..1fcb218b6 --- /dev/null +++ b/components/email/reply-quote-collapse.ts @@ -0,0 +1,151 @@ +"use client"; + +import { Extension } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { Plugin, PluginKey, Selection } from '@tiptap/pm/state'; +import { Decoration, DecorationSet } from '@tiptap/pm/view'; + +const START_ATTRIBUTE = 'data-reply-quote-start'; +const HEADER_BLOCKS = 'p, h1, h2, h3, h4, h5, h6, table, ul, ol, pre, blockquote'; + +/** Mark only attribution generated for a reply. Unknown plugin markup stays visible. */ +export function markReplyQuoteHeader(html: string): string { + if (typeof document === 'undefined') return html; + // A template is inert: attribution containing an image must not load it here. + const template = document.createElement('template'); + template.innerHTML = html; + const first = template.content.firstElementChild; + if (!first) return `

${html}

`; + // The default attribution is a div of inline content, parsed as a paragraph + // by the editor. Make that paragraph explicit so its marker round-trips. + if (first.tagName === 'DIV' && !first.querySelector(HEADER_BLOCKS)) { + const paragraph = document.createElement('p'); + for (const attribute of first.attributes) paragraph.setAttribute(attribute.name, attribute.value); + paragraph.innerHTML = first.innerHTML; + first.replaceWith(paragraph); + paragraph.setAttribute(START_ATTRIBUTE, 'true'); + } else if (first.matches(HEADER_BLOCKS)) { + first.setAttribute(START_ATTRIBUTE, 'true'); + } else { + return html; + } + // A leading text node would otherwise remain visible outside the group. + if (template.content.firstChild?.nodeType === Node.TEXT_NODE + && template.content.firstChild.textContent?.trim()) return html; + return template.innerHTML; +} + +interface QuoteRange { from: number; to: number } +interface QuoteState { expanded: Set } +const quoteKey = new PluginKey('replyQuoteCollapse'); + +function quoteRanges(doc: PMNode): QuoteRange[] { + const ranges: QuoteRange[] = []; + let start: number | null = null; + doc.forEach((node, pos) => { + if (node.type.name === 'signatureBlock' || node.attrs['data-signature-block']) start = null; + if (node.attrs.replyQuoteStart) start = pos; + else if (start !== null && (node.type.name === 'quotedHtml' || node.type.name === 'blockquote')) { + if (node.textContent.trim() || String(node.attrs.html ?? '').trim()) { + ranges.push({ from: start, to: pos + node.nodeSize }); + } + start = null; + } else if (start !== null) { + // Do not hide unrelated authored blocks while searching for a quote. + start = null; + } + }); + return ranges; +} + +/** Visibility is a view decoration, never a document edit or an undo step. */ +export const ReplyQuoteCollapse = Extension.create<{ show: string; hide: string }>({ + name: 'replyQuoteCollapse', + + addOptions() { + return { show: 'Show quoted text', hide: 'Hide quoted text' }; + }, + + addGlobalAttributes() { + return [{ + types: ['paragraph', 'heading', 'table', 'bulletList', 'orderedList', 'codeBlock', 'blockquote'], + attributes: { + replyQuoteStart: { + default: false, + parseHTML: (el) => el.getAttribute(START_ATTRIBUTE) === 'true', + renderHTML: (attrs) => attrs.replyQuoteStart ? { [START_ATTRIBUTE]: 'true' } : {}, + }, + }, + }]; + }, + + addProseMirrorPlugins() { + const labels = this.options; + return [new Plugin({ + key: quoteKey, + state: { + init: () => ({ expanded: new Set() }), + apply: (tr, previous) => { + const expanded = new Set(); + for (const pos of previous.expanded) { + const mapped = tr.mapping.mapResult(pos); + if (!mapped.deleted) expanded.add(mapped.pos); + } + const toggle = tr.getMeta(quoteKey) as { from: number; expanded: boolean } | undefined; + if (toggle) { + if (toggle.expanded) expanded.add(toggle.from); + else expanded.delete(toggle.from); + } else if (tr.selectionSet) { + // Arrow keys, Find and editor commands must not leave the caret + // inside invisible text. Revealing it changes no message content. + for (const range of quoteRanges(tr.doc)) { + if (tr.selection.from < range.to && tr.selection.to > range.from) expanded.add(range.from); + } + } + return { expanded }; + }, + }, + props: { + decorations(state) { + const expanded = quoteKey.getState(state)?.expanded ?? new Set(); + const decorations: Decoration[] = []; + for (const range of quoteRanges(state.doc)) { + const open = expanded.has(range.from); + decorations.push(Decoration.widget(range.from, (view) => { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = '•••'; + button.className = 'my-1 inline-block cursor-pointer rounded-full bg-foreground/10 px-3 py-[3px] text-[11px] leading-none tracking-[2px] text-muted-foreground hover:bg-foreground/20 hover:text-foreground'; + button.title = open ? labels.hide : labels.show; + button.setAttribute('aria-label', button.title); + button.setAttribute('aria-expanded', String(open)); + button.addEventListener('click', () => { + const tr = view.state.tr.setMeta(quoteKey, { from: range.from, expanded: !open }); + if (open) tr.setSelection(Selection.near(tr.doc.resolve(range.from), -1)); + const restoreFocus = document.activeElement === button; + view.dispatch(tr); + if (restoreFocus) { + const toggles = view.dom.querySelectorAll('[data-reply-quote-toggle]'); + toggles.forEach((toggle) => { + if (Number(toggle.dataset.replyQuoteToggle) === range.from) toggle.focus(); + }); + } + }); + button.dataset.replyQuoteToggle = String(range.from); + return button; + }, { key: `reply-quote-${range.from}-${open}`, side: -1, stopEvent: () => true })); + if (!open) { + state.doc.nodesBetween(range.from, range.to, (node, pos, parent) => { + if (parent === state.doc) { + decorations.push(Decoration.node(pos, pos + node.nodeSize, { 'data-reply-quote-collapsed': 'true' })); + } + return false; + }); + } + } + return DecorationSet.create(state.doc, decorations); + }, + }, + })]; + }, +}); diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index 5d82fbd97..4845cfd5a 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -22,6 +22,7 @@ import { Table } from "@tiptap/extension-table"; import { TableRow } from "@tiptap/extension-table-row"; import { TableHeader } from "@tiptap/extension-table-header"; import { TableCell } from "@tiptap/extension-table-cell"; +import { ReplyQuoteCollapse } from "@/components/email/reply-quote-collapse"; import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html"; import { paragraphStyleWithoutSpacing, trailingParagraphBreakRule } from "@/components/email/paragraph-breaks"; import { SignatureBlock } from "@/components/email/signature-block"; @@ -225,6 +226,7 @@ export function RichTextEditor({ }: RichTextEditorProps) { const rtlEditingSupport = useSettingsStore((st) => st.rtlEditingSupport); const tComposer = useTranslations("email_composer"); + const tQuote = useTranslations("email_viewer"); const onImageUploadRef = React.useRef(onImageUpload); onImageUploadRef.current = onImageUpload; const onEditorReadyRef = React.useRef(onEditorReady); @@ -287,6 +289,7 @@ export function RichTextEditor({ // Quoted/forwarded original email body - held verbatim as an atomic // node so layout-heavy HTML survives 1:1 (see quoted-html.ts). QuotedHtml, + ReplyQuoteCollapse.configure({ show: tQuote("show_quoted_text"), hide: tQuote("hide_quoted_text") }), // Identity signature - held verbatim as a non-editable atomic node so // rich/branded signatures keep their inline styling in the editor and // in the sent mail (see signature-block.ts). Double-click unlocks it