From 8e46d5e5dce5f57cdc2a031d8bf9cba785aa03a6 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 06:31:01 +0100
Subject: [PATCH] feat: edit signatures with the composer's formatting editor
instead of HTML code
The two boxes "Text signature" and "HTML signature" with byte counters
become one Signature field with the composer's editor; the plain-text part
is generated from it. "Edit as HTML" shows with advanced settings on, the
size shows only near the server's limit and a signature over it isn't
saved instead of being cut off. With Composing > Plain text only it is a
plain text box. The org-signature test looks for the new field's label.
---
.../identity-form-org-signature.test.tsx | 9 +-
.../__tests__/signature-editor.test.tsx | 96 ++++++++++
components/identity/identity-form.tsx | 120 +++----------
components/identity/signature-editor.tsx | 168 ++++++++++++++++++
4 files changed, 289 insertions(+), 104 deletions(-)
create mode 100644 components/identity/__tests__/signature-editor.test.tsx
create mode 100644 components/identity/signature-editor.tsx
diff --git a/components/identity/__tests__/identity-form-org-signature.test.tsx b/components/identity/__tests__/identity-form-org-signature.test.tsx
index c83a580a9..9e27c7fb4 100644
--- a/components/identity/__tests__/identity-form-org-signature.test.tsx
+++ b/components/identity/__tests__/identity-form-org-signature.test.tsx
@@ -44,8 +44,7 @@ describe('identity signature editor and the organisation signature', () => {
const locked = screen.getByTestId('identity-locked-signature');
expect(locked.innerHTML).toContain('Alice
Phone: +49 1');
expect(locked.querySelector('[data-org-locked]')).not.toBeNull();
- expect(screen.queryByLabelText('text_signature_label')).toBeNull();
- expect(screen.queryByLabelText('html_signature_label')).toBeNull();
+ expect(screen.queryByText('signature_label')).toBeNull();
// Saving leaves the identity's own signature as it is.
fireEvent.submit(locked.closest('form')!);
@@ -58,13 +57,13 @@ describe('identity signature editor and the organisation signature', () => {
setPolicy({ hidden: true });
render(
Ann
'); + fireEvent.change(source, { target: { value: 'Ann Lee
' } }); + submit(); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave.mock.calls[0][0]).toMatchObject({ htmlSignature: 'Ann Lee
', textSignature: expect.stringMatching(/^Ann\s+Lee$/) }); + }); + + it('shows the size only near the server limit, and will not save a signature that is too long', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(Ann
' } }); + expect(screen.queryByTestId('signature-size')).toBeNull(); + + fireEvent.change(editor(), { target: { value: `${'a'.repeat(1900)}
` } }); + expect(screen.getByTestId('signature-size').textContent).toMatch(/^signature_size/); + + fireEvent.change(editor(), { target: { value: `${'a'.repeat(2100)}
` } }); + expect(screen.getByTestId('signature-size').textContent).toMatch(/^signature_too_long/); + submit(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('is a plain text box for someone who writes plain text only, and keeps the HTML version', async () => { + useSettingsStore.setState({ plainTextMode: true }); + const onSave = vi.fn().mockResolvedValue(undefined); + render(Ann
' }); + }); +}); diff --git a/components/identity/identity-form.tsx b/components/identity/identity-form.tsx index f8ba2acc3..e48c83d54 100644 --- a/components/identity/identity-form.tsx +++ b/components/identity/identity-form.tsx @@ -10,31 +10,8 @@ import { getEmailValidationError, validateEmailList } from '@/lib/validation'; import { getPlainTextSignature } from '@/lib/signature-utils'; import { useOrgSignatureSetup } from '@/hooks/use-org-signature'; import { OrgLockedNote } from '@/components/settings/org-locked-note'; - -// Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes -const SIGNATURE_MAX_BYTES = 2047; -const utf8Encoder = new TextEncoder(); - -function utf8ByteLength(s: string): number { - return utf8Encoder.encode(s).length; -} - -function truncateToUtf8Bytes(s: string, maxBytes: number): string { - if (utf8ByteLength(s) <= maxBytes) return s; - let lo = 0; - let hi = s.length; - while (lo < hi) { - const mid = (lo + hi + 1) >>> 1; - if (utf8ByteLength(s.slice(0, mid)) <= maxBytes) lo = mid; - else hi = mid - 1; - } - // Don't split a surrogate pair: if we landed right after a high surrogate, back off one code unit. - if (lo > 0) { - const prev = s.charCodeAt(lo - 1); - if (prev >= 0xD800 && prev <= 0xDBFF) lo -= 1; - } - return s.slice(0, lo); -} +import { useSettingsStore } from '@/stores/settings-store'; +import { SignatureEditor, SIGNATURE_MAX_BYTES, signatureBytes } from './signature-editor'; interface IdentityFormData { name: string; @@ -56,9 +33,10 @@ interface IdentityFormProps { export function IdentityForm({ identity, onSave, onCancel, onDirtyChange }: IdentityFormProps) { const t = useTranslations('identities.form'); const tValidation = useTranslations('identities.validation_errors'); - const tDisplay = useTranslations('identities.display'); const tOrg = useTranslations('org_signature'); const isEditing = !!identity; + // Composing > Plain text only: the signature is plain text too. + const plainTextMode = useSettingsStore((s) => s.plainTextMode); const [formData, setFormData] = useState- {t('signature_byte_counter', { bytes, max: SIGNATURE_MAX_BYTES })} - {atLimit && {t('signature_byte_limit_reached')}} -
- ); -} diff --git a/components/identity/signature-editor.tsx b/components/identity/signature-editor.tsx new file mode 100644 index 000000000..a3b8938b1 --- /dev/null +++ b/components/identity/signature-editor.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { useId, useState, type ReactNode } from 'react'; +import { useTranslations } from 'next-intl'; +import { RichTextEditor } from '@/components/email/rich-text-editor'; +import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay } from '@/lib/email-sanitization'; +import { htmlToPlainText } from '@/lib/html-to-text'; +import { textToParagraphHtml } from '@/lib/text-to-paragraph-html'; +import { useSettingsStore } from '@/stores/settings-store'; +import { cn } from '@/lib/utils'; + +/** Stalwart's JMAP Identity/set caps each signature field at 2047 UTF-8 bytes. */ +export const SIGNATURE_MAX_BYTES = 2047; +/** The size shows from here on, so it is there before the limit is reached. */ +const SHOW_SIZE_FROM = Math.floor(SIGNATURE_MAX_BYTES * 0.9); + +const utf8Encoder = new TextEncoder(); +const utf8ByteLength = (value: string) => utf8Encoder.encode(value).length; + +/** The plain-text part a save sends with a formatted signature. */ +export function textFromSignatureHtml(html: string): string { + const clean = sanitizeSignatureHtml(html); + return clean ? htmlToPlainText(clean).trim() : ''; +} + +/** Whether a formatted signature holds anything: text, or a picture. */ +function hasSignatureContent(html: string): boolean { + const clean = sanitizeSignatureHtml(html); + return !!clean && (!!htmlToPlainText(clean).trim() || /