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.
This commit is contained in:
Linus Rath
2026-11-04 06:31:01 +01:00
parent 94b9fe8803
commit 8e46d5e5dc
4 changed files with 289 additions and 104 deletions
@@ -44,8 +44,7 @@ describe('identity signature editor and the organisation signature', () => {
const locked = screen.getByTestId('identity-locked-signature');
expect(locked.innerHTML).toContain('Alice<br>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(<IdentityForm identity={ALICE} onSave={vi.fn()} onCancel={() => {}} />);
expect(screen.queryByTestId('identity-locked-signature')).toBeNull();
expect(screen.queryByLabelText('text_signature_label')).toBeNull();
expect(screen.queryByText('signature_label')).toBeNull();
});
it('keeps the editors, with a hint, when not restricted', () => {
it('keeps the editor, with a hint, when not restricted', () => {
setPolicy();
render(<IdentityForm identity={ALICE} onSave={vi.fn()} onCancel={() => {}} />);
expect(screen.getByLabelText('text_signature_label')).toBeInTheDocument();
expect(screen.getByText('signature_label')).toBeInTheDocument();
expect(screen.getByTestId('identity-org-signature-default')).toHaveTextContent('identity_default');
expect(screen.queryByTestId('identity-locked-signature')).toBeNull();
});
@@ -0,0 +1,96 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import { IdentityForm } from '../identity-form';
import { useSettingsStore } from '@/stores/settings-store';
import type { Identity } from '@/lib/jmap/types';
// The signature in the identity editor: one field with the composer's
// formatting editor, its plain-text part generated, "Edit as HTML" only with
// advanced settings on, a size line only near the server's limit, and a
// plain text box for someone who writes plain text only.
// The editor hands the form its HTML as typed.
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: ({ content, onChange }: { content: string; onChange: (html: string) => void }) => (
<textarea data-testid="signature-rich-editor" value={content} onChange={(e) => onChange(e.target.value)} />
),
}));
const ANN = { id: 'id-ann', name: 'Ann', email: 'ann@example.org', mayDelete: true, textSignature: '', htmlSignature: '' } as Identity;
const editor = () => screen.getByTestId('signature-rich-editor') as HTMLTextAreaElement;
const submit = () => fireEvent.submit(document.getElementById('identity-name')!.closest('form')!);
afterEach(() => {
useSettingsStore.setState({ showAdvancedSettings: false, plainTextMode: false });
});
describe('the signature field', () => {
it('is one formatted field, and a save sends the HTML with a plain text made from it', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<IdentityForm identity={ANN} onSave={onSave} onCancel={() => {}} />);
// No box for typing HTML, and no second text box.
expect(screen.queryByLabelText('html_signature_label')).toBeNull();
expect(screen.queryByLabelText('text_signature_label')).toBeNull();
expect(screen.getByText('signature_label')).toBeInTheDocument();
fireEvent.change(editor(), { target: { value: '<p>Ann Lee</p><p><a href="https://ann.example">ann.example</a></p>' } });
submit();
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
const sent = onSave.mock.calls[0][0];
expect(sent.htmlSignature).toBe('<p>Ann Lee</p><p><a href="https://ann.example">ann.example</a></p>');
expect(sent.textSignature).toMatch(/^Ann Lee\s+ann\.example/);
});
it('opens a text-only signature in the editor', () => {
render(<IdentityForm identity={{ ...ANN, textSignature: 'Cheers,\nAnn' }} onSave={vi.fn()} onCancel={() => {}} />);
expect(editor().value).toBe('<p>Cheers,<br>Ann</p>');
});
it('offers Edit as HTML only with advanced settings on', async () => {
const { unmount } = render(<IdentityForm identity={{ ...ANN, htmlSignature: '<p>Ann</p>' }} onSave={vi.fn()} onCancel={() => {}} />);
expect(screen.queryByRole('button', { name: 'edit_as_html' })).toBeNull();
unmount();
useSettingsStore.setState({ showAdvancedSettings: true });
const onSave = vi.fn().mockResolvedValue(undefined);
render(<IdentityForm identity={{ ...ANN, htmlSignature: '<p>Ann</p>' }} onSave={onSave} onCancel={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: 'edit_as_html' }));
const source = screen.getByLabelText('html_source_label') as HTMLTextAreaElement;
expect(source.value).toBe('<p>Ann</p>');
fireEvent.change(source, { target: { value: '<p>Ann <i>Lee</i></p>' } });
submit();
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave.mock.calls[0][0]).toMatchObject({ htmlSignature: '<p>Ann <i>Lee</i></p>', 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(<IdentityForm identity={ANN} onSave={onSave} onCancel={() => {}} />);
fireEvent.change(editor(), { target: { value: '<p>Ann</p>' } });
expect(screen.queryByTestId('signature-size')).toBeNull();
fireEvent.change(editor(), { target: { value: `<p>${'a'.repeat(1900)}</p>` } });
expect(screen.getByTestId('signature-size').textContent).toMatch(/^signature_size/);
fireEvent.change(editor(), { target: { value: `<p>${'a'.repeat(2100)}</p>` } });
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(<IdentityForm identity={{ ...ANN, htmlSignature: '<p><b>Ann</b></p>', textSignature: 'Ann' }} onSave={onSave} onCancel={() => {}} />);
expect(screen.queryByTestId('signature-rich-editor')).toBeNull();
const box = screen.getByLabelText('signature_plain_label') as HTMLTextAreaElement;
expect(box.value).toBe('Ann');
fireEvent.change(box, { target: { value: 'Ann Lee' } });
submit();
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave.mock.calls[0][0]).toMatchObject({ textSignature: 'Ann Lee', htmlSignature: '<p><b>Ann</b></p>' });
});
});
+21 -99
View File
@@ -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<IdentityFormData>({
name: identity?.name || '',
@@ -133,6 +111,11 @@ export function IdentityForm({ identity, onSave, onCancel, onDirtyChange }: Iden
}
}
// Too long for the server: the field says so under it; nothing is cut off.
if (signatureEditable && signatureBytes(formData.htmlSignature ?? '', formData.textSignature ?? '', plainTextMode) > SIGNATURE_MAX_BYTES) {
newErrors.signature = 'too_long';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
@@ -303,58 +286,20 @@ export function IdentityForm({ identity, onSave, onCancel, onDirtyChange }: Iden
/>
)}
{signatureEditable && (<>
{/* Text Signature */}
<div>
<label htmlFor="identity-text-sig" className="block text-sm font-medium mb-1">
{t('text_signature_label')}
</label>
<textarea
id="identity-text-sig"
value={formData.textSignature ?? ''}
onChange={(e) => setFormData({ ...formData, textSignature: truncateToUtf8Bytes(e.target.value, SIGNATURE_MAX_BYTES) })}
rows={3}
{signatureEditable && (
<SignatureEditor
html={formData.htmlSignature ?? ''}
text={formData.textSignature ?? ''}
plainOnly={plainTextMode}
disabled={isSubmitting}
aria-label={t('text_signature_label')}
aria-describedby="identity-text-sig-counter"
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-hidden focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
onChange={(next) => setFormData({ ...formData, ...next })}
note={org.signature ? (
<p className="text-xs text-muted-foreground mt-1" data-testid="identity-org-signature-default">
{tOrg('identity_default')}
</p>
) : null}
/>
<SignatureByteCounter id="identity-text-sig-counter" value={formData.textSignature || ''} />
{org.signature && (
<p className="text-xs text-muted-foreground mt-1" data-testid="identity-org-signature-default">
{tOrg('identity_default')}
</p>
)}
</div>
{/* HTML Signature */}
<div>
<label htmlFor="identity-html-sig" className="block text-sm font-medium mb-1">
{t('html_signature_label')}
</label>
<textarea
id="identity-html-sig"
value={formData.htmlSignature ?? ''}
onChange={(e) => setFormData({ ...formData, htmlSignature: truncateToUtf8Bytes(e.target.value, SIGNATURE_MAX_BYTES) })}
rows={5}
disabled={isSubmitting}
aria-label={t('html_signature_label')}
aria-describedby="identity-html-sig-counter"
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground font-mono transition-all duration-200 placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-hidden focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
<SignatureByteCounter id="identity-html-sig-counter" value={formData.htmlSignature || ''} />
{formData.htmlSignature && (
<div className="mt-2 p-2 border rounded bg-muted">
<div className="text-xs text-muted-foreground mb-1">{tDisplay('preview')}</div>
<div
dangerouslySetInnerHTML={{
__html: sanitizeSignatureHtmlForDisplay(formData.htmlSignature)
}}
/>
</div>
)}
</div>
</>)}
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-4">
@@ -399,26 +344,3 @@ function LockedSignature({ html, text }: { html: string; text: string }) {
</div>
);
}
function SignatureByteCounter({ id, value }: { id: string; value: string }) {
const t = useTranslations('identities.form');
const bytes = utf8ByteLength(value);
const atLimit = bytes >= SIGNATURE_MAX_BYTES;
const nearLimit = !atLimit && bytes >= Math.floor(SIGNATURE_MAX_BYTES * 0.9);
const tone = atLimit
? 'text-destructive'
: nearLimit
? 'text-amber-600 dark:text-amber-400'
: 'text-muted-foreground';
return (
<p
id={id}
className={`text-xs mt-1 tabular-nums ${tone}`}
role="status"
aria-live="polite"
>
{t('signature_byte_counter', { bytes, max: SIGNATURE_MAX_BYTES })}
{atLimit && <span className="ms-1">{t('signature_byte_limit_reached')}</span>}
</p>
);
}
+168
View File
@@ -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() || /<img\b/i.test(clean));
}
/**
* The bytes the larger of the two fields takes as a save sends them: the
* sanitized HTML and the plain text. A plain-text-only signature sends its
* text alone.
*/
export function signatureBytes(html: string, text: string, plainOnly: boolean): number {
const textBytes = utf8ByteLength(text);
if (plainOnly) return textBytes;
return Math.max(textBytes, utf8ByteLength(html.trim() ? sanitizeSignatureHtml(html) : ''));
}
export interface SignatureChange {
htmlSignature?: string;
textSignature?: string;
}
/**
* One signature field. With formatting it is the composer's editor (bold,
* links, lists, tables); the plain-text version is generated from it on every
* change, so the two can't drift apart. "Edit as HTML" (shown with advanced
* settings on) swaps in the HTML source. Someone who writes plain text only
* (Composing) gets a plain text box, which leaves a stored HTML version alone.
*/
export function SignatureEditor({ html, text, plainOnly, disabled, onChange, note }: {
html: string;
text: string;
plainOnly: boolean;
disabled?: boolean;
onChange: (change: SignatureChange) => void;
/** A line under the field (the organisation's default signature). */
note?: ReactNode;
}) {
const t = useTranslations('uxs_vacation');
const showAdvanced = useSettingsStore((s) => s.showAdvancedSettings);
const [source, setSource] = useState(false);
const labelId = useId();
const sizeId = useId();
const sourceId = useId();
const bytes = signatureBytes(html, text, plainOnly);
const tooLong = bytes > SIGNATURE_MAX_BYTES;
const sizeShown = bytes >= SHOW_SIZE_FROM;
const editHtml = (value: string) => {
const kept = hasSignatureContent(value) ? value : '';
onChange({ htmlSignature: kept, textSignature: kept ? textFromSignatureHtml(kept) : '' });
};
return (
<div>
<div className="flex items-center justify-between gap-2 mb-1">
<span id={labelId} className="block text-sm font-medium">
{plainOnly ? t('signature_plain_label') : t('signature_label')}
</span>
{!plainOnly && (showAdvanced || source) && (
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => setSource((on) => !on)}
aria-pressed={source}
disabled={disabled}
>
{source ? t('edit_formatted') : t('edit_as_html')}
</button>
)}
</div>
{plainOnly ? (
<>
<textarea
value={text}
onChange={(e) => onChange({ textSignature: e.target.value })}
rows={4}
disabled={disabled}
aria-labelledby={labelId}
aria-describedby={sizeShown ? sizeId : undefined}
aria-invalid={tooLong}
className={cn(
'flex w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground transition-all duration-200 hover:border-muted-foreground focus-visible:outline-hidden focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
tooLong ? 'border-destructive' : 'border-input',
)}
/>
<p className="text-xs text-muted-foreground mt-1">{t('signature_plain_description')}</p>
</>
) : source ? (
<>
<textarea
id={sourceId}
value={html}
onChange={(e) => editHtml(e.target.value)}
rows={6}
disabled={disabled}
aria-label={t('html_source_label')}
aria-describedby={sizeShown ? sizeId : undefined}
aria-invalid={tooLong}
className={cn(
'flex w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground font-mono transition-all duration-200 hover:border-muted-foreground focus-visible:outline-hidden focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50',
tooLong ? 'border-destructive' : 'border-input',
)}
/>
{html.trim() && (
<div
className="mt-2 p-2 border rounded bg-muted text-sm break-words min-w-0"
data-testid="signature-source-preview"
dangerouslySetInnerHTML={{ __html: sanitizeSignatureHtmlForDisplay(html) }}
/>
)}
</>
) : (
<div className={cn('rounded-md border overflow-hidden', tooLong ? 'border-destructive' : 'border-input')}>
<RichTextEditor
content={html || textToParagraphHtml(text)}
onChange={editHtml}
ariaLabel={t('signature_label')}
ariaInvalid={tooLong}
ariaDescribedBy={sizeShown ? sizeId : undefined}
/>
</div>
)}
{sizeShown && (
<p
id={sizeId}
data-testid="signature-size"
role="status"
aria-live="polite"
className={cn('text-xs mt-1 tabular-nums', tooLong ? 'text-destructive' : 'text-amber-600 dark:text-amber-400')}
>
{tooLong
? t('signature_too_long', { bytes, max: SIGNATURE_MAX_BYTES })
: t('signature_size', { bytes, max: SIGNATURE_MAX_BYTES })}
</p>
)}
{note}
</div>
);
}