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}`); 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', () => { it('inserts above the quote marker', () => {
const sig = buildEmbeddedSignatureHtml({ textSignature: 'S' }, { separator: true }); const sig = buildEmbeddedSignatureHtml({ textSignature: 'S' }, { separator: true });
const marker = '<div data-quoted-html="true">'; const marker = '<div data-quoted-html="true">';
@@ -93,6 +107,11 @@ describe('signatureIdentityFor', () => {
expect(signatureIdentityFor(alias, [alias, primary, signedAlias])).toBe(primary); 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', () => { it('returns null when neither has one', () => {
expect(signatureIdentityFor(alias, [alias, { id: 'p', mayDelete: false }])).toBeNull(); expect(signatureIdentityFor(alias, [alias, { id: 'p', mayDelete: false }])).toBeNull();
expect(signatureIdentityFor(null, [primary])).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 }>( export function signatureIdentityFor<T extends { textSignature?: string; htmlSignature?: string; mayDelete?: boolean }>(
identity: T | null | undefined, identity: T | null | undefined,
identities: T[], identities: T[],
options: { pickedByUser?: boolean } = {},
): T | null { ): T | null {
if (!identity) return null; if (!identity) return null;
if (hasSignature(identity)) return identity; 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]; const primary = identities.find((i) => !i.mayDelete) ?? identities[0];
return primary && hasSignature(primary) ? primary : null; return primary && hasSignature(primary) ? primary : null;
} }
@@ -242,11 +247,28 @@ export function buildEmbeddedSignatureHtml(
return ''; 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( 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', '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. */ /** True when the body carries an embedded signature range. */
export function containsEmbeddedSignature(html: string): boolean { export function containsEmbeddedSignature(html: string): boolean {
return SIGNATURE_RANGE_RE.test(html); return SIGNATURE_RANGE_RE.test(html);
@@ -259,7 +281,7 @@ export function containsEmbeddedSignature(html: string): boolean {
*/ */
export function spliceSignature(html: string, nextSignatureHtml: string): string { export function spliceSignature(html: string, nextSignatureHtml: string): string {
if (SIGNATURE_RANGE_RE.test(html)) { 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; if (!nextSignatureHtml) return html;
return `${html}${nextSignatureHtml}`; return `${html}${nextSignatureHtml}`;
@@ -267,7 +289,7 @@ export function spliceSignature(html: string, nextSignatureHtml: string): string
/** The body without its embedded signature range. */ /** The body without its embedded signature range. */
export function stripEmbeddedSignature(html: string): string { 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; return identities.find((i) => i.id === selectedIdentityId) ?? defaultIdentity;
}, [identities, selectedIdentityId]); }, [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( const signatureIdentity = React.useMemo(
() => signatureIdentityFor(primaryIdentity, identities), () => signatureIdentityFor(primaryIdentity, identities, { pickedByUser: identityPickedByUser }),
[primaryIdentity, identities], [primaryIdentity, identities, identityPickedByUser],
); );
const openIdentityPicker = () => { const openIdentityPicker = () => {
@@ -3220,7 +3224,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
onClose={() => setIdentitySheetOpen(false)} onClose={() => setIdentitySheetOpen(false)}
identities={identities} identities={identities}
selectedIdentityId={selectedIdentityId} selectedIdentityId={selectedIdentityId}
onPick={(identity) => { setSelectedIdentityId(identity.id); setFromOverride(null); }} onPick={(identity) => { setSelectedIdentityId(identity.id); setFromOverride(null); setIdentityPickedByUser(true); }}
/> />
<AttachmentPreviewModal {...filePreview.modalProps} /> <AttachmentPreviewModal {...filePreview.modalProps} />