From 60d0e98fc78cca22f332238f4ccff5c54afd0e23 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:35:30 +0100 Subject: [PATCH] fix: keep a hand-picked sender's own signature and swap it after typing under it --- .../__tests__/compose-screen-wiring.test.ts | 15 ++++++++++ src/lib/__tests__/signature-utils.test.ts | 19 +++++++++++++ src/lib/signature-utils.ts | 28 +++++++++++++++++-- src/screens/ComposeScreen.tsx | 12 +++++--- 4 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 src/lib/__tests__/compose-screen-wiring.test.ts diff --git a/src/lib/__tests__/compose-screen-wiring.test.ts b/src/lib/__tests__/compose-screen-wiring.test.ts new file mode 100644 index 00000000..ea35ff14 --- /dev/null +++ b/src/lib/__tests__/compose-screen-wiring.test.ts @@ -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(' \{[^}]*setIdentityPickedByUser\(true\)/); + }); +}); diff --git a/src/lib/__tests__/signature-utils.test.ts b/src/lib/__tests__/signature-utils.test.ts index d5b7c1b0..8c8adfc6 100644 --- a/src/lib/__tests__/signature-utils.test.ts +++ b/src/lib/__tests__/signature-utils.test.ts @@ -75,6 +75,20 @@ describe('embedded signature range', () => { expect(spliceSignature('

x

', a)).toBe(`

x

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

', 'data-signature-block="end">PS: call me

'); + expect(typed).not.toBe(a); + const body = `

hello

${typed}
q
`; + expect(containsEmbeddedSignature(body)).toBe(true); + const swapped = spliceSignature(body, b); + expect(swapped).toBe(`

hello

${b}

PS: call me

q
`); + // One signature, never two. + expect(swapped.match(/data-signature-block="body"/g)).toHaveLength(1); + expect(stripEmbeddedSignature(body)).toBe('

hello

PS: call me

q
'); + }); + it('inserts above the quote marker', () => { const sig = buildEmbeddedSignatureHtml({ textSignature: 'S' }, { separator: true }); const marker = '
'; @@ -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(); diff --git a/src/lib/signature-utils.ts b/src/lib/signature-utils.ts index e778037f..e49bfd3f 100644 --- a/src/lib/signature-utils.ts +++ b/src/lib/signature-utils.ts @@ -83,9 +83,14 @@ export function hasSignature(signature?: SignatureSource): boolean { export function signatureIdentityFor( 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( - `]*>[\\s\\S]*?]*>(?:)?

`, + `]*>[\\s\\S]*?]*>((?:(?!

)[\\s\\S])*)

`, '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(//gi, '') + .replace(/<[^>]*>/g, '') + .replace(/ | /g, ' ') + .trim(); + return text ? `

${endContent}

` : ''; +} + /** 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)); } /** diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 5604df87..68fd5003 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -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); }} />