mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
fix: keep a hand-picked sender's own signature and swap it after typing under it
This commit is contained in:
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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(/ | /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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user