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(
{ + 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(` Custom header
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 f462c9568..321f1a618 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 { 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