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 = '';
+ 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