mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +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}`);
|
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();
|
||||||
|
|||||||
@@ -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(/ | /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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
Reference in New Issue
Block a user