From f9f8af2f1139f79372f958226a7860a875caab2a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 12 May 2026 16:03:10 +0200 Subject: [PATCH] fix: preserve signature styling and reactivity in above-quote mode #272 --- app/[locale]/page.tsx | 4 +- components/email/email-composer.tsx | 135 +++++++++++++++++---- components/email/rich-text-editor.tsx | 64 +++++++++- components/settings/composing-settings.tsx | 8 ++ lib/signature-utils.ts | 9 +- locales/cs/common.json | 4 + locales/de/common.json | 4 + locales/en/common.json | 4 + locales/es/common.json | 4 + locales/fr/common.json | 4 + locales/it/common.json | 4 + locales/ja/common.json | 4 + locales/ko/common.json | 4 + locales/lv/common.json | 4 + locales/nl/common.json | 4 + locales/pl/common.json | 4 + locales/pt/common.json | 4 + locales/ru/common.json | 4 + locales/tr/common.json | 4 + locales/uk/common.json | 4 + locales/zh/common.json | 4 + stores/settings-store.ts | 3 + 22 files changed, 260 insertions(+), 27 deletions(-) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index c36f366b..072c7145 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -1659,7 +1659,9 @@ export default function Home() { // Append signature from the sending identity (fall back to primary // when the reply-from lives on the same identity but a different alias). - const finalBody = appendPlainTextSignature(body, sendingIdentity); + const finalBody = appendPlainTextSignature(body, sendingIdentity, { + separator: useSettingsStore.getState().signatureSeparatorEnabled, + }); const originalEmailId = selectedEmail.id; diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 75012ce8..cb1aa8d3 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -35,6 +35,7 @@ import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature import { resolveReplyFrom } from "@/lib/reply-identity"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { RichTextEditor } from "@/components/email/rich-text-editor"; +import type { Editor } from "@tiptap/react"; /** Strip HTML tags and decode entities to get a plain-text version */ function htmlToPlainText(html: string): string { @@ -116,6 +117,39 @@ type ComposerAttachment = { abortController?: AbortController; }; +type SignatureIdentityLike = { + htmlSignature?: string; + textSignature?: string; +} | null | undefined; + +// Render the embedded signature for "above quote" mode. Bracketed with +// `data-signature-block` marker paragraphs so we can swap the inner content +// when the user switches identity without losing the surrounding draft or +// quoted message. The markers are preserved through TipTap by the +// StyledParagraph extension. +function buildEmbeddedSignatureHtml( + identity: SignatureIdentityLike, + options: { embed: boolean; separator: boolean } +): string { + if (!options.embed) return ''; + const startMarker = options.separator + ? `

--

` + : `

`; + const endMarker = `

`; + if (identity?.htmlSignature) { + return `${startMarker}${sanitizeEmailHtml(identity.htmlSignature)}${endMarker}`; + } + if (identity?.textSignature) { + const escaped = identity.textSignature + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
'); + return `${startMarker}

${escaped}

${endMarker}`; + } + return ''; +} + export function EmailComposer({ onSend, onClose, @@ -136,6 +170,7 @@ export function EmailComposer({ const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled); const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords); const signaturePosition = useSettingsStore((state) => state.signaturePosition); + const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled); const identities = useIdentityStore((s) => s.identities); const primaryIdentity = identities[0] ?? null; @@ -206,8 +241,9 @@ export function EmailComposer({ // drafting area and the quoted content so it reads naturally as a // closing for the reply body. Send-time append is skipped — see // shouldEmbedSignatureAboveQuote. + const plainSep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n'; const signatureBlock = shouldEmbedSignatureAboveQuote - ? `\n\n-- \n${getPlainTextSignature(initialSignatureIdentity)}` + ? `${plainSep}${getPlainTextSignature(initialSignatureIdentity)}` : ''; if (mode === 'forward') { @@ -225,21 +261,10 @@ export function EmailComposer({ const from = replyTo.from?.[0]; const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); - // When "above quote" is configured, splice signature between the user's - // drafting area and the quoted content so it reads naturally as a closing - // for the reply body. Send-time append is skipped — see - // shouldEmbedSignatureAboveQuote. - const buildEmbeddedSignatureHtml = (): string => { - if (!shouldEmbedSignatureAboveQuote) return ''; - if (initialSignatureIdentity?.htmlSignature) { - return `

--
${sanitizeEmailHtml(initialSignatureIdentity.htmlSignature)}`; - } - if (initialSignatureIdentity?.textSignature) { - return `

--
${initialSignatureIdentity.textSignature.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}`; - } - return ''; - }; - const signatureBlock = buildEmbeddedSignatureHtml(); + const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, { + embed: shouldEmbedSignatureAboveQuote, + separator: signatureSeparatorEnabled, + }); // Build quoted content as HTML if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { @@ -335,6 +360,69 @@ export function EmailComposer({ const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature) ? currentIdentity : primaryIdentity; + + // Hold the TipTap editor instance so we can swap the embedded signature + // when the user switches identity in "above quote" mode without rebuilding + // the whole body (which would lose user edits to the surrounding draft). + const editorRef = useRef(null); + const prevSignatureIdentityIdRef = useRef(signatureIdentity?.id); + const prevSignatureSeparatorRef = useRef(signatureSeparatorEnabled); + + useEffect(() => { + const editor = editorRef.current; + const identityChanged = prevSignatureIdentityIdRef.current !== signatureIdentity?.id; + const separatorChanged = prevSignatureSeparatorRef.current !== signatureSeparatorEnabled; + prevSignatureIdentityIdRef.current = signatureIdentity?.id; + prevSignatureSeparatorRef.current = signatureSeparatorEnabled; + if (!editor) return; + if (!identityChanged && !separatorChanged) return; + if (plainTextMode) return; + if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return; + if (signaturePosition !== 'above_quote') return; + + const currentHtml = editor.getHTML(); + const doc = new DOMParser().parseFromString(currentHtml, 'text/html'); + const startEl = doc.querySelector('[data-signature-block="separator"], [data-signature-block="start"]'); + if (!startEl) return; + const endEl = doc.querySelector('[data-signature-block="end"]'); + + const newSignature = buildEmbeddedSignatureHtml(signatureIdentity, { + embed: true, + separator: signatureSeparatorEnabled, + }); + if (!newSignature) return; + + // Build a temporary container holding the replacement nodes so we can + // splice them in without re-serializing/parsing twice. + const replacementHost = doc.createElement('div'); + replacementHost.innerHTML = newSignature; + const replacementNodes = Array.from(replacementHost.childNodes); + + const parent = startEl.parentNode; + if (!parent) return; + + // Remove the existing signature range [startEl … endEl] inclusive, or + // from startEl to the next blockquote if no end marker is present. + const removeUntil = endEl && endEl.parentNode === parent ? endEl : null; + let cursor: ChildNode | null = startEl; + const toRemove: ChildNode[] = []; + while (cursor) { + toRemove.push(cursor); + if (cursor === removeUntil) break; + const next: ChildNode | null = cursor.nextSibling; + if (!removeUntil && next && (next as Element).tagName === 'BLOCKQUOTE') break; + cursor = next; + } + const insertBefore = toRemove[toRemove.length - 1]?.nextSibling ?? null; + toRemove.forEach((node) => parent.removeChild(node)); + replacementNodes.forEach((node) => parent.insertBefore(node, insertBefore)); + + const nextHtml = doc.body.innerHTML; + if (nextHtml !== currentHtml) { + editor.commands.setContent(nextHtml, { emitUpdate: true }); + } + }, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]); + useEffect(() => { if (!autoSelectReplyIdentity) return; if (selectedIdentityId || initialData?.selectedIdentityId) return; @@ -1038,11 +1126,12 @@ export function EmailComposer({ // Build HTML signature block (used only in rich text mode) const buildSignatureHtml = (): string => { if (signatureAlreadyInBody) return ''; + const sep = signatureSeparatorEnabled ? `

--
` : `

`; if (signatureIdentity?.htmlSignature) { - return `

--
${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`; + return `${sep}${sanitizeEmailHtml(signatureIdentity.htmlSignature)}`; } if (signatureIdentity?.textSignature) { - return `

--
${signatureIdentity.textSignature.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}`; + return `${sep}${signatureIdentity.textSignature.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}`; } return ''; }; @@ -1053,9 +1142,10 @@ export function EmailComposer({ : null; // In plain text mode, send text/plain only (no HTML body) + const signatureOpts = { separator: signatureSeparatorEnabled }; const finalBody = plainTextMode - ? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity)) - : (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity)); + ? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts)) + : (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity, signatureOpts)); const rewritten = plainTextMode ? null : rewriteInlineImages(body); const finalHtmlBody = plainTextMode @@ -1628,6 +1718,7 @@ export function EmailComposer({ onImageUpload={handleImageUpload} placeholder={t('body_placeholder')} hasError={validationErrors.body} + onEditorReady={(ed) => { editorRef.current = ed; }} /> )} @@ -1638,13 +1729,13 @@ export function EmailComposer({ : plainTextMode ? ( getPlainTextSignature(signatureIdentity) ? (
- {'-- \n'}{getPlainTextSignature(signatureIdentity)} + {signatureSeparatorEnabled ? '-- \n' : ''}{getPlainTextSignature(signatureIdentity)}
) : null ) : composerSignatureHtml ? (
--
${composerSignatureHtml}` }} + dangerouslySetInnerHTML={{ __html: `${signatureSeparatorEnabled ? '
--
' : ''}${composerSignatureHtml}` }} /> ) : null} diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index 88975a65..0588d107 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -1,8 +1,10 @@ "use client"; import React, { useEffect, useCallback, useState, useRef } from "react"; -import { useEditor, EditorContent } from "@tiptap/react"; +import { useEditor, EditorContent, type Editor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; +import Paragraph from "@tiptap/extension-paragraph"; +import Heading from "@tiptap/extension-heading"; import Underline from "@tiptap/extension-underline"; import Link from "@tiptap/extension-link"; import TextAlign from "@tiptap/extension-text-align"; @@ -44,6 +46,51 @@ export interface InlineImageUpload { cid?: string; } +// Pasted email content (signatures, replies, quoted text) commonly carries +// inline styles on block elements. StarterKit's default Paragraph/Heading +// drop unknown attributes; extend them to round-trip `style` and `class` so +// signature formatting survives the editor. +const styledBlockAttributes = { + style: { + default: null as string | null, + parseHTML: (el: HTMLElement) => el.getAttribute("style"), + renderHTML: (attrs: Record) => + attrs.style ? { style: attrs.style } : {}, + }, + class: { + default: null as string | null, + parseHTML: (el: HTMLElement) => el.getAttribute("class"), + renderHTML: (attrs: Record) => + attrs.class ? { class: attrs.class } : {}, + }, + "data-signature-block": { + default: null as string | null, + parseHTML: (el: HTMLElement) => el.getAttribute("data-signature-block"), + renderHTML: (attrs: Record) => + attrs["data-signature-block"] + ? { "data-signature-block": attrs["data-signature-block"] } + : {}, + }, +}; + +const StyledParagraph = Paragraph.extend({ + addAttributes() { + return { + ...this.parent?.(), + ...styledBlockAttributes, + }; + }, +}); + +const StyledHeading = Heading.extend({ + addAttributes() { + return { + ...this.parent?.(), + ...styledBlockAttributes, + }; + }, +}); + interface RichTextEditorProps { content: string; onChange: (html: string) => void; @@ -51,6 +98,7 @@ interface RichTextEditorProps { placeholder?: string; className?: string; hasError?: boolean; + onEditorReady?: (editor: Editor) => void; } function ToolbarButton({ @@ -131,17 +179,23 @@ export function RichTextEditor({ placeholder, className, hasError, + onEditorReady, }: RichTextEditorProps) { const onImageUploadRef = React.useRef(onImageUpload); onImageUploadRef.current = onImageUpload; + const onEditorReadyRef = React.useRef(onEditorReady); + onEditorReadyRef.current = onEditorReady; const editor = useEditor({ extensions: [ StarterKit.configure({ - heading: { levels: [1, 2] }, + heading: false, + paragraph: false, link: false, underline: false, }), + StyledParagraph, + StyledHeading.configure({ levels: [1, 2] }), Underline, Link.configure({ openOnClick: false, @@ -239,6 +293,12 @@ export function RichTextEditor({ } }, [content, editor]); + // Expose the editor instance once it's ready so parents can target + // specific nodes (e.g. swap the embedded signature on identity change). + useEffect(() => { + if (editor) onEditorReadyRef.current?.(editor); + }, [editor]); + const addLink = useCallback(() => { if (!editor) return; const previousUrl = editor.getAttributes("link").href; diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx index 68e64245..9223d78b 100644 --- a/components/settings/composing-settings.tsx +++ b/components/settings/composing-settings.tsx @@ -28,6 +28,7 @@ export function ComposingSettings() { attachmentReminderKeywords, subAddressDelimiter, signaturePosition, + signatureSeparatorEnabled, updateSetting, } = useSettingsStore(); @@ -62,6 +63,13 @@ export function ComposingSettings() { /> + + updateSetting('signatureSeparatorEnabled', checked)} + /> + + ()( plainTextMode: state.plainTextMode, subAddressDelimiter: state.subAddressDelimiter, signaturePosition: state.signaturePosition, + signatureSeparatorEnabled: state.signatureSeparatorEnabled, sessionTimeout: state.sessionTimeout, emailNotificationsEnabled: state.emailNotificationsEnabled, emailNotificationSound: state.emailNotificationSound,