diff --git a/components/settings/__tests__/composing-signatures.test.tsx b/components/settings/__tests__/composing-signatures.test.tsx new file mode 100644 index 000000000..b6ea6db99 --- /dev/null +++ b/components/settings/__tests__/composing-signatures.test.tsx @@ -0,0 +1,87 @@ +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ComposingSettings } from '../composing-settings'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useIdentityStore } from '@/stores/identity-store'; +import type { Identity } from '@/lib/jmap/types'; + +// settings-p2-3: the signature was four clicks deep (Composing > "Email +// addresses" > Account > Manage identities > Edit identity). Composing now +// holds each address with its signature editor, saved as you type. + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'en', +})); +vi.mock('../template-settings', () => ({ TemplateSettings: () => null })); +vi.mock('../proofreading-settings', () => ({ ProofreadingSettings: () => null })); + +const identity = (id: string, email: string, textSignature = ''): Identity => ({ + id, name: id.toUpperCase(), email, textSignature, htmlSignature: '', mayDelete: true, +}); + +const updateIdentity = vi.fn(); + +beforeEach(() => { + vi.useFakeTimers(); + updateIdentity.mockReset().mockResolvedValue(undefined); + useSettingsStore.getState().resetToDefaults(); + // The plain text box: the formatting editor needs a real browser. + useSettingsStore.setState({ plainTextMode: true }); + const ids = [identity('work', 'sam@work.test', 'Sam'), identity('home', 'sam@home.test')]; + useIdentityStore.setState({ identities: ids }); + useAuthStore.setState({ client: { updateIdentity, hasDelayedSend: () => false } as never, identities: ids, primaryIdentity: ids[0] }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +const editorOf = (email: string) => { + const box = screen.getAllByTestId('address-signature').find((el) => el.getAttribute('data-address') === email)!; + return within(box).getByRole('textbox'); +}; + +describe('Composing > Signatures', () => { + it('shows every sending address with its signature on the page', () => { + render(); + expect(screen.getAllByTestId('address-signature').map((el) => el.getAttribute('data-address'))) + .toEqual(['sam@work.test', 'sam@home.test']); + expect(editorOf('sam@work.test')).toHaveValue('Sam'); + expect(screen.getByText('WORK ')).toBeTruthy(); + }); + + it('saves a signature to its address once typing pauses', async () => { + render(); + fireEvent.change(editorOf('sam@home.test'), { target: { value: 'Cheers, Sam' } }); + expect(updateIdentity).not.toHaveBeenCalled(); + await act(async () => { await vi.advanceTimersByTimeAsync(1000); }); + expect(updateIdentity).toHaveBeenCalledTimes(1); + expect(updateIdentity).toHaveBeenCalledWith('home', { htmlSignature: null, textSignature: 'Cheers, Sam' }); + expect(useIdentityStore.getState().identities.find((i) => i.id === 'home')?.textSignature).toBe('Cheers, Sam'); + expect(screen.getByText('saved')).toBeTruthy(); + }); + + it('saves at once when the field loses focus', async () => { + render(); + fireEvent.change(editorOf('sam@work.test'), { target: { value: 'Sam Lee' } }); + await act(async () => { fireEvent.blur(editorOf('sam@work.test')); }); + expect(updateIdentity).toHaveBeenCalledWith('work', { htmlSignature: null, textSignature: 'Sam Lee' }); + }); + + it('does not save a signature over the server limit', async () => { + render(); + fireEvent.change(editorOf('sam@work.test'), { target: { value: 'x'.repeat(2100) } }); + await act(async () => { await vi.advanceTimersByTimeAsync(1500); }); + expect(updateIdentity).not.toHaveBeenCalled(); + }); + + it('shows one editor without an address line when there is one address', () => { + const only = [identity('work', 'sam@work.test', 'Sam')]; + useIdentityStore.setState({ identities: only }); + render(); + expect(screen.getAllByTestId('address-signature')).toHaveLength(1); + expect(screen.queryByText('WORK ')).toBeNull(); + }); +}); diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx index 5f0955815..e70ee57bd 100644 --- a/components/settings/composing-settings.tsx +++ b/components/settings/composing-settings.tsx @@ -10,13 +10,13 @@ import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-s import { TemplateSettings } from './template-settings'; import { ProofreadingSettings } from './proofreading-settings'; import { PrivacyLinkRow } from './privacy-settings'; -import { Button } from '@/components/ui/button'; -import { X, ChevronRight } from '@/components/icons'; +import { SignaturesSection } from './signatures-section'; +import { X } from '@/components/icons'; /** * The Composing page: the editor, replying, what happens before a send, - * templates (their own tab before) and pointers to where signatures and - * read receipts live, since people look for them here. + * templates (their own tab before), the signature of each address, and a + * pointer to where read receipts live, since people look for them here. */ export function ComposingSettings() { const t = useTranslations('settings.email_behavior'); @@ -250,18 +250,7 @@ export function ComposingSettings() { {templatesEnabled && } - - - - - + ); } diff --git a/components/settings/signatures-section.tsx b/components/settings/signatures-section.tsx new file mode 100644 index 000000000..925279cd5 --- /dev/null +++ b/components/settings/signatures-section.tsx @@ -0,0 +1,137 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import type { Identity } from '@/lib/jmap/types'; +import { SettingsSection } from './settings-section'; +import { SignatureEditor, SIGNATURE_MAX_BYTES, signatureBytes, type SignatureChange } from '@/components/identity/signature-editor'; +import { LockedSignature } from '@/components/identity/identity-form'; +import { useOrgSignatureSetup } from '@/hooks/use-org-signature'; +import { sanitizeSignatureHtml } from '@/lib/email-sanitization'; +import { toUnicodeEmail } from '@/lib/idn'; +import { useAuthStore } from '@/stores/auth-store'; +import { useIdentityStore, saveIdentityChanges } from '@/stores/identity-store'; +import { useSettingsStore } from '@/stores/settings-store'; + +/** How long typing has to pause before the signature is saved. */ +export const SIGNATURE_SAVE_DELAY_MS = 1000; + +/** + * Settings > Composing > Signatures: every address you send from with its + * signature editor right there, saved as you type (settings-p2-3). Gmail + * and Outlook keep the signature on the compose settings; it used to sit + * four clicks deep in the address editor. + */ +export function SignaturesSection() { + const t = useTranslations('uxs2_ident.signatures'); + const identities = useIdentityStore((s) => s.identities); + + return ( + + {identities.length === 0 ? ( +

{t('no_addresses')}

+ ) : ( +
+ {identities.map((identity) => ( + 1} /> + ))} +
+ )} +
+ ); +} + +function AddressSignature({ identity, showAddress }: { identity: Identity; showAddress: boolean }) { + const t = useTranslations('uxs2_ident.signatures'); + const client = useAuthStore((s) => s.client); + const plainTextMode = useSettingsStore((s) => s.plainTextMode); + // The admin's `signature` restriction, as in the address editor. + const org = useOrgSignatureSetup({ name: identity.name, email: identity.email }); + const hidden = org.restriction.hidden; + const locked = org.restriction.locked && !hidden; + + const [draft, setDraft] = useState({ html: identity.htmlSignature ?? '', text: identity.textSignature ?? '' }); + const [status, setStatus] = useState<'idle' | 'saved' | 'error'>('idle'); + const draftRef = useRef(draft); + draftRef.current = draft; + const savedRef = useRef(draft); + const timer = useRef | null>(null); + + const flush = useCallback(async () => { + if (timer.current) { clearTimeout(timer.current); timer.current = null; } + const next = draftRef.current; + const last = savedRef.current; + if (next.html === last.html && next.text === last.text) return; + // Too long: the editor says so under the field, and nothing is cut off. + if (signatureBytes(next.html, next.text, plainTextMode) > SIGNATURE_MAX_BYTES) return; + if (!client) return; + // JMAP clears a field with null, as the address editor sends it. + const updates = { + htmlSignature: next.html.trim() ? sanitizeSignatureHtml(next.html) : null, + textSignature: next.text.trim() ? next.text : null, + }; + savedRef.current = next; + try { + if (!(await saveIdentityChanges(client, identity, updates))) { + savedRef.current = last; + return; + } + useAuthStore.getState().syncIdentities(); + setStatus('saved'); + } catch { + savedRef.current = last; + setStatus('error'); + } + }, [client, identity, plainTextMode]); + + // Leaving the page saves what is still waiting for its pause. + const flushRef = useRef(flush); + flushRef.current = flush; + useEffect(() => () => { void flushRef.current(); }, []); + + const onChange = (change: SignatureChange) => { + setDraft((prev) => { + const next = { html: change.htmlSignature ?? prev.html, text: change.textSignature ?? prev.text }; + draftRef.current = next; + return next; + }); + setStatus('idle'); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => { void flushRef.current(); }, SIGNATURE_SAVE_DELAY_MS); + }; + + if (hidden) return null; + + return ( +
{ + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) void flush(); + }} + > + {showAddress && ( +

+ {identity.name ? `${identity.name} <${toUnicodeEmail(identity.email)}>` : toUnicodeEmail(identity.email)} +

+ )} + {locked ? ( + + ) : ( + + )} +

+ {status === 'saved' && {t('saved')}} + {status === 'error' && {t('error')}} +

+
+ ); +} diff --git a/lib/__tests__/settings-search-addresses.test.ts b/lib/__tests__/settings-search-addresses.test.ts index 47f1f1af1..106b08b91 100644 --- a/lib/__tests__/settings-search-addresses.test.ts +++ b/lib/__tests__/settings-search-addresses.test.ts @@ -17,7 +17,13 @@ const everywhere = (messages: unknown, query: string) => (Object.keys(tabSearchE .filter((r) => matchesAllWords(subResultText(r), query)) .map((r) => `${tab}: ${r.label}`)); -describe('settings search: addresses and your name', () => { +describe('settings search: addresses, signatures and your name', () => { + it('finds the signatures on Composing', () => { + for (const query of ['signature', 'identity', 'sender', 'from address']) { + expect(everywhere(en, query), query).toContain('composing: Signatures'); + } + }); + it('finds the sending addresses on Account for identity and alias', () => { for (const query of ['identity', 'alias', 'sender address']) { expect(everywhere(en, query), query).toContain('account: Email addresses'); @@ -29,6 +35,7 @@ describe('settings search: addresses and your name', () => { }); it('finds them in German too', () => { + expect(everywhere(de, 'Absender')).toContain('composing: Signaturen'); expect(everywhere(de, 'Identität')).toContain('account: E-Mail-Adressen'); }); }); diff --git a/lib/settings-search.ts b/lib/settings-search.ts index ec46be264..3549b85d8 100644 --- a/lib/settings-search.ts +++ b/lib/settings-search.ts @@ -251,7 +251,7 @@ export const tabSearchEntries: Record = { { label: 'settings.email_behavior.placeholder_warning.label' }, { label: 'settings.templates.title' }, { label: 'settings.templates.export_import', advanced: true }, - { label: 'settings.email_behavior.signatures_link.label', synonyms: syn('signature') }, + { label: 'uxs2_ident.signatures.title', synonyms: 'uxs2_ident.search.signatures' }, ], vacation: [ { label: 'uxs_vacation.from_label' },