Merge pull request #1421 from pcatapan/codex/composer-quote-collapse

This commit is contained in:
Linus Rath
2026-10-29 14:12:41 +01:00
6 changed files with 372 additions and 4 deletions
+5
View File
@@ -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;
}
@@ -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(<EmailComposer mode={mode} replyTo={TO_ME} />);
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(<EmailComposer mode="reply" replyTo={{ ...TO_ME, htmlBody: undefined }} />);
const html = screen.getByTestId('rich-text-editor').getAttribute('data-html')!;
expect(html).toContain('data-reply-quote-start="true"');
expect(html).toContain('<blockquote');
});
it('leaves forwarding attribution unmarked', () => {
render(<EmailComposer mode="forward" replyTo={TO_ME} />);
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(<EmailComposer mode="reply" replyTo={{ ...TO_ME, quoteHeaderHtml: '<p><b>From:</b> Bob</p>', quoteWrapInBlockquote: false }} />);
const html = screen.getByTestId('rich-text-editor').getAttribute('data-html')!;
expect(html).toContain('<p data-reply-quote-start="true"><b>From:</b> Bob</p>');
});
});
@@ -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('<div>On Monday, Bob wrote:<br></div>');
const ORIGINAL = '<p>Original message</p><img src="cid:logo"><blockquote>Earlier reply</blockquote>';
const QUOTE = buildQuotedHtmlBlock(ORIGINAL);
const ReplyParagraph = Paragraph.extend({
parseHTML() {
return [...(this.parent?.() ?? []), trailingParagraphBreakRule];
},
});
let editor: Editor | undefined;
function mount(html = `<p>My response</p>${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<HTMLButtonElement>('[data-reply-quote-toggle]')!;
}
function quote() {
return editor!.view.dom.querySelector<HTMLElement>('.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(`<p>Response</p><blockquote>My own quotation</blockquote>${HEADER}<blockquote>Original plain-text message</blockquote>`);
const quotes = editor!.view.dom.querySelectorAll<HTMLElement>('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('<b>My signature</b>');
const e = mount(`<p>Response</p>${position === 'above' ? signature : ''}${HEADER}${QUOTE}${position === 'below' ? signature : ''}`);
const signatureNode = e.view.dom.querySelector<HTMLElement>('[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: '<p>Updated original</p>' }));
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([
`<p>Forwarded message</p>${QUOTE}`,
'<p>Old draft</p><blockquote>Unknown attribution</blockquote>',
`${HEADER}<p>New authored paragraph</p><blockquote>Authored quote</blockquote>`,
`${HEADER}<blockquote></blockquote>`,
])('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 = '<section><div><p>Custom header</p></div></section>';
expect(markReplyQuoteHeader(unknown)).toBe(unknown);
mount(`<p>Response</p>${markReplyQuoteHeader('<p><b>From:</b> Bob</p>')}${QUOTE}`);
expect(toggle()).toHaveAttribute('aria-expanded', 'false');
});
});
+9 -4
View File
@@ -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}<br>${replyTo.quoteHeaderHtml}${island}`;
return `${prefix}${signatureBlock}<br>${replyHeader(replyTo.quoteHeaderHtml)}${island}`;
}
const escaped = replyTo.body
? replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')
@@ -748,7 +753,7 @@ export function EmailComposer({
const bodyHtml = wrap
? `<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escaped}</blockquote>`
: escaped;
return `${prefix}${signatureBlock}<br>${replyTo.quoteHeaderHtml}${bodyHtml}`;
return `${prefix}${signatureBlock}<br>${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}<br><div>${quoteHeader}</div>${island}`;
return `${prefix}${signatureBlock}<br>${replyHeader(`<div>${quoteHeader}</div>`)}${island}`;
}
if (replyTo.body) {
@@ -774,7 +779,7 @@ export function EmailComposer({
if (mode === 'forward') {
return `${prefix}${signatureBlock}<br><br>${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${escapeHtml(fromStrFull)}<br>${tQuote('date_label')}: ${escapeHtml(date)}<br>${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}<br><br>${escapedOriginal}`;
} else if (mode === 'reply' || mode === 'replyAll') {
return `${prefix}${signatureBlock}<br><br>${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
return `${prefix}${signatureBlock}<br><br>${replyHeader(`<p>${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br></p>`)}<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
}
}
return prefix;
+151
View File
@@ -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 `<p ${START_ATTRIBUTE}="true">${html}</p>`;
// 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<number> }
const quoteKey = new PluginKey<QuoteState>('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<QuoteState>({
key: quoteKey,
state: {
init: () => ({ expanded: new Set<number>() }),
apply: (tr, previous) => {
const expanded = new Set<number>();
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<number>();
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<HTMLButtonElement>('[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);
},
},
})];
},
});
+3
View File
@@ -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 { keepLoadedParagraphSpacing, 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