fix: leave an empty line above the signature delimiter in replies #1503

This commit is contained in:
Linus Rath
2026-11-02 19:49:23 +01:00
parent 3f3dfebb84
commit 3530e056ce
3 changed files with 48 additions and 6 deletions
@@ -304,6 +304,33 @@ describe('reply signature above the quote (#842)', () => {
expect(html).toContain('<i>Sales team</i>');
expect(html.indexOf('<i>Sales team</i>')).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(<EmailComposer mode="reply" replyTo={TO_ME} />);
const html = screen.getByTestId('rich-text-editor').getAttribute('data-html')!;
expect(html.startsWith('<p></p><p data-signature-block="separator">-- </p>')).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(<EmailComposer {...props} />);
setIdentities([ME]);
rerender(<EmailComposer {...props} />);
await waitFor(() => expect(editorState.html).toContain('<b>Alice</b>'));
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 () => {
+13 -1
View File
@@ -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 = `<p></p>${signatureHtml}`;
} else {
removed = { parent: doc.body, insertBefore: quote };
}
}
const host = doc.createElement('div');
host.innerHTML = signatureHtml;
+8 -5
View File
@@ -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 ? '<p></p>' : '')}${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}<br>${replyHeader(replyTo.quoteHeaderHtml)}${island}`;
return `${head}<br>${replyHeader(replyTo.quoteHeaderHtml)}${island}`;
}
const escaped = replyTo.body
? replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')
@@ -807,7 +810,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>${replyHeader(replyTo.quoteHeaderHtml)}${bodyHtml}`;
return `${head}<br>${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}<br>${replyHeader(`<div>${quoteHeader}</div>`)}${island}`;
return `${head}<br>${replyHeader(`<div>${quoteHeader}</div>`)}${island}`;
}
if (replyTo.body) {
const escapedOriginal = replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>');
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}`;
return `${head}<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>${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 `${head}<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;