From 8720ab6e1a0a900e677063a629acb4823f3661be Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:12:23 +0100 Subject: [PATCH 1/5] feat: ask about everything before sending in one dialog, and allow sending without text Tests changed: the "asked once" tests reached the second check through a second dialog, which no longer exists, so they now stop the send at "Send this message?" instead. The empty-body test asserted the error under the editor; an empty body now asks instead of blocking Send. --- .../email/__tests__/composer-a11y.test.tsx | 14 +- .../__tests__/empty-subject-warning.test.tsx | 65 +++- .../__tests__/placeholder-warning.test.tsx | 54 ++- components/email/email-composer.tsx | 337 ++++++------------ components/email/send-warnings-dialog.tsx | 146 ++++++++ locales/ar/common.json | 12 + locales/ca/common.json | 12 + locales/cs/common.json | 12 + locales/da/common.json | 12 + locales/de/common.json | 12 + locales/en/common.json | 12 + locales/es/common.json | 12 + locales/et/common.json | 12 + locales/fa/common.json | 12 + locales/fr/common.json | 12 + locales/he/common.json | 12 + locales/hu/common.json | 12 + locales/it/common.json | 12 + locales/ja/common.json | 12 + locales/ko/common.json | 12 + locales/lv/common.json | 12 + locales/mn/common.json | 12 + locales/nb/common.json | 12 + locales/nl/common.json | 12 + locales/pl/common.json | 12 + locales/pt/common.json | 12 + locales/ro/common.json | 12 + locales/ru/common.json | 12 + locales/sk/common.json | 12 + locales/th/common.json | 12 + locales/tr/common.json | 12 + locales/uk/common.json | 12 + locales/vi/common.json | 12 + locales/zh-TW/common.json | 12 + locales/zh/common.json | 12 + 35 files changed, 714 insertions(+), 262 deletions(-) create mode 100644 components/email/send-warnings-dialog.tsx diff --git a/components/email/__tests__/composer-a11y.test.tsx b/components/email/__tests__/composer-a11y.test.tsx index bdec82551..9e9207abd 100644 --- a/components/email/__tests__/composer-a11y.test.tsx +++ b/components/email/__tests__/composer-a11y.test.tsx @@ -370,17 +370,17 @@ describe('message body (SR-07)', () => { expect(screen.getByLabelText('body_label').tagName).toBe('TEXTAREA'); }); - it('marks an empty body invalid and links the error text', async () => { - render(); + it('asks before sending an empty body instead of marking it invalid', async () => { + const onSend = vi.fn(); + render(); const editor = screen.getByTestId('rich-text-editor'); - expect(editor).not.toHaveAttribute('data-invalid'); fireEvent.keyDown(screen.getByTestId('composer-subject'), { key: 'Enter', ctrlKey: true }); - await act(async () => {}); - expect(editor).toHaveAttribute('data-invalid', 'true'); - const error = document.getElementById(editor.getAttribute('data-describedby')!); - expect(error).toHaveTextContent('validation.body_required'); + const dialog = await screen.findByRole('alertdialog'); + expect(dialog).toHaveTextContent('empty_text_title'); + expect(editor).not.toHaveAttribute('data-invalid'); + expect(onSend).not.toHaveBeenCalled(); }); }); diff --git a/components/email/__tests__/empty-subject-warning.test.tsx b/components/email/__tests__/empty-subject-warning.test.tsx index 4908bc2b9..26b50b734 100644 --- a/components/email/__tests__/empty-subject-warning.test.tsx +++ b/components/email/__tests__/empty-subject-warning.test.tsx @@ -263,27 +263,76 @@ describe('composer empty subject warning', () => { ); }); - it('does not ask again after Send anyway and a trip back to add an attachment', async () => { + // compose-27: the subject and the attachment used to be two dialogs in a row. + it('asks about a missing subject and a missing attachment in one dialog', async () => { useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['hello'] } as never); try { const onSend = vi.fn(); render(); fireEvent.click(sendButton()); - fireEvent.click(await screen.findByText('empty_subject.send_anyway')); - // The body says "Hello", so the attachment reminder stops the send. - fireEvent.click(await screen.findByText('forgot_attachment.back')); - await waitFor(() => expect(screen.queryByText('forgot_attachment.title')).not.toBeInTheDocument()); - fireEvent.click(sendButton()); + const dialog = await screen.findByRole('alertdialog'); + expect(dialog).toHaveTextContent('warnings_title'); + expect(dialog).toHaveTextContent('warning_subject'); + expect(dialog).toHaveTextContent('warning_attachment'); + // Several warnings: no "don't ask again", which would be unclear. + expect(within(dialog).queryByRole('checkbox')).not.toBeInTheDocument(); - expect(await screen.findByText('forgot_attachment.title')).toBeInTheDocument(); - expect(screen.queryByText('empty_subject.title')).not.toBeInTheDocument(); + fireEvent.click(within(dialog).getByText('send_anyway')); + + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + expect(screen.queryByText('forgot_attachment.title')).not.toBeInTheDocument(); } finally { useSettingsStore.setState({ attachmentReminderEnabled: false, attachmentReminderKeywords: [] } as never); } }); + it('goes back to editing from the combined dialog without sending', async () => { + useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['hello'] } as never); + try { + const onSend = vi.fn(); + render(); + + fireEvent.click(sendButton()); + fireEvent.click(await screen.findByText('back')); + + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(onSend).not.toHaveBeenCalled(); + } finally { + useSettingsStore.setState({ attachmentReminderEnabled: false, attachmentReminderKeywords: [] } as never); + } + }); + + // compose-28: Send works with an empty body and asks first. + it('keeps Send enabled with an empty body and asks "Send without text?"', async () => { + const onSend = vi.fn(); + render(); + expect(sendButton()).not.toBeDisabled(); + + fireEvent.click(sendButton()); + + const dialog = await screen.findByRole('alertdialog'); + expect(dialog).toHaveTextContent('empty_text_title'); + expect(onSend).not.toHaveBeenCalled(); + + fireEvent.click(within(dialog).getByText('send_anyway')); + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + expect(onSend.mock.calls[0][0]).toMatchObject({ subject: 'Lunch?' }); + }); + + it('lists no subject and no text together', async () => { + const onSend = vi.fn(); + render(); + + fireEvent.click(sendButton()); + + const dialog = await screen.findByRole('alertdialog'); + expect(dialog).toHaveTextContent('warning_subject'); + expect(dialog).toHaveTextContent('warning_text'); + expect(onSend).not.toHaveBeenCalled(); + }); + it('goes back to editing without sending', async () => { const onSend = vi.fn(); render(); diff --git a/components/email/__tests__/placeholder-warning.test.tsx b/components/email/__tests__/placeholder-warning.test.tsx index d73fc79c9..2baf32c91 100644 --- a/components/email/__tests__/placeholder-warning.test.tsx +++ b/components/email/__tests__/placeholder-warning.test.tsx @@ -235,6 +235,7 @@ describe('composer placeholder warning', () => { placeholderWarningEnabled: true, attachmentReminderEnabled: false, attachmentReminderKeywords: [], + sendConfirmation: false, } as never); }); @@ -293,33 +294,36 @@ describe('composer placeholder warning', () => { expect(onSend).not.toHaveBeenCalled(); }); - it('does not ask again after Send anyway and a trip back to add an attachment', async () => { - useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['see you'] } as never); - const onSend = vi.fn(); - render(); - + // Send anyway, then "Send this message?" answered with Cancel: the + // placeholders were confirmed, the message is still here. + const confirmThenCancel = async () => { fireEvent.click(sendButton()); fireEvent.click(await screen.findByText('placeholder_warning.send_anyway')); - // The body says "See you", so the attachment reminder stops the send. - fireEvent.click(await screen.findByText('forgot_attachment.back')); - await waitFor(() => expect(screen.queryByText('forgot_attachment.title')).not.toBeInTheDocument()); + const confirm = await screen.findByRole('alertdialog'); + expect(confirm).toHaveTextContent('send_confirm.title'); + fireEvent.click(within(confirm).getByRole('button', { name: 'cancel' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + }; + + it('does not ask again after Send anyway and a cancelled send', async () => { + useSettingsStore.setState({ sendConfirmation: true } as never); + const onSend = vi.fn(); + render(); + await confirmThenCancel(); fireEvent.click(sendButton()); - expect(await screen.findByText('forgot_attachment.title')).toBeInTheDocument(); + const dialog = await screen.findByRole('alertdialog'); + expect(dialog).toHaveTextContent('send_confirm.title'); expect(screen.queryByText('placeholder_warning.title')).not.toBeInTheDocument(); expect(onSend).not.toHaveBeenCalled(); }); it('asks again when a new placeholder shows up', async () => { - useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['see you'] } as never); + useSettingsStore.setState({ sendConfirmation: true } as never); const onSend = vi.fn(); render(); - - fireEvent.click(sendButton()); - fireEvent.click(await screen.findByText('placeholder_warning.send_anyway')); - fireEvent.click(await screen.findByText('forgot_attachment.back')); - await waitFor(() => expect(screen.queryByText('forgot_attachment.title')).not.toBeInTheDocument()); + await confirmThenCancel(); fireEvent.change(screen.getByDisplayValue('Lunch'), { target: { value: 'Lunch with {{name}}' } }); fireEvent.click(sendButton()); @@ -330,6 +334,26 @@ describe('composer placeholder warning', () => { expect(onSend).not.toHaveBeenCalled(); }); + // compose-27: one question for everything the checks found. + it('lists the placeholders and the missing attachment in one dialog', async () => { + useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['see you'] } as never); + const onSend = vi.fn(); + render(); + + fireEvent.click(sendButton()); + + const dialog = await screen.findByRole('alertdialog'); + expect(dialog).toHaveTextContent('warnings_title'); + expect(dialog).toHaveTextContent('warning_placeholders'); + expect(dialog).toHaveTextContent('warning_attachment'); + expect(within(dialog).getByText('[TIME]')).toBeInTheDocument(); + + fireEvent.click(within(dialog).getByText('send_anyway')); + + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + it('ignores placeholders in quoted text and in the signature', async () => { const onSend = vi.fn(); const body = '

Thanks!

' diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 3409f77db..b4d9104a1 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -52,6 +52,7 @@ import { actionsFor, runAction } from "@/lib/plugin-sandbox/actions"; import { matchesCombo } from "@/lib/plugin-sandbox/shortcuts"; import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; import { ScheduleSendDialog } from "./schedule-send-dialog"; +import { SendWarningsDialog, type SendWarning } from "./send-warnings-dialog"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; import { useContextMenu, isContextMenuKey } from "@/hooks/use-context-menu"; @@ -598,7 +599,6 @@ export function EmailComposer({ const tViewer = useTranslations('email_viewer'); const tClassification = useTranslations('classification'); const tEdit = useTranslations('desktopEdit'); - const tProofreading = useTranslations('proofreading'); const tOfflineDrafts = useTranslations('offlineDrafts'); const tOffline = useTranslations('offline'); const tOfflineNotes = useTranslations('offlineConflicts'); @@ -1201,19 +1201,11 @@ export function EmailComposer({ const [closeDialogKind, setCloseDialogKind] = useState<'close' | 'discard'>('close'); const [showAllAttachments, setShowAllAttachments] = useState(false); const [previewAttachment, setPreviewAttachment] = useState(null); - const [showAttachmentWarning, setShowAttachmentWarning] = useState(false); - const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState(''); - const [attachmentWarningDelayedUntil, setAttachmentWarningDelayedUntil] = useState(); - const [showEmptySubjectWarning, setShowEmptySubjectWarning] = useState(false); - const [emptySubjectDelayedUntil, setEmptySubjectDelayedUntil] = useState(); - const [emptySubjectDontAskAgain, setEmptySubjectDontAskAgain] = useState(false); - const [showPlaceholderWarning, setShowPlaceholderWarning] = useState(false); - // A proofreading checker asked to warn before sending with open issues. - const [proofreadingWarning, setProofreadingWarning] = useState<{ count: number; delayedUntil?: string } | null>(null); + // What the checks before sending found (no subject, no text, placeholders, + // a missing attachment, open proofreading issues), asked about in one dialog. + const [sendWarnings, setSendWarnings] = useState<{ items: SendWarning[]; delayedUntil?: string } | null>(null); + // Open proofreading issues, once a checker asked to warn about them. const proofreadingConfirmedRef = useRef(false); - const [placeholderWarningMatches, setPlaceholderWarningMatches] = useState([]); - const [placeholderWarningDelayedUntil, setPlaceholderWarningDelayedUntil] = useState(); - const [placeholderDontAskAgain, setPlaceholderDontAskAgain] = useState(false); const [showScheduleDialog, setShowScheduleDialog] = useState(false); // Asks before sending at once when the account can't hold a message back. const { dialogProps: noDelayDialogProps, confirm: confirmNoDelay } = useConfirmDialog(); @@ -3595,14 +3587,14 @@ export function EmailComposer({ const bodyPlainText = plainTextMode ? body.trim() : htmlToPlainText(body).trim(); const hasContent = bodyPlainText || attachments.some(att => att.blobId && !att.uploading); // A missing subject no longer blocks Send (#684) - users hit the disabled - // button without understanding why. It is confirmed in a dialog instead. - const canSend = toAddresses.length > 0 && hasContent; + // button without understanding why. It is confirmed in a dialog instead, + // and so is a message with no text and no file. + const canSend = toAddresses.length > 0; const getSendTooltip = (): string | undefined => { if (isWaitingForUploads) return t('validation.attachments_uploading'); if (canSend) return undefined; if (toAddresses.length === 0) return t('validation.recipient_required'); - if (!hasContent) return t('validation.body_required'); return undefined; }; @@ -3697,6 +3689,8 @@ export function EmailComposer({ const confirmingSendRef = useRef(false); // The user confirmed sending without a subject for this message. const emptySubjectConfirmedRef = useRef(false); + // ... and without text. + const emptyTextConfirmedRef = useRef(false); // Placeholders the user chose to send as they are. The warning comes back // only for one that is not in here. const confirmedPlaceholdersRef = useRef>(new Set()); @@ -3741,9 +3735,9 @@ export function EmailComposer({ else editor.commands.focus(); }; - const handleSend = async ({ skipAttachmentCheck = false, skipSubjectCheck = false, delayedUntil }: { - skipAttachmentCheck?: boolean; - skipSubjectCheck?: boolean; + const handleSend = async ({ warningsConfirmed = false, delayedUntil }: { + /** "Send anyway" in the warnings dialog: don't check those again. */ + warningsConfirmed?: boolean; delayedUntil?: string; } = {}) => { if (isSendingRef.current) return; @@ -3791,7 +3785,6 @@ export function EmailComposer({ if (!canSend) { const errors: { to?: boolean; body?: boolean } = {}; if (toAddresses.length === 0) errors.to = true; - if (!hasContent) errors.body = true; setValidationErrors(errors); if (errors.to) { @@ -3830,69 +3823,69 @@ export function EmailComposer({ return; } - // Empty subject confirmation (#684). Asked once per message: after "Send - // anyway" and a trip back to add an attachment, it asked again. - if (!skipSubjectCheck && !emptySubjectConfirmedRef.current && emptySubjectWarningEnabled && !subject.trim()) { - setEmptySubjectDontAskAgain(false); - setEmptySubjectDelayedUntil(delayedUntil); - setShowEmptySubjectWarning(true); - return; - } - - // Unfilled placeholders: template fields left as {{name}}, fill-ins like - // [TIME] from AI drafts, and markers like TODO. The signature is left - // out, or a placeholder in it would stop every message. - if (placeholderWarningEnabled) { - const authored = plainTextMode - ? plainTextBodyWithoutSignature(body, signatureIdentity) - : stripEmbeddedSignatureHtml(body); - const bodyText = extractUserAuthoredText(authored, { - plainTextMode, - forwardedSeparator: tQuote('forwarded_separator'), - }); - // The fill-in words of the UI language, and of English: AI drafts and - // templates are often English whatever the UI language is. - const found = findUnfilledPlaceholders(subject, bodyText, [ - enMessages.email_composer.placeholder_words, - t.raw('placeholder_words'), - ]); - if (found.some(p => !confirmedPlaceholdersRef.current.has(p))) { - setPlaceholderWarningMatches(found); - setPlaceholderDontAskAgain(false); - setPlaceholderWarningDelayedUntil(delayedUntil); - setShowPlaceholderWarning(true); - return; + // What the user may have missed, asked about in one dialog (two in a + // row used to ask about each in turn). Answered with "Send anyway", the + // subject, the text, the placeholders listed and the proofreading issues + // aren't asked about again for this message. + if (!warningsConfirmed) { + const warnings: SendWarning[] = []; + // Empty subject confirmation (#684). + if (!emptySubjectConfirmedRef.current && emptySubjectWarningEnabled && !subject.trim()) { + warnings.push({ kind: 'subject' }); + } + // No text and no file: Send stays enabled and asks instead. + if (!emptyTextConfirmedRef.current && !hasContent) { + warnings.push({ kind: 'text' }); } - } - // Attachment reminder check - if (!skipAttachmentCheck && attachmentReminderEnabled) { - const hasAttachments = attachmentsRef.current.some(att => att.blobId && !att.uploading && !att.error); - if (!hasAttachments) { - // Scan only the user-authored text: the quoted original of a - // reply/forward often mentions an attachment itself, which used to fire - // the reminder even when the user typed no keyword and added nothing (#570). - const bodyText = extractUserAuthoredText(body, { + // Unfilled placeholders: template fields left as {{name}}, fill-ins like + // [TIME] from AI drafts, and markers like TODO. The signature is left + // out, or a placeholder in it would stop every message. + if (placeholderWarningEnabled) { + const authored = plainTextMode + ? plainTextBodyWithoutSignature(body, signatureIdentity) + : stripEmbeddedSignatureHtml(body); + const bodyText = extractUserAuthoredText(authored, { plainTextMode, forwardedSeparator: tQuote('forwarded_separator'), }); - const searchText = `${subject} ${bodyText}`.toLowerCase(); - const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase())); - if (matched) { - setAttachmentWarningKeyword(matched); - setAttachmentWarningDelayedUntil(delayedUntil); - setShowAttachmentWarning(true); - return; + // The fill-in words of the UI language, and of English: AI drafts and + // templates are often English whatever the UI language is. + const found = findUnfilledPlaceholders(subject, bodyText, [ + enMessages.email_composer.placeholder_words, + t.raw('placeholder_words'), + ]); + if (found.some(p => !confirmedPlaceholdersRef.current.has(p))) { + warnings.push({ kind: 'placeholders', matches: found }); } } - } - // Open proofreading issues, when a checker asked for the warning (the - // LanguageTool plugin's "warn before sending" option). Once per message. - if (!proofreadingConfirmedRef.current) { - const open = proofreading.sendWarningIssues().length; - if (open > 0) { - setProofreadingWarning({ count: open, delayedUntil }); + // Attachment reminder check + if (attachmentReminderEnabled) { + const hasAttachments = attachmentsRef.current.some(att => att.blobId && !att.uploading && !att.error); + if (!hasAttachments) { + // Scan only the user-authored text: the quoted original of a + // reply/forward often mentions an attachment itself, which used to fire + // the reminder even when the user typed no keyword and added nothing (#570). + const bodyText = extractUserAuthoredText(body, { + plainTextMode, + forwardedSeparator: tQuote('forwarded_separator'), + }); + const searchText = `${subject} ${bodyText}`.toLowerCase(); + const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase())); + if (matched) warnings.push({ kind: 'attachment', keyword: matched }); + } + } + + // Open proofreading issues, when a checker asked for the warning (the + // LanguageTool plugin's "warn before sending" option). + if (!proofreadingConfirmedRef.current) { + const open = proofreading.sendWarningIssues().length; + if (open > 0) warnings.push({ kind: 'proofreading', count: open }); + } + + if (warnings.length > 0) { + setSendWarnings({ items: warnings, delayedUntil }); return; } } @@ -4651,9 +4644,7 @@ export function EmailComposer({ filesPicker || showSaveAsTemplate || showScheduleDialog || - showAttachmentWarning || - showEmptySubjectWarning || - showPlaceholderWarning || + sendWarnings || showCloseDialog ) return; @@ -5832,163 +5823,45 @@ export function EmailComposer({ disabled={!canSend || isSending || isSavingAndClosing} /> - setShowAttachmentWarning(false)} - role="alertdialog" - showClose={false} - title={t('forgot_attachment.title')} - description={t('forgot_attachment.message', { keyword: attachmentWarningKeyword })} - footer={ - <> - - - - } - /> - - setShowEmptySubjectWarning(false)} - role="alertdialog" - showClose={false} - title={t('empty_subject.title')} - description={t('empty_subject.message')} - footer={ - <> - - - - } - > - - - - setShowPlaceholderWarning(false)} - role="alertdialog" - showClose={false} - title={t('placeholder_warning.title')} - description={t('placeholder_warning.message')} - footer={ - <> - - - - } - > -
    - {placeholderWarningMatches.slice(0, 5).map((p) => ( -
  • {p}
  • - ))} - {placeholderWarningMatches.length > 5 && ( -
  • - {t('placeholder_warning.more', { count: placeholderWarningMatches.length - 5 })} -
  • - )} -
- -
- - setProofreadingWarning(null)} - role="alertdialog" - showClose={false} - title={tProofreading('send_warning.title')} - description={tProofreading('send_warning.body', { count: proofreadingWarning?.count ?? 0 })} - footer={ - <> - - - - } + { + const first = sendWarnings?.items[0]; + setSendWarnings(null); + // The focus trap hands focus back to the Send button as it tears + // down: go to what the first warning is about on the next tick. + setTimeout(() => { + if (first?.kind === 'subject') subjectInputRef.current?.focus(); + else if (first?.kind === 'placeholders') selectPlaceholder(first.matches[0]); + else if (first?.kind === 'proofreading') proofreading.navigate(1); + else if (first?.kind === 'text') { + if (plainTextModeRef.current) bodyRef.current?.focus(); + else editorRef.current?.commands.focus(); + } + }, 0); + }} + onSendAnyway={(dontAskAgain) => { + if (!sendWarnings) return; + const { items, delayedUntil } = sendWarnings; + for (const warning of items) { + if (warning.kind === 'subject') { + emptySubjectConfirmedRef.current = true; + if (dontAskAgain) updateSetting('emptySubjectWarningEnabled', false); + } else if (warning.kind === 'text') { + emptyTextConfirmedRef.current = true; + } else if (warning.kind === 'placeholders') { + warning.matches.forEach((p) => confirmedPlaceholdersRef.current.add(p)); + if (dontAskAgain) updateSetting('placeholderWarningEnabled', false); + } else if (warning.kind === 'proofreading') { + proofreadingConfirmedRef.current = true; + } + } + setSendWarnings(null); + handleSend({ warningsConfirmed: true, delayedUntil }); + }} /> void; + /** "Send anyway". `dontAskAgain`: the box under a single warning that has a setting. */ + onSendAnyway: (dontAskAgain: boolean) => void; +} + +/** The warnings that can be switched off in Settings (subject and placeholders). */ +const HAS_SETTING = new Set(["subject", "placeholders"]); + +/** + * One question before sending, whatever it found: a single warning keeps its + * own title and text, several are listed in one dialog with one "Send anyway" + * (two dialogs in a row asked about each in turn). + */ +export function SendWarningsDialog({ warnings, onBack, onSendAnyway }: SendWarningsDialogProps) { + const t = useTranslations("email_composer"); + const tSend = useTranslations("compose_send"); + const tProofreading = useTranslations("proofreading"); + const [dontAskAgain, setDontAskAgain] = useState(false); + const [shownFor, setShownFor] = useState(null); + // A new question starts with the box unticked. + if (warnings !== shownFor) { + setShownFor(warnings); + setDontAskAgain(false); + } + + const items = warnings ?? []; + const single = items.length === 1 ? items[0] : null; + + const placeholderChips = (matches: string[]) => ( +
    + {matches.slice(0, 5).map((p) => ( +
  • {p}
  • + ))} + {matches.length > 5 && ( +
  • + {t("placeholder_warning.more", { count: matches.length - 5 })} +
  • + )} +
+ ); + + let title: string; + let description: string | undefined; + let backLabel = tSend("back"); + let sendLabel = tSend("send_anyway"); + let body: React.ReactNode = null; + + if (single?.kind === "subject") { + title = t("empty_subject.title"); + description = t("empty_subject.message"); + backLabel = t("empty_subject.back"); + sendLabel = t("empty_subject.send_anyway"); + } else if (single?.kind === "text") { + title = tSend("empty_text_title"); + description = tSend("empty_text_message"); + } else if (single?.kind === "placeholders") { + title = t("placeholder_warning.title"); + description = t("placeholder_warning.message"); + backLabel = t("placeholder_warning.back"); + sendLabel = t("placeholder_warning.send_anyway"); + body = placeholderChips(single.matches); + } else if (single?.kind === "attachment") { + title = t("forgot_attachment.title"); + description = t("forgot_attachment.message", { keyword: single.keyword }); + backLabel = t("forgot_attachment.back"); + sendLabel = t("forgot_attachment.send_anyway"); + } else if (single?.kind === "proofreading") { + title = tProofreading("send_warning.title"); + description = tProofreading("send_warning.body", { count: single.count }); + backLabel = tProofreading("send_warning.review"); + sendLabel = tProofreading("send_warning.send"); + } else { + title = tSend("warnings_title"); + body = ( +
    + {items.map((w) => ( +
  • + +
    + {w.kind === "subject" && tSend("warning_subject")} + {w.kind === "text" && tSend("warning_text")} + {w.kind === "attachment" && tSend("warning_attachment", { keyword: w.keyword })} + {w.kind === "proofreading" && tSend("warning_proofreading", { count: w.count })} + {w.kind === "placeholders" && ( + <> + {tSend("warning_placeholders")} + {placeholderChips(w.matches)} + + )} +
    +
  • + ))} +
+ ); + } + + return ( + 0} + onClose={onBack} + role="alertdialog" + showClose={false} + title={title} + description={description} + testId="send-warnings" + footer={ + <> + + + + } + > + {body} + {single && HAS_SETTING.has(single.kind) && ( + + )} + + ); +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 5bf41a9d5..053453a2a 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "العودة إلى التعديل", + "send_anyway": "الإرسال على أي حال", + "warnings_title": "هل تريد إرسال هذه الرسالة على أي حال؟", + "warning_subject": "بلا موضوع", + "warning_text": "بلا نص", + "warning_attachment": "تذكر \"{keyword}\" لكن لا يوجد ملف مرفق", + "warning_placeholders": "نص مؤقت سيراه المستلمون:", + "warning_proofreading": "مشكلات إملائية أو نحوية مفتوحة: {count}", + "empty_text_title": "هل تريد الإرسال بدون نص؟", + "empty_text_message": "لا تحتوي هذه الرسالة على نص ولا على مرفق. هل تريد إرسالها على أي حال؟" + }, "org_templates": { "section_personal": "قوالبي", "section_organisation": "المؤسسة", diff --git a/locales/ca/common.json b/locales/ca/common.json index eca36135b..7f4471792 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "Torna a l'edició", + "send_anyway": "Envia igualment", + "warnings_title": "Vols enviar aquest missatge igualment?", + "warning_subject": "Sense assumpte", + "warning_text": "Sense text", + "warning_attachment": "Esmenta «{keyword}», però no hi ha cap fitxer adjunt", + "warning_placeholders": "Text de mostra que veuran els destinataris:", + "warning_proofreading": "Errors d'ortografia o gramàtica pendents: {count}", + "empty_text_title": "Vols enviar-lo sense text?", + "empty_text_message": "Aquest missatge no té text ni cap fitxer adjunt. Vols enviar-lo igualment?" + }, "org_templates": { "section_personal": "Les meves plantilles", "section_organisation": "Organització", diff --git a/locales/cs/common.json b/locales/cs/common.json index f1f5076dd..c20188847 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "Zpět k úpravám", + "send_anyway": "Přesto odeslat", + "warnings_title": "Přesto tuto zprávu odeslat?", + "warning_subject": "Bez předmětu", + "warning_text": "Bez textu", + "warning_attachment": "Zmiňuje „{keyword}“, ale není připojen žádný soubor", + "warning_placeholders": "Zástupný text, který příjemci uvidí:", + "warning_proofreading": "Nevyřešené pravopisné či gramatické chyby: {count}", + "empty_text_title": "Odeslat bez textu?", + "empty_text_message": "Tato zpráva nemá text ani přílohu. Přesto ji odeslat?" + }, "org_templates": { "section_personal": "Moje šablony", "section_organisation": "Organizace", diff --git a/locales/da/common.json b/locales/da/common.json index 7461d652b..9c99fd35c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "Tilbage til redigering", + "send_anyway": "Send alligevel", + "warnings_title": "Send denne besked alligevel?", + "warning_subject": "Intet emne", + "warning_text": "Ingen tekst", + "warning_attachment": "Nævner \"{keyword}\", men ingen fil er vedhæftet", + "warning_placeholders": "Pladsholdertekst, som modtagerne vil se:", + "warning_proofreading": "Åbne stave- eller grammatikfejl: {count}", + "empty_text_title": "Send uden tekst?", + "empty_text_message": "Denne besked har ingen tekst og ingen vedhæftet fil. Send den alligevel?" + }, "org_templates": { "section_personal": "Mine skabeloner", "section_organisation": "Organisation", diff --git a/locales/de/common.json b/locales/de/common.json index 31d15c0ed..a45ec38e1 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "Zurück zur Bearbeitung", + "send_anyway": "Trotzdem senden", + "warnings_title": "Diese Nachricht trotzdem senden?", + "warning_subject": "Kein Betreff", + "warning_text": "Kein Text", + "warning_attachment": "Erwähnt „{keyword}“, aber keine Datei ist angehängt", + "warning_placeholders": "Platzhaltertext, den die Empfänger sehen:", + "warning_proofreading": "Offene Rechtschreib- oder Grammatikfehler: {count}", + "empty_text_title": "Ohne Text senden?", + "empty_text_message": "Diese Nachricht hat keinen Text und keinen Anhang. Trotzdem senden?" + }, "org_templates": { "section_personal": "Meine Vorlagen", "section_organisation": "Organisation", diff --git a/locales/en/common.json b/locales/en/common.json index e0491ee32..26fa9449a 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "Back to editing", + "send_anyway": "Send anyway", + "warnings_title": "Send this message anyway?", + "warning_subject": "No subject", + "warning_text": "No text", + "warning_attachment": "Mentions \"{keyword}\", but no file is attached", + "warning_placeholders": "Placeholder text that recipients will see:", + "warning_proofreading": "Open spelling or grammar issues: {count}", + "empty_text_title": "Send without text?", + "empty_text_message": "This message has no text and no attachment. Send it anyway?" + }, "org_templates": { "section_personal": "My templates", "section_organisation": "Organisation", diff --git a/locales/es/common.json b/locales/es/common.json index 1050f0f56..3fb63b84d 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "Volver a editar", + "send_anyway": "Enviar de todos modos", + "warnings_title": "¿Enviar este mensaje de todos modos?", + "warning_subject": "Sin asunto", + "warning_text": "Sin texto", + "warning_attachment": "Menciona «{keyword}», pero no hay ningún archivo adjunto", + "warning_placeholders": "Texto de marcador que verán los destinatarios:", + "warning_proofreading": "Errores de ortografía o gramática pendientes: {count}", + "empty_text_title": "¿Enviar sin texto?", + "empty_text_message": "Este mensaje no tiene texto ni archivos adjuntos. ¿Enviarlo de todos modos?" + }, "org_templates": { "section_personal": "Mis plantillas", "section_organisation": "Organización", diff --git a/locales/et/common.json b/locales/et/common.json index df425b8bd..76ac8af66 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "Tagasi muutmise juurde", + "send_anyway": "Saada ikkagi", + "warnings_title": "Kas saata see kiri ikkagi?", + "warning_subject": "Teema puudub", + "warning_text": "Tekst puudub", + "warning_attachment": "Mainib sõna „{keyword}“, kuid ühtegi faili pole lisatud", + "warning_placeholders": "Kohatäitetekst, mida saajad näevad:", + "warning_proofreading": "Lahendamata õigekirja- või grammatikavead: {count}", + "empty_text_title": "Kas saata ilma tekstita?", + "empty_text_message": "Selles kirjas pole teksti ega manust. Kas saata see ikkagi?" + }, "org_templates": { "section_personal": "Minu mallid", "section_organisation": "Organisatsioon", diff --git a/locales/fa/common.json b/locales/fa/common.json index bf242d98d..40122d82c 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "بازگشت به ویرایش", + "send_anyway": "ارسال به هر حال", + "warnings_title": "این پیام به هر حال ارسال شود؟", + "warning_subject": "بدون موضوع", + "warning_text": "بدون متن", + "warning_attachment": "به «{keyword}» اشاره می‌کند، اما هیچ فایلی پیوست نشده است", + "warning_placeholders": "متن جایگزینی که گیرندگان خواهند دید:", + "warning_proofreading": "خطاهای املایی یا دستوری باز: {count}", + "empty_text_title": "بدون متن ارسال شود؟", + "empty_text_message": "این پیام متن و پیوست ندارد. به هر حال ارسال شود؟" + }, "org_templates": { "section_personal": "قالب‌های من", "section_organisation": "سازمان", diff --git a/locales/fr/common.json b/locales/fr/common.json index 03545491c..fb6acde3b 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "Retour à l'édition", + "send_anyway": "Envoyer quand même", + "warnings_title": "Envoyer ce message quand même ?", + "warning_subject": "Pas d'objet", + "warning_text": "Pas de texte", + "warning_attachment": "Mentionne « {keyword} », mais aucun fichier n'est joint", + "warning_placeholders": "Texte de remplacement que les destinataires verront :", + "warning_proofreading": "Fautes d'orthographe ou de grammaire restantes : {count}", + "empty_text_title": "Envoyer sans texte ?", + "empty_text_message": "Ce message n'a ni texte ni pièce jointe. L'envoyer quand même ?" + }, "org_templates": { "section_personal": "Mes modèles", "section_organisation": "Organisation", diff --git a/locales/he/common.json b/locales/he/common.json index f8258a747..411fd5d51 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -3793,6 +3793,18 @@ } } }, + "compose_send": { + "back": "חזור לעריכה", + "send_anyway": "שלח בכל זאת", + "warnings_title": "לשלוח את ההודעה בכל זאת?", + "warning_subject": "אין נושא", + "warning_text": "אין טקסט", + "warning_attachment": "מוזכר \"{keyword}\", אבל לא צורף קובץ", + "warning_placeholders": "טקסט ממלא מקום שהנמענים יראו:", + "warning_proofreading": "שגיאות כתיב או דקדוק פתוחות: {count}", + "empty_text_title": "לשלוח ללא טקסט?", + "empty_text_message": "להודעה הזו אין טקסט ואין קובץ מצורף. לשלוח אותה בכל זאת?" + }, "org_templates": { "section_personal": "התבניות שלי", "section_organisation": "ארגון", diff --git a/locales/hu/common.json b/locales/hu/common.json index bd65a8ec0..ec24d247c 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "Vissza a szerkesztéshez", + "send_anyway": "Küldés így is", + "warnings_title": "Elküldi így is ezt az üzenetet?", + "warning_subject": "Nincs tárgy", + "warning_text": "Nincs szöveg", + "warning_attachment": "Említi ezt: „{keyword}”, de nincs csatolt fájl", + "warning_placeholders": "Helykitöltő szöveg, amelyet a címzettek látni fognak:", + "warning_proofreading": "Nyitott helyesírási vagy nyelvtani hibák: {count}", + "empty_text_title": "Küldés szöveg nélkül?", + "empty_text_message": "Ennek az üzenetnek nincs szövege és melléklete. Elküldi így is?" + }, "org_templates": { "section_personal": "Saját sablonok", "section_organisation": "Szervezet", diff --git a/locales/it/common.json b/locales/it/common.json index b79013a3a..6198c7e9a 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "Torna alla modifica", + "send_anyway": "Invia comunque", + "warnings_title": "Inviare comunque questo messaggio?", + "warning_subject": "Nessun oggetto", + "warning_text": "Nessun testo", + "warning_attachment": "Menziona \"{keyword}\", ma non c'è nessun file allegato", + "warning_placeholders": "Testo segnaposto che i destinatari vedranno:", + "warning_proofreading": "Errori di ortografia o grammatica aperti: {count}", + "empty_text_title": "Inviare senza testo?", + "empty_text_message": "Questo messaggio non ha testo né allegati. Inviarlo comunque?" + }, "org_templates": { "section_personal": "I miei modelli", "section_organisation": "Organizzazione", diff --git a/locales/ja/common.json b/locales/ja/common.json index 0fc511808..374b5b7e2 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "編集に戻る", + "send_anyway": "そのまま送信", + "warnings_title": "このメッセージをそのまま送信しますか?", + "warning_subject": "件名がありません", + "warning_text": "本文がありません", + "warning_attachment": "「{keyword}」とありますが、ファイルが添付されていません", + "warning_placeholders": "受信者に表示されるプレースホルダー:", + "warning_proofreading": "未解決のスペル・文法の問題: {count}", + "empty_text_title": "本文なしで送信しますか?", + "empty_text_message": "このメッセージには本文も添付ファイルもありません。そのまま送信しますか?" + }, "org_templates": { "section_personal": "マイテンプレート", "section_organisation": "組織", diff --git a/locales/ko/common.json b/locales/ko/common.json index 21bc7b936..cf92bbb07 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "편집으로 돌아가기", + "send_anyway": "그래도 보내기", + "warnings_title": "이 메일을 그래도 보낼까요?", + "warning_subject": "제목 없음", + "warning_text": "본문 없음", + "warning_attachment": "\"{keyword}\"라고 적혀 있지만 첨부된 파일이 없어요", + "warning_placeholders": "받는 사람에게 보이는 자리 표시 텍스트:", + "warning_proofreading": "남은 맞춤법 또는 문법 문제: {count}", + "empty_text_title": "본문 없이 보낼까요?", + "empty_text_message": "이 메일에는 본문도 첨부 파일도 없어요. 그래도 보낼까요?" + }, "org_templates": { "section_personal": "내 템플릿", "section_organisation": "조직", diff --git a/locales/lv/common.json b/locales/lv/common.json index 2958f811c..ce05b0de3 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "Atpakaļ pie rediģēšanas", + "send_anyway": "Sūtīt tik un tā", + "warnings_title": "Vai tomēr sūtīt šo vēstuli?", + "warning_subject": "Nav temata", + "warning_text": "Nav teksta", + "warning_attachment": "Piemin “{keyword}”, bet neviens fails nav pievienots", + "warning_placeholders": "Vietturu teksts, ko redzēs saņēmēji:", + "warning_proofreading": "Neatrisinātas pareizrakstības vai gramatikas kļūdas: {count}", + "empty_text_title": "Sūtīt bez teksta?", + "empty_text_message": "Šai vēstulei nav ne teksta, ne pielikuma. Vai tomēr to sūtīt?" + }, "org_templates": { "section_personal": "Manas veidnes", "section_organisation": "Organizācija", diff --git a/locales/mn/common.json b/locales/mn/common.json index 57128748e..8c6d231f2 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "Засварлах руу буцах", + "send_anyway": "Ямар ч байсан илгээх", + "warnings_title": "Энэ захиаг ямар ч байсан илгээх үү?", + "warning_subject": "Гарчиггүй", + "warning_text": "Текстгүй", + "warning_attachment": "\"{keyword}\" гэж дурдсан ч файл хавсаргаагүй", + "warning_placeholders": "Хүлээн авагчид харах орлуулах текст:", + "warning_proofreading": "Засаагүй зөв бичгийн болон дүрмийн алдаа: {count}", + "empty_text_title": "Текстгүй илгээх үү?", + "empty_text_message": "Энэ захиад текст ч, хавсралт ч алга. Ямар ч байсан илгээх үү?" + }, "org_templates": { "section_personal": "Миний загварууд", "section_organisation": "Байгууллага", diff --git a/locales/nb/common.json b/locales/nb/common.json index 27c9f7642..ab96e67a2 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "Tilbake til redigering", + "send_anyway": "Send likevel", + "warnings_title": "Sende denne meldingen likevel?", + "warning_subject": "Ingen emne", + "warning_text": "Ingen tekst", + "warning_attachment": "Nevner «{keyword}», men ingen fil er lagt ved", + "warning_placeholders": "Plassholdertekst som mottakerne vil se:", + "warning_proofreading": "Åpne stave- eller grammatikkfeil: {count}", + "empty_text_title": "Sende uten tekst?", + "empty_text_message": "Denne meldingen har verken tekst eller vedlegg. Sende den likevel?" + }, "org_templates": { "section_personal": "Mine maler", "section_organisation": "Organisasjon", diff --git a/locales/nl/common.json b/locales/nl/common.json index 69e5afbcc..bccdc4a88 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "Terug naar bewerken", + "send_anyway": "Toch verzenden", + "warnings_title": "Dit bericht toch verzenden?", + "warning_subject": "Geen onderwerp", + "warning_text": "Geen tekst", + "warning_attachment": "Noemt ‘{keyword}’, maar er is geen bestand bijgevoegd", + "warning_placeholders": "Tijdelijke tekst die ontvangers zien:", + "warning_proofreading": "Openstaande spel- of grammaticafouten: {count}", + "empty_text_title": "Verzenden zonder tekst?", + "empty_text_message": "Dit bericht heeft geen tekst en geen bijlage. Toch verzenden?" + }, "org_templates": { "section_personal": "Mijn sjablonen", "section_organisation": "Organisatie", diff --git a/locales/pl/common.json b/locales/pl/common.json index 35e69ac26..c011ba2f6 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "Wróć do edycji", + "send_anyway": "Wyślij mimo to", + "warnings_title": "Wysłać tę wiadomość mimo to?", + "warning_subject": "Brak tematu", + "warning_text": "Brak treści", + "warning_attachment": "Wspomina „{keyword}”, ale nie dołączono pliku", + "warning_placeholders": "Tekst zastępczy, który zobaczą odbiorcy:", + "warning_proofreading": "Nierozwiązane błędy pisowni lub gramatyki: {count}", + "empty_text_title": "Wysłać bez treści?", + "empty_text_message": "Ta wiadomość nie ma treści ani załącznika. Wysłać ją mimo to?" + }, "org_templates": { "section_personal": "Moje szablony", "section_organisation": "Organizacja", diff --git a/locales/pt/common.json b/locales/pt/common.json index 7a094b8ea..a10b6f2b4 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "Voltar à edição", + "send_anyway": "Enviar mesmo assim", + "warnings_title": "Enviar esta mensagem mesmo assim?", + "warning_subject": "Sem assunto", + "warning_text": "Sem texto", + "warning_attachment": "Menciona \"{keyword}\", mas nenhum arquivo foi anexado", + "warning_placeholders": "Texto de espaço reservado que os destinatários verão:", + "warning_proofreading": "Erros de ortografia ou gramática em aberto: {count}", + "empty_text_title": "Enviar sem texto?", + "empty_text_message": "Esta mensagem não tem texto nem anexo. Enviar mesmo assim?" + }, "org_templates": { "section_personal": "Meus modelos", "section_organisation": "Organização", diff --git a/locales/ro/common.json b/locales/ro/common.json index 1c3d817ce..8a994a38f 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "Înapoi la editare", + "send_anyway": "Trimite oricum", + "warnings_title": "Trimiteți totuși acest mesaj?", + "warning_subject": "Fără subiect", + "warning_text": "Fără text", + "warning_attachment": "Menționează „{keyword}”, dar niciun fișier nu este atașat", + "warning_placeholders": "Text substituent pe care îl vor vedea destinatarii:", + "warning_proofreading": "Greșeli de ortografie sau gramatică nerezolvate: {count}", + "empty_text_title": "Trimiteți fără text?", + "empty_text_message": "Acest mesaj nu are text și nici atașament. Îl trimiteți totuși?" + }, "org_templates": { "section_personal": "Șabloanele mele", "section_organisation": "Organizație", diff --git a/locales/ru/common.json b/locales/ru/common.json index c5c112ea2..7fa2dfdb0 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "Вернуться к редактированию", + "send_anyway": "Отправить всё равно", + "warnings_title": "Всё равно отправить это письмо?", + "warning_subject": "Нет темы", + "warning_text": "Нет текста", + "warning_attachment": "Упоминается «{keyword}», но файл не прикреплён", + "warning_placeholders": "Текст-заполнитель, который увидят получатели:", + "warning_proofreading": "Неисправленные орфографические или грамматические ошибки: {count}", + "empty_text_title": "Отправить без текста?", + "empty_text_message": "В этом письме нет ни текста, ни вложений. Всё равно отправить?" + }, "org_templates": { "section_personal": "Мои шаблоны", "section_organisation": "Организация", diff --git a/locales/sk/common.json b/locales/sk/common.json index 81d50e05d..14519e009 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "Späť na úpravu", + "send_anyway": "Odoslať aj tak", + "warnings_title": "Odoslať túto správu aj tak?", + "warning_subject": "Bez predmetu", + "warning_text": "Bez textu", + "warning_attachment": "Spomína „{keyword}“, ale nie je pripojený žiadny súbor", + "warning_placeholders": "Zástupný text, ktorý uvidia príjemcovia:", + "warning_proofreading": "Neopravené pravopisné alebo gramatické chyby: {count}", + "empty_text_title": "Odoslať bez textu?", + "empty_text_message": "Táto správa nemá text ani prílohu. Odoslať ju aj tak?" + }, "org_templates": { "section_personal": "Moje šablóny", "section_organisation": "Organizácia", diff --git a/locales/th/common.json b/locales/th/common.json index c34c371f1..2aa2198ea 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -1147,6 +1147,18 @@ } } }, + "compose_send": { + "back": "กลับไปแก้ไข", + "send_anyway": "ส่งต่อไป", + "warnings_title": "ส่งข้อความนี้ต่อไปหรือไม่", + "warning_subject": "ไม่มีหัวเรื่อง", + "warning_text": "ไม่มีข้อความ", + "warning_attachment": "มีคำว่า \"{keyword}\" แต่ไม่ได้แนบไฟล์", + "warning_placeholders": "ข้อความตัวยึดตำแหน่งที่ผู้รับจะเห็น:", + "warning_proofreading": "ปัญหาการสะกดหรือไวยากรณ์ที่ยังค้างอยู่: {count}", + "empty_text_title": "ส่งโดยไม่มีข้อความหรือไม่", + "empty_text_message": "ข้อความนี้ไม่มีเนื้อหาและไม่มีไฟล์แนบ ส่งต่อไปหรือไม่" + }, "org_templates": { "section_personal": "แม่แบบของฉัน", "section_organisation": "องค์กร", diff --git a/locales/tr/common.json b/locales/tr/common.json index 43380e45c..12f69457d 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "Düzenlemeye dön", + "send_anyway": "Yine de gönder", + "warnings_title": "Bu ileti yine de gönderilsin mi?", + "warning_subject": "Konu yok", + "warning_text": "Metin yok", + "warning_attachment": "\"{keyword}\" geçiyor ama hiç dosya eklenmemiş", + "warning_placeholders": "Alıcıların göreceği yer tutucu metin:", + "warning_proofreading": "Açık yazım veya dilbilgisi sorunları: {count}", + "empty_text_title": "Metin olmadan gönderilsin mi?", + "empty_text_message": "Bu iletide metin ve ek yok. Yine de gönderilsin mi?" + }, "org_templates": { "section_personal": "Şablonlarım", "section_organisation": "Kuruluş", diff --git a/locales/uk/common.json b/locales/uk/common.json index d8d7ecf49..d7933e370 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "Назад до редагування", + "send_anyway": "Усе одно надіслати", + "warnings_title": "Усе одно надіслати цей лист?", + "warning_subject": "Без теми", + "warning_text": "Без тексту", + "warning_attachment": "Згадується «{keyword}», але файл не прикріплено", + "warning_placeholders": "Текст-заповнювач, який побачать одержувачі:", + "warning_proofreading": "Невиправлені орфографічні чи граматичні помилки: {count}", + "empty_text_title": "Надіслати без тексту?", + "empty_text_message": "У цьому листі немає ні тексту, ні вкладень. Усе одно надіслати?" + }, "org_templates": { "section_personal": "Мої шаблони", "section_organisation": "Організація", diff --git a/locales/vi/common.json b/locales/vi/common.json index ccb97537d..2bdd89e99 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -3908,6 +3908,18 @@ } } }, + "compose_send": { + "back": "Quay lại chỉnh sửa", + "send_anyway": "Vẫn gửi", + "warnings_title": "Vẫn gửi thư này?", + "warning_subject": "Không có tiêu đề", + "warning_text": "Không có nội dung", + "warning_attachment": "Có nhắc đến \"{keyword}\" nhưng chưa đính kèm tệp nào", + "warning_placeholders": "Văn bản giữ chỗ mà người nhận sẽ thấy:", + "warning_proofreading": "Lỗi chính tả hoặc ngữ pháp chưa sửa: {count}", + "empty_text_title": "Gửi mà không có nội dung?", + "empty_text_message": "Thư này không có nội dung và không có tệp đính kèm. Vẫn gửi?" + }, "org_templates": { "section_personal": "Mẫu của tôi", "section_organisation": "Tổ chức", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 3bc47f02b..8650ef97c 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "繼續編輯", + "send_anyway": "仍然傳送", + "warnings_title": "仍然傳送這封郵件?", + "warning_subject": "沒有主旨", + "warning_text": "沒有內文", + "warning_attachment": "提到了「{keyword}」,但沒有附加檔案", + "warning_placeholders": "收件者會看到的預留位置文字:", + "warning_proofreading": "未處理的拼字或文法問題:{count}", + "empty_text_title": "不寫內文就傳送?", + "empty_text_message": "這封郵件沒有內文,也沒有附件。仍然傳送?" + }, "org_templates": { "section_personal": "我的範本", "section_organisation": "組織", diff --git a/locales/zh/common.json b/locales/zh/common.json index d4646cf11..500507321 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -3896,6 +3896,18 @@ } } }, + "compose_send": { + "back": "返回编辑", + "send_anyway": "仍然发送", + "warnings_title": "仍然发送这封邮件?", + "warning_subject": "没有主题", + "warning_text": "没有正文", + "warning_attachment": "提到了“{keyword}”,但没有附加文件", + "warning_placeholders": "收件人会看到的占位文本:", + "warning_proofreading": "未处理的拼写或语法问题:{count}", + "empty_text_title": "不写正文就发送?", + "empty_text_message": "这封邮件没有正文,也没有附件。仍然发送?" + }, "org_templates": { "section_personal": "我的模板", "section_organisation": "组织", From 007b007302dccafd55206077c9a6f7b131729c17 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:22:53 +0100 Subject: [PATCH 2/5] feat: open the schedule dialog from Send's arrow and say when a scheduled message goes --- .../composer-plugin-surfaces.test.tsx | 15 +++++++++++++++ components/email/email-composer.tsx | 19 +++++++++++++------ .../mail/__tests__/undo-send-toast.test.tsx | 12 ++++++++++++ components/mail/mail-app.tsx | 6 ++++-- 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 | 3 ++- 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/vi/common.json | 3 ++- locales/zh-TW/common.json | 3 ++- locales/zh/common.json | 3 ++- 34 files changed, 104 insertions(+), 38 deletions(-) diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx index 787389c18..51927e1d5 100644 --- a/components/email/__tests__/composer-plugin-surfaces.test.tsx +++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx @@ -329,6 +329,21 @@ describe('composer-send-menu', () => { expect(follows(schedule, plugin)).toBe(true); }); + // compose-23: a menu with "Schedule send" as its only item. + it('opens the schedule dialog straight from the arrow when it is the only entry', () => { + useAuthStore.setState({ + client: { hasDelayedSend: () => true, getMaxDelayedSend: () => 30 * 86400, createDraft: vi.fn() } as never, + }); + render(); + + const arrow = screen.getAllByRole('button', { name: 'schedule_send' })[0]; + expect(arrow).toHaveAttribute('aria-haspopup', 'dialog'); + fireEvent.click(arrow); + + expect(screen.queryByRole('menuitem', { name: /schedule_send/ })).toBeNull(); + expect(screen.getByRole('dialog')).toHaveTextContent('schedule_send_description'); + }); + it('keeps a plain Send button without plugin entries or scheduled send', () => { render(); expect(screen.queryByRole('button', { name: 'more_actions' })).toBeNull(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index b4d9104a1..95948a852 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -2793,6 +2793,13 @@ export function EmailComposer({ // Plugin entries make the Send and Attach buttons menus even where the app // itself has nothing else to put there. const hasSendMenuActions = usePluginActions('composer-send-menu').length > 0; + // With nothing but "Schedule send" in Send's menu, the arrow opens the + // schedule dialog itself: a menu with one item was one click too many. + const sendArrowSchedules = !hasSendMenuActions && !!composerClient?.hasDelayedSend(); + const handleSendArrow = (e: React.MouseEvent) => { + if (sendArrowSchedules) openScheduleDialog(); + else toggleSendMenu(e); + }; const hasAttachMenuActions = usePluginActions('composer-attach-menu').length > 0; // Ticked plugin options count in the Options button's badge like the app's own. const pluginOptionsOn = usePluginActions('composer-options-menu').filter((a) => a.checked).length; @@ -4850,14 +4857,14 @@ export function EmailComposer({ @@ -5717,13 +5724,13 @@ export function EmailComposer({ diff --git a/components/mail/__tests__/undo-send-toast.test.tsx b/components/mail/__tests__/undo-send-toast.test.tsx index 17f92fa53..92d7e83e3 100644 --- a/components/mail/__tests__/undo-send-toast.test.tsx +++ b/components/mail/__tests__/undo-send-toast.test.tsx @@ -119,6 +119,18 @@ describe('undo send toast', () => { expect(toast.pauseOnHover).toBe(false); }); + // compose-23: "Email scheduled for sending" with "Undo send" said neither + // when it goes nor that the button cancels it. + it('says when a scheduled message goes, with Cancel and Send now', async () => { + render(); + sendDelayedBy(3 * 86_400_000); + + await waitFor(() => expect(useToastStore.getState().toasts.some((t) => t.title === 'compose_send.scheduled_for')).toBe(true)); + const toast = useToastStore.getState().toasts.find((t) => t.title === 'compose_send.scheduled_for')!; + expect(toast.action?.label).toBe('cancel'); + expect(toast.secondaryAction?.label).toBe('email_viewer.send_now'); + }); + it('says so when the undo comes too late', async () => { cancelUndoSend.mockRejectedValue(new Error('The requested message is no longer in the queue.')); vi.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 11d4246c0..bbf7e7833 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -2941,9 +2941,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { ? Math.max(remainingMs - 1000, 1000) : Math.max(sendDelaySeconds, 8) * 1000; + // A message scheduled for later says when it goes, and offers Cancel + // where a send-delay window offers Undo send. toast.success(isUndoWindow ? t('email_viewer.sending_in', { seconds: Math.round(remainingMs / 1000) }) - : t('email_viewer.scheduled_send_created'), { + : t('compose_send.scheduled_for', { when: formatScheduledSendAt(pending.sendAt, useSettingsStore.getState().timeFormat) }), { duration: undoDurationMs, pauseOnHover: false, secondaryAction: (pending.emailId && pending.identityId) @@ -2985,7 +2987,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } : undefined, action: { - label: t('email_viewer.undo_send'), + label: isUndoWindow ? t('email_viewer.undo_send') : tCommon('cancel'), onClick: () => { if (!undoClient) return refuseSignedOut(); void (async () => { diff --git a/locales/ar/common.json b/locales/ar/common.json index 053453a2a..5ba9c4823 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "نص مؤقت سيراه المستلمون:", "warning_proofreading": "مشكلات إملائية أو نحوية مفتوحة: {count}", "empty_text_title": "هل تريد الإرسال بدون نص؟", - "empty_text_message": "لا تحتوي هذه الرسالة على نص ولا على مرفق. هل تريد إرسالها على أي حال؟" + "empty_text_message": "لا تحتوي هذه الرسالة على نص ولا على مرفق. هل تريد إرسالها على أي حال؟", + "scheduled_for": "مجدولة في {when}" }, "org_templates": { "section_personal": "قوالبي", diff --git a/locales/ca/common.json b/locales/ca/common.json index 7f4471792..7fb01c5d9 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "Text de mostra que veuran els destinataris:", "warning_proofreading": "Errors d'ortografia o gramàtica pendents: {count}", "empty_text_title": "Vols enviar-lo sense text?", - "empty_text_message": "Aquest missatge no té text ni cap fitxer adjunt. Vols enviar-lo igualment?" + "empty_text_message": "Aquest missatge no té text ni cap fitxer adjunt. Vols enviar-lo igualment?", + "scheduled_for": "Programat per a {when}" }, "org_templates": { "section_personal": "Les meves plantilles", diff --git a/locales/cs/common.json b/locales/cs/common.json index c20188847..219e9da67 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "Zástupný text, který příjemci uvidí:", "warning_proofreading": "Nevyřešené pravopisné či gramatické chyby: {count}", "empty_text_title": "Odeslat bez textu?", - "empty_text_message": "Tato zpráva nemá text ani přílohu. Přesto ji odeslat?" + "empty_text_message": "Tato zpráva nemá text ani přílohu. Přesto ji odeslat?", + "scheduled_for": "Naplánováno na {when}" }, "org_templates": { "section_personal": "Moje šablony", diff --git a/locales/da/common.json b/locales/da/common.json index 9c99fd35c..9fdc94b90 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "Pladsholdertekst, som modtagerne vil se:", "warning_proofreading": "Åbne stave- eller grammatikfejl: {count}", "empty_text_title": "Send uden tekst?", - "empty_text_message": "Denne besked har ingen tekst og ingen vedhæftet fil. Send den alligevel?" + "empty_text_message": "Denne besked har ingen tekst og ingen vedhæftet fil. Send den alligevel?", + "scheduled_for": "Planlagt til {when}" }, "org_templates": { "section_personal": "Mine skabeloner", diff --git a/locales/de/common.json b/locales/de/common.json index a45ec38e1..2e72aacef 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "Platzhaltertext, den die Empfänger sehen:", "warning_proofreading": "Offene Rechtschreib- oder Grammatikfehler: {count}", "empty_text_title": "Ohne Text senden?", - "empty_text_message": "Diese Nachricht hat keinen Text und keinen Anhang. Trotzdem senden?" + "empty_text_message": "Diese Nachricht hat keinen Text und keinen Anhang. Trotzdem senden?", + "scheduled_for": "Geplant für {when}" }, "org_templates": { "section_personal": "Meine Vorlagen", diff --git a/locales/en/common.json b/locales/en/common.json index 26fa9449a..f8e008234 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "Placeholder text that recipients will see:", "warning_proofreading": "Open spelling or grammar issues: {count}", "empty_text_title": "Send without text?", - "empty_text_message": "This message has no text and no attachment. Send it anyway?" + "empty_text_message": "This message has no text and no attachment. Send it anyway?", + "scheduled_for": "Scheduled for {when}" }, "org_templates": { "section_personal": "My templates", diff --git a/locales/es/common.json b/locales/es/common.json index 3fb63b84d..1b6a20894 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "Texto de marcador que verán los destinatarios:", "warning_proofreading": "Errores de ortografía o gramática pendientes: {count}", "empty_text_title": "¿Enviar sin texto?", - "empty_text_message": "Este mensaje no tiene texto ni archivos adjuntos. ¿Enviarlo de todos modos?" + "empty_text_message": "Este mensaje no tiene texto ni archivos adjuntos. ¿Enviarlo de todos modos?", + "scheduled_for": "Programado para {when}" }, "org_templates": { "section_personal": "Mis plantillas", diff --git a/locales/et/common.json b/locales/et/common.json index 76ac8af66..411dda041 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "Kohatäitetekst, mida saajad näevad:", "warning_proofreading": "Lahendamata õigekirja- või grammatikavead: {count}", "empty_text_title": "Kas saata ilma tekstita?", - "empty_text_message": "Selles kirjas pole teksti ega manust. Kas saata see ikkagi?" + "empty_text_message": "Selles kirjas pole teksti ega manust. Kas saata see ikkagi?", + "scheduled_for": "Ajastatud: {when}" }, "org_templates": { "section_personal": "Minu mallid", diff --git a/locales/fa/common.json b/locales/fa/common.json index 40122d82c..f99b675ef 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "متن جایگزینی که گیرندگان خواهند دید:", "warning_proofreading": "خطاهای املایی یا دستوری باز: {count}", "empty_text_title": "بدون متن ارسال شود؟", - "empty_text_message": "این پیام متن و پیوست ندارد. به هر حال ارسال شود؟" + "empty_text_message": "این پیام متن و پیوست ندارد. به هر حال ارسال شود؟", + "scheduled_for": "زمان‌بندی‌شده برای {when}" }, "org_templates": { "section_personal": "قالب‌های من", diff --git a/locales/fr/common.json b/locales/fr/common.json index fb6acde3b..56f599aed 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "Texte de remplacement que les destinataires verront :", "warning_proofreading": "Fautes d'orthographe ou de grammaire restantes : {count}", "empty_text_title": "Envoyer sans texte ?", - "empty_text_message": "Ce message n'a ni texte ni pièce jointe. L'envoyer quand même ?" + "empty_text_message": "Ce message n'a ni texte ni pièce jointe. L'envoyer quand même ?", + "scheduled_for": "Programmé pour {when}" }, "org_templates": { "section_personal": "Mes modèles", diff --git a/locales/he/common.json b/locales/he/common.json index 411fd5d51..95ccc1e67 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -3803,7 +3803,8 @@ "warning_placeholders": "טקסט ממלא מקום שהנמענים יראו:", "warning_proofreading": "שגיאות כתיב או דקדוק פתוחות: {count}", "empty_text_title": "לשלוח ללא טקסט?", - "empty_text_message": "להודעה הזו אין טקסט ואין קובץ מצורף. לשלוח אותה בכל זאת?" + "empty_text_message": "להודעה הזו אין טקסט ואין קובץ מצורף. לשלוח אותה בכל זאת?", + "scheduled_for": "מתוזמן ל-{when}" }, "org_templates": { "section_personal": "התבניות שלי", diff --git a/locales/hu/common.json b/locales/hu/common.json index ec24d247c..35741dc7e 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "Helykitöltő szöveg, amelyet a címzettek látni fognak:", "warning_proofreading": "Nyitott helyesírási vagy nyelvtani hibák: {count}", "empty_text_title": "Küldés szöveg nélkül?", - "empty_text_message": "Ennek az üzenetnek nincs szövege és melléklete. Elküldi így is?" + "empty_text_message": "Ennek az üzenetnek nincs szövege és melléklete. Elküldi így is?", + "scheduled_for": "Ütemezve: {when}" }, "org_templates": { "section_personal": "Saját sablonok", diff --git a/locales/it/common.json b/locales/it/common.json index 6198c7e9a..061922c51 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "Testo segnaposto che i destinatari vedranno:", "warning_proofreading": "Errori di ortografia o grammatica aperti: {count}", "empty_text_title": "Inviare senza testo?", - "empty_text_message": "Questo messaggio non ha testo né allegati. Inviarlo comunque?" + "empty_text_message": "Questo messaggio non ha testo né allegati. Inviarlo comunque?", + "scheduled_for": "Programmato per {when}" }, "org_templates": { "section_personal": "I miei modelli", diff --git a/locales/ja/common.json b/locales/ja/common.json index 374b5b7e2..aacaa9fa1 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "受信者に表示されるプレースホルダー:", "warning_proofreading": "未解決のスペル・文法の問題: {count}", "empty_text_title": "本文なしで送信しますか?", - "empty_text_message": "このメッセージには本文も添付ファイルもありません。そのまま送信しますか?" + "empty_text_message": "このメッセージには本文も添付ファイルもありません。そのまま送信しますか?", + "scheduled_for": "{when} に送信予定" }, "org_templates": { "section_personal": "マイテンプレート", diff --git a/locales/ko/common.json b/locales/ko/common.json index cf92bbb07..62961153b 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "받는 사람에게 보이는 자리 표시 텍스트:", "warning_proofreading": "남은 맞춤법 또는 문법 문제: {count}", "empty_text_title": "본문 없이 보낼까요?", - "empty_text_message": "이 메일에는 본문도 첨부 파일도 없어요. 그래도 보낼까요?" + "empty_text_message": "이 메일에는 본문도 첨부 파일도 없어요. 그래도 보낼까요?", + "scheduled_for": "{when}에 보내기 예약됨" }, "org_templates": { "section_personal": "내 템플릿", diff --git a/locales/lv/common.json b/locales/lv/common.json index ce05b0de3..47ba99e8b 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "Vietturu teksts, ko redzēs saņēmēji:", "warning_proofreading": "Neatrisinātas pareizrakstības vai gramatikas kļūdas: {count}", "empty_text_title": "Sūtīt bez teksta?", - "empty_text_message": "Šai vēstulei nav ne teksta, ne pielikuma. Vai tomēr to sūtīt?" + "empty_text_message": "Šai vēstulei nav ne teksta, ne pielikuma. Vai tomēr to sūtīt?", + "scheduled_for": "Ieplānots: {when}" }, "org_templates": { "section_personal": "Manas veidnes", diff --git a/locales/mn/common.json b/locales/mn/common.json index 8c6d231f2..2ceac09b1 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "Хүлээн авагчид харах орлуулах текст:", "warning_proofreading": "Засаагүй зөв бичгийн болон дүрмийн алдаа: {count}", "empty_text_title": "Текстгүй илгээх үү?", - "empty_text_message": "Энэ захиад текст ч, хавсралт ч алга. Ямар ч байсан илгээх үү?" + "empty_text_message": "Энэ захиад текст ч, хавсралт ч алга. Ямар ч байсан илгээх үү?", + "scheduled_for": "{when}-д илгээхээр товлосон" }, "org_templates": { "section_personal": "Миний загварууд", diff --git a/locales/nb/common.json b/locales/nb/common.json index ab96e67a2..89d1f352a 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "Plassholdertekst som mottakerne vil se:", "warning_proofreading": "Åpne stave- eller grammatikkfeil: {count}", "empty_text_title": "Sende uten tekst?", - "empty_text_message": "Denne meldingen har verken tekst eller vedlegg. Sende den likevel?" + "empty_text_message": "Denne meldingen har verken tekst eller vedlegg. Sende den likevel?", + "scheduled_for": "Planlagt til {when}" }, "org_templates": { "section_personal": "Mine maler", diff --git a/locales/nl/common.json b/locales/nl/common.json index bccdc4a88..9d2cac016 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "Tijdelijke tekst die ontvangers zien:", "warning_proofreading": "Openstaande spel- of grammaticafouten: {count}", "empty_text_title": "Verzenden zonder tekst?", - "empty_text_message": "Dit bericht heeft geen tekst en geen bijlage. Toch verzenden?" + "empty_text_message": "Dit bericht heeft geen tekst en geen bijlage. Toch verzenden?", + "scheduled_for": "Gepland voor {when}" }, "org_templates": { "section_personal": "Mijn sjablonen", diff --git a/locales/pl/common.json b/locales/pl/common.json index c011ba2f6..2a90864c5 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "Tekst zastępczy, który zobaczą odbiorcy:", "warning_proofreading": "Nierozwiązane błędy pisowni lub gramatyki: {count}", "empty_text_title": "Wysłać bez treści?", - "empty_text_message": "Ta wiadomość nie ma treści ani załącznika. Wysłać ją mimo to?" + "empty_text_message": "Ta wiadomość nie ma treści ani załącznika. Wysłać ją mimo to?", + "scheduled_for": "Zaplanowano na {when}" }, "org_templates": { "section_personal": "Moje szablony", diff --git a/locales/pt/common.json b/locales/pt/common.json index a10b6f2b4..5988e60e6 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "Texto de espaço reservado que os destinatários verão:", "warning_proofreading": "Erros de ortografia ou gramática em aberto: {count}", "empty_text_title": "Enviar sem texto?", - "empty_text_message": "Esta mensagem não tem texto nem anexo. Enviar mesmo assim?" + "empty_text_message": "Esta mensagem não tem texto nem anexo. Enviar mesmo assim?", + "scheduled_for": "Agendado para {when}" }, "org_templates": { "section_personal": "Meus modelos", diff --git a/locales/ro/common.json b/locales/ro/common.json index 8a994a38f..8f7064f48 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "Text substituent pe care îl vor vedea destinatarii:", "warning_proofreading": "Greșeli de ortografie sau gramatică nerezolvate: {count}", "empty_text_title": "Trimiteți fără text?", - "empty_text_message": "Acest mesaj nu are text și nici atașament. Îl trimiteți totuși?" + "empty_text_message": "Acest mesaj nu are text și nici atașament. Îl trimiteți totuși?", + "scheduled_for": "Programat pentru {when}" }, "org_templates": { "section_personal": "Șabloanele mele", diff --git a/locales/ru/common.json b/locales/ru/common.json index 7fa2dfdb0..f21e430b1 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "Текст-заполнитель, который увидят получатели:", "warning_proofreading": "Неисправленные орфографические или грамматические ошибки: {count}", "empty_text_title": "Отправить без текста?", - "empty_text_message": "В этом письме нет ни текста, ни вложений. Всё равно отправить?" + "empty_text_message": "В этом письме нет ни текста, ни вложений. Всё равно отправить?", + "scheduled_for": "Запланировано на {when}" }, "org_templates": { "section_personal": "Мои шаблоны", diff --git a/locales/sk/common.json b/locales/sk/common.json index 14519e009..e24d849f5 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "Zástupný text, ktorý uvidia príjemcovia:", "warning_proofreading": "Neopravené pravopisné alebo gramatické chyby: {count}", "empty_text_title": "Odoslať bez textu?", - "empty_text_message": "Táto správa nemá text ani prílohu. Odoslať ju aj tak?" + "empty_text_message": "Táto správa nemá text ani prílohu. Odoslať ju aj tak?", + "scheduled_for": "Naplánované na {when}" }, "org_templates": { "section_personal": "Moje šablóny", diff --git a/locales/th/common.json b/locales/th/common.json index 2aa2198ea..b6d400ff4 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -1157,7 +1157,8 @@ "warning_placeholders": "ข้อความตัวยึดตำแหน่งที่ผู้รับจะเห็น:", "warning_proofreading": "ปัญหาการสะกดหรือไวยากรณ์ที่ยังค้างอยู่: {count}", "empty_text_title": "ส่งโดยไม่มีข้อความหรือไม่", - "empty_text_message": "ข้อความนี้ไม่มีเนื้อหาและไม่มีไฟล์แนบ ส่งต่อไปหรือไม่" + "empty_text_message": "ข้อความนี้ไม่มีเนื้อหาและไม่มีไฟล์แนบ ส่งต่อไปหรือไม่", + "scheduled_for": "ตั้งเวลาส่งไว้ที่ {when}" }, "org_templates": { "section_personal": "แม่แบบของฉัน", diff --git a/locales/tr/common.json b/locales/tr/common.json index 12f69457d..6aff55f44 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "Alıcıların göreceği yer tutucu metin:", "warning_proofreading": "Açık yazım veya dilbilgisi sorunları: {count}", "empty_text_title": "Metin olmadan gönderilsin mi?", - "empty_text_message": "Bu iletide metin ve ek yok. Yine de gönderilsin mi?" + "empty_text_message": "Bu iletide metin ve ek yok. Yine de gönderilsin mi?", + "scheduled_for": "{when} için zamanlandı" }, "org_templates": { "section_personal": "Şablonlarım", diff --git a/locales/uk/common.json b/locales/uk/common.json index d7933e370..5d4fc1d4e 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "Текст-заповнювач, який побачать одержувачі:", "warning_proofreading": "Невиправлені орфографічні чи граматичні помилки: {count}", "empty_text_title": "Надіслати без тексту?", - "empty_text_message": "У цьому листі немає ні тексту, ні вкладень. Усе одно надіслати?" + "empty_text_message": "У цьому листі немає ні тексту, ні вкладень. Усе одно надіслати?", + "scheduled_for": "Заплановано на {when}" }, "org_templates": { "section_personal": "Мої шаблони", diff --git a/locales/vi/common.json b/locales/vi/common.json index 2bdd89e99..48fcabf42 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -3918,7 +3918,8 @@ "warning_placeholders": "Văn bản giữ chỗ mà người nhận sẽ thấy:", "warning_proofreading": "Lỗi chính tả hoặc ngữ pháp chưa sửa: {count}", "empty_text_title": "Gửi mà không có nội dung?", - "empty_text_message": "Thư này không có nội dung và không có tệp đính kèm. Vẫn gửi?" + "empty_text_message": "Thư này không có nội dung và không có tệp đính kèm. Vẫn gửi?", + "scheduled_for": "Đã lên lịch gửi lúc {when}" }, "org_templates": { "section_personal": "Mẫu của tôi", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 8650ef97c..5e71acdc7 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "收件者會看到的預留位置文字:", "warning_proofreading": "未處理的拼字或文法問題:{count}", "empty_text_title": "不寫內文就傳送?", - "empty_text_message": "這封郵件沒有內文,也沒有附件。仍然傳送?" + "empty_text_message": "這封郵件沒有內文,也沒有附件。仍然傳送?", + "scheduled_for": "已排定於 {when} 傳送" }, "org_templates": { "section_personal": "我的範本", diff --git a/locales/zh/common.json b/locales/zh/common.json index 500507321..4a013b35d 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -3906,7 +3906,8 @@ "warning_placeholders": "收件人会看到的占位文本:", "warning_proofreading": "未处理的拼写或语法问题:{count}", "empty_text_title": "不写正文就发送?", - "empty_text_message": "这封邮件没有正文,也没有附件。仍然发送?" + "empty_text_message": "这封邮件没有正文,也没有附件。仍然发送?", + "scheduled_for": "已安排在 {when} 发送" }, "org_templates": { "section_personal": "我的模板", From 781013cc4410f1ae06ad936aed82a2719ecbac17 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:41:51 +0100 Subject: [PATCH 3/5] feat: close the composer as soon as Send is pressed and show Sending, then Sent with View --- .../__tests__/composer-send-failed.test.tsx | 238 ++++++++++++++++++ components/email/email-composer.tsx | 18 ++ .../__tests__/send-closes-composer.test.tsx | 156 ++++++++++++ components/mail/mail-app.tsx | 36 ++- lib/outgoing/send-progress-toast.ts | 48 ++++ locales/ar/common.json | 5 +- locales/ca/common.json | 5 +- locales/cs/common.json | 5 +- locales/da/common.json | 5 +- locales/de/common.json | 5 +- locales/en/common.json | 5 +- locales/es/common.json | 5 +- locales/et/common.json | 5 +- locales/fa/common.json | 5 +- locales/fr/common.json | 5 +- locales/he/common.json | 5 +- locales/hu/common.json | 5 +- locales/it/common.json | 5 +- locales/ja/common.json | 5 +- locales/ko/common.json | 5 +- locales/lv/common.json | 5 +- locales/mn/common.json | 5 +- locales/nb/common.json | 5 +- locales/nl/common.json | 5 +- locales/pl/common.json | 5 +- locales/pt/common.json | 5 +- locales/ro/common.json | 5 +- locales/ru/common.json | 5 +- locales/sk/common.json | 5 +- locales/th/common.json | 5 +- locales/tr/common.json | 5 +- locales/uk/common.json | 5 +- locales/vi/common.json | 5 +- locales/zh-TW/common.json | 5 +- locales/zh/common.json | 5 +- .../email-store-send-list-loading.test.ts | 43 ++++ stores/email-store.ts | 12 +- 37 files changed, 658 insertions(+), 43 deletions(-) create mode 100644 components/email/__tests__/composer-send-failed.test.tsx create mode 100644 components/mail/__tests__/send-closes-composer.test.tsx create mode 100644 lib/outgoing/send-progress-toast.ts create mode 100644 stores/__tests__/email-store-send-list-loading.test.ts diff --git a/components/email/__tests__/composer-send-failed.test.tsx b/components/email/__tests__/composer-send-failed.test.tsx new file mode 100644 index 000000000..aec0f1b79 --- /dev/null +++ b/components/email/__tests__/composer-send-failed.test.tsx @@ -0,0 +1,238 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; + +// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ──────────────── + +const editorKeyDown = vi.hoisted(() => vi.fn()); + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: () => React.createElement('div', { + 'data-testid': 'rich-text-editor', + onKeyDown: (event: React.KeyboardEvent) => { + if (!event.defaultPrevented) editorKeyDown(); + }, + }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null })); +vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); +vi.mock('@/hooks/use-focus-trap', () => ({ + useFocusTrap: () => ({ current: null }), +})); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ + useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }), + stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }), +})); + +// ─── Store mocks ────────────────────────────────────────────────────────────── + +vi.mock('@/stores/auth-store', () => { + const state = { + client: null, + identities: [], + primaryIdentity: null, + isAuthenticated: false, + isDemoMode: false, + activeAccountId: null, + connectionLost: false, + getClientForAccount: () => undefined, + getAllConnectedClients: () => new Map(), + syncIdentities: () => {}, + refreshIdentities: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAuthStore: hook }; +}); + +vi.mock('@/stores/identity-store', () => { + const state = { + identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }], + defaultIdentityId: 'id-me', + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useIdentityStore: hook }; +}); + +vi.mock('@/stores/account-store', () => { + const state = { accounts: [], getAccountById: () => undefined }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAccountStore: hook }; +}); + +vi.mock('@/stores/email-store', () => { + const state = { + draftSaveEnabled: false, + sendRawEmail: async () => ({ sent: true }), + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useEmailStore: hook }; +}); + +const updateSetting = vi.fn(); + +// compose-24: the host closes the composer as soon as the send starts. A send +// that fails must still hand the message back, or it would vanish. + + +vi.mock('@/stores/settings-store', () => { + const state = { + timeFormat: '24h', + plainTextMode: false, + subAddressDelimiter: '+', + autoSelectReplyIdentity: true, + attachmentReminderEnabled: false, + attachmentReminderKeywords: [], + emptySubjectWarningEnabled: true, + sendDelaySeconds: 0, + signaturePosition: 'above_quote', + signatureSeparatorEnabled: false, + requestReadReceiptDefault: false, + addTrustedSender: () => {}, + trustedSendersAddressBook: null, + updateSetting: (...args: unknown[]) => updateSetting(...args), + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useSettingsStore: hook }; +}); + +vi.mock('@/stores/contact-store', () => { + const state = { + contacts: [], + getAutocomplete: async () => [], + addToTrustedSendersBook: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useContactStore: hook }; +}); + +vi.mock('@/stores/template-store', () => { + const state = { templates: [], addTemplate: async () => {} }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useTemplateStore: hook }; +}); + +// ─── Misc dependency mocks ──────────────────────────────────────────────────── + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: () => {}, success: () => {} }, +})); + +vi.mock('@/lib/plugin-hooks', () => ({ + identityHooks: { + onSignatureRender: { size: 0, transform: async (value: unknown) => value }, + onIdentitySelect: { size: 0, emit: async () => {} }, + }, + templateHooks: { + onTemplateRender: { size: 0, transform: async (value: unknown) => value }, + onTemplateApply: { size: 0, emit: async () => {} }, + }, + emailHooks: { + onComposerOpen: { call: async () => [] }, + onRecipientChange: { call: async () => [] }, + getRecipientSuggestions: { call: async () => [] }, + onRecipientChipsChange: { transform: async (chips: unknown) => chips }, + onDraftChange: { emit: () => {} }, + onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, + onComposeSend: { takeOver: async () => 'default' }, + onTransformOutgoingEmail: { transform: async (email: unknown) => email }, + }, + contactHooks: { + search: { call: async () => [] }, + onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial }, + }, +})); + +vi.mock('@/lib/email-sanitization', () => ({ + sanitizeSignatureHtml: (v: string) => v, + sanitizeEmailHtml: (v: string) => v, + parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +})); + +vi.mock('@/lib/email-threading', () => ({ + computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }), +})); +vi.mock('@/lib/signature-utils', () => ({ + appendPlainTextSignature: (body: string) => body, + getPlainTextSignature: () => '', +})); +vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/components/email/quoted-html', () => ({ + buildQuotedHtmlBlock: () => '', + serializeEditorContent: () => '', +})); +vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s })); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +const READY = { + to: 'bob@example.com', + cc: '', + bcc: '', + subject: 'Quarterly numbers', + body: '

Here they are.

', + showCc: false, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: 'draft-3', +}; + +const sendButton = () => screen.getAllByTestId('composer-send')[0] as HTMLButtonElement; + +describe('a send that fails after the host closed the composer', () => { + it('hands the message back to the host', async () => { + const onSend = vi.fn().mockRejectedValue(new Error('refused')); + const onSendFailed = vi.fn(); + render(); + + fireEvent.click(sendButton()); + + await waitFor(() => expect(onSendFailed).toHaveBeenCalledTimes(1)); + expect(onSendFailed.mock.calls[0][0]).toMatchObject({ + to: 'bob@example.com', + subject: 'Quarterly numbers', + body: '

Here they are.

', + draftId: 'draft-3', + }); + }); + + it('says nothing to the host when the message went out', async () => { + const onSend = vi.fn().mockResolvedValue(undefined); + const onSendFailed = vi.fn(); + render(); + + fireEvent.click(sendButton()); + + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + expect(onSendFailed).not.toHaveBeenCalled(); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 95948a852..22ea0db2b 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -368,6 +368,11 @@ interface EmailComposerProps { /** The id plugins know this composer by, for onAfterEmailSend. */ composerId?: string; }) => void | false | Promise; + /** + * onSend failed. A host that closes the composer as soon as onSend starts + * gets the message back here, to open it again: it must never vanish. + */ + onSendFailed?: (state: ComposerDraftData) => void; onScheduledSendCreated?: () => void | Promise; onClose?: () => void; /** @@ -572,6 +577,7 @@ const OFFLINE_SEND_ERRORS: Record = { export function EmailComposer({ onSend, + onSendFailed, onScheduledSendCreated, onClose, requestCloseRef, @@ -4052,6 +4058,9 @@ export function EmailComposer({ const lastStage = (html: string | undefined, text: string) => finalizeOutgoing({ html: html || undefined, text }, outgoingContext, 'last'); const inlineAttachments = rewritten?.attachments ?? []; + // The message as it is now, once onSend has it: the host may close the + // composer before the send is through. + let handedToHost: ComposerDraftData | null = null; try { const effectiveDelayedUntil = await resolveDelayedUntil(delayedUntil); @@ -4251,6 +4260,13 @@ export function EmailComposer({ // Read when the close is reported: the host tears the composer down // while onSend is still running. Reset below if the send fails. sentRef.current = true; + handedToHost = { + ...snapshotStateRef.current(), + // The save before the send may have moved the draft to a new id. + draftId: draftIdRef.current, + draftAccountId: draftAccountIdRef.current, + draftLocalAccountId: draftLocalAccountIdRef.current, + }; const outcome = await onSend?.({ to: outgoing.to, cc: outgoing.cc, @@ -4326,6 +4342,8 @@ export function EmailComposer({ explicitCloseRef.current = false; sentRef.current = false; debug.error('Failed to send email:', err); + // A host that closed the composer at Send opens it again with the text. + if (handedToHost) onSendFailed?.(handedToHost); if (isLabelRequiredError(err)) { setLabelInvalid(true); toast.error(tClassification('required_toast')); diff --git a/components/mail/__tests__/send-closes-composer.test.tsx b/components/mail/__tests__/send-closes-composer.test.tsx new file mode 100644 index 000000000..aa257cc5a --- /dev/null +++ b/components/mail/__tests__/send-closes-composer.test.tsx @@ -0,0 +1,156 @@ +import React, { Suspense, lazy } from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mailbox } from '@/lib/jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePluginStore } from '@/stores/plugin-store'; +import { useThemeStore } from '@/stores/theme-store'; +import { useToastStore } from '@/stores/toast-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; + +// compose-24: after Send the composer stayed on screen, greyed and without a +// word, until the server answered. It closes at once now, "Sending…" and then +// "Sent · View" say what happens, and a send that fails brings the text back. +vi.mock('next/dynamic', () => ({ default: (load: () => Promise>>) => { + const Component = lazy(async () => ({ default: await load() })); + return function Dynamic(props: Record) { + return ; + }; +} })); +vi.mock('@/components/email/email-composer', () => ({ + EmailComposer: ({ onSend, onSendFailed, initialData }: { + onSend?: (data: unknown) => Promise; + onSendFailed?: (state: unknown) => void; + initialData?: { subject?: string } | null; + }) => ( +
+ stub subject: {initialData?.subject ?? ''} + +
+ ), +})); +vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null })); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); +vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); +vi.mock('@/components/layout/mobile-header', () => ({ + MobileHeader: ({ onOpenSearch }: { onOpenSearch?: () => void }) => , +})); +vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null })); +vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null })); +vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) })); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); +vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] })); +vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} })); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false })); +vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} })); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: true, isTablet: false, isDesktop: false }), + useMediaQuery: () => false, + useIsDesktop: () => false, +})); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ EmailList: () =>
message list
})); + +const { MailApp } = await import('../mail-app'); + +const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox; +const sendEmail = vi.fn(); + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) }); + const client = { + getAccountId: () => 'acc', getUsername: () => 'me@example.com', + getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null, + }; + useAuthStore.setState({ + isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login', identities: [], + getClientForAccount: () => client as never, + getAllConnectedClients: () => new Map([['login', client]]) as never, + }); + useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', sendDelaySeconds: 0 } as never); + sendEmail.mockReset().mockResolvedValue({}); + useToastStore.setState({ toasts: [] }); + useEmailStore.setState({ + emails: [], selectedEmail: null, selectedMailbox: inbox.id, mailboxes: [inbox], + viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, + isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', selectedKeyword: null, + selectedEmailKeys: new Set(), + fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined), + fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined), + refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined), + sendEmail, + } as never); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const toastTitled = (title: string) => useToastStore.getState().toasts.find((t) => t.title === title); + +async function openComposer() { + render(); + fireEvent.click(screen.getByRole('button', { name: 'sidebar.compose' })); + await screen.findByText('stub send', {}, { timeout: 5000 }); +} + +describe('sending', () => { + it('closes the composer at once and says "Sending…", then "Sent" with View', async () => { + let finish: (result: unknown) => void = () => {}; + sendEmail.mockReturnValue(new Promise((resolve) => { finish = resolve; })); + await openComposer(); + + fireEvent.click(screen.getByText('stub send')); + + // The server hasn't answered yet. + await waitFor(() => expect(screen.queryByText('stub send')).not.toBeInTheDocument()); + expect(sendEmail).toHaveBeenCalled(); + expect(toastTitled('compose_send.sending')).toBeDefined(); + + await act(async () => { finish({ scheduled: false, emailId: 'sent-1' }); }); + + await waitFor(() => expect(toastTitled('compose_send.sent')).toBeDefined()); + expect(toastTitled('compose_send.sent')!.action?.label).toBe('compose_send.view'); + expect(toastTitled('compose_send.sending')).toBeUndefined(); + }); + + it('opens the composer again with the text when the send fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + sendEmail.mockRejectedValue(new Error('refused')); + await openComposer(); + + fireEvent.click(screen.getByText('stub send')); + + expect(await screen.findByText('stub subject: Hi', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(toastTitled('compose_send.sending')).toBeUndefined(); + expect(toastTitled('compose_send.sent')).toBeUndefined(); + }); + + it('leaves a delayed send to the undo toast', async () => { + sendEmail.mockResolvedValue({ scheduled: true, emailSubmissionId: 'sub-1', sendAt: new Date(Date.now() + 5000).toISOString() }); + await openComposer(); + + fireEvent.click(screen.getByText('stub send')); + + await waitFor(() => expect(sendEmail).toHaveBeenCalled()); + await waitFor(() => expect(toastTitled('compose_send.sending')).toBeUndefined()); + expect(toastTitled('compose_send.sent')).toBeUndefined(); + expect(screen.queryByText('stub send')).not.toBeInTheDocument(); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index bbf7e7833..68922ddfd 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -44,6 +44,7 @@ import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search- import { toast } from "@/stores/toast-store"; import { cancelFailureToast, partlyRefusedWarning, rescheduleFailureToast, rescheduledToast } from "@/lib/send-failure-message"; import { formatScheduledSendAt } from "@/lib/scheduled-send-label"; +import { startSendProgress } from "@/lib/outgoing/send-progress-toast"; import { formatRejectedRecipients, RescheduleRestoredError } from "@/lib/jmap/client"; import { subscribePushStateChanges } from "@/lib/push-events"; import { createMailPushListener } from "@/lib/mail-push-listener"; @@ -2547,6 +2548,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // the user's message without so much as a toast (#702). let submitted = false; + // The composer closes at once and "Sending…" shows meanwhile. A send that + // fails comes back through onSendFailed, which opens the composer again. + setShowComposer(false); + setPendingDraft(null); + if (isMobile && !useEmailStore.getState().selectedEmail) setActiveView('list'); + const progress = startSendProgress({ + sending: t('compose_send.sending'), + sent: t('compose_send.sent'), + view: t('compose_send.view'), + }); + try { const effectiveMode = pendingDraft?.mode ?? composerMode; const originalEmailId = answeredEmailId; @@ -2554,7 +2566,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const result = await sendEmail(sendClient, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, composerSendOptions(data)); submitted = true; announceSent(result, data); - setShowComposer(false); + // "Sent" with View. A delayed or scheduled send gets the undo toast, and + // a filing error, a refused recipient or the offline outbox say so themselves. + if (result.scheduled || result.filingError || result.rejectedRecipients?.length || result.queued) { + progress.dismiss(); + } else { + const sentId = result.emailId; + const ownerAccountId = result.emailAccountId && result.emailAccountId !== sendClient.getAccountId() ? result.emailAccountId : undefined; + progress.sent(sentId && sendClient === client + ? () => void applyMailDeepLinkRef.current({ kind: 'message', id: sentId, ownerAccountId }) + : undefined); + } // Sending an edited draft destroys it server-side - and every autosave // before that already replaced it under a fresh id (createDraft is a // destroy+create). If the email on display is the draft this session @@ -2639,11 +2661,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { return; } - // Delayed sends get the undo toast instead; a filing error or a refused - // recipient already warned. - // A message kept in the offline outbox said so itself (#523). - if (!result.filingError && !result.rejectedRecipients?.length && !result.queued) toast.success(t('notifications.email_sent')); - // Refresh the current mailbox to update the UI if (!isScheduledView) { await refreshCurrentMailbox(client); @@ -2651,6 +2668,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } } catch (error) { console.error("Failed to send email:", error); + if (submitted) progress.sent(); + else progress.dismiss(); // Post-send bookkeeping - refreshing Sent, flagging the original as // answered, re-reading the thread - is cosmetic once the mail is out. if (!submitted) throw error; @@ -5250,6 +5269,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { setComposerPrefill(null); return outcome; }} + onSendFailed={(state) => { + setPendingDraft(state); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); + }} onScheduledSendCreated={async () => { if (client) { await refreshScheduledMetadata(client); diff --git a/lib/outgoing/send-progress-toast.ts b/lib/outgoing/send-progress-toast.ts new file mode 100644 index 000000000..b94e8753f --- /dev/null +++ b/lib/outgoing/send-progress-toast.ts @@ -0,0 +1,48 @@ +/** + * The toast of a send from the moment the composer closes: "Sending…" while + * the server takes the message, then "Sent" with View. The composer used to + * stay on screen, greyed and without a word, until the server answered. + */ + +import { toast } from '@/stores/toast-store'; +import { announce } from '@/lib/announce'; +import { effectiveToastDuration } from '@/lib/toast-duration'; + +export interface SendProgressLabels { + sending: string; + sent: string; + view: string; +} + +export interface SendProgress { + /** The message is out. `view` opens it; without it there is no View. */ + sent(view?: () => void): void; + /** Another toast says what happened (undo, scheduled, failed, kept offline). */ + dismiss(): void; +} + +export function startSendProgress(labels: SendProgressLabels): SendProgress { + const id = toast.progress(labels.sending, { progress: null }); + let settled = false; + return { + sent(view) { + if (settled) return; + settled = true; + const action = view ? { label: labels.view, onClick: view } : undefined; + toast.update(id, { + type: 'success', + title: labels.sent, + progress: undefined, + action, + duration: effectiveToastDuration({ type: 'success', action }), + }); + // An update isn't read out as a new toast is. + announce(labels.sent); + }, + dismiss() { + if (settled) return; + settled = true; + toast.dismiss(id); + }, + }; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 5ba9c4823..e16ef0ab3 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "مشكلات إملائية أو نحوية مفتوحة: {count}", "empty_text_title": "هل تريد الإرسال بدون نص؟", "empty_text_message": "لا تحتوي هذه الرسالة على نص ولا على مرفق. هل تريد إرسالها على أي حال؟", - "scheduled_for": "مجدولة في {when}" + "scheduled_for": "مجدولة في {when}", + "sending": "جارٍ الإرسال…", + "sent": "تم الإرسال", + "view": "عرض" }, "org_templates": { "section_personal": "قوالبي", diff --git a/locales/ca/common.json b/locales/ca/common.json index 7fb01c5d9..a49fe1c5f 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "Errors d'ortografia o gramàtica pendents: {count}", "empty_text_title": "Vols enviar-lo sense text?", "empty_text_message": "Aquest missatge no té text ni cap fitxer adjunt. Vols enviar-lo igualment?", - "scheduled_for": "Programat per a {when}" + "scheduled_for": "Programat per a {when}", + "sending": "S'està enviant…", + "sent": "Enviat", + "view": "Mostra" }, "org_templates": { "section_personal": "Les meves plantilles", diff --git a/locales/cs/common.json b/locales/cs/common.json index 219e9da67..f6253092f 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "Nevyřešené pravopisné či gramatické chyby: {count}", "empty_text_title": "Odeslat bez textu?", "empty_text_message": "Tato zpráva nemá text ani přílohu. Přesto ji odeslat?", - "scheduled_for": "Naplánováno na {when}" + "scheduled_for": "Naplánováno na {when}", + "sending": "Odesílání…", + "sent": "Odesláno", + "view": "Zobrazit" }, "org_templates": { "section_personal": "Moje šablony", diff --git a/locales/da/common.json b/locales/da/common.json index 9fdc94b90..9df578928 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "Åbne stave- eller grammatikfejl: {count}", "empty_text_title": "Send uden tekst?", "empty_text_message": "Denne besked har ingen tekst og ingen vedhæftet fil. Send den alligevel?", - "scheduled_for": "Planlagt til {when}" + "scheduled_for": "Planlagt til {when}", + "sending": "Sender…", + "sent": "Sendt", + "view": "Vis" }, "org_templates": { "section_personal": "Mine skabeloner", diff --git a/locales/de/common.json b/locales/de/common.json index 2e72aacef..716e74114 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "Offene Rechtschreib- oder Grammatikfehler: {count}", "empty_text_title": "Ohne Text senden?", "empty_text_message": "Diese Nachricht hat keinen Text und keinen Anhang. Trotzdem senden?", - "scheduled_for": "Geplant für {when}" + "scheduled_for": "Geplant für {when}", + "sending": "Wird gesendet…", + "sent": "Gesendet", + "view": "Anzeigen" }, "org_templates": { "section_personal": "Meine Vorlagen", diff --git a/locales/en/common.json b/locales/en/common.json index f8e008234..48c763384 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "Open spelling or grammar issues: {count}", "empty_text_title": "Send without text?", "empty_text_message": "This message has no text and no attachment. Send it anyway?", - "scheduled_for": "Scheduled for {when}" + "scheduled_for": "Scheduled for {when}", + "sending": "Sending…", + "sent": "Sent", + "view": "View" }, "org_templates": { "section_personal": "My templates", diff --git a/locales/es/common.json b/locales/es/common.json index 1b6a20894..0a734bd11 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "Errores de ortografía o gramática pendientes: {count}", "empty_text_title": "¿Enviar sin texto?", "empty_text_message": "Este mensaje no tiene texto ni archivos adjuntos. ¿Enviarlo de todos modos?", - "scheduled_for": "Programado para {when}" + "scheduled_for": "Programado para {when}", + "sending": "Enviando…", + "sent": "Enviado", + "view": "Ver" }, "org_templates": { "section_personal": "Mis plantillas", diff --git a/locales/et/common.json b/locales/et/common.json index 411dda041..2a329b7f9 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "Lahendamata õigekirja- või grammatikavead: {count}", "empty_text_title": "Kas saata ilma tekstita?", "empty_text_message": "Selles kirjas pole teksti ega manust. Kas saata see ikkagi?", - "scheduled_for": "Ajastatud: {when}" + "scheduled_for": "Ajastatud: {when}", + "sending": "Saatmine…", + "sent": "Saadetud", + "view": "Vaata" }, "org_templates": { "section_personal": "Minu mallid", diff --git a/locales/fa/common.json b/locales/fa/common.json index f99b675ef..ae6a86694 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "خطاهای املایی یا دستوری باز: {count}", "empty_text_title": "بدون متن ارسال شود؟", "empty_text_message": "این پیام متن و پیوست ندارد. به هر حال ارسال شود؟", - "scheduled_for": "زمان‌بندی‌شده برای {when}" + "scheduled_for": "زمان‌بندی‌شده برای {when}", + "sending": "در حال ارسال…", + "sent": "ارسال شد", + "view": "مشاهده" }, "org_templates": { "section_personal": "قالب‌های من", diff --git a/locales/fr/common.json b/locales/fr/common.json index 56f599aed..857a4208b 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "Fautes d'orthographe ou de grammaire restantes : {count}", "empty_text_title": "Envoyer sans texte ?", "empty_text_message": "Ce message n'a ni texte ni pièce jointe. L'envoyer quand même ?", - "scheduled_for": "Programmé pour {when}" + "scheduled_for": "Programmé pour {when}", + "sending": "Envoi…", + "sent": "Envoyé", + "view": "Afficher" }, "org_templates": { "section_personal": "Mes modèles", diff --git a/locales/he/common.json b/locales/he/common.json index 95ccc1e67..84da00998 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -3804,7 +3804,10 @@ "warning_proofreading": "שגיאות כתיב או דקדוק פתוחות: {count}", "empty_text_title": "לשלוח ללא טקסט?", "empty_text_message": "להודעה הזו אין טקסט ואין קובץ מצורף. לשלוח אותה בכל זאת?", - "scheduled_for": "מתוזמן ל-{when}" + "scheduled_for": "מתוזמן ל-{when}", + "sending": "שולח…", + "sent": "נשלח", + "view": "הצג" }, "org_templates": { "section_personal": "התבניות שלי", diff --git a/locales/hu/common.json b/locales/hu/common.json index 35741dc7e..f119f53b3 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "Nyitott helyesírási vagy nyelvtani hibák: {count}", "empty_text_title": "Küldés szöveg nélkül?", "empty_text_message": "Ennek az üzenetnek nincs szövege és melléklete. Elküldi így is?", - "scheduled_for": "Ütemezve: {when}" + "scheduled_for": "Ütemezve: {when}", + "sending": "Küldés…", + "sent": "Elküldve", + "view": "Megtekintés" }, "org_templates": { "section_personal": "Saját sablonok", diff --git a/locales/it/common.json b/locales/it/common.json index 061922c51..1b2b07908 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "Errori di ortografia o grammatica aperti: {count}", "empty_text_title": "Inviare senza testo?", "empty_text_message": "Questo messaggio non ha testo né allegati. Inviarlo comunque?", - "scheduled_for": "Programmato per {when}" + "scheduled_for": "Programmato per {when}", + "sending": "Invio in corso…", + "sent": "Inviato", + "view": "Visualizza" }, "org_templates": { "section_personal": "I miei modelli", diff --git a/locales/ja/common.json b/locales/ja/common.json index aacaa9fa1..1c080bd35 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "未解決のスペル・文法の問題: {count}", "empty_text_title": "本文なしで送信しますか?", "empty_text_message": "このメッセージには本文も添付ファイルもありません。そのまま送信しますか?", - "scheduled_for": "{when} に送信予定" + "scheduled_for": "{when} に送信予定", + "sending": "送信中…", + "sent": "送信しました", + "view": "表示" }, "org_templates": { "section_personal": "マイテンプレート", diff --git a/locales/ko/common.json b/locales/ko/common.json index 62961153b..ae2a92c38 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "남은 맞춤법 또는 문법 문제: {count}", "empty_text_title": "본문 없이 보낼까요?", "empty_text_message": "이 메일에는 본문도 첨부 파일도 없어요. 그래도 보낼까요?", - "scheduled_for": "{when}에 보내기 예약됨" + "scheduled_for": "{when}에 보내기 예약됨", + "sending": "보내는 중…", + "sent": "보냈어요", + "view": "보기" }, "org_templates": { "section_personal": "내 템플릿", diff --git a/locales/lv/common.json b/locales/lv/common.json index 47ba99e8b..b264be861 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "Neatrisinātas pareizrakstības vai gramatikas kļūdas: {count}", "empty_text_title": "Sūtīt bez teksta?", "empty_text_message": "Šai vēstulei nav ne teksta, ne pielikuma. Vai tomēr to sūtīt?", - "scheduled_for": "Ieplānots: {when}" + "scheduled_for": "Ieplānots: {when}", + "sending": "Sūta…", + "sent": "Nosūtīts", + "view": "Skatīt" }, "org_templates": { "section_personal": "Manas veidnes", diff --git a/locales/mn/common.json b/locales/mn/common.json index 2ceac09b1..a9a4e523d 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "Засаагүй зөв бичгийн болон дүрмийн алдаа: {count}", "empty_text_title": "Текстгүй илгээх үү?", "empty_text_message": "Энэ захиад текст ч, хавсралт ч алга. Ямар ч байсан илгээх үү?", - "scheduled_for": "{when}-д илгээхээр товлосон" + "scheduled_for": "{when}-д илгээхээр товлосон", + "sending": "Илгээж байна…", + "sent": "Илгээгдлээ", + "view": "Харах" }, "org_templates": { "section_personal": "Миний загварууд", diff --git a/locales/nb/common.json b/locales/nb/common.json index 89d1f352a..bb2e3880a 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "Åpne stave- eller grammatikkfeil: {count}", "empty_text_title": "Sende uten tekst?", "empty_text_message": "Denne meldingen har verken tekst eller vedlegg. Sende den likevel?", - "scheduled_for": "Planlagt til {when}" + "scheduled_for": "Planlagt til {when}", + "sending": "Sender…", + "sent": "Sendt", + "view": "Vis" }, "org_templates": { "section_personal": "Mine maler", diff --git a/locales/nl/common.json b/locales/nl/common.json index 9d2cac016..a9941ecf0 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "Openstaande spel- of grammaticafouten: {count}", "empty_text_title": "Verzenden zonder tekst?", "empty_text_message": "Dit bericht heeft geen tekst en geen bijlage. Toch verzenden?", - "scheduled_for": "Gepland voor {when}" + "scheduled_for": "Gepland voor {when}", + "sending": "Bezig met verzenden…", + "sent": "Verzonden", + "view": "Bekijken" }, "org_templates": { "section_personal": "Mijn sjablonen", diff --git a/locales/pl/common.json b/locales/pl/common.json index 2a90864c5..9766bb92d 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "Nierozwiązane błędy pisowni lub gramatyki: {count}", "empty_text_title": "Wysłać bez treści?", "empty_text_message": "Ta wiadomość nie ma treści ani załącznika. Wysłać ją mimo to?", - "scheduled_for": "Zaplanowano na {when}" + "scheduled_for": "Zaplanowano na {when}", + "sending": "Wysyłanie…", + "sent": "Wysłano", + "view": "Pokaż" }, "org_templates": { "section_personal": "Moje szablony", diff --git a/locales/pt/common.json b/locales/pt/common.json index 5988e60e6..55b28a608 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "Erros de ortografia ou gramática em aberto: {count}", "empty_text_title": "Enviar sem texto?", "empty_text_message": "Esta mensagem não tem texto nem anexo. Enviar mesmo assim?", - "scheduled_for": "Agendado para {when}" + "scheduled_for": "Agendado para {when}", + "sending": "Enviando…", + "sent": "Enviado", + "view": "Ver" }, "org_templates": { "section_personal": "Meus modelos", diff --git a/locales/ro/common.json b/locales/ro/common.json index 8f7064f48..318bf1d4f 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "Greșeli de ortografie sau gramatică nerezolvate: {count}", "empty_text_title": "Trimiteți fără text?", "empty_text_message": "Acest mesaj nu are text și nici atașament. Îl trimiteți totuși?", - "scheduled_for": "Programat pentru {when}" + "scheduled_for": "Programat pentru {when}", + "sending": "Se trimite…", + "sent": "Trimis", + "view": "Vezi" }, "org_templates": { "section_personal": "Șabloanele mele", diff --git a/locales/ru/common.json b/locales/ru/common.json index f21e430b1..c8f382676 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "Неисправленные орфографические или грамматические ошибки: {count}", "empty_text_title": "Отправить без текста?", "empty_text_message": "В этом письме нет ни текста, ни вложений. Всё равно отправить?", - "scheduled_for": "Запланировано на {when}" + "scheduled_for": "Запланировано на {when}", + "sending": "Отправка…", + "sent": "Отправлено", + "view": "Открыть" }, "org_templates": { "section_personal": "Мои шаблоны", diff --git a/locales/sk/common.json b/locales/sk/common.json index e24d849f5..aefbf6012 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "Neopravené pravopisné alebo gramatické chyby: {count}", "empty_text_title": "Odoslať bez textu?", "empty_text_message": "Táto správa nemá text ani prílohu. Odoslať ju aj tak?", - "scheduled_for": "Naplánované na {when}" + "scheduled_for": "Naplánované na {when}", + "sending": "Odosielanie…", + "sent": "Odoslané", + "view": "Zobraziť" }, "org_templates": { "section_personal": "Moje šablóny", diff --git a/locales/th/common.json b/locales/th/common.json index b6d400ff4..fcad4fef7 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -1158,7 +1158,10 @@ "warning_proofreading": "ปัญหาการสะกดหรือไวยากรณ์ที่ยังค้างอยู่: {count}", "empty_text_title": "ส่งโดยไม่มีข้อความหรือไม่", "empty_text_message": "ข้อความนี้ไม่มีเนื้อหาและไม่มีไฟล์แนบ ส่งต่อไปหรือไม่", - "scheduled_for": "ตั้งเวลาส่งไว้ที่ {when}" + "scheduled_for": "ตั้งเวลาส่งไว้ที่ {when}", + "sending": "กำลังส่ง…", + "sent": "ส่งแล้ว", + "view": "ดู" }, "org_templates": { "section_personal": "แม่แบบของฉัน", diff --git a/locales/tr/common.json b/locales/tr/common.json index 6aff55f44..71a097b10 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "Açık yazım veya dilbilgisi sorunları: {count}", "empty_text_title": "Metin olmadan gönderilsin mi?", "empty_text_message": "Bu iletide metin ve ek yok. Yine de gönderilsin mi?", - "scheduled_for": "{when} için zamanlandı" + "scheduled_for": "{when} için zamanlandı", + "sending": "Gönderiliyor…", + "sent": "Gönderildi", + "view": "Görüntüle" }, "org_templates": { "section_personal": "Şablonlarım", diff --git a/locales/uk/common.json b/locales/uk/common.json index 5d4fc1d4e..c0c04b84f 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "Невиправлені орфографічні чи граматичні помилки: {count}", "empty_text_title": "Надіслати без тексту?", "empty_text_message": "У цьому листі немає ні тексту, ні вкладень. Усе одно надіслати?", - "scheduled_for": "Заплановано на {when}" + "scheduled_for": "Заплановано на {when}", + "sending": "Надсилання…", + "sent": "Надіслано", + "view": "Переглянути" }, "org_templates": { "section_personal": "Мої шаблони", diff --git a/locales/vi/common.json b/locales/vi/common.json index 48fcabf42..e0132525a 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -3919,7 +3919,10 @@ "warning_proofreading": "Lỗi chính tả hoặc ngữ pháp chưa sửa: {count}", "empty_text_title": "Gửi mà không có nội dung?", "empty_text_message": "Thư này không có nội dung và không có tệp đính kèm. Vẫn gửi?", - "scheduled_for": "Đã lên lịch gửi lúc {when}" + "scheduled_for": "Đã lên lịch gửi lúc {when}", + "sending": "Đang gửi…", + "sent": "Đã gửi", + "view": "Xem" }, "org_templates": { "section_personal": "Mẫu của tôi", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 5e71acdc7..b0d322bed 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "未處理的拼字或文法問題:{count}", "empty_text_title": "不寫內文就傳送?", "empty_text_message": "這封郵件沒有內文,也沒有附件。仍然傳送?", - "scheduled_for": "已排定於 {when} 傳送" + "scheduled_for": "已排定於 {when} 傳送", + "sending": "正在傳送…", + "sent": "已傳送", + "view": "檢視" }, "org_templates": { "section_personal": "我的範本", diff --git a/locales/zh/common.json b/locales/zh/common.json index 4a013b35d..83dc1a395 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -3907,7 +3907,10 @@ "warning_proofreading": "未处理的拼写或语法问题:{count}", "empty_text_title": "不写正文就发送?", "empty_text_message": "这封邮件没有正文,也没有附件。仍然发送?", - "scheduled_for": "已安排在 {when} 发送" + "scheduled_for": "已安排在 {when} 发送", + "sending": "正在发送…", + "sent": "已发送", + "view": "查看" }, "org_templates": { "section_personal": "我的模板", diff --git a/stores/__tests__/email-store-send-list-loading.test.ts b/stores/__tests__/email-store-send-list-loading.test.ts new file mode 100644 index 000000000..72d867a0a --- /dev/null +++ b/stores/__tests__/email-store-send-list-loading.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore } from '../email-store'; +import { useAuthStore } from '../auth-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// isLoading is the message list's: while it is set, the list fades under +// "Loading emails…". A send set it too, so every send reloaded the list on +// screen until the message was out (compose-24). + +describe('the message list during a send', () => { + let client: IJMAPClient; + let finishSend: (value: unknown) => void; + + beforeEach(() => { + client = { + getAccountId: () => 'acc-1', + pruneEmailSubmissions: vi.fn().mockResolvedValue(0), + sendEmail: vi.fn(() => new Promise((resolve) => { finishSend = resolve; })), + getAllMailboxesWithState: vi.fn().mockResolvedValue({ mailboxes: [], states: {} }), + } as unknown as IJMAPClient; + useAuthStore.setState({ client: null }); + useEmailStore.setState({ isLoading: false, pendingUndoSend: null }); + }); + + it('is not put in its loading state while the message goes out', async () => { + const sending = useEmailStore.getState().sendEmail(client, ['bob@example.net'], 'Subject', 'body'); + + expect(useEmailStore.getState().isLoading).toBe(false); + + finishSend({ scheduled: false, emailId: 'sent-1' }); + await sending; + expect(useEmailStore.getState().isLoading).toBe(false); + }); + + it('keeps loading a folder that a send finished in the middle of', async () => { + useEmailStore.setState({ isLoading: true }); + const sending = useEmailStore.getState().sendEmail(client, ['bob@example.net'], 'Subject', 'body'); + finishSend({ scheduled: false, emailId: 'sent-1' }); + await sending; + + expect(useEmailStore.getState().isLoading).toBe(true); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 96d37325e..0ac3db403 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -4399,7 +4399,9 @@ export const useEmailStore = create((set, get) => ({ const outbox = options?.outbox ? getOutboxHooks() : null; const queueArgs = () => [to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom, options]; if (outbox?.shouldQueue(client)) return outbox.queueEmail(client, queueArgs()); - set({ isLoading: true, error: null }); + // Not isLoading: that is the message list's own, and a send put the list + // under "Loading emails…" until the message was out. + set({ error: null }); try { // Files attached while offline are uploaded now that the server answers. const sendAttachments = attachments && getOutboxHooks() ? await getOutboxHooks()!.resolveHeldAttachments(client, attachments) : attachments; @@ -4414,7 +4416,6 @@ export const useEmailStore = create((set, get) => ({ if (useAuthStore.getState().client === client) void get().fetchMailboxes(client); // Refresh handled by UI layer for immediate feedback set({ - isLoading: false, pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt ? { submissionId: result.emailSubmissionId, @@ -4435,12 +4436,10 @@ export const useEmailStore = create((set, get) => ({ // (the client says so: SendNotStartedError): keep it. Any other // failure may have come after the server took the message. if (outbox && error instanceof Error && error.name === 'SendNotStartedError' && outbox.shouldQueue(client)) { - set({ isLoading: false }); return outbox.queueEmail(client, queueArgs()); } set({ error: error instanceof Error ? error.message : "Failed to send email", - isLoading: false }); throw error; } @@ -4451,7 +4450,8 @@ export const useEmailStore = create((set, get) => ({ // encrypted with the user present, so the finished message can wait. const outbox = options?.outbox ? getOutboxHooks() : null; if (outbox?.shouldQueue(client)) return outbox.queueRaw(client, rawMimeBlob, { identityId, delayedUntil, envelopeRecipients }); - set({ isLoading: true, error: null }); + // Not isLoading, as in sendEmail. + set({ error: null }); try { const mailboxes = await client.getMailboxes(); const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); @@ -4460,7 +4460,6 @@ export const useEmailStore = create((set, get) => ({ const result = await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id, delayedUntil, envelopeRecipients); void client.pruneEmailSubmissions(); set({ - isLoading: false, pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt ? { submissionId: result.emailSubmissionId, emailId: result.emailId, identityId, sendAt: result.sendAt, isSmime: true, submissionAccountId: result.submissionAccountId } : get().pendingUndoSend, @@ -4469,7 +4468,6 @@ export const useEmailStore = create((set, get) => ({ } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to send email", - isLoading: false, }); throw error; } From 0ced5482d5624662da4ec8cc19e6d062256e2165 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:03:15 +0100 Subject: [PATCH 4/5] feat: list your contacts in the composer's recipient picker, with the directory on its own tab --- .../directory-picker-dialog.test.tsx | 73 +++++++++++- components/contacts/directory-browser.tsx | 43 ++++--- .../contacts/directory-picker-dialog.tsx | 111 ++++++++++++++++-- .../email/__tests__/composer-mention.test.tsx | 17 +++ components/email/email-composer.tsx | 9 +- locales/ar/common.json | 9 +- locales/ca/common.json | 9 +- locales/cs/common.json | 9 +- locales/da/common.json | 9 +- locales/de/common.json | 9 +- locales/en/common.json | 9 +- locales/es/common.json | 9 +- locales/et/common.json | 9 +- locales/fa/common.json | 9 +- locales/fr/common.json | 9 +- locales/he/common.json | 9 +- locales/hu/common.json | 9 +- locales/it/common.json | 9 +- locales/ja/common.json | 9 +- locales/ko/common.json | 9 +- locales/lv/common.json | 9 +- locales/mn/common.json | 9 +- locales/nb/common.json | 9 +- locales/nl/common.json | 9 +- locales/pl/common.json | 9 +- locales/pt/common.json | 9 +- locales/ro/common.json | 9 +- locales/ru/common.json | 9 +- locales/sk/common.json | 9 +- locales/th/common.json | 9 +- locales/tr/common.json | 9 +- locales/uk/common.json | 9 +- locales/vi/common.json | 9 +- locales/zh-TW/common.json | 9 +- locales/zh/common.json | 9 +- 35 files changed, 462 insertions(+), 61 deletions(-) diff --git a/components/contacts/__tests__/directory-picker-dialog.test.tsx b/components/contacts/__tests__/directory-picker-dialog.test.tsx index 192f8dc4b..8f41baf9d 100644 --- a/components/contacts/__tests__/directory-picker-dialog.test.tsx +++ b/components/contacts/__tests__/directory-picker-dialog.test.tsx @@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { DirectoryPickerDialog } from '../directory-picker-dialog'; import { useContactStore, type DirectoryEntry, type DirectoryStatus } from '@/stores/contact-store'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { ContactCard } from '@/lib/jmap/types'; // next-intl is mocked globally in vitest.setup (t returns the key). @@ -25,7 +26,11 @@ function rows() { } describe('DirectoryPickerDialog', () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + // No contacts of the user's own: the picker opens on the directory. + useContactStore.setState({ contacts: [] }); + }); it('lists the directory before any search, one page at a time', () => { setDirectory(people(60), 'ready'); @@ -109,3 +114,69 @@ describe('DirectoryPickerDialog', () => { expect(screen.queryByText('refresh_failed')).not.toBeInTheDocument(); }); }); + +// compose-17: the picker listed only the server's directory, not the +// contacts the To field's suggestions offer. +describe('DirectoryPickerDialog with contacts', () => { + function contact(id: string, name: string, ...emails: string[]): ContactCard { + return { + id, addressBookIds: { book: true }, + name: { full: name }, + emails: Object.fromEntries(emails.map((address, i) => [`e${i}`, { address }])), + } as unknown as ContactCard; + } + + beforeEach(() => { + vi.clearAllMocks(); + setDirectory([{ name: 'Server Account', email: 'server@example.org' }], 'ready'); + useContactStore.setState({ + contacts: [ + contact('c1', 'Zoe Fischer', 'zoe@example.com', 'zoe.f@work.example'), + contact('c2', 'Anna Becker', 'anna@example.com'), + { id: 'g1', kind: 'group', name: { full: 'Team' } } as unknown as ContactCard, + ], + }); + }); + + it('opens on the contacts, one row per address, without groups', () => { + render(); + + expect(screen.getByRole('tab', { name: 'tab_contacts' })).toHaveAttribute('aria-selected', 'true'); + expect(rows().map((r) => r.textContent)).toEqual([ + expect.stringContaining('anna@example.com'), + expect.stringContaining('zoe.f@work.example'), + expect.stringContaining('zoe@example.com'), + ]); + expect(screen.queryByText('Server Account')).not.toBeInTheDocument(); + }); + + it('searches the contacts', () => { + render(); + fireEvent.change(screen.getByPlaceholderText('search_placeholder'), { target: { value: 'anna' } }); + expect(rows()).toHaveLength(1); + fireEvent.change(screen.getByPlaceholderText('search_placeholder'), { target: { value: 'nobody' } }); + expect(screen.getByText('contacts_no_match')).toBeInTheDocument(); + }); + + it('adds what was checked on both tabs', () => { + const onAdd = vi.fn(); + render(); + fireEvent.click(rows()[0]); + fireEvent.click(screen.getByRole('tab', { name: 'tab_directory' })); + fireEvent.click(rows()[0]); + fireEvent.click(screen.getByText('add_to')); + + expect(onAdd).toHaveBeenCalledWith('to', [ + expect.objectContaining({ name: 'Anna Becker', email: 'anna@example.com' }), + expect.objectContaining({ email: 'server@example.org' }), + ]); + }); + + it('shows the contacts alone where the server has no directory', () => { + const noDirectory = { supportsPrincipals: () => false } as unknown as IJMAPClient; + render(); + expect(screen.queryByRole('tab')).not.toBeInTheDocument(); + expect(rows()).toHaveLength(3); + }); +}); + diff --git a/components/contacts/directory-browser.tsx b/components/contacts/directory-browser.tsx index 5f5f89d8b..1998b702b 100644 --- a/components/contacts/directory-browser.tsx +++ b/components/contacts/directory-browser.tsx @@ -51,6 +51,13 @@ interface DirectoryBrowserProps { /** Set while the list is being printed. */ printing?: boolean; className?: string; + /** + * Browse these instead of the server's directory (the recipient picker's + * Contacts tab): nothing is fetched and there is nothing to refresh. + */ + entries?: DirectoryEntry[]; + /** With `entries`: the list's name and what an empty list or search says. */ + labels?: { list: string; emptyTitle: string; emptyHint?: string; noMatch: string }; } /** @@ -69,13 +76,17 @@ export function DirectoryBrowser({ onPrint, printing = false, className, + entries: ownEntries, + labels, }: DirectoryBrowserProps) { const t = useTranslations("contacts.directory"); const tContacts = useTranslations("contacts"); const tCommon = useTranslations("common"); const locale = useLocale(); - const entries = useContactStore((s) => s.directoryPrincipals); - const status = useContactStore((s) => s.directoryStatus); + const directoryEntries = useContactStore((s) => s.directoryPrincipals); + const directoryStatus = useContactStore((s) => s.directoryStatus); + const entries = ownEntries ?? directoryEntries; + const status = ownEntries ? "ready" : directoryStatus; const fetchDirectory = useContactStore((s) => s.fetchDirectory); const [query, setQuery] = useState(""); const [page, setPage] = useState(0); @@ -83,8 +94,8 @@ export function DirectoryBrowser({ // The directory loads at sign-in; fetch here only if that never ran. useEffect(() => { - if (client && status === "idle") void fetchDirectory(client); - }, [client, status, fetchDirectory]); + if (!ownEntries && client && status === "idle") void fetchDirectory(client); + }, [ownEntries, client, status, fetchDirectory]); useEffect(() => { if (autoFocusSearch) searchRef.current?.focus(); @@ -117,7 +128,9 @@ export function DirectoryBrowser({ }; let placeholder: React.ReactNode = null; - if (entries.length === 0) { + if (entries.length === 0 && ownEntries) { + placeholder = } title={labels?.emptyTitle ?? t("empty_title")} hint={labels?.emptyHint} />; + } else if (entries.length === 0) { if (loading) { placeholder = ( } title={t("loading")} /> @@ -132,7 +145,7 @@ export function DirectoryBrowser({ placeholder = } title={t("empty_title")} hint={t("empty_hint")} />; } } else if (filtered.length === 0) { - placeholder = } title={t("no_match")} />; + placeholder = } title={labels?.noMatch ?? t("no_match")} />; } return ( @@ -163,13 +176,15 @@ export function DirectoryBrowser({ onClear={() => { setQuery(""); setPage(0); }} clearLabel={tContacts("clear_search")} /> - - - + {!ownEntries && ( + + + + )} {onPrint && ( @@ -186,7 +201,7 @@ export function DirectoryBrowser({ )} -
+
{placeholder ?? pageEntries.map((entry) => { const key = directoryKey(entry); const checked = selected.has(key); diff --git a/components/contacts/directory-picker-dialog.tsx b/components/contacts/directory-picker-dialog.tsx index 493a84e3c..ca031d91c 100644 --- a/components/contacts/directory-picker-dialog.tsx +++ b/components/contacts/directory-picker-dialog.tsx @@ -1,36 +1,94 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useTranslations } from "next-intl"; import { X } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { useFocusTrap } from "@/hooks/use-focus-trap"; +import { cn } from "@/lib/utils"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; -import { useContactStore, type DirectoryEntry } from "@/stores/contact-store"; +import type { ContactCard } from "@/lib/jmap/types"; +import { useContactStore, getContactDisplayName, type DirectoryEntry } from "@/stores/contact-store"; import { DirectoryBrowser, directoryKey } from "./directory-browser"; export type RecipientField = "to" | "cc" | "bcc"; +type PickerTab = "contacts" | "directory"; + interface DirectoryPickerDialogProps { client: IJMAPClient | null; onClose: () => void; - /** Called with the checked entries in directory order; the dialog closes afterwards. */ + /** Called with the checked entries in list order; the dialog closes afterwards. */ onAdd: (field: RecipientField, entries: DirectoryEntry[]) => void; } -/** The composer's "Select from directory" dialog. */ +/** Every address of the user's own contacts, one row each; groups are left out. */ +export function contactEntries(contacts: ContactCard[]): DirectoryEntry[] { + const seen = new Set(); + const entries: DirectoryEntry[] = []; + for (const contact of contacts) { + if (contact.kind === "group") continue; + const name = getContactDisplayName(contact); + for (const email of Object.values(contact.emails ?? {})) { + const address = email?.address?.trim(); + if (!address) continue; + const entry = { id: `${contact.id}:${address}`, name: name || address, email: address }; + if (seen.has(directoryKey(entry))) continue; + seen.add(directoryKey(entry)); + entries.push(entry); + } + } + return entries; +} + +/** + * The composer's recipient picker: the user's own contacts, and the server's + * directory (other accounts) on a tab of its own where the server has one. + * It used to list only the directory, without the contacts autocomplete offers. + */ export function DirectoryPickerDialog({ client, onClose, onAdd }: DirectoryPickerDialogProps) { const t = useTranslations("contacts.directory"); + const tSend = useTranslations("compose_send"); const modalRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true }); + const contacts = useContactStore((s) => s.contacts); + const ownEntries = useMemo(() => contactEntries(contacts ?? []), [contacts]); + const hasDirectory = !!client?.supportsPrincipals?.(); + // Contacts first; straight to the directory for someone who has none. + const [tab, setTab] = useState(() => (ownEntries.length === 0 && hasDirectory ? "directory" : "contacts")); const [selected, setSelected] = useState>(() => new Set()); const add = (field: RecipientField) => { - const entries = useContactStore.getState().directoryPrincipals.filter((e) => selected.has(directoryKey(e))); + // Checked on either tab, each address once. + const pool = [...ownEntries, ...useContactStore.getState().directoryPrincipals]; + const seen = new Set(); + const entries = pool.filter((e) => { + const key = directoryKey(e); + if (!selected.has(key) || seen.has(key)) return false; + seen.add(key); + return true; + }); if (entries.length === 0) return; onAdd(field, entries); onClose(); }; + const tabButton = (value: PickerTab, label: string) => ( + + ); + return (

- {t("picker_title")} + {tSend("picker_title")}

- + {hasDirectory && ( +
+ {tabButton("contacts", tSend("tab_contacts"))} + {tabButton("directory", tSend("tab_directory"))} +
+ )} + +
+ +
diff --git a/components/email/__tests__/composer-mention.test.tsx b/components/email/__tests__/composer-mention.test.tsx index d50d46ad5..42c312b12 100644 --- a/components/email/__tests__/composer-mention.test.tsx +++ b/components/email/__tests__/composer-mention.test.tsx @@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import React from 'react'; import type { Editor } from '@tiptap/core'; import { useAuthStore } from '@/stores/auth-store'; +import { useContactStore } from '@/stores/contact-store'; import { EmailComposer } from '../email-composer'; import { useSettingsStore } from '@/stores/settings-store'; @@ -546,6 +547,22 @@ describe('composer with the @ recipient list', () => { expect(to.value).toBe('Sarah Kim '); }); + // compose-17: the button was there only with a server directory, so + // contacts alone were never offered. + it('offers the recipient picker for the contacts when the server has no directory', async () => { + const contactState = (useContactStore as unknown as { getState: () => { contacts: unknown[] } }).getState(); + contactState.contacts = [{ id: 'c1', name: { full: 'Zoe Fischer' }, emails: { e: { address: 'zoe@example.com' } } }]; + try { + await renderComposer(); + const button = screen.getByTestId('composer-directory-picker'); + expect(button).toHaveAccessibleName('picker_button'); + fireEvent.click(button); + expect(screen.getByRole('dialog', { name: 'directory' })).toBeInTheDocument(); + } finally { + contactState.contacts = []; + } + }); + // The directory picker is drawn inside the composer itself, not portaled. it('lets Escape in the directory picker leave the composer alone', async () => { // Every other capability answers nothing. diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 22ea0db2b..5f4a9ab85 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -1775,6 +1775,9 @@ export function EmailComposer({ // Whether a Sent mailbox is known so the on-demand server search is worth // offering (falls back to hiding the "search the server" row otherwise). const canSearchServer = useContactStore((s) => s.sentMailboxId != null); + // The recipient picker: the user's contacts, and the server's directory. + const hasContacts = useContactStore((s) => (s.contacts?.length ?? 0) > 0); + const tSend = useTranslations('compose_send'); const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook); const addTrustedSender = useSettingsStore((s) => s.addTrustedSender); const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook); @@ -5091,15 +5094,15 @@ export function EmailComposer({ onMoveChip={handleMoveChip} />
- {activeClient?.supportsPrincipals?.() && ( + {(hasContacts || activeClient?.supportsPrincipals?.()) && ( + + + + } + > +
+ + { + setName(e.target.value); + if (error) setError(null); + }} + className={cn('mt-1', error && 'border-destructive')} + aria-invalid={error ? true : undefined} + aria-describedby={error ? `${fieldId}-error` : undefined} + autoFocus + /> + {error && ( +

+ {tSettings(`validation.${error}`)} +

+ )} +
+
+ ); +} diff --git a/components/templates/template-form.tsx b/components/templates/template-form.tsx index 5109d4a2e..7f6b78fbc 100644 --- a/components/templates/template-form.tsx +++ b/components/templates/template-form.tsx @@ -22,6 +22,8 @@ import type { Editor } from '@tiptap/react'; interface TemplateFormProps { template?: EmailTemplate; initialData?: { + /** A name typed before the full editor opened (Save as template). */ + name?: string; subject?: string; body?: string; /** The body is the composer's HTML, not plain text (#1506). */ @@ -57,7 +59,7 @@ export function TemplateForm({ template, initialData, onSave, onCancel, shared = const browserList = useTemplateStore((s) => s.templates); const templates = categorySource ?? browserList; - const [name, setName] = useState(template?.name || ''); + const [name, setName] = useState(template?.name || initialData?.name || ''); const [category, setCategory] = useState(template?.category || ''); const [subject, setSubject] = useState(template?.subject || initialData?.subject || ''); const [body, setBody] = useState(template?.body || initialData?.body || ''); diff --git a/components/templates/template-picker.tsx b/components/templates/template-picker.tsx index a8cbd72b3..f84990342 100644 --- a/components/templates/template-picker.tsx +++ b/components/templates/template-picker.tsx @@ -114,7 +114,8 @@ export function TemplatePicker({ isOpen, onClose, onSelect, accountId }: Templat )} - {template.subject && ( + {/* Not the name a second time ("Meeting follow-up / Meeting follow-up"). */} + {template.subject && template.subject.trim() !== template.name.trim() && (

{template.subject}

@@ -131,13 +132,15 @@ export function TemplatePicker({ isOpen, onClose, onSelect, accountId }: Templat ); - const renderSection = (title: string, items: EmailTemplate[], source: TemplateSource) => { + const renderSection = (title: string, items: EmailTemplate[], source: TemplateSource, withHeading = true) => { if (items.length === 0) return null; return (
-

- {title} -

+ {withHeading && ( +

+ {title} +

+ )}
{items.map((item) => renderTemplateItem(item, source))}
); @@ -171,7 +174,9 @@ export function TemplatePicker({ isOpen, onClose, onSelect, accountId }: Templat {Object.entries(byCategory) .filter(([cat]) => cat !== '') .map(([cat, items]) => renderSection(cat, items, source))} - {renderSection(t('section_uncategorized'), byCategory[''] ?? [], source)} + {/* "Other" only sets them apart from favourites, recent or categories. */} + {renderSection(t('section_uncategorized'), byCategory[''] ?? [], source, + favorites.length > 0 || recent.length > 0 || Object.keys(byCategory).some((cat) => cat !== ''))} ); if (!withHeading) return
{body}
; diff --git a/locales/ar/common.json b/locales/ar/common.json index 8fc060a58..24d70a5f5 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "الدليل", "contacts_empty_title": "لا توجد جهات اتصال بعد", "contacts_empty_hint": "يظهر هنا الأشخاص الذين تحفظهم في جهات الاتصال.", - "contacts_no_match": "لا توجد جهة اتصال تطابق بحثك" + "contacts_no_match": "لا توجد جهة اتصال تطابق بحثك", + "template_name": "اسم القالب", + "template_edit_details": "تعديل التفاصيل…" }, "org_templates": { "section_personal": "قوالبي", diff --git a/locales/ca/common.json b/locales/ca/common.json index ea9846760..660791f93 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "Directori", "contacts_empty_title": "Encara no hi ha contactes", "contacts_empty_hint": "Aquí apareixen les persones que deses a Contactes.", - "contacts_no_match": "Cap contacte coincideix amb la cerca" + "contacts_no_match": "Cap contacte coincideix amb la cerca", + "template_name": "Nom de la plantilla", + "template_edit_details": "Edita els detalls…" }, "org_templates": { "section_personal": "Les meves plantilles", diff --git a/locales/cs/common.json b/locales/cs/common.json index 211878338..28032fd09 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "Adresář organizace", "contacts_empty_title": "Zatím žádné kontakty", "contacts_empty_hint": "Zde se zobrazí lidé, které uložíte do Kontaktů.", - "contacts_no_match": "Hledání neodpovídá žádný kontakt" + "contacts_no_match": "Hledání neodpovídá žádný kontakt", + "template_name": "Název šablony", + "template_edit_details": "Upravit podrobnosti…" }, "org_templates": { "section_personal": "Moje šablony", diff --git a/locales/da/common.json b/locales/da/common.json index 3b27a738f..a1e3dac0f 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "Adressefortegnelse", "contacts_empty_title": "Ingen kontakter endnu", "contacts_empty_hint": "Personer, du gemmer i Kontakter, vises her.", - "contacts_no_match": "Ingen kontakter matcher din søgning" + "contacts_no_match": "Ingen kontakter matcher din søgning", + "template_name": "Skabelonens navn", + "template_edit_details": "Rediger detaljer…" }, "org_templates": { "section_personal": "Mine skabeloner", diff --git a/locales/de/common.json b/locales/de/common.json index ae5039e3e..c2976faf0 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "Verzeichnis", "contacts_empty_title": "Noch keine Kontakte", "contacts_empty_hint": "Personen, die Sie in Kontakten speichern, erscheinen hier.", - "contacts_no_match": "Kein Kontakt passt zu Ihrer Suche" + "contacts_no_match": "Kein Kontakt passt zu Ihrer Suche", + "template_name": "Name der Vorlage", + "template_edit_details": "Details bearbeiten…" }, "org_templates": { "section_personal": "Meine Vorlagen", diff --git a/locales/en/common.json b/locales/en/common.json index dbef12f39..3af2a2f74 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "Directory", "contacts_empty_title": "No contacts yet", "contacts_empty_hint": "People you save in Contacts show up here.", - "contacts_no_match": "No contact matches your search" + "contacts_no_match": "No contact matches your search", + "template_name": "Template name", + "template_edit_details": "Edit details…" }, "org_templates": { "section_personal": "My templates", diff --git a/locales/es/common.json b/locales/es/common.json index 44b012eb1..0509e004c 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "Directorio", "contacts_empty_title": "Aún no hay contactos", "contacts_empty_hint": "Aquí aparecen las personas que guardas en Contactos.", - "contacts_no_match": "Ningún contacto coincide con la búsqueda" + "contacts_no_match": "Ningún contacto coincide con la búsqueda", + "template_name": "Nombre de la plantilla", + "template_edit_details": "Editar detalles…" }, "org_templates": { "section_personal": "Mis plantillas", diff --git a/locales/et/common.json b/locales/et/common.json index fed4d3fa0..0f7f020db 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "Kataloog", "contacts_empty_title": "Kontakte veel pole", "contacts_empty_hint": "Siin kuvatakse inimesed, kelle salvestate kontaktidesse.", - "contacts_no_match": "Otsingule ei vasta ükski kontakt" + "contacts_no_match": "Otsingule ei vasta ükski kontakt", + "template_name": "Malli nimi", + "template_edit_details": "Muuda üksikasju…" }, "org_templates": { "section_personal": "Minu mallid", diff --git a/locales/fa/common.json b/locales/fa/common.json index 6ce99c57a..e109ff7b6 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "فهرست سازمانی", "contacts_empty_title": "هنوز مخاطبی وجود ندارد", "contacts_empty_hint": "افرادی که در مخاطبین ذخیره می‌کنید اینجا نمایش داده می‌شوند.", - "contacts_no_match": "هیچ مخاطبی با جستجوی شما مطابقت ندارد" + "contacts_no_match": "هیچ مخاطبی با جستجوی شما مطابقت ندارد", + "template_name": "نام قالب", + "template_edit_details": "ویرایش جزئیات…" }, "org_templates": { "section_personal": "قالب‌های من", diff --git a/locales/fr/common.json b/locales/fr/common.json index 18783b5e7..4956fb6ad 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "Annuaire", "contacts_empty_title": "Aucun contact pour l'instant", "contacts_empty_hint": "Les personnes enregistrées dans Contacts s'affichent ici.", - "contacts_no_match": "Aucun contact ne correspond à votre recherche" + "contacts_no_match": "Aucun contact ne correspond à votre recherche", + "template_name": "Nom du modèle", + "template_edit_details": "Modifier les détails…" }, "org_templates": { "section_personal": "Mes modèles", diff --git a/locales/he/common.json b/locales/he/common.json index 1735dee2a..a9f5fda38 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -3814,7 +3814,9 @@ "tab_directory": "ספרייה", "contacts_empty_title": "אין עדיין אנשי קשר", "contacts_empty_hint": "אנשים שתשמרו באנשי הקשר יופיעו כאן.", - "contacts_no_match": "אין איש קשר שתואם לחיפוש" + "contacts_no_match": "אין איש קשר שתואם לחיפוש", + "template_name": "שם התבנית", + "template_edit_details": "עריכת פרטים…" }, "org_templates": { "section_personal": "התבניות שלי", diff --git a/locales/hu/common.json b/locales/hu/common.json index 74a817551..c66311018 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "Címtár", "contacts_empty_title": "Még nincsenek névjegyek", "contacts_empty_hint": "A névjegyekbe mentett személyek itt jelennek meg.", - "contacts_no_match": "Egy névjegy sem felel meg a keresésnek" + "contacts_no_match": "Egy névjegy sem felel meg a keresésnek", + "template_name": "Sablon neve", + "template_edit_details": "Részletek szerkesztése…" }, "org_templates": { "section_personal": "Saját sablonok", diff --git a/locales/it/common.json b/locales/it/common.json index 5b254ce9c..90b2ef2e9 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "Rubrica aziendale", "contacts_empty_title": "Ancora nessun contatto", "contacts_empty_hint": "Le persone salvate in Contatti compaiono qui.", - "contacts_no_match": "Nessun contatto corrisponde alla ricerca" + "contacts_no_match": "Nessun contatto corrisponde alla ricerca", + "template_name": "Nome del modello", + "template_edit_details": "Modifica dettagli…" }, "org_templates": { "section_personal": "I miei modelli", diff --git a/locales/ja/common.json b/locales/ja/common.json index 73cf4eb8e..79317ef5d 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "ディレクトリ", "contacts_empty_title": "連絡先はまだありません", "contacts_empty_hint": "連絡先に保存した人がここに表示されます。", - "contacts_no_match": "検索に一致する連絡先はありません" + "contacts_no_match": "検索に一致する連絡先はありません", + "template_name": "テンプレート名", + "template_edit_details": "詳細を編集…" }, "org_templates": { "section_personal": "マイテンプレート", diff --git a/locales/ko/common.json b/locales/ko/common.json index 827cb61f0..e2eab9c80 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "디렉터리", "contacts_empty_title": "아직 연락처가 없어요", "contacts_empty_hint": "연락처에 저장한 사람이 여기에 표시돼요.", - "contacts_no_match": "검색과 일치하는 연락처가 없어요" + "contacts_no_match": "검색과 일치하는 연락처가 없어요", + "template_name": "템플릿 이름", + "template_edit_details": "세부 정보 편집…" }, "org_templates": { "section_personal": "내 템플릿", diff --git a/locales/lv/common.json b/locales/lv/common.json index 5ceb5f684..ace10bc3f 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "Katalogs", "contacts_empty_title": "Vēl nav kontaktu", "contacts_empty_hint": "Šeit redzami cilvēki, kurus saglabājat kontaktos.", - "contacts_no_match": "Meklēšanai neatbilst neviens kontakts" + "contacts_no_match": "Meklēšanai neatbilst neviens kontakts", + "template_name": "Veidnes nosaukums", + "template_edit_details": "Rediģēt detaļas…" }, "org_templates": { "section_personal": "Manas veidnes", diff --git a/locales/mn/common.json b/locales/mn/common.json index 37458cb71..ba80232aa 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "Лавлах", "contacts_empty_title": "Одоогоор харилцагч алга", "contacts_empty_hint": "Харилцагчдад хадгалсан хүмүүс энд харагдана.", - "contacts_no_match": "Хайлтад тохирох харилцагч алга" + "contacts_no_match": "Хайлтад тохирох харилцагч алга", + "template_name": "Загварын нэр", + "template_edit_details": "Дэлгэрэнгүйг засах…" }, "org_templates": { "section_personal": "Миний загварууд", diff --git a/locales/nb/common.json b/locales/nb/common.json index 4a8f8f941..84f0718f6 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "Katalog", "contacts_empty_title": "Ingen kontakter ennå", "contacts_empty_hint": "Personer du lagrer i Kontakter, vises her.", - "contacts_no_match": "Ingen kontakter samsvarer med søket" + "contacts_no_match": "Ingen kontakter samsvarer med søket", + "template_name": "Navn på mal", + "template_edit_details": "Rediger detaljer…" }, "org_templates": { "section_personal": "Mine maler", diff --git a/locales/nl/common.json b/locales/nl/common.json index 75d1a276d..bd8b6deda 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "Adresboek van de organisatie", "contacts_empty_title": "Nog geen contacten", "contacts_empty_hint": "Mensen die u in Contacten opslaat, verschijnen hier.", - "contacts_no_match": "Geen contact komt overeen met uw zoekopdracht" + "contacts_no_match": "Geen contact komt overeen met uw zoekopdracht", + "template_name": "Naam van sjabloon", + "template_edit_details": "Details bewerken…" }, "org_templates": { "section_personal": "Mijn sjablonen", diff --git a/locales/pl/common.json b/locales/pl/common.json index f728e9e79..87d6f7bdb 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "Katalog", "contacts_empty_title": "Brak kontaktów", "contacts_empty_hint": "Tutaj pojawią się osoby zapisane w Kontaktach.", - "contacts_no_match": "Żaden kontakt nie pasuje do wyszukiwania" + "contacts_no_match": "Żaden kontakt nie pasuje do wyszukiwania", + "template_name": "Nazwa szablonu", + "template_edit_details": "Edytuj szczegóły…" }, "org_templates": { "section_personal": "Moje szablony", diff --git a/locales/pt/common.json b/locales/pt/common.json index 58f9dbdeb..d153f9f63 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "Diretório", "contacts_empty_title": "Nenhum contato ainda", "contacts_empty_hint": "As pessoas que você salvar em Contatos aparecem aqui.", - "contacts_no_match": "Nenhum contato corresponde à sua pesquisa" + "contacts_no_match": "Nenhum contato corresponde à sua pesquisa", + "template_name": "Nome do modelo", + "template_edit_details": "Editar detalhes…" }, "org_templates": { "section_personal": "Meus modelos", diff --git a/locales/ro/common.json b/locales/ro/common.json index 3f006f780..d69744bf6 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "Director", "contacts_empty_title": "Încă nu există contacte", "contacts_empty_hint": "Aici apar persoanele pe care le salvați în Contacte.", - "contacts_no_match": "Niciun contact nu corespunde căutării" + "contacts_no_match": "Niciun contact nu corespunde căutării", + "template_name": "Numele șablonului", + "template_edit_details": "Editează detaliile…" }, "org_templates": { "section_personal": "Șabloanele mele", diff --git a/locales/ru/common.json b/locales/ru/common.json index afa5dc6c8..c82e2d783 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "Справочник", "contacts_empty_title": "Контактов пока нет", "contacts_empty_hint": "Здесь появятся люди, которых вы сохраните в контактах.", - "contacts_no_match": "Нет контактов, подходящих под запрос" + "contacts_no_match": "Нет контактов, подходящих под запрос", + "template_name": "Название шаблона", + "template_edit_details": "Изменить подробности…" }, "org_templates": { "section_personal": "Мои шаблоны", diff --git a/locales/sk/common.json b/locales/sk/common.json index 901a328b8..73fc1a0eb 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "Adresár organizácie", "contacts_empty_title": "Zatiaľ žiadne kontakty", "contacts_empty_hint": "Tu sa zobrazia ľudia, ktorých uložíte do Kontaktov.", - "contacts_no_match": "Vyhľadávaniu nezodpovedá žiadny kontakt" + "contacts_no_match": "Vyhľadávaniu nezodpovedá žiadny kontakt", + "template_name": "Názov šablóny", + "template_edit_details": "Upraviť podrobnosti…" }, "org_templates": { "section_personal": "Moje šablóny", diff --git a/locales/th/common.json b/locales/th/common.json index 7b86f34b3..76af0639e 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -1168,7 +1168,9 @@ "tab_directory": "ไดเรกทอรีกลาง", "contacts_empty_title": "ยังไม่มีผู้ติดต่อ", "contacts_empty_hint": "คนที่คุณบันทึกไว้ในผู้ติดต่อจะแสดงที่นี่", - "contacts_no_match": "ไม่มีผู้ติดต่อที่ตรงกับการค้นหา" + "contacts_no_match": "ไม่มีผู้ติดต่อที่ตรงกับการค้นหา", + "template_name": "ชื่อแม่แบบ", + "template_edit_details": "แก้ไขรายละเอียด…" }, "org_templates": { "section_personal": "แม่แบบของฉัน", diff --git a/locales/tr/common.json b/locales/tr/common.json index 6a02a84e8..8fc0c1d0e 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "Dizin", "contacts_empty_title": "Henüz kişi yok", "contacts_empty_hint": "Kişiler'e kaydettiğiniz kişiler burada görünür.", - "contacts_no_match": "Aramanızla eşleşen kişi yok" + "contacts_no_match": "Aramanızla eşleşen kişi yok", + "template_name": "Şablon adı", + "template_edit_details": "Ayrıntıları düzenle…" }, "org_templates": { "section_personal": "Şablonlarım", diff --git a/locales/uk/common.json b/locales/uk/common.json index 9cdeb4bd7..9a12ae296 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "Довідник", "contacts_empty_title": "Контактів ще немає", "contacts_empty_hint": "Тут з’являться люди, яких ви збережете в контактах.", - "contacts_no_match": "Немає контактів, що відповідають пошуку" + "contacts_no_match": "Немає контактів, що відповідають пошуку", + "template_name": "Назва шаблону", + "template_edit_details": "Редагувати подробиці…" }, "org_templates": { "section_personal": "Мої шаблони", diff --git a/locales/vi/common.json b/locales/vi/common.json index 459eb3aea..d120c2103 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -3929,7 +3929,9 @@ "tab_directory": "Danh bạ tổ chức", "contacts_empty_title": "Chưa có liên hệ nào", "contacts_empty_hint": "Những người bạn lưu trong Danh bạ sẽ hiện ở đây.", - "contacts_no_match": "Không có liên hệ nào khớp với tìm kiếm" + "contacts_no_match": "Không có liên hệ nào khớp với tìm kiếm", + "template_name": "Tên mẫu", + "template_edit_details": "Sửa chi tiết…" }, "org_templates": { "section_personal": "Mẫu của tôi", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index cda397e2b..c0b50d357 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "通訊錄目錄", "contacts_empty_title": "還沒有聯絡人", "contacts_empty_hint": "你在聯絡人中儲存的人會顯示在這裡。", - "contacts_no_match": "沒有符合搜尋的聯絡人" + "contacts_no_match": "沒有符合搜尋的聯絡人", + "template_name": "範本名稱", + "template_edit_details": "編輯詳細資料…" }, "org_templates": { "section_personal": "我的範本", diff --git a/locales/zh/common.json b/locales/zh/common.json index ede1e67b7..78f0c9f09 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -3917,7 +3917,9 @@ "tab_directory": "通讯录目录", "contacts_empty_title": "还没有联系人", "contacts_empty_hint": "你在联系人中保存的人会显示在这里。", - "contacts_no_match": "没有符合搜索的联系人" + "contacts_no_match": "没有符合搜索的联系人", + "template_name": "模板名称", + "template_edit_details": "编辑详细信息…" }, "org_templates": { "section_personal": "我的模板",