mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
feat: edit each address's signature right on the Composing settings page
This commit is contained in:
@@ -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(<ComposingSettings />);
|
||||
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 <sam@work.test>')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('saves a signature to its address once typing pauses', async () => {
|
||||
render(<ComposingSettings />);
|
||||
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(<ComposingSettings />);
|
||||
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(<ComposingSettings />);
|
||||
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(<ComposingSettings />);
|
||||
expect(screen.getAllByTestId('address-signature')).toHaveLength(1);
|
||||
expect(screen.queryByText('WORK <sam@work.test>')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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 && <TemplateSettings />}
|
||||
|
||||
<SettingsSection id="signatures" title={t('signatures_link.label')}>
|
||||
<SettingItem label={t('signatures_link.label')} description={t('signatures_link.description')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('settings-tab-change', { detail: { tab: 'account', section: 'addresses' } }))}
|
||||
>
|
||||
{t('signatures_link.button')}
|
||||
<ChevronRight className="w-4 h-4 ms-1" />
|
||||
</Button>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
<SignaturesSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<SettingsSection id="signatures" title={t('title')} description={t('description')}>
|
||||
{identities.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('no_addresses')}</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{identities.map((identity) => (
|
||||
<AddressSignature key={identity.id} identity={identity} showAddress={identities.length > 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
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<ReturnType<typeof setTimeout> | 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 (
|
||||
<div
|
||||
data-testid="address-signature"
|
||||
data-address={identity.email}
|
||||
onBlur={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) void flush();
|
||||
}}
|
||||
>
|
||||
{showAddress && (
|
||||
<p className="text-sm font-medium text-foreground mb-2 wrap-anywhere" dir="auto">
|
||||
{identity.name ? `${identity.name} <${toUnicodeEmail(identity.email)}>` : toUnicodeEmail(identity.email)}
|
||||
</p>
|
||||
)}
|
||||
{locked ? (
|
||||
<LockedSignature
|
||||
html={org.signature ? org.signature.html : draft.html}
|
||||
text={org.signature ? org.signature.text : draft.text}
|
||||
/>
|
||||
) : (
|
||||
<SignatureEditor
|
||||
html={draft.html}
|
||||
text={draft.text}
|
||||
plainOnly={plainTextMode}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs mt-1 min-h-4" role="status" aria-live="polite">
|
||||
{status === 'saved' && <span className="text-muted-foreground">{t('saved')}</span>}
|
||||
{status === 'error' && <span className="text-destructive">{t('error')}</span>}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -251,7 +251,7 @@ export const tabSearchEntries: Record<Tab, SettingsSearchEntry[]> = {
|
||||
{ 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' },
|
||||
|
||||
Reference in New Issue
Block a user