From 3530e056ce1f49dc2cabb0e1f9cbe9ffc45571cf Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:49:23 +0100 Subject: [PATCH] fix: leave an empty line above the signature delimiter in replies #1503 --- .../composer-reply-signature.test.tsx | 27 +++++++++++++++++++ components/email/compose-format.ts | 14 +++++++++- components/email/email-composer.tsx | 13 +++++---- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/components/email/__tests__/composer-reply-signature.test.tsx b/components/email/__tests__/composer-reply-signature.test.tsx index 9205d4c35..fa15f205c 100644 --- a/components/email/__tests__/composer-reply-signature.test.tsx +++ b/components/email/__tests__/composer-reply-signature.test.tsx @@ -304,6 +304,33 @@ describe('reply signature above the quote (#842)', () => { expect(html).toContain('Sales team'); expect(html.indexOf('Sales team')).toBeLessThan(html.indexOf('reply_line')); }); + + // #1503: with the delimiter on, a reply started with the "-- " line, so + // there was no line above the signature to type the reply into. + it('leaves an empty line above the delimiter', () => { + useSettingsStore.setState({ signaturePosition: 'above_quote' }); + setIdentities([ME]); + render(); + + const html = screen.getByTestId('rich-text-editor').getAttribute('data-html')!; + expect(html.startsWith('

--

')).toBe(true); + }); + + it('leaves an empty line above a delimiter that arrives with late identities', async () => { + useSettingsStore.setState({ signaturePosition: 'above_quote' }); + setIdentities([]); + const props = { mode: 'reply' as const, replyTo: TO_ME }; + const { rerender } = render(); + setIdentities([ME]); + rerender(); + + await waitFor(() => expect(editorState.html).toContain('Alice')); + const lines = new DOMParser().parseFromString(editorState.html, 'text/html').body.children; + expect(lines[0].tagName).toBe('P'); + expect(lines[0].textContent).toBe(''); + expect(lines[0].hasAttribute('data-signature-block')).toBe(false); + expect(lines[1].getAttribute('data-signature-block')).toBe('separator'); + }); }); it('still signs a reply below the quote at send time by default', async () => { diff --git a/components/email/compose-format.ts b/components/email/compose-format.ts index 82e9e9081..2d3b83cf7 100644 --- a/components/email/compose-format.ts +++ b/components/email/compose-format.ts @@ -139,7 +139,19 @@ export function replaceEmbeddedSignature( ? children.find((node) => node.textContent?.includes(options.quoteHeader!)) : undefined; const quote = header ?? children.find(isQuoteBoundary) ?? null; - removed = { parent: doc.body, insertBefore: quote }; + // With no line above it, the signature would open the body and leave no + // place to type (#1503). Put an empty one first, as getInitialBody does, + // and keep the bare line breaks for the gap down to the quote. + const above = quote ? children.slice(0, children.indexOf(quote)) : children; + const hasLineAbove = above.some((node) => node.nodeType === 1 + ? (node as Element).tagName !== 'BR' + : !!node.textContent?.trim()); + if (!hasLineAbove) { + removed = { parent: doc.body, insertBefore: above[0] ?? quote }; + signatureHtml = `

${signatureHtml}`; + } else { + removed = { parent: doc.body, insertBefore: quote }; + } } const host = doc.createElement('div'); host.innerHTML = signatureHtml; diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index dbab1a316..1f0ad42ae 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -771,10 +771,13 @@ export function EmailComposer({ : (from.email || from.name || tCommon('unknown'))) : tCommon('unknown'); + // Like a new mail, an empty line above the signature gives the cursor a + // place for the reply. Without it the reply started on the "-- " line (#1503). const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, { embed: shouldEmbedSignatureAboveQuote, separator: signatureSeparatorEnabled, }); + const head = `${prefix || (signatureBlock ? '

' : '')}${signatureBlock}`; // Whether the quoted original may show its remote images in the editor: // the viewer's rule, minus the per-message "load" click it doesn't know. @@ -798,7 +801,7 @@ export function EmailComposer({ rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody)), { remoteAllowed: quoteRemoteAllowed, showQuoteBorder: replyTo.quoteWrapInBlockquote !== false }, ); - return `${prefix}${signatureBlock}
${replyHeader(replyTo.quoteHeaderHtml)}${island}`; + return `${head}
${replyHeader(replyTo.quoteHeaderHtml)}${island}`; } const escaped = replyTo.body ? replyTo.body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
') @@ -807,7 +810,7 @@ export function EmailComposer({ const bodyHtml = wrap ? `
${escaped}
` : escaped; - return `${prefix}${signatureBlock}
${replyHeader(replyTo.quoteHeaderHtml)}${bodyHtml}`; + return `${head}
${replyHeader(replyTo.quoteHeaderHtml)}${bodyHtml}`; } // Build quoted content as HTML @@ -825,15 +828,15 @@ export function EmailComposer({ rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody)), { remoteAllowed: quoteRemoteAllowed }, ); - return `${prefix}${signatureBlock}
${replyHeader(`
${quoteHeader}
`)}${island}`; + return `${head}
${replyHeader(`
${quoteHeader}
`)}${island}`; } if (replyTo.body) { const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
'); 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}`; + return `${head}

${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}

${replyHeader(`

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

`)}
${escapedOriginal}
`; + return `${head}

${replyHeader(`

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

`)}
${escapedOriginal}
`; } } return prefix;