From 30a863c79d5a9d6ec52afb554c7e8b6abe0d45d3 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 07:13:34 +0100
Subject: [PATCH] fix: put the template, placeholder and composer dialogs on
the shared dialog: close X at the end, Escape, outside click, and a discard
prompt for typed changes
---
.../email/__tests__/composer-title.test.tsx | 8 +
components/email/email-composer.tsx | 313 +++++++---------
.../__tests__/template-dialogs.test.tsx | 132 +++++++
.../templates/placeholder-fill-modal.tsx | 89 +++--
components/templates/template-form.tsx | 129 +++----
.../templates/template-manager-modal.tsx | 343 ++++++++----------
locales/ar/common.json | 3 +
locales/ca/common.json | 3 +
locales/cs/common.json | 3 +
locales/da/common.json | 3 +
locales/de/common.json | 3 +
locales/en/common.json | 11 +-
locales/es/common.json | 3 +
locales/et/common.json | 3 +
locales/fa/common.json | 3 +
locales/fr/common.json | 3 +
locales/he/common.json | 3 +
locales/hu/common.json | 3 +
locales/it/common.json | 3 +
locales/ja/common.json | 3 +
locales/ko/common.json | 3 +
locales/lv/common.json | 3 +
locales/mn/common.json | 3 +
locales/nb/common.json | 3 +
locales/nl/common.json | 3 +
locales/pl/common.json | 3 +
locales/pt/common.json | 3 +
locales/ro/common.json | 3 +
locales/ru/common.json | 3 +
locales/sk/common.json | 3 +
locales/th/common.json | 3 +
locales/tr/common.json | 3 +
locales/uk/common.json | 3 +
locales/zh-TW/common.json | 3 +
locales/zh/common.json | 3 +
35 files changed, 617 insertions(+), 492 deletions(-)
create mode 100644 components/templates/__tests__/template-dialogs.test.tsx
diff --git a/components/email/__tests__/composer-title.test.tsx b/components/email/__tests__/composer-title.test.tsx
index 8f5e80ee9..cebc908c1 100644
--- a/components/email/__tests__/composer-title.test.tsx
+++ b/components/email/__tests__/composer-title.test.tsx
@@ -226,4 +226,12 @@ describe('composer title', () => {
render();
expect(screen.getByRole('button', { name: 'close' })).toBeInTheDocument();
});
+
+ // R4: Close at the top end. On a phone it moves to the start (Send is at the end there).
+ it('puts Close at the end of the header, and at the start only on a phone', () => {
+ render();
+ const close = screen.getByRole('button', { name: 'close' });
+ expect(close.parentElement!.lastElementChild).toBe(close);
+ expect(close.className).toContain('max-md:order-first');
+ });
});
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 85119f443..f027b8139 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -2,10 +2,12 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useMvComposeWindow } from "@/components/email/mv-compose-context";
-import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { Button, TOUCH_HIT_AREA } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
+import { Dialog } from "@/components/ui/dialog";
+import { IconButton } from "@/components/ui/icon-button";
+import { useDiscardChanges } from "@/components/settings/use-discard-changes";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { Input } from "@/components/ui/input";
import {
@@ -970,35 +972,14 @@ export function EmailComposer({
const sendMenuRef = useRef(null);
const mobileSendMenuRef = useRef(null);
- const saveTemplateModalRef = useFocusTrap({
- isActive: showSaveAsTemplate,
- onEscape: () => setShowSaveAsTemplate(false),
- restoreFocus: true,
- });
-
- const closeDialogRef = useFocusTrap({
- isActive: showCloseDialog,
- onEscape: () => dismissCloseDialog(),
- restoreFocus: true,
- });
-
- const attachmentWarningRef = useFocusTrap({
- isActive: showAttachmentWarning,
- onEscape: () => setShowAttachmentWarning(false),
- restoreFocus: true,
- });
-
- const emptySubjectWarningRef = useFocusTrap({
- isActive: showEmptySubjectWarning,
- onEscape: () => setShowEmptySubjectWarning(false),
- restoreFocus: true,
- });
-
- const placeholderWarningRef = useFocusTrap({
- isActive: showPlaceholderWarning,
- onEscape: () => setShowPlaceholderWarning(false),
- restoreFocus: true,
- });
+ // Save as template: changes made in its form are asked about before it closes (R5).
+ const [saveTemplateDirty, setSaveTemplateDirty] = useState(false);
+ const { confirmDiscard: confirmDiscardTemplate, discardDialog: discardTemplateDialog } = useDiscardChanges();
+ const closeSaveAsTemplate = async () => {
+ if (!(await confirmDiscardTemplate(saveTemplateDirty))) return;
+ setSaveTemplateDirty(false);
+ setShowSaveAsTemplate(false);
+ };
const { client: activeClient } = useAuthStore();
const composerOwnClientRef = useRef(activeClient);
@@ -3861,11 +3842,8 @@ export function EmailComposer({
) : (
-
-
-
+
+
{mode === 'reply' ? t('reply') : mode === 'replyAll' ? t('reply_all') : mode === 'forward' ? t('forward') : t('new_message')}
@@ -3952,6 +3930,16 @@ export function EmailComposer({
{t('send')}
)}
+ {/* Close at the top end (R4). On a phone it moves to the start, as
+ the phone's mail apps have it, with Send at the end. */}
+ handleClose()}
+ className="-me-2 max-md:order-first max-md:me-0 max-md:-ms-2 max-md:h-11 max-md:w-11"
+ label={tCommon('close')}
+ data-testid="composer-close"
+ >
+
+