mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
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:
@@ -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>' });
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user