feat: start new messages in the default compose font and size

This commit is contained in:
Linus Rath
2026-10-29 17:53:42 +01:00
parent 4d91d9b296
commit 1b24c1980c
5 changed files with 83 additions and 6 deletions
@@ -444,3 +444,30 @@ describe('confirm before sending (MO-16)', () => {
expect(onSend).not.toHaveBeenCalled();
});
});
describe('default compose font (CO-16)', () => {
const READY = { ...DRAFT, to: 'bob@example.com' };
it('starts the editor in the chosen font and sends it with the mail', async () => {
settings.composeFontFamily = 'serif';
settings.composeFontSize = '18px';
const onSend = vi.fn();
render(<EmailComposer initialData={READY} onSend={onSend} />);
expect(JSON.parse(screen.getByTestId('rich-text-editor').getAttribute('data-default-style')!))
.toEqual({ fontFamily: 'serif', fontSize: '18px' });
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
const sent = onSend.mock.calls[0][0] as { htmlBody?: string };
expect(sent.htmlBody).toMatch(/^<div style="font-family: serif; font-size: 18px">/);
});
it('leaves the mail as it was without the setting, and ignores values that are not a font', async () => {
settings.composeFontFamily = 'serif"><script>';
const onSend = vi.fn();
render(<EmailComposer initialData={READY} onSend={onSend} />);
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect((onSend.mock.calls[0][0] as { htmlBody?: string }).htmlBody).toMatch(/^<div>/);
});
});
@@ -126,3 +126,11 @@ describe('RichTextEditor formatting toolbar (SR-08)', () => {
expect(document.activeElement).toBe(item('font-size'));
});
});
describe('RichTextEditor default text style (CO-16)', () => {
it('shows the text in the chosen font and size', async () => {
const { editor } = await setup({ defaultTextStyle: { fontFamily: 'monospace', fontSize: '20px' } });
expect(editor.view.dom.style.fontFamily).toBe('monospace');
expect(editor.view.dom.style.fontSize).toBe('20px');
});
});
+9 -1
View File
@@ -138,6 +138,7 @@ import {
import type { ComposePrefill } from "@/lib/plugin-compose-gate";
import { composerContextMode, composerMounted, composerUnmounted, draftSaved } from "@/lib/plugin-composer-hooks";
import { isValidEmail } from "@/lib/validation";
import { composeTextStyle, composeTextStyleCss } from "@/components/email/font-size";
import { buildMentionCandidates, type MentionCandidate } from "@/lib/recipient-mentions";
import { describeRecipientChanges, recipientDisplayName, type RecipientChange, type RecipientFields } from "@/lib/recipient-announcements";
import { normalizeCid } from "@/lib/attachment-visibility";
@@ -496,6 +497,11 @@ export function EmailComposer({
const placeholderWarningEnabled = useSettingsStore((state) => state.placeholderWarningEnabled);
// "Confirm before sending" (an admin can lock it; the store applies the lock).
const sendConfirmation = useSettingsStore((state) => state.sendConfirmation);
// The font and size messages start in (Settings > Accessibility); the
// sent HTML carries them, so recipients see them too.
const composeFontFamily = useSettingsStore((state) => state.composeFontFamily);
const composeFontSize = useSettingsStore((state) => state.composeFontSize);
const composeStyle = useMemo(() => composeTextStyle(composeFontFamily, composeFontSize), [composeFontFamily, composeFontSize]);
const recipientMentionsEnabled = useSettingsStore((state) => state.recipientMentionsEnabled);
const mentionAddsRecipientTo = useSettingsStore((state) => state.mentionAddsRecipientTo);
const updateSetting = useSettingsStore((state) => state.updateSetting);
@@ -3475,11 +3481,12 @@ export function EmailComposer({
}
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
const composeStyleCss = composeTextStyleCss(composeStyle);
// What the send-pipeline finalisers add (organisation signature) goes in
// here, on the send only: drafts never get it. The `last` stage (the
// disclaimer) runs on what goes out once plugins had their say, below.
const { html: finalHtmlBody, text: finalBody } = finalizeOutgoing({
html: plainTextMode ? undefined : `<div>${rewritten!.html}</div>${buildSignatureHtml()}`,
html: plainTextMode ? undefined : `${composeStyleCss ? `<div style="${composeStyleCss}">` : '<div>'}${rewritten!.html}</div>${buildSignatureHtml()}`,
text: builtTextBody,
}, outgoingContext);
const lastStage = (html: string | undefined, text: string) =>
@@ -4453,6 +4460,7 @@ export function EmailComposer({
ariaLabel={t('body_label')}
ariaInvalid={validationErrors.body}
ariaDescribedBy={`${fieldIdBase}-body-error`}
defaultTextStyle={composeStyle}
collapseToolbarOnMobile={!showFormattingOnMobile}
toolbarPlacement={mvWindow ? (mvShowFormatting ? 'bottom' : 'hidden') : 'top'}
onEditorReady={handleEditorReady}
+29
View File
@@ -77,3 +77,32 @@ declare module "@tiptap/core" {
};
}
}
/** The font and size a message starts with (Settings > Accessibility). */
export interface ComposeTextStyle {
fontFamily?: string;
fontSize?: string;
}
// The settings sync from the server: only plain family lists and lengths
// go into a style attribute.
const SAFE_FONT_FAMILY = /^[a-z0-9 ,'-]{1,200}$/i;
const SAFE_FONT_SIZE = /^\d{1,3}(\.\d{1,2})?(px|pt|em|rem|%)$/;
/** The composeFontFamily / composeFontSize settings, checked; empty means the default. */
export function composeTextStyle(fontFamily: string | undefined, fontSize: string | undefined): ComposeTextStyle {
const style: ComposeTextStyle = {};
const family = fontFamily?.trim();
const size = fontSize?.trim();
if (family && SAFE_FONT_FAMILY.test(family)) style.fontFamily = family;
if (size && SAFE_FONT_SIZE.test(size)) style.fontSize = size;
return style;
}
/** The style as CSS declarations for an inline style attribute ("" when there is none). */
export function composeTextStyleCss(style: ComposeTextStyle): string {
return [
style.fontFamily ? `font-family: ${style.fontFamily}` : '',
style.fontSize ? `font-size: ${style.fontSize}` : '',
].filter(Boolean).join('; ');
}
+10 -5
View File
@@ -15,7 +15,7 @@ import { PlainTextPaste } from "@/components/email/plain-text-paste";
import { pastedImageFiles } from "@/components/email/paste-images";
import { TextStyle, BackgroundColor } from "@tiptap/extension-text-style";
import Color from "@tiptap/extension-color";
import { FontSize, FONT_SIZES } from "@/components/email/font-size";
import { FontSize, FONT_SIZES, composeTextStyleCss, type ComposeTextStyle } from "@/components/email/font-size";
import { ResizableImage } from "@/components/email/resizable-image";
import Placeholder from "@tiptap/extension-placeholder";
import { Table } from "@tiptap/extension-table";
@@ -132,12 +132,16 @@ interface RichTextEditorProps {
ariaInvalid?: boolean;
/** The id of the text that explains the error. */
ariaDescribedBy?: string;
/** The font and size the text starts in (the sent mail carries them too). */
defaultTextStyle?: ComposeTextStyle;
}
/** What the editing area tells assistive technology, besides TipTap's role=textbox. */
export function editorA11yAttributes({ ariaLabel, ariaInvalid, ariaDescribedBy }: Pick<RichTextEditorProps, 'ariaLabel' | 'ariaInvalid' | 'ariaDescribedBy'>): Record<string, string> {
export function editorA11yAttributes({ ariaLabel, ariaInvalid, ariaDescribedBy, defaultTextStyle }: Pick<RichTextEditorProps, 'ariaLabel' | 'ariaInvalid' | 'ariaDescribedBy' | 'defaultTextStyle'>): Record<string, string> {
const style = defaultTextStyle ? composeTextStyleCss(defaultTextStyle) : "";
return {
class: "tiptap min-h-[100px] px-4 py-3 text-sm text-foreground",
...(style ? { style } : {}),
role: "textbox",
"aria-multiline": "true",
...(ariaLabel ? { "aria-label": ariaLabel } : {}),
@@ -362,6 +366,7 @@ export function RichTextEditor({
ariaLabel,
ariaInvalid,
ariaDescribedBy,
defaultTextStyle,
}: RichTextEditorProps) {
const rtlEditingSupport = useSettingsStore((st) => st.rtlEditingSupport);
const tComposer = useTranslations("email_composer");
@@ -470,7 +475,7 @@ export function RichTextEditor({
},
content,
editorProps: {
attributes: editorA11yAttributes({ ariaLabel, ariaInvalid, ariaDescribedBy }),
attributes: editorA11yAttributes({ ariaLabel, ariaInvalid, ariaDescribedBy, defaultTextStyle }),
handleKeyDown: (_view, event) => {
if (event.altKey && event.key === "F10" && focusToolbarRef.current?.()) {
event.preventDefault();
@@ -550,10 +555,10 @@ export function RichTextEditor({
editor.setOptions({
editorProps: {
...editor.options.editorProps,
attributes: editorA11yAttributes({ ariaLabel, ariaInvalid, ariaDescribedBy }),
attributes: editorA11yAttributes({ ariaLabel, ariaInvalid, ariaDescribedBy, defaultTextStyle }),
},
});
}, [editor, ariaLabel, ariaInvalid, ariaDescribedBy]);
}, [editor, ariaLabel, ariaInvalid, ariaDescribedBy, defaultTextStyle]);
// Expose the editor instance once it's ready so parents can target
// specific nodes (e.g. swap the embedded signature on identity change).