fix: keep a hand-picked sender's own signature and swap it after typing under it

This commit is contained in:
Linus Rath
2026-10-31 03:35:30 +01:00
parent db8eb731f1
commit 60d0e98fc7
4 changed files with 67 additions and 7 deletions
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// Composer behaviour that lives in ComposeScreen itself, checked on its
// source (the suite has no React Native renderer).
const source = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8');
describe('ComposeScreen wiring', () => {
it('lets a From picked by hand bring its own (possibly empty) signature', () => {
expect(source).toMatch(/signatureIdentityFor\(primaryIdentity, identities, \{ pickedByUser: identityPickedByUser \}\)/);
const sheet = source.slice(source.indexOf('<IdentitySheet'));
expect(sheet.slice(0, 400)).toMatch(/onPick=\{\(identity\) => \{[^}]*setIdentityPickedByUser\(true\)/);
});
});
+19
View File
@@ -75,6 +75,20 @@ describe('embedded signature range', () => {
expect(spliceSignature('<p>x</p>', a)).toBe(`<p>x</p>${a}`);
});
it('swaps the signature after the user typed into the line under it, keeping the text', () => {
const a = buildEmbeddedSignatureHtml({ textSignature: 'A' }, { separator: true });
const b = buildEmbeddedSignatureHtml({ textSignature: 'B' }, { separator: true });
const typed = a.replace('data-signature-block="end"><br></p>', 'data-signature-block="end">PS: call me</p>');
expect(typed).not.toBe(a);
const body = `<p>hello</p>${typed}<div data-quoted-html="true">q</div>`;
expect(containsEmbeddedSignature(body)).toBe(true);
const swapped = spliceSignature(body, b);
expect(swapped).toBe(`<p>hello</p>${b}<p>PS: call me</p><div data-quoted-html="true">q</div>`);
// One signature, never two.
expect(swapped.match(/data-signature-block="body"/g)).toHaveLength(1);
expect(stripEmbeddedSignature(body)).toBe('<p>hello</p><p>PS: call me</p><div data-quoted-html="true">q</div>');
});
it('inserts above the quote marker', () => {
const sig = buildEmbeddedSignatureHtml({ textSignature: 'S' }, { separator: true });
const marker = '<div data-quoted-html="true">';
@@ -93,6 +107,11 @@ describe('signatureIdentityFor', () => {
expect(signatureIdentityFor(alias, [alias, primary, signedAlias])).toBe(primary);
});
it("keeps a hand-picked identity's own empty signature", () => {
expect(signatureIdentityFor(alias, [alias, primary], { pickedByUser: true })).toBeNull();
expect(signatureIdentityFor(signedAlias, [alias, primary, signedAlias], { pickedByUser: true })).toBe(signedAlias);
});
it('returns null when neither has one', () => {
expect(signatureIdentityFor(alias, [alias, { id: 'p', mayDelete: false }])).toBeNull();
expect(signatureIdentityFor(null, [primary])).toBeNull();
+25 -3
View File
@@ -83,9 +83,14 @@ export function hasSignature(signature?: SignatureSource): boolean {
export function signatureIdentityFor<T extends { textSignature?: string; htmlSignature?: string; mayDelete?: boolean }>(
identity: T | null | undefined,
identities: T[],
options: { pickedByUser?: boolean } = {},
): T | null {
if (!identity) return null;
if (hasSignature(identity)) return identity;
// An identity the user picked by hand keeps its own, also an empty one:
// switching to a team address without a signature sent the team mail with
// the personal signature (webmail email-composer.tsx ownSignatureIdentity).
if (options.pickedByUser) return null;
const primary = identities.find((i) => !i.mayDelete) ?? identities[0];
return primary && hasSignature(primary) ? primary : null;
}
@@ -242,11 +247,28 @@ export function buildEmbeddedSignatureHtml(
return '';
}
// The end marker is the empty line under the signature, so a tap below the
// signature puts the caret there; its content (group 1) may be text the user
// typed.
const SIGNATURE_RANGE_RE = new RegExp(
`<p\\s+${SIGNATURE_RANGE_MARKER}=["'](?:separator|start)["'][^>]*>[\\s\\S]*?<p\\s+${SIGNATURE_RANGE_MARKER}=["']end["'][^>]*>(?:<br\\s*/?>)?</p>`,
`<p\\s+${SIGNATURE_RANGE_MARKER}=["'](?:separator|start)["'][^>]*>[\\s\\S]*?<p\\s+${SIGNATURE_RANGE_MARKER}=["']end["'][^>]*>((?:(?!</p>)[\\s\\S])*)</p>`,
'i',
);
/**
* Text typed into the end marker is the user's, not part of the signature:
* kept as an ordinary paragraph after it (webmail compose-format.ts
* removeSignatureRange).
*/
function keptEndText(endContent: string): string {
const text = endContent
.replace(/<br\s*\/?>/gi, '')
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;| /g, ' ')
.trim();
return text ? `<p>${endContent}</p>` : '';
}
/** True when the body carries an embedded signature range. */
export function containsEmbeddedSignature(html: string): boolean {
return SIGNATURE_RANGE_RE.test(html);
@@ -259,7 +281,7 @@ export function containsEmbeddedSignature(html: string): boolean {
*/
export function spliceSignature(html: string, nextSignatureHtml: string): string {
if (SIGNATURE_RANGE_RE.test(html)) {
return html.replace(SIGNATURE_RANGE_RE, () => nextSignatureHtml);
return html.replace(SIGNATURE_RANGE_RE, (_m, end: string) => `${nextSignatureHtml}${keptEndText(end)}`);
}
if (!nextSignatureHtml) return html;
return `${html}${nextSignatureHtml}`;
@@ -267,7 +289,7 @@ export function spliceSignature(html: string, nextSignatureHtml: string): string
/** The body without its embedded signature range. */
export function stripEmbeddedSignature(html: string): string {
return html.replace(SIGNATURE_RANGE_RE, '');
return html.replace(SIGNATURE_RANGE_RE, (_m, end: string) => keptEndText(end));
}
/**
+8 -4
View File
@@ -985,10 +985,14 @@ export default function ComposeScreen({ route, navigation }: Props) {
return identities.find((i) => i.id === selectedIdentityId) ?? defaultIdentity;
}, [identities, selectedIdentityId]);
// An alias without a signature falls back to the primary identity's.
// Set once the user picks From by hand: their pick then brings its own
// signature, also when it has none (webmail identityPickedByUser).
const [identityPickedByUser, setIdentityPickedByUser] = React.useState(false);
// An alias without a signature falls back to the primary identity's,
// unless the user picked it.
const signatureIdentity = React.useMemo(
() => signatureIdentityFor(primaryIdentity, identities),
[primaryIdentity, identities],
() => signatureIdentityFor(primaryIdentity, identities, { pickedByUser: identityPickedByUser }),
[primaryIdentity, identities, identityPickedByUser],
);
const openIdentityPicker = () => {
@@ -3220,7 +3224,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
onClose={() => setIdentitySheetOpen(false)}
identities={identities}
selectedIdentityId={selectedIdentityId}
onPick={(identity) => { setSelectedIdentityId(identity.id); setFromOverride(null); }}
onPick={(identity) => { setSelectedIdentityId(identity.id); setFromOverride(null); setIdentityPickedByUser(true); }}
/>
<AttachmentPreviewModal {...filePreview.modalProps} />