From 94bb55765d783f6852f2942f678ca0043a5ad145 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:04:28 +0100 Subject: [PATCH 01/34] fix: give pop-out and quick compose windows the main composer's layout, with Send bottom right and Discard as a bin --- .../composer-windows-layout.test.tsx | 240 ++++++++++++++++++ components/email/email-composer.tsx | 33 +-- 2 files changed, 258 insertions(+), 15 deletions(-) create mode 100644 components/email/__tests__/composer-windows-layout.test.tsx diff --git a/components/email/__tests__/composer-windows-layout.test.tsx b/components/email/__tests__/composer-windows-layout.test.tsx new file mode 100644 index 000000000..fd1ccd18c --- /dev/null +++ b/components/email/__tests__/composer-windows-layout.test.tsx @@ -0,0 +1,240 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; + +// One layout in every composer: Send at the bottom end, Discard as a bin left +// of it, the formatting toolbar showing. A pop-out or quick compose window is +// narrower than a tablet, and used to get the phone's layout: Send in the +// header, Discard where Send was, the toolbar behind a button. + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: ({ collapseToolbarOnMobile }: { collapseToolbarOnMobile?: boolean }) => + React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-collapsed': String(!!collapseToolbarOnMobile) }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/ui/avatar', () => ({ Avatar: () => 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 }), +})); + +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 }; +}); + +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: () => {}, + }; + 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 }; +}); + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: () => {}, success: () => {}, warning: () => {} }, +})); + +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 })); + +const DATA = { + to: 'bob@example.com', + cc: '', + bcc: '', + subject: 'Lunch', + body: '

Hello there

', + showCc: false, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: null, +}; + +const footer = () => document.querySelector('[data-composer-footer]')!; +const header = () => document.querySelector('[data-composer-header]')!; + +describe('a composer in a window of its own', () => { + it('has one Send, at the bottom end, with Discard as a bin right before it', () => { + render(); + const sends = screen.getAllByTestId('composer-send'); + expect(sends).toHaveLength(1); + expect(footer()).toContainElement(sends[0]); + expect(sends[0].className).not.toMatch(/(^|\s)hidden(\s|$)/); + expect(within(header()).queryByTestId('composer-send')).toBeNull(); + + const discard = screen.getByTestId('composer-discard'); + expect(discard.querySelector('svg')).not.toBeNull(); + // Right before Send, in the same group. + expect(discard.nextElementSibling).toBe(sends[0]); + }); + + it('shows the formatting toolbar, with no button to fold it away', () => { + render(); + expect(screen.getByTestId('rich-text-editor')).toHaveAttribute('data-collapsed', 'false'); + expect(screen.queryByRole('button', { name: 'formatting' })).toBeNull(); + }); + + it('keeps the close button where the main window has it', () => { + render(); + expect(screen.getByTestId('composer-close').className).not.toContain('order-first'); + }); +}); + +describe('a composer in the main window', () => { + it('keeps the phone layout for a phone: Send in the header there, toolbar folded', () => { + render(); + expect(within(header()).getByTestId('composer-send')).toBeInTheDocument(); + expect(screen.getByTestId('rich-text-editor')).toHaveAttribute('data-collapsed', 'true'); + expect(screen.getByRole('button', { name: 'formatting' })).toBeInTheDocument(); + }); + + it('shows Discard as a bin too, with its name for screen readers', () => { + render(); + const discard = screen.getByTestId('composer-discard'); + expect(discard).toHaveAccessibleName('discard'); + expect(discard.querySelector('svg')).not.toBeNull(); + expect(within(discard).getByText('discard').className).toContain('sr-only'); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 3409f77db..42a8fb93b 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -1196,6 +1196,9 @@ export function EmailComposer({ // bar folded away behind "Aa" until asked for. const mvWindow = useMvComposeWindow(); const [mvShowFormatting, setMvShowFormatting] = useState(false); + // A pop-out or quick compose window is narrower than a tablet, but it is a + // window on a computer: it keeps the main window's layout, not the phone's. + const phoneLayout = !ownWindow; // "close" (X, Escape): save or discard. "discard" (the Discard button): the // user already chose, so only confirm it. const [closeDialogKind, setCloseDialogKind] = useState<'close' | 'discard'>('close'); @@ -4825,25 +4828,25 @@ export function EmailComposer({ {saveStatus === 'saving' && (
- {t('saving')} + {t('saving')}
)} {saveStatus === 'saved' && (
- {savedOnDevice ? tOfflineDrafts('saved_on_device') : t('draft_saved')} + {savedOnDevice ? tOfflineDrafts('saved_on_device') : t('draft_saved')}
)} {saveStatus === 'error' && (
- {t('save_failed')} + {t('save_failed')}
)} {/* Mobile: keep scheduled send reachable without consuming footer space. */} - {composerClient?.hasDelayedSend() || hasSendMenuActions ? ( + {phoneLayout && (composerClient?.hasDelayedSend() || hasSendMenuActions ? (
- )} + ))} {rightPanelToggle} - {popOutButton("hidden md:grid h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground", "w-4 h-4")} + {popOutButton(cn(phoneLayout ? "hidden md:grid" : "grid", "h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"), "w-4 h-4")} {/* Close at the top end (R4). On a phone it moves to the start, as the phone's mail apps have it, with Send at the end. */} handleClose()} - className="-me-2 max-md:order-first max-md:me-0 max-md:-ms-2 max-md:h-11 max-md:w-11" + className={phoneLayout ? "-me-2 max-md:order-first max-md:me-0 max-md:-ms-2 max-md:h-11 max-md:w-11" : "-me-2"} label={tCommon('close')} data-testid="composer-close" data-own-window-close={ownWindow ? "" : undefined} @@ -5300,7 +5303,7 @@ export function EmailComposer({ ariaInvalid={validationErrors.body} ariaDescribedBy={`${fieldIdBase}-body-error`} defaultTextStyle={composeStyle} - collapseToolbarOnMobile={!showFormattingOnMobile} + collapseToolbarOnMobile={phoneLayout && !showFormattingOnMobile} toolbarPlacement={mvWindow ? (mvShowFormatting ? 'bottom' : 'hidden') : 'top'} onEditorReady={handleEditorReady} mentionCandidates={recipientMentionsEnabled ? mentionCandidates : undefined} @@ -5565,7 +5568,7 @@ export function EmailComposer({ {t('attach')} )} - {!plainTextMode && ( + {!plainTextMode && (mvWindow || phoneLayout) && ( )} {composerClient?.hasDelayedSend() || hasSendMenuActions ? ( -
+
- - - } - /> - - 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 b425f4b21149344399921bb24fe888fad7ba16e2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:18:29 +0100 Subject: [PATCH 03/34] feat: turn undo send on by default with 5 seconds The policy-defaults test now expects the new build default of 5 seconds for an invalid admin value, and the settings-search tests look for the row under its new label "Undo send". --- .../settings/accessibility-settings.tsx | 15 +++-- components/settings/composing-settings.tsx | 15 +++-- .../plugin-host-api-app-info.test.ts | 4 +- lib/__tests__/settings-search.test.ts | 4 +- lib/settings-schema.ts | 6 +- lib/settings-search.ts | 4 +- locales/ar/common.json | 6 ++ locales/ca/common.json | 6 ++ locales/cs/common.json | 6 ++ locales/da/common.json | 6 ++ locales/de/common.json | 6 ++ locales/en/common.json | 6 ++ locales/es/common.json | 6 ++ locales/et/common.json | 6 ++ locales/fa/common.json | 6 ++ locales/fr/common.json | 6 ++ locales/he/common.json | 6 ++ locales/hu/common.json | 6 ++ locales/it/common.json | 6 ++ locales/ja/common.json | 6 ++ locales/ko/common.json | 6 ++ locales/lv/common.json | 6 ++ locales/mn/common.json | 6 ++ locales/nb/common.json | 6 ++ locales/nl/common.json | 6 ++ locales/pl/common.json | 6 ++ locales/pt/common.json | 6 ++ locales/ro/common.json | 6 ++ locales/ru/common.json | 6 ++ locales/sk/common.json | 6 ++ locales/th/common.json | 6 ++ locales/tr/common.json | 6 ++ locales/uk/common.json | 6 ++ locales/vi/common.json | 6 ++ locales/zh-TW/common.json | 6 ++ locales/zh/common.json | 6 ++ .../settings-store-policy-defaults.test.ts | 2 +- .../settings-undo-send-default.test.ts | 58 +++++++++++++++++++ stores/settings-store.ts | 38 ++++++++++-- 39 files changed, 294 insertions(+), 32 deletions(-) create mode 100644 stores/__tests__/settings-undo-send-default.test.ts diff --git a/components/settings/accessibility-settings.tsx b/components/settings/accessibility-settings.tsx index 3f6f606e2..80dd75506 100644 --- a/components/settings/accessibility-settings.tsx +++ b/components/settings/accessibility-settings.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; -import { useSettingsStore } from '@/stores/settings-store'; +import { useSettingsStore, undoSendOptions } from '@/stores/settings-store'; import { usePolicyStore } from '@/stores/policy-store'; import { SettingsSection, SettingItem, RadioGroup, Select, ToggleSwitch } from './settings-section'; import { Button } from '@/components/ui/button'; @@ -62,6 +62,7 @@ export function AccessibilitySettings() { const t = useTranslations('settings.accessibility'); const tRoot = useTranslations(); const tEmail = useTranslations('settings.email_behavior'); + const tWords = useTranslations('compose_words'); const tAppearance = useTranslations('settings.appearance'); const tNotifications = useTranslations('settings.notifications'); const tCalendar = useTranslations('calendar.settings'); @@ -514,16 +515,14 @@ export function AccessibilitySettings() { options={toastOptions} /> - + updateSetting('sendDelaySeconds', Number(value) as SendDelaySeconds)} - options={[ - { value: '0', label: t('send_delay.off') }, - { value: '10', label: t('send_delay.seconds', { seconds: 10 }) }, - { value: '30', label: t('send_delay.seconds', { seconds: 30 }) }, - { value: '60', label: t('send_delay.seconds', { seconds: 60 }) }, - ]} + options={undoSendOptions(sendDelaySeconds).map((seconds) => ({ + value: String(seconds), + label: seconds === 0 ? t('send_delay.off') : t('send_delay.seconds', { seconds }), + }))} /> {sendDelaySeconds > 0 && !delayedSendSupported && (

{t('send_delay.unsupported')}

diff --git a/lib/__tests__/plugin-host-api-app-info.test.ts b/lib/__tests__/plugin-host-api-app-info.test.ts index 94c79dad8..b7a8ea269 100644 --- a/lib/__tests__/plugin-host-api-app-info.test.ts +++ b/lib/__tests__/plugin-host-api-app-info.test.ts @@ -57,8 +57,8 @@ describe('api.app.searchSettings', () => { expect(results.length).toBeGreaterThan(0); expect(results[0].pageId).toBe('composing'); expect(results[0].page).toBe('Verfassen'); - const de = (await import('@/locales/de/common.json')).default as unknown as { settings: { email_behavior: { send_delay: { label: string } } } }; - expect(results.map((r) => r.label)).toContain(de.settings.email_behavior.send_delay.label); + const de = (await import('@/locales/de/common.json')).default as unknown as { compose_words: { undo_send: { label: string } } }; + expect(results.map((r) => r.label)).toContain(de.compose_words.undo_send.label); }); it('leaves out pages the server does not offer', async () => { diff --git a/lib/__tests__/settings-search.test.ts b/lib/__tests__/settings-search.test.ts index c4728bd46..e593b052a 100644 --- a/lib/__tests__/settings-search.test.ts +++ b/lib/__tests__/settings-search.test.ts @@ -40,7 +40,7 @@ describe('settings search index (English messages)', () => { expect(labelsFor('message_list')).toContain('Sender favicons'); expect(labelsFor('message_list')).toContain('Clear search when switching folders'); expect(labelsFor('reading')).toContain('Trusted senders'); - expect(labelsFor('composing')).toContain('Undo send / send delay'); + expect(labelsFor('composing')).toContain('Undo send'); expect(labelsFor('general')).toContain('Time zone'); expect(labelsFor('organize')).toContain('Nested tags'); }); @@ -80,7 +80,7 @@ describe('settings search index (English messages)', () => { it('finds the newer signature settings on their tabs', () => { expect(labelsFor('composing')).toContain('Signature on new mail'); expect(labelsFor('composing')).toContain('Signature on replies & forwards'); - expect(labelsFor('composing')).toContain('Undo send / send delay'); + expect(labelsFor('composing')).toContain('Undo send'); }); it('marks the rows the Advanced switch hides', () => { diff --git a/lib/settings-schema.ts b/lib/settings-schema.ts index 995bb4e41..5773e2048 100644 --- a/lib/settings-schema.ts +++ b/lib/settings-schema.ts @@ -281,12 +281,12 @@ const ENTRIES: SettingSchemaEntry[] = [ bool('signatureNewMailEnabled', 'composing', `${EB}signature_new_mail.label`), bool('signatureReplyForwardEnabled', 'composing', `${EB}signature_reply_forward.label`), bool('signatureSeparatorEnabled', 'composing', `${EB}signature_separator.label`), - // 10/30/60 read "{seconds} seconds" (send_delay.seconds): formatted. - choice('sendDelaySeconds', 'composing', `${EB}send_delay.label`, [ + // 5/10/30 read "{seconds} seconds" (send_delay.seconds): formatted. + choice('sendDelaySeconds', 'composing', 'compose_words.undo_send.label', [ { value: 0, label: `${EB}send_delay.off` }, + { value: 5, label: null }, { value: 10, label: null }, { value: 30, label: null }, - { value: 60, label: null }, ]), bool('sendConfirmation', 'accessibility', `${A}confirm_send.label`), bool('attachmentReminderEnabled', 'composing', `${EB}attachment_reminder.label`), diff --git a/lib/settings-search.ts b/lib/settings-search.ts index 982b15062..b1d8ea701 100644 --- a/lib/settings-search.ts +++ b/lib/settings-search.ts @@ -167,7 +167,7 @@ export const tabSearchEntries: Record = { { label: 'settings.email_behavior.return_to_list_after_action.label' }, { label: 'settings.accessibility.sections.timing' }, { label: 'settings.accessibility.toast_duration.label' }, - { label: 'settings.email_behavior.send_delay.label' }, + { label: 'compose_words.undo_send.label' }, { label: 'settings.accessibility.confirm_send.label' }, { label: 'settings.accessibility.confirm_delete.label' }, { label: 'settings.accessibility.confirm_archive.label' }, @@ -256,7 +256,7 @@ export const tabSearchEntries: Record = { { label: 'settings.email_behavior.signature_reply_forward.label' }, { label: 'settings.email_behavior.signature_separator.label', advanced: true }, { label: 'settings.composing_page.sections.sending' }, - { label: 'settings.email_behavior.send_delay.label' }, + { label: 'compose_words.undo_send.label' }, { label: 'settings.email_behavior.attachment_reminder.label' }, { label: 'settings.email_behavior.empty_subject_warning.label' }, { label: 'settings.email_behavior.placeholder_warning.label' }, diff --git a/locales/ar/common.json b/locales/ar/common.json index 5bf41a9d5..aa4b6e34b 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6384,6 +6384,12 @@ "not_running": "لم تُرسل. هناك فحص تشترطه مؤسستك لا يعمل حاليًا. حاول مرة أخرى بعد قليل.", "stopped_working": "لم تُرسل. توقف فحص تشترطه مؤسستك عن العمل. أعد تحميل الصفحة. إذا لم يساعد ذلك، أبلغ المسؤول." }, + "compose_words": { + "undo_send": { + "label": "التراجع عن الإرسال", + "description": "يمنحك بضع ثوانٍ لاسترجاع الرسالة بعد الضغط على إرسال." + } + }, "plugin_list": { "filters": "المرشحات", "remove_filter": "إزالة المرشح {label}" diff --git a/locales/ca/common.json b/locales/ca/common.json index eca36135b..a70f2df8d 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6384,6 +6384,12 @@ "not_running": "No s'ha enviat. Una comprovació que exigeix la vostra organització no s'està executant. Torneu-ho a provar d'aquí a un moment.", "stopped_working": "No s'ha enviat. Una comprovació que exigeix la vostra organització ha deixat de funcionar. Torneu a carregar la pàgina. Si no ho soluciona, aviseu l'administrador." }, + "compose_words": { + "undo_send": { + "label": "Desfés l'enviament", + "description": "Us dona uns segons per recuperar un missatge després de prémer Envia." + } + }, "plugin_list": { "filters": "Filtres", "remove_filter": "Treu el filtre {label}" diff --git a/locales/cs/common.json b/locales/cs/common.json index f1f5076dd..9275aea8c 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6384,6 +6384,12 @@ "not_running": "Neodesláno. Kontrola, kterou vaše organizace vyžaduje, právě neběží. Zkuste to za chvíli znovu.", "stopped_working": "Neodesláno. Kontrola, kterou vaše organizace vyžaduje, přestala fungovat. Načtěte stránku znovu. Pokud to nepomůže, obraťte se na správce." }, + "compose_words": { + "undo_send": { + "label": "Vrátit odeslání", + "description": "Dává vám několik sekund na to, abyste zprávu po stisknutí Odeslat vzali zpět." + } + }, "plugin_list": { "filters": "Filtry", "remove_filter": "Odebrat filtr {label}" diff --git a/locales/da/common.json b/locales/da/common.json index 7461d652b..cd5076687 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6384,6 +6384,12 @@ "not_running": "Ikke sendt. En kontrol, som din organisation kræver, kører ikke. Prøv igen om et øjeblik.", "stopped_working": "Ikke sendt. En kontrol, som din organisation kræver, virker ikke længere. Genindlæs siden. Hvis det ikke hjælper, så kontakt din administrator." }, + "compose_words": { + "undo_send": { + "label": "Fortryd afsendelse", + "description": "Giver dig et par sekunder til at tage en besked tilbage, efter du har trykket på Send." + } + }, "plugin_list": { "filters": "Filtre", "remove_filter": "Fjern filteret {label}" diff --git a/locales/de/common.json b/locales/de/common.json index 31d15c0ed..d881a451c 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6384,6 +6384,12 @@ "not_running": "Nicht gesendet. Eine Prüfung, die Ihre Organisation vorschreibt, läuft gerade nicht. Versuchen Sie es gleich noch einmal.", "stopped_working": "Nicht gesendet. Eine Prüfung, die Ihre Organisation vorschreibt, funktioniert nicht mehr. Laden Sie die Seite neu. Wenn das nicht hilft, wenden Sie sich an Ihre Administration." }, + "compose_words": { + "undo_send": { + "label": "Senden rückgängig machen", + "description": "Gibt Ihnen ein paar Sekunden, um eine Nachricht nach dem Klick auf Senden zurückzuholen." + } + }, "plugin_list": { "filters": "Filter", "remove_filter": "Filter {label} entfernen" diff --git a/locales/en/common.json b/locales/en/common.json index e0491ee32..287ef7a97 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6384,6 +6384,12 @@ "not_running": "Not sent. A check your organisation requires isn't running. Try again in a moment.", "stopped_working": "Not sent. A check your organisation requires has stopped working. Reload the page. If that doesn't help, tell your administrator." }, + "compose_words": { + "undo_send": { + "label": "Undo send", + "description": "Gives you a few seconds to take a message back after you press Send." + } + }, "plugin_list": { "filters": "Filters", "remove_filter": "Remove filter {label}" diff --git a/locales/es/common.json b/locales/es/common.json index 1050f0f56..8004e3917 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6384,6 +6384,12 @@ "not_running": "No enviado. Una comprobación que exige tu organización no está en marcha. Vuelve a intentarlo en un momento.", "stopped_working": "No enviado. Una comprobación que exige tu organización ha dejado de funcionar. Vuelve a cargar la página. Si no se soluciona, avisa al administrador." }, + "compose_words": { + "undo_send": { + "label": "Deshacer envío", + "description": "Le da unos segundos para recuperar un mensaje después de pulsar Enviar." + } + }, "plugin_list": { "filters": "Filtros", "remove_filter": "Quitar el filtro {label}" diff --git a/locales/et/common.json b/locales/et/common.json index df425b8bd..4455842c1 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6384,6 +6384,12 @@ "not_running": "Ei saadetud. Teie organisatsiooni nõutud kontroll praegu ei tööta. Proovige hetke pärast uuesti.", "stopped_working": "Ei saadetud. Teie organisatsiooni nõutud kontroll ei tööta enam. Laadige leht uuesti. Kui see ei aita, teatage administraatorile." }, + "compose_words": { + "undo_send": { + "label": "Saatmise tühistamine", + "description": "Annab sulle pärast Saada vajutamist mõne sekundi, et kiri tagasi võtta." + } + }, "plugin_list": { "filters": "Filtrid", "remove_filter": "Eemalda filter {label}" diff --git a/locales/fa/common.json b/locales/fa/common.json index bf242d98d..e7e78d762 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6384,6 +6384,12 @@ "not_running": "ارسال نشد. بررسی‌ای که سازمان شما الزامی کرده در حال اجرا نیست. چند لحظه دیگر دوباره تلاش کنید.", "stopped_working": "ارسال نشد. بررسی‌ای که سازمان شما الزامی کرده دیگر کار نمی‌کند. صفحه را دوباره بارگیری کنید. اگر کمکی نکرد، به مدیر سیستم خبر دهید." }, + "compose_words": { + "undo_send": { + "label": "لغو ارسال", + "description": "پس از زدن ارسال، چند ثانیه فرصت می‌دهد تا پیام را پس بگیرید." + } + }, "plugin_list": { "filters": "فیلترها", "remove_filter": "حذف فیلتر {label}" diff --git a/locales/fr/common.json b/locales/fr/common.json index 03545491c..86156651d 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6384,6 +6384,12 @@ "not_running": "Non envoyé. Une vérification exigée par votre organisation ne fonctionne pas. Réessayez dans un instant.", "stopped_working": "Non envoyé. Une vérification exigée par votre organisation ne fonctionne plus. Rechargez la page. Si cela ne suffit pas, prévenez votre administrateur." }, + "compose_words": { + "undo_send": { + "label": "Annuler l'envoi", + "description": "Vous laisse quelques secondes pour reprendre un message après avoir cliqué sur Envoyer." + } + }, "plugin_list": { "filters": "Filtres", "remove_filter": "Retirer le filtre {label}" diff --git a/locales/he/common.json b/locales/he/common.json index f8258a747..6953652ce 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6385,6 +6385,12 @@ "not_running": "לא נשלח. בדיקה שהארגון שלך מחייב לא פועלת כרגע. נסו שוב בעוד רגע.", "stopped_working": "לא נשלח. בדיקה שהארגון שלך מחייב הפסיקה לפעול. טענו את הדף מחדש. אם זה לא עוזר, פנו למנהל המערכת." }, + "compose_words": { + "undo_send": { + "label": "ביטול שליחה", + "description": "נותן לך כמה שניות להחזיר הודעה אחרי שלחצת על שלח." + } + }, "plugin_list": { "filters": "מסננים", "remove_filter": "הסר את המסנן {label}" diff --git a/locales/hu/common.json b/locales/hu/common.json index bd65a8ec0..534eec4cb 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6384,6 +6384,12 @@ "not_running": "Nincs elküldve. Egy, a szervezete által előírt ellenőrzés most nem fut. Próbálja újra egy pillanat múlva.", "stopped_working": "Nincs elküldve. Egy, a szervezete által előírt ellenőrzés leállt. Töltse be újra az oldalt. Ha ez nem segít, szóljon a rendszergazdának." }, + "compose_words": { + "undo_send": { + "label": "Küldés visszavonása", + "description": "A Küldés megnyomása után néhány másodpercet ad az üzenet visszavonására." + } + }, "plugin_list": { "filters": "Szűrők", "remove_filter": "{label} szűrő eltávolítása" diff --git a/locales/it/common.json b/locales/it/common.json index b79013a3a..f7cdeb69d 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6384,6 +6384,12 @@ "not_running": "Non inviato. Un controllo richiesto dalla tua organizzazione non è attivo. Riprova tra un momento.", "stopped_working": "Non inviato. Un controllo richiesto dalla tua organizzazione ha smesso di funzionare. Ricarica la pagina. Se non basta, avvisa l'amministratore." }, + "compose_words": { + "undo_send": { + "label": "Annulla invio", + "description": "Ti dà qualche secondo per riprendere un messaggio dopo aver premuto Invia." + } + }, "plugin_list": { "filters": "Filtri", "remove_filter": "Rimuovi il filtro {label}" diff --git a/locales/ja/common.json b/locales/ja/common.json index 0fc511808..6352c0c2e 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6384,6 +6384,12 @@ "not_running": "送信されませんでした。組織で必須のチェックが動作していません。しばらくしてからもう一度お試しください。", "stopped_working": "送信されませんでした。組織で必須のチェックが動作しなくなりました。ページを再読み込みしてください。解決しない場合は管理者に連絡してください。" }, + "compose_words": { + "undo_send": { + "label": "送信取り消し", + "description": "送信を押した後、数秒間メッセージを取り消せます。" + } + }, "plugin_list": { "filters": "フィルター", "remove_filter": "フィルター「{label}」を解除" diff --git a/locales/ko/common.json b/locales/ko/common.json index 21bc7b936..3be04c536 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6384,6 +6384,12 @@ "not_running": "보내지 않았습니다. 조직에서 요구하는 검사가 실행되고 있지 않습니다. 잠시 후 다시 시도하세요.", "stopped_working": "보내지 않았습니다. 조직에서 요구하는 검사가 작동을 멈췄습니다. 페이지를 새로 고치세요. 그래도 안 되면 관리자에게 알리세요." }, + "compose_words": { + "undo_send": { + "label": "보내기 취소", + "description": "보내기를 누른 후 몇 초 동안 메시지를 되돌릴 수 있습니다." + } + }, "plugin_list": { "filters": "필터", "remove_filter": "{label} 필터 제거" diff --git a/locales/lv/common.json b/locales/lv/common.json index 2958f811c..2d99614c6 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6384,6 +6384,12 @@ "not_running": "Nav nosūtīts. Jūsu organizācijas pieprasītā pārbaude pašlaik nedarbojas. Mēģiniet vēlreiz pēc brīža.", "stopped_working": "Nav nosūtīts. Jūsu organizācijas pieprasītā pārbaude vairs nedarbojas. Pārlādējiet lapu. Ja tas nepalīdz, sazinieties ar administratoru." }, + "compose_words": { + "undo_send": { + "label": "Atsaukt sūtīšanu", + "description": "Dod jums dažas sekundes, lai pēc Sūtīt nospiešanas paņemtu ziņojumu atpakaļ." + } + }, "plugin_list": { "filters": "Filtri", "remove_filter": "Noņemt filtru {label}" diff --git a/locales/mn/common.json b/locales/mn/common.json index 57128748e..e633fd898 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6384,6 +6384,12 @@ "not_running": "Илгээгдсэнгүй. Танай байгууллагын шаарддаг шалгалт одоогоор ажиллахгүй байна. Хэсэг хугацааны дараа дахин оролдоно уу.", "stopped_working": "Илгээгдсэнгүй. Танай байгууллагын шаарддаг шалгалт ажиллахаа больсон. Хуудсыг дахин ачаална уу. Тус болохгүй бол админдаа мэдэгдээрэй." }, + "compose_words": { + "undo_send": { + "label": "Илгээлтийг буцаах", + "description": "Илгээх товчийг дарсны дараа мессежээ буцаах хэдэн секунд өгнө." + } + }, "plugin_list": { "filters": "Шүүлтүүр", "remove_filter": "{label} шүүлтүүрийг хасах" diff --git a/locales/nb/common.json b/locales/nb/common.json index 27c9f7642..c1d848c1b 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6384,6 +6384,12 @@ "not_running": "Ikke sendt. En kontroll organisasjonen din krever, kjører ikke. Prøv igjen om et øyeblikk.", "stopped_working": "Ikke sendt. En kontroll organisasjonen din krever, har sluttet å virke. Last inn siden på nytt. Hvis det ikke hjelper, si fra til administratoren." }, + "compose_words": { + "undo_send": { + "label": "Angre sending", + "description": "Gir deg noen sekunder til å ta tilbake en melding etter at du har trykket Send." + } + }, "plugin_list": { "filters": "Filtre", "remove_filter": "Fjern filteret {label}" diff --git a/locales/nl/common.json b/locales/nl/common.json index 69e5afbcc..edaf8141a 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6384,6 +6384,12 @@ "not_running": "Niet verzonden. Een controle die je organisatie verplicht stelt, draait niet. Probeer het zo meteen opnieuw.", "stopped_working": "Niet verzonden. Een controle die je organisatie verplicht stelt, werkt niet meer. Laad de pagina opnieuw. Helpt dat niet, meld het dan bij je beheerder." }, + "compose_words": { + "undo_send": { + "label": "Verzenden ongedaan maken", + "description": "Geeft je een paar seconden om een bericht terug te halen nadat je op Verzenden hebt gedrukt." + } + }, "plugin_list": { "filters": "Filters", "remove_filter": "Filter {label} verwijderen" diff --git a/locales/pl/common.json b/locales/pl/common.json index 35e69ac26..65ad39388 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6384,6 +6384,12 @@ "not_running": "Nie wysłano. Kontrola wymagana przez Twoją organizację nie działa. Spróbuj ponownie za chwilę.", "stopped_working": "Nie wysłano. Kontrola wymagana przez Twoją organizację przestała działać. Odśwież stronę. Jeśli to nie pomoże, zgłoś to administratorowi." }, + "compose_words": { + "undo_send": { + "label": "Cofnij wysyłkę", + "description": "Daje kilka sekund na wycofanie wiadomości po naciśnięciu Wyślij." + } + }, "plugin_list": { "filters": "Filtry", "remove_filter": "Usuń filtr {label}" diff --git a/locales/pt/common.json b/locales/pt/common.json index 7a094b8ea..a61acd6ee 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6384,6 +6384,12 @@ "not_running": "Não enviado. Uma verificação exigida pela sua organização não está em execução. Tente novamente daqui a pouco.", "stopped_working": "Não enviado. Uma verificação exigida pela sua organização deixou de funcionar. Recarregue a página. Se não resolver, avise o administrador." }, + "compose_words": { + "undo_send": { + "label": "Desfazer envio", + "description": "Dá alguns segundos para você recuperar uma mensagem depois de clicar em Enviar." + } + }, "plugin_list": { "filters": "Filtros", "remove_filter": "Remover o filtro {label}" diff --git a/locales/ro/common.json b/locales/ro/common.json index 1c3d817ce..1728249f2 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6384,6 +6384,12 @@ "not_running": "Nu a fost trimis. O verificare cerută de organizația ta nu rulează. Încearcă din nou peste o clipă.", "stopped_working": "Nu a fost trimis. O verificare cerută de organizația ta nu mai funcționează. Reîncarcă pagina. Dacă nu ajută, anunță administratorul." }, + "compose_words": { + "undo_send": { + "label": "Anulează trimiterea", + "description": "Îți oferă câteva secunde să retragi un mesaj după ce apeși Trimite." + } + }, "plugin_list": { "filters": "Filtre", "remove_filter": "Eliminați filtrul {label}" diff --git a/locales/ru/common.json b/locales/ru/common.json index c5c112ea2..e1e631ac4 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6384,6 +6384,12 @@ "not_running": "Не отправлено. Проверка, которую требует ваша организация, сейчас не работает. Повторите попытку через минуту.", "stopped_working": "Не отправлено. Проверка, которую требует ваша организация, перестала работать. Перезагрузите страницу. Если это не поможет, сообщите администратору." }, + "compose_words": { + "undo_send": { + "label": "Отмена отправки", + "description": "Даёт несколько секунд, чтобы вернуть письмо после нажатия «Отправить»." + } + }, "plugin_list": { "filters": "Фильтры", "remove_filter": "Убрать фильтр {label}" diff --git a/locales/sk/common.json b/locales/sk/common.json index 81d50e05d..68f9fc879 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6384,6 +6384,12 @@ "not_running": "Neodoslané. Kontrola, ktorú vyžaduje vaša organizácia, práve nebeží. Skúste to o chvíľu znova.", "stopped_working": "Neodoslané. Kontrola, ktorú vyžaduje vaša organizácia, prestala fungovať. Načítajte stránku znova. Ak to nepomôže, obráťte sa na správcu." }, + "compose_words": { + "undo_send": { + "label": "Vrátiť odoslanie", + "description": "Dáva vám niekoľko sekúnd na to, aby ste správu po stlačení Odoslať vzali späť." + } + }, "plugin_list": { "filters": "Filtre", "remove_filter": "Odstrániť filter {label}" diff --git a/locales/th/common.json b/locales/th/common.json index c34c371f1..c172e5bd5 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -3305,6 +3305,12 @@ "not_running": "ยังไม่ได้ส่ง การตรวจสอบที่องค์กรของคุณกำหนดไม่ได้ทำงานอยู่ ลองอีกครั้งในอีกสักครู่", "stopped_working": "ยังไม่ได้ส่ง การตรวจสอบที่องค์กรของคุณกำหนดหยุดทำงาน โหลดหน้านี้ใหม่ หากยังไม่ได้ผล โปรดแจ้งผู้ดูแลระบบ" }, + "compose_words": { + "undo_send": { + "label": "ยกเลิกการส่ง", + "description": "ให้เวลาคุณสองสามวินาทีเพื่อเรียกข้อความกลับหลังจากกดส่ง" + } + }, "plugin_list": { "filters": "ตัวกรอง", "remove_filter": "นำตัวกรอง {label} ออก" diff --git a/locales/tr/common.json b/locales/tr/common.json index 43380e45c..de9c5d653 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6384,6 +6384,12 @@ "not_running": "Gönderilmedi. Kuruluşunuzun zorunlu tuttuğu bir denetim şu anda çalışmıyor. Biraz sonra yeniden deneyin.", "stopped_working": "Gönderilmedi. Kuruluşunuzun zorunlu tuttuğu bir denetim çalışmayı durdurdu. Sayfayı yeniden yükleyin. İşe yaramazsa yöneticinize bildirin." }, + "compose_words": { + "undo_send": { + "label": "Göndermeyi geri al", + "description": "Gönder'e bastıktan sonra iletiyi geri almanız için birkaç saniye verir." + } + }, "plugin_list": { "filters": "Filtreler", "remove_filter": "{label} filtresini kaldır" diff --git a/locales/uk/common.json b/locales/uk/common.json index d8d7ecf49..9c8f5634f 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6384,6 +6384,12 @@ "not_running": "Не надіслано. Перевірка, якої вимагає ваша організація, зараз не працює. Спробуйте ще раз за мить.", "stopped_working": "Не надіслано. Перевірка, якої вимагає ваша організація, перестала працювати. Перезавантажте сторінку. Якщо це не допоможе, повідомте адміністратора." }, + "compose_words": { + "undo_send": { + "label": "Скасувати надсилання", + "description": "Дає кілька секунд, щоб повернути лист після натискання «Надіслати»." + } + }, "plugin_list": { "filters": "Фільтри", "remove_filter": "Прибрати фільтр {label}" diff --git a/locales/vi/common.json b/locales/vi/common.json index ccb97537d..f03718003 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6396,6 +6396,12 @@ "not_running": "Chưa gửi. Một bước kiểm tra mà tổ chức của bạn yêu cầu hiện không chạy. Hãy thử lại sau giây lát.", "stopped_working": "Chưa gửi. Một bước kiểm tra mà tổ chức của bạn yêu cầu đã ngừng hoạt động. Hãy tải lại trang. Nếu vẫn không được, hãy báo cho quản trị viên." }, + "compose_words": { + "undo_send": { + "label": "Hoàn tác gửi", + "description": "Cho bạn vài giây để lấy lại thư sau khi nhấn Gửi." + } + }, "plugin_list": { "filters": "Bộ lọc", "remove_filter": "Xóa bộ lọc {label}" diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 3bc47f02b..f10bfecdd 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6384,6 +6384,12 @@ "not_running": "未傳送。貴組織要求的一項檢查目前未執行。請稍後再試。", "stopped_working": "未傳送。貴組織要求的一項檢查已停止運作。請重新載入頁面。如果仍無法解決,請告知管理員。" }, + "compose_words": { + "undo_send": { + "label": "取消傳送", + "description": "按下傳送後,給您幾秒鐘撤回郵件。" + } + }, "plugin_list": { "filters": "篩選", "remove_filter": "移除篩選條件 {label}" diff --git a/locales/zh/common.json b/locales/zh/common.json index d4646cf11..ba262f0c1 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6384,6 +6384,12 @@ "not_running": "未发送。你的组织要求的一项检查当前未运行。请稍后再试。", "stopped_working": "未发送。你的组织要求的一项检查已停止工作。请重新加载页面。如果仍不行,请告诉管理员。" }, + "compose_words": { + "undo_send": { + "label": "撤销发送", + "description": "点击发送后,给您几秒钟时间撤回邮件。" + } + }, "plugin_list": { "filters": "筛选", "remove_filter": "移除筛选条件 {label}" diff --git a/stores/__tests__/settings-store-policy-defaults.test.ts b/stores/__tests__/settings-store-policy-defaults.test.ts index f4eaf1b88..006fa5ba5 100644 --- a/stores/__tests__/settings-store-policy-defaults.test.ts +++ b/stores/__tests__/settings-store-policy-defaults.test.ts @@ -87,7 +87,7 @@ describe('admin policy defaults (#1248)', () => { expect('notASetting' in settings()).toBe(false); expect(settings().showSharedMailAccounts).toBe(true); expect(settings().emailsPerPage).toBe(50); - expect(settings().sendDelaySeconds).toBe(0); + expect(settings().sendDelaySeconds).toBe(5); }); it('respects the allowed values of a restriction', () => { diff --git a/stores/__tests__/settings-undo-send-default.test.ts b/stores/__tests__/settings-undo-send-default.test.ts new file mode 100644 index 000000000..e3078cc0c --- /dev/null +++ b/stores/__tests__/settings-undo-send-default.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, it, expect } from 'vitest'; +import { useSettingsStore, migrateSettings, undoSendOptions } from '../settings-store'; + +// Undo send is on by default with 5 seconds; it was off (0) before. Off that +// the user never picked moves to 5, Off they picked stays. + +const migrate = (state: Record, version: number) => + migrateSettings(state, version) as unknown as Record; + +describe('undo send default', () => { + beforeEach(() => { + useSettingsStore.getState().resetToDefaults(); + }); + + it('gives 5 seconds to undo on a fresh install', () => { + expect(useSettingsStore.getState().sendDelaySeconds).toBe(5); + }); + + it('turns on undo send for someone who never chose Off', () => { + expect(migrate({ sendDelaySeconds: 0, explicitSettings: [] }, 12).sendDelaySeconds).toBe(5); + }); + + it('keeps Off that the user chose', () => { + const out = migrate({ sendDelaySeconds: 0, explicitSettings: ['sendDelaySeconds'] }, 12); + expect(out.sendDelaySeconds).toBe(0); + }); + + it('keeps a delay the user chose, 60 seconds included', () => { + expect(migrate({ sendDelaySeconds: 30, explicitSettings: ['sendDelaySeconds'] }, 12).sendDelaySeconds).toBe(30); + expect(migrate({ sendDelaySeconds: 60 }, 7).sendDelaySeconds).toBe(60); + }); + + it('does not take Off from before explicitSettings existed as chosen', () => { + const out = migrate({ sendDelaySeconds: 0 }, 7); + expect(out.sendDelaySeconds).toBe(5); + expect(out.explicitSettings).not.toContain('sendDelaySeconds'); + }); + + it('turns undo send on in a server copy or export from an older build', () => { + const store = useSettingsStore.getState(); + expect(store.importSettings(JSON.stringify({ sendDelaySeconds: 0, explicitSettings: [] }))).toBe(true); + expect(useSettingsStore.getState().sendDelaySeconds).toBe(5); + + expect(store.importSettings(JSON.stringify({ sendDelaySeconds: 0, explicitSettings: ['sendDelaySeconds'] }))).toBe(true); + expect(useSettingsStore.getState().sendDelaySeconds).toBe(0); + }); + + it('keeps Off once the user picks it', () => { + useSettingsStore.getState().updateSetting('sendDelaySeconds', 0); + const exported = JSON.parse(useSettingsStore.getState().exportSettings()) as Record; + expect(migrate(exported, 13).sendDelaySeconds).toBe(0); + }); + + it('offers Off, 5, 10 and 30 seconds, and 60 only to someone who has it', () => { + expect(undoSendOptions(5)).toEqual([0, 5, 10, 30]); + expect(undoSendOptions(60)).toEqual([0, 5, 10, 30, 60]); + }); +}); diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 1d771cb5f..b140b29a8 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -359,7 +359,18 @@ export type CalendarHoverPreview = 'off' | 'instant' | 'delay-500ms' | 'delay-1s */ export type MonthTimedEventStyle = 'bar' | 'text' | 'dot'; export const MONTH_TIMED_EVENT_STYLES: readonly MonthTimedEventStyle[] = ['bar', 'text', 'dot']; -export type SendDelaySeconds = 0 | 10 | 30 | 60; +export type SendDelaySeconds = 0 | 5 | 10 | 30 | 60; +/** + * The undo send windows a setting may hold. The page offers Off, 5, 10 and + * 30 seconds; 60 was offered before and stays valid for those who chose it. + */ +export const SEND_DELAY_SECONDS: readonly SendDelaySeconds[] = [0, 5, 10, 30, 60]; +const isSendDelaySeconds = (value: unknown): value is SendDelaySeconds => + SEND_DELAY_SECONDS.includes(value as SendDelaySeconds); +/** The choices the settings pages offer: 60 only to someone who has it. */ +export function undoSendOptions(current: SendDelaySeconds): SendDelaySeconds[] { + return SEND_DELAY_SECONDS.filter((seconds) => seconds !== 60 || current === 60); +} export type ProtocolOpenMode = 'active-session' | 'new-tab'; /** @@ -973,7 +984,8 @@ const DEFAULT_SETTINGS = { writingLanguage: 'auto', rtlEditingSupport: false, subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER, - sendDelaySeconds: 0 as SendDelaySeconds, + // Undo send is on out of the box, as in other mail apps (v13). + sendDelaySeconds: 5 as SendDelaySeconds, signaturePosition: 'below_quote' as SignaturePosition, // On, as before the toggles existed: saved settings and server copies // from older builds lack these keys and fall back to this default. @@ -1182,7 +1194,7 @@ function isPolicySettingKey(key: string): boolean { function isAllowedPolicyValue(key: string, value: unknown, restriction?: SettingRestriction): boolean { if (!isPolicySettingKey(key) || typeof value !== typeof builtInDefault(key)) return false; if (typeof value === 'number' && !Number.isFinite(value)) return false; - if (key === 'sendDelaySeconds' && ![0, 10, 30, 60].includes(value as number)) return false; + if (key === 'sendDelaySeconds' && !isSendDelaySeconds(value)) return false; if (key === 'subAddressDelimiter' && !isValidSubAddressDelimiter(value as string)) return false; if (key === 'messageListOrderScope' && value !== 'inbox' && value !== 'all') return false; if (key === 'searchDefaultScope' && !isSearchDefaultScope(value)) return false; @@ -1627,6 +1639,7 @@ export const useSettingsStore = create()( migrateAnimationsSetting(settings); migrateNotificationSoundDefault(settings); migrateTintListRowsDefault(settings); + migrateUndoSendDefault(settings); migrateAttachmentPosition(settings); // Apply settings, noting which keys were taken over for the @@ -1645,7 +1658,7 @@ export const useSettingsStore = create()( if (key === 'subAddressDelimiter' && !isValidSubAddressDelimiter(settings[key])) { return; } - if (key === 'sendDelaySeconds' && ![0, 10, 30, 60].includes(settings[key])) { + if (key === 'sendDelaySeconds' && !isSendDelaySeconds(settings[key])) { put(key, { sendDelaySeconds: 0 }); return; } @@ -2043,7 +2056,7 @@ export const useSettingsStore = create()( }, { name: 'settings-storage', - version: 12, + version: 13, migrate: migrateSettings, onRehydrateStorage: () => { return (state) => { @@ -2094,7 +2107,7 @@ export function migrateSettings(persisted: unknown, version: number): SettingsSt state.density = state.listDensity; delete state.listDensity; } - if (![0, 10, 30, 60].includes(state.sendDelaySeconds as number)) { + if (!isSendDelaySeconds(state.sendDelaySeconds)) { state.sendDelaySeconds = 0; } if (version < 3 && typeof state.protocolOpenMode !== 'string' && typeof state.protocolMailtoOpenMode === 'string') { @@ -2154,6 +2167,7 @@ export function migrateSettings(persisted: unknown, version: number): SettingsSt // taken as chosen below. migrateNotificationSoundDefault(state); migrateTintListRowsDefault(state); + migrateUndoSendDefault(state); // The old default must not be taken as chosen below either. migrateAttachmentPosition(state); // v8: admin policy defaults (#1248) skip settings the user chose. @@ -2218,6 +2232,18 @@ export function migrateTintListRowsDefault(state: Record): void if (!chosen) state.tintListRowsByTag = false; } +/** + * v13: undo send is on by default, with 5 seconds. Off (0) that the user + * never picked was only the old default and moves to 5; Off they picked + * stays. Imports and server copies from older builds still carry the 0 and + * go through this too. + */ +export function migrateUndoSendDefault(state: Record): void { + if (state.sendDelaySeconds !== 0) return; + const chosen = Array.isArray(state.explicitSettings) && state.explicitSettings.includes('sendDelaySeconds'); + if (!chosen) state.sendDelaySeconds = 5; +} + // Helper functions to apply settings to DOM /** The settings applyAccessibilityTo() writes onto . */ 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 04/34] 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 461a7daf4f861694bac9b9e3c9bc254e21754b34 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:24:26 +0100 Subject: [PATCH 05/34] feat: let a composer move to its own window from a browser tab, as in Gmail --- .../popout/__tests__/popout-host.test.tsx | 27 ++++++++++- components/popout/popout-host.tsx | 5 +- hooks/use-popouts-available.ts | 21 +++++---- lib/__tests__/popout-windows.test.ts | 47 +++++++++++++++++++ lib/popout/support.ts | 31 ++++++++++-- 5 files changed, 113 insertions(+), 18 deletions(-) diff --git a/components/popout/__tests__/popout-host.test.tsx b/components/popout/__tests__/popout-host.test.tsx index e8a0dd03e..1627d07a7 100644 --- a/components/popout/__tests__/popout-host.test.tsx +++ b/components/popout/__tests__/popout-host.test.tsx @@ -92,9 +92,20 @@ afterEach(() => { const answers = () => heard.filter((message) => message.type === 'session-answer'); -describe('in an ordinary browser tab', () => { +/** A browser tab: not installed, with or without a mouse. */ +function stubTab(fine: boolean) { + delete window.bulwarkDesktop; + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: query.includes('pointer: fine') ? fine : false, + addEventListener() {}, + removeEventListener() {}, + })); + window.matchMedia = globalThis.matchMedia; +} + +describe('in a browser tab on a phone or tablet', () => { it('runs nothing: no channel and no lock', async () => { - delete window.bulwarkDesktop; + stubTab(false); const opened = FakeBroadcastChannel.open.size; render(); await settle(); @@ -106,6 +117,18 @@ describe('in an ordinary browser tab', () => { }); }); +describe('in a browser tab on a computer', () => { + it('lends its session to a composer popped out of it', async () => { + stubTab(true); + render(); + await settle(); + await expect(requestWindowSession({}, 2000)).resolves.toMatchObject({ + activeAccountId: 'alice', + sessions: [{ accountId: 'alice', authMode: 'oauth' }], + }); + }); +}); + describe('PopoutHost', () => { it('holds no lock while idle, which would keep the page out of the back/forward cache', async () => { render(); diff --git a/components/popout/popout-host.tsx b/components/popout/popout-host.tsx index 4239c73b6..fc7305e60 100644 --- a/components/popout/popout-host.tsx +++ b/components/popout/popout-host.tsx @@ -60,8 +60,9 @@ function claimOnce(name: string, work: () => void | Promise): void { /** * The main window's side of pop-outs: lends them its session and renews it * for them, passes on that mail changed, and takes over what a pop-out can't - * say any more once it has closed. Only where pop-outs are on offer: an - * ordinary browser tab runs none of this. + * say any more once it has closed. Only where pop-outs are on offer: a + * browser tab on a computer runs it for composers popped out of it, one on a + * phone or tablet runs none of this. */ export function PopoutHost() { const available = usePopoutsAvailable(); diff --git a/hooks/use-popouts-available.ts b/hooks/use-popouts-available.ts index 7628846a0..9e391d73d 100644 --- a/hooks/use-popouts-available.ts +++ b/hooks/use-popouts-available.ts @@ -7,27 +7,30 @@ import { resolveEmailBlobContext } from "@/stores/email-store"; import { toast } from "@/stores/toast-store"; import type { Email } from "@/lib/jmap/types"; import { openMessageWindow } from "@/lib/popout/open"; -import { popoutsAvailable } from "@/lib/popout/support"; +import { composerPopoutsAvailable, popoutsAvailable } from "@/lib/popout/support"; import { isPopoutWindow } from "@/lib/popout/window-role"; /** - * Whether to offer "Open in new window" here. False on the server and in the - * first render, so the markup hydrates the same; false in the demo, whose - * client has no session to share, and inside a pop-out (for a message: it is - * already in its own window). + * Whether to offer "Open in new window" here: for a composer, the default, + * also in a browser tab on a computer; for a message only in an installed or + * the desktop app. False on the server and in the first render, so the + * markup hydrates the same; false in the demo, whose client has no session to + * share, and inside a pop-out (for a message: it is already in its own window). + * The main window's side of pop-outs (PopoutHost) runs wherever a composer can + * move out. */ -export function usePopoutsAvailable(): boolean { +export function usePopoutsAvailable(kind: "compose" | "message" = "compose"): boolean { const isDemoMode = useAuthStore((s) => s.isDemoMode); const [available, setAvailable] = useState(false); useEffect(() => { - const check = () => setAvailable(popoutsAvailable() && !isPopoutWindow()); + const check = () => setAvailable((kind === "message" ? popoutsAvailable() : composerPopoutsAvailable()) && !isPopoutWindow()); check(); // Installing the running tab turns it into a window without a reload. if (typeof window.matchMedia !== "function") return; const media = window.matchMedia("(display-mode: standalone)"); media.addEventListener?.("change", check); return () => media.removeEventListener?.("change", check); - }, []); + }, [kind]); return available && !isDemoMode; } @@ -37,7 +40,7 @@ export function usePopoutsAvailable(): boolean { * blocks the window; nothing else changes then. */ export function useOpenEmailInWindow(): ((email: Email) => void) | null { - const available = usePopoutsAvailable(); + const available = usePopoutsAvailable("message"); const t = useTranslations("pwaWindows"); const open = useCallback((email: Email) => { const auth = useAuthStore.getState(); diff --git a/lib/__tests__/popout-windows.test.ts b/lib/__tests__/popout-windows.test.ts index f3675c552..a041f2114 100644 --- a/lib/__tests__/popout-windows.test.ts +++ b/lib/__tests__/popout-windows.test.ts @@ -132,6 +132,53 @@ describe('popoutsAvailable', () => { }); }); +describe('composerPopoutsAvailable', () => { + async function available() { + const { composerPopoutsAvailable } = await import('@/lib/popout/support'); + return composerPopoutsAvailable(); + } + + it('is on in a browser tab on a computer: a composer opens as a popup window, as in Gmail', async () => { + stubDisplay({ standalone: false }); + expect(await available()).toBe(true); + }); + + it('is off in a browser tab on a phone or tablet, where window.open makes a tab', async () => { + stubDisplay({ standalone: false, fine: false }); + expect(await available()).toBe(false); + }); + + it('is on wherever a message can move out too', async () => { + stubDisplay(); + expect(await available()).toBe(true); + stubDisplay({ standalone: false, fine: false }); + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: {} }; + expect(await available()).toBe(true); + }); + + it('is off in an installed app on a touch device', async () => { + stubDisplay({ fine: false }); + expect(await available()).toBe(false); + }); + + it('is off in a tab without what keeps a shared session safe', async () => { + stubDisplay({ standalone: false }); + vi.stubGlobal('BroadcastChannel', undefined); + expect(await available()).toBe(false); + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); + vi.stubGlobal('crypto', {}); + expect(await available()).toBe(false); + }); + + it('lets the hook offer a composer its own window from a tab, and still not a message', async () => { + stubDisplay({ standalone: false }); + const { renderHook } = await import('@testing-library/react'); + const { usePopoutsAvailable } = await import('@/hooks/use-popouts-available'); + expect(renderHook(() => usePopoutsAvailable()).result.current).toBe(true); + expect(renderHook(() => usePopoutsAvailable('message')).result.current).toBe(false); + }, 60_000); +}); + describe('opening windows', () => { it('opens a message as a popup in the app, with its login and account', async () => { const { openMessageWindow } = await import('@/lib/popout/open'); diff --git a/lib/popout/support.ts b/lib/popout/support.ts index f06f22aeb..150a6380b 100644 --- a/lib/popout/support.ts +++ b/lib/popout/support.ts @@ -1,7 +1,8 @@ /** * Whether "Open in new window" is on offer: an installed app or the desktop * app, on a device with windows. In a browser tab, a tab does the same job - * and the app behaves as it always has. + * for a message, and the app behaves as it always has. A composer is the + * exception: it moves to a window of its own from a tab too, as in Gmail. */ import { IS_LITE } from '@/lib/lite'; @@ -13,16 +14,36 @@ export function hasDesktopWindows(): boolean { return typeof window !== 'undefined' && !!window.bulwarkDesktop?.windows; } -/** Display mode is `browser` in Electron popups too, hence the bridge check. */ -export function popoutsAvailable(): boolean { +/** A second window can share this one's session, safely. */ +function windowsCanShareSessions(): boolean { // Lite has no such route: a static export can't list every message id. if (IS_LITE || typeof window === 'undefined') return false; // Sharing the session and renewing tokens safely across windows need these. - if (typeof BroadcastChannel === 'undefined' || !navigator.locks || !canSealSessions()) return false; + return typeof BroadcastChannel !== 'undefined' && !!navigator.locks && canSealSessions(); +} + +/** A device with a mouse or trackpad: a phone or tablet has no second window. */ +function hasFinePointer(): boolean { + return typeof window.matchMedia === 'function' && window.matchMedia('(pointer: fine)').matches; +} + +/** Display mode is `browser` in Electron popups too, hence the bridge check. */ +export function popoutsAvailable(): boolean { + if (!windowsCanShareSessions()) return false; if (hasDesktopWindows()) return true; if (!isStandaloneDisplayMode()) return false; // A home-screen app on a phone or tablet has no second window: window.open // leaves for the browser there. if ((window.navigator as Navigator & { standalone?: boolean }).standalone === true) return false; - return typeof window.matchMedia === 'function' && window.matchMedia('(pointer: fine)').matches; + return hasFinePointer(); +} + +/** + * A composer can move to a window of its own: wherever a message can, and in + * a browser tab on a computer too, where it opens as a popup window. + */ +export function composerPopoutsAvailable(): boolean { + if (popoutsAvailable()) return true; + if (!windowsCanShareSessions() || isStandaloneDisplayMode()) return false; + return hasFinePointer(); } From 08dcff789f3c2347ddf865dd80ad06374381621c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:24:27 +0100 Subject: [PATCH 06/34] feat: add a full screen button to the composer, Escape brings it back --- .../composer-windows-layout.test.tsx | 37 ++++++++++++++++++- components/email/email-composer.tsx | 29 ++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/components/email/__tests__/composer-windows-layout.test.tsx b/components/email/__tests__/composer-windows-layout.test.tsx index fd1ccd18c..a5a5dd5a4 100644 --- a/components/email/__tests__/composer-windows-layout.test.tsx +++ b/components/email/__tests__/composer-windows-layout.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, within } from '@testing-library/react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import React from 'react'; import { EmailComposer } from '../email-composer'; @@ -222,6 +222,41 @@ describe('a composer in a window of its own', () => { }); }); +describe('full screen', () => { + it('puts the composer over the whole window, and the same button puts it back', () => { + render(); + const root = screen.getByTestId('email-composer'); + expect(root).not.toHaveAttribute('data-maximized'); + + fireEvent.click(screen.getByRole('button', { name: 'maximize' })); + expect(root).toHaveAttribute('data-maximized'); + expect(root.className).toMatch(/(^|\s)fixed(\s|$)/); + expect(root.className).toContain('inset-0'); + + fireEvent.click(screen.getByRole('button', { name: 'restore' })); + expect(root).not.toHaveAttribute('data-maximized'); + expect(root.className).not.toMatch(/(^|\s)fixed(\s|$)/); + }); + + it('goes back with Escape, without closing the composer', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'maximize' })); + const root = screen.getByTestId('email-composer'); + + fireEvent.keyDown(screen.getByRole('button', { name: 'restore' }), { key: 'Escape' }); + + expect(root).not.toHaveAttribute('data-maximized'); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.queryByRole('alertdialog')).toBeNull(); + }); + + it('is not offered in a window of its own, which is all composer already', () => { + render(); + expect(screen.queryByRole('button', { name: 'maximize' })).toBeNull(); + }); +}); + describe('a composer in the main window', () => { it('keeps the phone layout for a phone: Send in the header there, toolbar folded', () => { render(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 42a8fb93b..dbbe94385 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -1199,6 +1199,10 @@ export function EmailComposer({ // A pop-out or quick compose window is narrower than a tablet, but it is a // window on a computer: it keeps the main window's layout, not the phone's. const phoneLayout = !ownWindow; + // Full screen: the composer over the whole window, as Gmail has it. The same + // button or Escape puts it back. Not in a window of its own, which is all composer. + const [maximized, setMaximized] = useState(false); + const canMaximize = !ownWindow && !mvWindow; // "close" (X, Escape): save or discard. "discard" (the Discard button): the // user already chose, so only confirm it. const [closeDialogKind, setCloseDialogKind] = useState<'close' | 'discard'>('close'); @@ -4668,6 +4672,11 @@ export function EmailComposer({ // And for proofreading's popup and panel: Escape closes only them. if (proofreading.popup || proofreading.panel) return; e.preventDefault(); + // Full screen first goes back to where the composer was. + if (maximized) { + setMaximized(false); + return; + } handleClose(); return; } @@ -4701,7 +4710,13 @@ export function EmailComposer({ data-testid="email-composer" // Edits made while the draft moves wouldn't move with it. inert={isPoppingOut || undefined} - className={cn("flex h-full bg-background relative", className)} + className={cn( + "flex h-full bg-background relative", + className, + // Over everything but dialogs (z-60) and toasts; below the desktop app's window controls. + maximized && "fixed inset-0 z-40 h-auto pt-[var(--titlebar-row-height)]", + )} + data-maximized={maximized || undefined} onKeyDownCapture={handleComposerKeyDown} // Bubble phase, in the same batch as the field's own onChange. A state // change in the capture phase renders first and puts the old value back. @@ -4888,6 +4903,18 @@ export function EmailComposer({ ))} {rightPanelToggle} + {canMaximize && ( + + )} {popOutButton(cn(phoneLayout ? "hidden md:grid" : "grid", "h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"), "w-4 h-4")} {/* Close at the top end (R4). On a phone it moves to the start, as the phone's mail apps have it, with Send at the end. */} From c0300bcb7fb0229be5e94be53268d90bd2ef63e7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:24:28 +0100 Subject: [PATCH 07/34] feat: keep the formatting toolbar in one row, with the rest behind More formatting --- ...rich-text-editor-toolbar-overflow.test.tsx | 114 +++++++++++++++ components/email/rich-text-editor.tsx | 136 +++++++++++++++++- 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 + 32 files changed, 335 insertions(+), 5 deletions(-) create mode 100644 components/email/__tests__/rich-text-editor-toolbar-overflow.test.tsx diff --git a/components/email/__tests__/rich-text-editor-toolbar-overflow.test.tsx b/components/email/__tests__/rich-text-editor-toolbar-overflow.test.tsx new file mode 100644 index 000000000..c45bc217c --- /dev/null +++ b/components/email/__tests__/rich-text-editor-toolbar-overflow.test.tsx @@ -0,0 +1,114 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; + +import { RichTextEditor, formattingFoldCount } from '../rich-text-editor'; + +// The formatting toolbar keeps one row: in a narrow composer (585 px at +// 1280x720) it wrapped to two and took the body's room. What doesn't fit goes +// behind "More formatting", the least used first. + +let toolbarWidth = 1200; +const observers = new Set(); +class ReportingResizeObserver { + constructor(private cb: ResizeObserverCallback) {} + observe() { observers.add(this.cb); } + unobserve() {} + disconnect() { observers.delete(this.cb); } +} +vi.stubGlobal('ResizeObserver', ReportingResizeObserver); + +const realClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')!; +Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + get(this: HTMLElement) { + return this.getAttribute('role') === 'toolbar' ? toolbarWidth : realClientWidth.get!.call(this); + }, +}); + +afterEach(() => { + toolbarWidth = 1200; +}); + +async function setup() { + let ready = false; + render( {}} onEditorReady={() => { ready = true; }} />); + await waitFor(() => expect(ready).toBe(true)); +} + +async function resize(width: number) { + toolbarWidth = width; + await act(async () => { + for (const cb of observers) cb([], {} as ResizeObserver); + }); +} + +const toolbar = () => screen.getByRole('toolbar', { name: 'formatting' }); +const rowItem = (name: string) => toolbar().querySelector(`[data-toolbar-item="${name}"]`); + +describe('formatting toolbar in a narrow composer', () => { + it('shows every button in one row when there is room, without a More button', async () => { + await setup(); + for (const name of ['heading-1', 'quote', 'align-left', 'table', 'undo']) expect(rowItem(name)).not.toBeNull(); + expect(rowItem('more-formatting')).toBeNull(); + }); + + it('moves what does not fit behind "More formatting", and shows it there', async () => { + await setup(); + await resize(561); + + // The least used go first: undo, redo, clear formatting, then alignment. + for (const name of ['undo', 'redo', 'clear-formatting', 'align-left', 'align-right']) expect(rowItem(name)).toBeNull(); + for (const name of ['bold', 'bullet-list', 'link', 'heading-1', 'table']) expect(rowItem(name)).not.toBeNull(); + + const more = rowItem('more-formatting') as HTMLButtonElement; + expect(more).toHaveAccessibleName('more_formatting'); + expect(more).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(more); + const panel = screen.getByRole('dialog', { name: 'more_formatting' }); + expect(more).toHaveAttribute('aria-expanded', 'true'); + for (const name of ['align-left', 'align-center', 'align-right', 'clear-formatting', 'undo', 'redo']) { + expect(within(panel).getByRole('button', { name: new RegExp(`^${name.replace('-', '_')}`) })).toBeInTheDocument(); + } + }); + + it('keeps the arrows in the row: the panel buttons are no Tab stops of the toolbar', async () => { + await setup(); + await resize(561); + fireEvent.click(rowItem('more-formatting')!); + const panel = screen.getByRole('dialog', { name: 'more_formatting' }); + const rowStops = Array.from(toolbar().querySelectorAll('[data-toolbar-item]')) + .filter((el) => !panel.contains(el) && el.tabIndex === 0); + expect(rowStops).toHaveLength(1); + }); + + it('brings everything back into the row when the composer gets wider', async () => { + await setup(); + await resize(400); + expect(rowItem('heading-1')).toBeNull(); + await resize(1000); + expect(rowItem('heading-1')).not.toBeNull(); + expect(rowItem('undo')).not.toBeNull(); + expect(rowItem('more-formatting')).toBeNull(); + }); +}); + +describe('formattingFoldCount', () => { + const sizes = { button: 28, gap: 2, separator: 5 }; + + it('folds nothing for a toolbar that is not laid out', () => { + expect(formattingFoldCount(0, sizes)).toBe(0); + }); + + it('folds more groups the narrower the row', () => { + const counts = [900, 700, 561, 480, 400, 300].map((width) => formattingFoldCount(width, sizes)); + expect(counts[0]).toBe(0); + for (let i = 1; i < counts.length; i++) expect(counts[i]).toBeGreaterThanOrEqual(counts[i - 1]); + expect(counts[counts.length - 1]).toBe(5); + }); + + it('makes room for the text direction button', () => { + const width = 700; + expect(formattingFoldCount(width, { ...sizes, directionButton: true })).toBeGreaterThanOrEqual(formattingFoldCount(width, sizes)); + }); +}); diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index 3448897cd..d4e4ac6cd 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -70,8 +70,56 @@ import { Rows3, Columns3, ImageIcon, + Ellipsis, } from "@/components/icons"; +/** + * Groups of the formatting toolbar that move behind "More formatting" when one + * row is too narrow for all of them, the least used first. Each with its + * buttons and the separators that go with it. + */ +const FOLDABLE_GROUPS = [ + { id: "history", buttons: 3, separators: 2 }, + { id: "align", buttons: 3, separators: 1 }, + { id: "headings", buttons: 2, separators: 1 }, + { id: "table", buttons: 1, separators: 0 }, + { id: "blocks", buttons: 2, separators: 0 }, +] as const; +type FoldableGroup = (typeof FOLDABLE_GROUPS)[number]["id"]; +/** + * Always in the row: font size, bold, italic, underline, strikethrough, the + * two colours, the lists, link, an image's description and More (12), with + * their 3 separators. + */ +const FIXED_TOOLBAR = { buttons: 12, separators: 3 }; + +/** + * How many of FOLDABLE_GROUPS go behind "More formatting" for one row of + * `width` px. 0 for a toolbar that isn't laid out (hidden, or not measured). + */ +export function formattingFoldCount(width: number, sizes: { button: number; gap: number; separator: number; directionButton?: boolean }): number { + if (!(width > 0)) return 0; + const span = (buttons: number, separators: number) => buttons * (sizes.button + sizes.gap) + separators * (sizes.separator + sizes.gap); + const groups = FOLDABLE_GROUPS.map((group) => span(group.buttons + (group.id === "align" && sizes.directionButton ? 1 : 0), group.separators)); + let needed = span(FIXED_TOOLBAR.buttons, FIXED_TOOLBAR.separators) + groups.reduce((sum, group) => sum + group, 0); + // All of it fits without the More button. + if (needed - (sizes.button + sizes.gap) <= width + 1) return 0; + for (let count = 1; count <= groups.length; count++) { + needed -= groups[count - 1]; + if (needed <= width + 1) return count; + } + return groups.length; +} + +/** The toolbar's inner width and the sizes of what is in it, in px. */ +function measureFormattingToolbar(toolbar: HTMLElement, directionButton: boolean): number { + const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + const style = getComputedStyle(toolbar); + const width = toolbar.clientWidth - (parseFloat(style.paddingLeft) || 0) - (parseFloat(style.paddingRight) || 0); + const button = toolbar.querySelector(":scope > [data-toolbar-item], :scope > div > [data-toolbar-item]")?.offsetWidth || 1.75 * rem; + return formattingFoldCount(width, { button, gap: 0.125 * rem, separator: 1 + 0.25 * rem, directionButton }); +} + export interface InlineImageUpload { src: string; cid?: string; @@ -699,6 +747,7 @@ export function RichTextEditor({ }, [editor, linkDialog]); const tToolbar = useTranslations("email_composer.toolbar"); + const tWindows = useTranslations("compose_windows"); const tCommon = useTranslations("common"); const [tableMenuOpen, setTableMenuOpen] = useState(false); const tableWrapperRef = useRef(null); @@ -717,11 +766,13 @@ export function RichTextEditor({ // focus last keeps it, the arrows, Home and End move inside. const toolbarRef = useRef(null); const rovingItemRef = useRef(null); + // Buttons in the "More formatting" panel are the panel's, with its own keys. + const inRow = (el: HTMLElement) => !el.closest("[data-toolbar-more-panel]"); const toolbarItems = () => Array.from(toolbarRef.current?.querySelectorAll("[data-toolbar-item]") ?? []) - .filter((el) => !el.disabled); + .filter((el) => !el.disabled && inRow(el)); const syncToolbarTabStops = (focus?: HTMLButtonElement) => { - const all = Array.from(toolbarRef.current?.querySelectorAll("[data-toolbar-item]") ?? []); + const all = Array.from(toolbarRef.current?.querySelectorAll("[data-toolbar-item]") ?? []).filter(inRow); const enabled = all.filter((el) => !el.disabled); const current = focus ?? enabled.find((el) => el.dataset.toolbarItem === rovingItemRef.current) ?? enabled[0]; if (current) rovingItemRef.current = current.dataset.toolbarItem ?? null; @@ -740,6 +791,36 @@ export function RichTextEditor({ return !!stop && document.activeElement === stop; }; + // One row: what doesn't fit goes behind "More formatting" (a wrapped + // second row took the body's room in a narrow composer). + const [foldCount, setFoldCount] = useState(0); + const [moreOpen, setMoreOpen] = useState(false); + const moreButtonRef = useRef(null); + const moreWrapperRef = useRef(null); + const hasEditor = !!editor; + useLayoutEffect(() => { + const toolbar = toolbarRef.current; + if (!toolbar) return; + const measure = () => setFoldCount(measureFormattingToolbar(toolbar, !!rtlEditingSupport)); + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => { + measure(); + setMoreOpen(false); + }); + observer.observe(toolbar); + return () => observer.disconnect(); + }, [hasEditor, rtlEditingSupport]); + + useEffect(() => { + if (!moreOpen) return; + const handler = (e: MouseEvent) => { + if (moreWrapperRef.current && !moreWrapperRef.current.contains(e.target as Node)) setMoreOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [moreOpen]); + useEffect(() => { if (!fontSizeMenuOpen) return; const handler = (e: MouseEvent) => { @@ -799,6 +880,15 @@ export function RichTextEditor({ const altTarget = findAltTarget(editor, altRequest); const imageSelected = editor.isActive("image"); + // A group that doesn't fit renders in the "More formatting" panel instead, in row order. + const folded = new Set(FOLDABLE_GROUPS.slice(0, foldCount).map((group) => group.id)); + const overflow: React.ReactNode[] = []; + const place = (group: FoldableGroup, node: React.ReactNode) => { + if (!folded.has(group)) return node; + overflow.push({node}); + return null; + }; + const onToolbarKeyDown = (e: React.KeyboardEvent) => { const target = e.target as HTMLElement; // Keys inside a popover are the popover's. @@ -819,7 +909,7 @@ export function RichTextEditor({ }; const onToolbarFocus = (e: React.FocusEvent) => { const target = e.target as HTMLElement; - if (target instanceof HTMLButtonElement && target.hasAttribute("data-toolbar-item")) syncToolbarTabStops(target); + if (target instanceof HTMLButtonElement && target.hasAttribute("data-toolbar-item") && inRow(target)) syncToolbarTabStops(target); }; const renderPalette = (current: string | null, apply: (color: string) => void, removeLabel: string, remove: () => void) => ( @@ -1084,6 +1174,7 @@ export function RichTextEditor({ + {place("headings", <> + )} + {place("blocks", <> + )} + {place("align", <> + )} )} -
+ {place("table",
) )} -
+
)} + {place("history", <> + )} + + {overflow.length > 0 && ( +
+ setMoreOpen((v) => !v)} + title={tWindows("more_formatting")} + > + + + {moreOpen && ( + setMoreOpen(false)} + className="p-1 flex w-max max-w-[16rem] flex-wrap items-center gap-0.5" + > +
+ {overflow} +
+
+ )} +
+ )}
{/* Editor */} diff --git a/locales/ar/common.json b/locales/ar/common.json index 5bf41a9d5..47150985b 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -7066,6 +7066,9 @@ "types_character": "تكتب هذه المفاتيح حرفًا على لوحة مفاتيحك، وسيتوقف عن العمل في كل التطبيقات. جرّب مفاتيح أخرى.", "reserved": "يستخدم النظام أو معظم التطبيقات هذه المفاتيح. جرّب مفاتيح أخرى." }, + "compose_windows": { + "more_formatting": "مزيد من التنسيق" + }, "desktopReminders": { "generic_title": "تذكير", "snooze": "تأجيل {count} د", diff --git a/locales/ca/common.json b/locales/ca/common.json index eca36135b..ec7b404e9 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -7066,6 +7066,9 @@ "types_character": "Aquestes tecles escriuen un caràcter al teu teclat, i deixaria de funcionar a totes les aplicacions. Prova altres tecles.", "reserved": "El sistema o la majoria d'aplicacions fan servir aquestes tecles. Prova altres tecles." }, + "compose_windows": { + "more_formatting": "Més format" + }, "desktopReminders": { "generic_title": "Recordatori", "snooze": "Posposa {count} min", diff --git a/locales/cs/common.json b/locales/cs/common.json index f1f5076dd..2201071a4 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -7066,6 +7066,9 @@ "types_character": "Tyto klávesy na vaší klávesnici píší znak, který by pak nefungoval v žádné aplikaci. Zkuste jiné klávesy.", "reserved": "Tyto klávesy používá systém nebo většina aplikací. Zkuste jiné klávesy." }, + "compose_windows": { + "more_formatting": "Další formátování" + }, "desktopReminders": { "generic_title": "Připomenutí", "snooze": "Odložit o {count} min", diff --git a/locales/da/common.json b/locales/da/common.json index 7461d652b..9c2068085 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -7066,6 +7066,9 @@ "types_character": "Disse taster skriver et tegn på dit tastatur, og det ville holde op med at virke i alle apps. Prøv andre taster.", "reserved": "Dit system eller de fleste apps bruger disse taster. Prøv andre taster." }, + "compose_windows": { + "more_formatting": "Mere formatering" + }, "desktopReminders": { "generic_title": "Påmindelse", "snooze": "Udsæt {count} min.", diff --git a/locales/de/common.json b/locales/de/common.json index 31d15c0ed..eb87601a1 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -7066,6 +7066,9 @@ "types_character": "Diese Tasten erzeugen auf Ihrer Tastatur ein Zeichen, das dann in keiner App mehr funktionieren würde. Versuchen Sie andere Tasten.", "reserved": "Ihr System oder die meisten Apps verwenden diese Tasten. Versuchen Sie andere Tasten." }, + "compose_windows": { + "more_formatting": "Weitere Formatierung" + }, "desktopReminders": { "generic_title": "Erinnerung", "snooze": "{count} Min. schlummern", diff --git a/locales/en/common.json b/locales/en/common.json index e0491ee32..df18634ac 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -7066,6 +7066,9 @@ "types_character": "These keys type a character on your keyboard, and it would stop working in every app. Try other keys.", "reserved": "Your system or most apps use these keys. Try other keys." }, + "compose_windows": { + "more_formatting": "More formatting" + }, "desktopReminders": { "generic_title": "Reminder", "snooze": "Snooze {count} min", diff --git a/locales/es/common.json b/locales/es/common.json index 1050f0f56..4fdecd792 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -7066,6 +7066,9 @@ "types_character": "Estas teclas escriben un carácter en tu teclado, y dejaría de funcionar en todas las aplicaciones. Prueba otras teclas.", "reserved": "Tu sistema o la mayoría de las aplicaciones usan estas teclas. Prueba otras teclas." }, + "compose_windows": { + "more_formatting": "Más formato" + }, "desktopReminders": { "generic_title": "Recordatorio", "snooze": "Posponer {count} min", diff --git a/locales/et/common.json b/locales/et/common.json index df425b8bd..cde6d1575 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -7066,6 +7066,9 @@ "types_character": "Need klahvid kirjutavad teie klaviatuuril märgi, mis lakkaks kõigis rakendustes töötamast. Proovige teisi klahve.", "reserved": "Neid klahve kasutab süsteem või enamik rakendusi. Proovige teisi klahve." }, + "compose_windows": { + "more_formatting": "Rohkem vormindust" + }, "desktopReminders": { "generic_title": "Meeldetuletus", "snooze": "Lükka {count} min edasi", diff --git a/locales/fa/common.json b/locales/fa/common.json index bf242d98d..fead83ed0 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -7066,6 +7066,9 @@ "types_character": "این کلیدها روی صفحه‌کلید شما نویسه‌ای می‌نویسند و آن نویسه دیگر در هیچ برنامه‌ای کار نمی‌کند. کلیدهای دیگری را امتحان کنید.", "reserved": "سیستم یا بیشتر برنامه‌ها از این کلیدها استفاده می‌کنند. کلیدهای دیگری را امتحان کنید." }, + "compose_windows": { + "more_formatting": "قالب‌بندی بیشتر" + }, "desktopReminders": { "generic_title": "یادآوری", "snooze": "تعویق {count} دقیقه", diff --git a/locales/fr/common.json b/locales/fr/common.json index 03545491c..2405729a9 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -7066,6 +7066,9 @@ "types_character": "Ces touches produisent un caractère sur votre clavier, et il ne fonctionnerait plus dans aucune application. Essayez d'autres touches.", "reserved": "Votre système ou la plupart des applications utilisent ces touches. Essayez d'autres touches." }, + "compose_windows": { + "more_formatting": "Plus de mise en forme" + }, "desktopReminders": { "generic_title": "Rappel", "snooze": "Reporter de {count} min", diff --git a/locales/he/common.json b/locales/he/common.json index f8258a747..f3c8a59bb 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -7067,6 +7067,9 @@ "types_character": "המקשים האלה מקלידים תו במקלדת שלכם, והוא יפסיק לעבוד בכל האפליקציות. נסו מקשים אחרים.", "reserved": "המערכת או רוב האפליקציות משתמשות במקשים האלה. נסו מקשים אחרים." }, + "compose_windows": { + "more_formatting": "עוד עיצוב" + }, "desktopReminders": { "generic_title": "תזכורת", "snooze": "דחייה ב-{count} דק'", diff --git a/locales/hu/common.json b/locales/hu/common.json index bd65a8ec0..52ce79571 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -7066,6 +7066,9 @@ "types_character": "Ezek a billentyűk karaktert írnak az Ön billentyűzetén, amely többé egyik alkalmazásban sem működne. Próbáljon más billentyűket.", "reserved": "Ezeket a billentyűket a rendszer vagy a legtöbb alkalmazás használja. Próbáljon más billentyűket." }, + "compose_windows": { + "more_formatting": "További formázás" + }, "desktopReminders": { "generic_title": "Emlékeztető", "snooze": "Halasztás {count} perccel", diff --git a/locales/it/common.json b/locales/it/common.json index b79013a3a..7fbfffd54 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -7066,6 +7066,9 @@ "types_character": "Questi tasti scrivono un carattere sulla tua tastiera, che smetterebbe di funzionare in tutte le app. Prova altri tasti.", "reserved": "Il sistema o la maggior parte delle app usano questi tasti. Prova altri tasti." }, + "compose_windows": { + "more_formatting": "Altre opzioni di formattazione" + }, "desktopReminders": { "generic_title": "Promemoria", "snooze": "Rimanda di {count} min", diff --git a/locales/ja/common.json b/locales/ja/common.json index 0fc511808..7e2153f1e 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -7066,6 +7066,9 @@ "types_character": "このキーはお使いのキーボードで文字を入力します。割り当てると、すべてのアプリでその文字を入力できなくなります。別のキーをお試しください。", "reserved": "このキーはシステムまたは多くのアプリが使用しています。別のキーをお試しください。" }, + "compose_windows": { + "more_formatting": "その他の書式" + }, "desktopReminders": { "generic_title": "リマインダー", "snooze": "{count} 分後に再通知", diff --git a/locales/ko/common.json b/locales/ko/common.json index 21bc7b936..184b4c591 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -7066,6 +7066,9 @@ "types_character": "이 키는 사용 중인 키보드에서 문자를 입력하며, 지정하면 모든 앱에서 그 문자를 입력할 수 없게 됩니다. 다른 키를 사용해 보세요.", "reserved": "시스템 또는 대부분의 앱이 이 키를 사용합니다. 다른 키를 사용해 보세요." }, + "compose_windows": { + "more_formatting": "추가 서식" + }, "desktopReminders": { "generic_title": "알림", "snooze": "{count}분 후 다시 알림", diff --git a/locales/lv/common.json b/locales/lv/common.json index 2958f811c..53457abe5 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -7066,6 +7066,9 @@ "types_character": "Šie taustiņi jūsu tastatūrā raksta rakstzīmi, kas pārstātu darboties visās lietotnēs. Izmēģiniet citus taustiņus.", "reserved": "Šos taustiņus izmanto sistēma vai lielākā daļa lietotņu. Izmēģiniet citus taustiņus." }, + "compose_windows": { + "more_formatting": "Vairāk formatēšanas" + }, "desktopReminders": { "generic_title": "Atgādinājums", "snooze": "Atlikt par {count} min", diff --git a/locales/mn/common.json b/locales/mn/common.json index 57128748e..1d6eefc33 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -7066,6 +7066,9 @@ "types_character": "Эдгээр товч таны гар дээр тэмдэгт бичдэг тул тэр тэмдэгт бүх аппад ажиллахаа болино. Өөр товч туршина уу.", "reserved": "Систем эсвэл ихэнх апп эдгээр товчийг ашигладаг. Өөр товч туршина уу." }, + "compose_windows": { + "more_formatting": "Бусад хэлбэржүүлэлт" + }, "desktopReminders": { "generic_title": "Сануулга", "snooze": "{count} минут хойшлуулах", diff --git a/locales/nb/common.json b/locales/nb/common.json index 27c9f7642..9952353ed 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -7066,6 +7066,9 @@ "types_character": "Disse tastene skriver et tegn på tastaturet ditt, og det ville slutte å virke i alle apper. Prøv andre taster.", "reserved": "Systemet ditt eller de fleste apper bruker disse tastene. Prøv andre taster." }, + "compose_windows": { + "more_formatting": "Mer formatering" + }, "desktopReminders": { "generic_title": "Påminnelse", "snooze": "Utsett {count} min", diff --git a/locales/nl/common.json b/locales/nl/common.json index 69e5afbcc..8a57c3d58 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -7066,6 +7066,9 @@ "types_character": "Deze toetsen typen een teken op je toetsenbord, en dat zou in geen enkele app meer werken. Probeer andere toetsen.", "reserved": "Je systeem of de meeste apps gebruiken deze toetsen. Probeer andere toetsen." }, + "compose_windows": { + "more_formatting": "Meer opmaak" + }, "desktopReminders": { "generic_title": "Herinnering", "snooze": "{count} min uitstellen", diff --git a/locales/pl/common.json b/locales/pl/common.json index 35e69ac26..7c63c556d 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -7066,6 +7066,9 @@ "types_character": "Te klawisze wpisują znak na Twojej klawiaturze i przestałby on działać we wszystkich aplikacjach. Wypróbuj inne klawisze.", "reserved": "Te klawisze są używane przez system lub większość aplikacji. Wypróbuj inne klawisze." }, + "compose_windows": { + "more_formatting": "Więcej formatowania" + }, "desktopReminders": { "generic_title": "Przypomnienie", "snooze": "Odłóż o {count} min", diff --git a/locales/pt/common.json b/locales/pt/common.json index 7a094b8ea..784fe41f3 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -7066,6 +7066,9 @@ "types_character": "Estas teclas escrevem um carácter no seu teclado, e ele deixaria de funcionar em todas as aplicações. Experimente outras teclas.", "reserved": "O seu sistema ou a maioria das aplicações usam estas teclas. Experimente outras teclas." }, + "compose_windows": { + "more_formatting": "Mais formatação" + }, "desktopReminders": { "generic_title": "Lembrete", "snooze": "Adiar {count} min", diff --git a/locales/ro/common.json b/locales/ro/common.json index 1c3d817ce..859167031 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -7066,6 +7066,9 @@ "types_character": "Aceste taste scriu un caracter pe tastatura ta, iar el n-ar mai funcționa în nicio aplicație. Încearcă alte taste.", "reserved": "Sistemul sau majoritatea aplicațiilor folosesc aceste taste. Încearcă alte taste." }, + "compose_windows": { + "more_formatting": "Mai multe opțiuni de formatare" + }, "desktopReminders": { "generic_title": "Memento", "snooze": "Amână {count} min", diff --git a/locales/ru/common.json b/locales/ru/common.json index c5c112ea2..a69ddb27f 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -7066,6 +7066,9 @@ "types_character": "На вашей клавиатуре эти клавиши вводят символ, и он перестал бы работать во всех приложениях. Попробуйте другие клавиши.", "reserved": "Эти клавиши использует система или большинство приложений. Попробуйте другие клавиши." }, + "compose_windows": { + "more_formatting": "Дополнительное форматирование" + }, "desktopReminders": { "generic_title": "Напоминание", "snooze": "Отложить на {count} мин", diff --git a/locales/sk/common.json b/locales/sk/common.json index 81d50e05d..a2d4e899d 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -7066,6 +7066,9 @@ "types_character": "Tieto klávesy na vašej klávesnici píšu znak, ktorý by potom nefungoval v žiadnej aplikácii. Skúste iné klávesy.", "reserved": "Tieto klávesy používa systém alebo väčšina aplikácií. Skúste iné klávesy." }, + "compose_windows": { + "more_formatting": "Ďalšie formátovanie" + }, "desktopReminders": { "generic_title": "Pripomienka", "snooze": "Odložiť o {count} min", diff --git a/locales/th/common.json b/locales/th/common.json index c34c371f1..0f75e53a6 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -7074,6 +7074,9 @@ "types_character": "ปุ่มเหล่านี้พิมพ์อักขระบนแป้นพิมพ์ของคุณ และอักขระนั้นจะใช้ไม่ได้ในทุกแอป ลองใช้ปุ่มอื่น", "reserved": "ระบบหรือแอปส่วนใหญ่ใช้ปุ่มเหล่านี้ ลองใช้ปุ่มอื่น" }, + "compose_windows": { + "more_formatting": "การจัดรูปแบบเพิ่มเติม" + }, "desktopReminders": { "generic_title": "การแจ้งเตือน", "snooze": "เลื่อน {count} นาที", diff --git a/locales/tr/common.json b/locales/tr/common.json index 43380e45c..6b6e6b1d6 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -7066,6 +7066,9 @@ "types_character": "Bu tuşlar klavyenizde bir karakter yazar ve bu karakter hiçbir uygulamada çalışmaz olur. Başka tuşlar deneyin.", "reserved": "Bu tuşları sisteminiz veya çoğu uygulama kullanıyor. Başka tuşlar deneyin." }, + "compose_windows": { + "more_formatting": "Daha fazla biçimlendirme" + }, "desktopReminders": { "generic_title": "Anımsatıcı", "snooze": "{count} dk ertele", diff --git a/locales/uk/common.json b/locales/uk/common.json index d8d7ecf49..9fce99633 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -7066,6 +7066,9 @@ "types_character": "На вашій клавіатурі ці клавіші вводять символ, і він перестав би працювати в усіх застосунках. Спробуйте інші клавіші.", "reserved": "Ці клавіші використовує система або більшість застосунків. Спробуйте інші клавіші." }, + "compose_windows": { + "more_formatting": "Додаткове форматування" + }, "desktopReminders": { "generic_title": "Нагадування", "snooze": "Відкласти на {count} хв", diff --git a/locales/vi/common.json b/locales/vi/common.json index ccb97537d..73d2113ab 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -7078,6 +7078,9 @@ "types_character": "Các phím này gõ một ký tự trên bàn phím của bạn, và ký tự đó sẽ không dùng được trong mọi ứng dụng. Hãy thử phím khác.", "reserved": "Hệ thống hoặc hầu hết ứng dụng dùng các phím này. Hãy thử phím khác." }, + "compose_windows": { + "more_formatting": "Thêm định dạng" + }, "desktopReminders": { "generic_title": "Lời nhắc", "snooze": "Báo lại sau {count} phút", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 3bc47f02b..599c9c3b5 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -7066,6 +7066,9 @@ "types_character": "這些鍵在您的鍵盤上會輸入一個字元,占用後該字元在所有應用程式中都無法輸入。請試試其他按鍵。", "reserved": "系統或大多數應用程式正在使用這些鍵。請試試其他按鍵。" }, + "compose_windows": { + "more_formatting": "更多格式" + }, "desktopReminders": { "generic_title": "提醒", "snooze": "{count} 分鐘後再提醒", diff --git a/locales/zh/common.json b/locales/zh/common.json index d4646cf11..d558b39e7 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -7066,6 +7066,9 @@ "types_character": "这些键在您的键盘上会输入一个字符,占用后该字符在所有应用中都无法输入。请试试其他按键。", "reserved": "系统或大多数应用在使用这些键。请试试其他按键。" }, + "compose_windows": { + "more_formatting": "更多格式" + }, "desktopReminders": { "generic_title": "提醒", "snooze": "{count} 分钟后再提醒", From 80321e21a2d678d2bf48ff211ce57e35fb07122c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:26:28 +0100 Subject: [PATCH 08/34] fix: explain replying from the received address in plain words The admin-locks test finds the row by its label key, which moved to the compose_words namespace with the new description. --- .../composing-settings-locks.test.tsx | 2 +- .../composing-settings-words.test.tsx | 41 +++++++++++++++++++ components/settings/composing-settings.tsx | 2 +- lib/settings-schema.ts | 2 +- lib/settings-search.ts | 2 +- locales/ar/common.json | 4 ++ locales/ca/common.json | 4 ++ locales/cs/common.json | 4 ++ locales/da/common.json | 4 ++ locales/de/common.json | 4 ++ locales/en/common.json | 4 ++ locales/es/common.json | 4 ++ locales/et/common.json | 4 ++ locales/fa/common.json | 4 ++ locales/fr/common.json | 4 ++ locales/he/common.json | 4 ++ locales/hu/common.json | 4 ++ locales/it/common.json | 4 ++ locales/ja/common.json | 4 ++ locales/ko/common.json | 4 ++ locales/lv/common.json | 4 ++ locales/mn/common.json | 4 ++ locales/nb/common.json | 4 ++ locales/nl/common.json | 4 ++ locales/pl/common.json | 4 ++ locales/pt/common.json | 4 ++ locales/ro/common.json | 4 ++ locales/ru/common.json | 4 ++ locales/sk/common.json | 4 ++ locales/th/common.json | 4 ++ locales/tr/common.json | 4 ++ locales/uk/common.json | 4 ++ locales/vi/common.json | 4 ++ locales/zh-TW/common.json | 4 ++ locales/zh/common.json | 4 ++ 35 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 components/settings/__tests__/composing-settings-words.test.tsx diff --git a/components/settings/__tests__/composing-settings-locks.test.tsx b/components/settings/__tests__/composing-settings-locks.test.tsx index 7dfd0d3dc..a2eb5281c 100644 --- a/components/settings/__tests__/composing-settings-locks.test.tsx +++ b/components/settings/__tests__/composing-settings-locks.test.tsx @@ -21,7 +21,7 @@ vi.mock('../settings-section', async (importOriginal) => ({ const ROWS = { plainTextMode: 'plain_text_mode.label', - autoSelectReplyIdentity: 'auto_select_reply_identity.label', + autoSelectReplyIdentity: 'reply_from_received.label', replyIdentityMatch: 'reply_identity_match.label', } as const; diff --git a/components/settings/__tests__/composing-settings-words.test.tsx b/components/settings/__tests__/composing-settings-words.test.tsx new file mode 100644 index 000000000..1b17010f7 --- /dev/null +++ b/components/settings/__tests__/composing-settings-words.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ComposingSettings } from '../composing-settings'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useAuthStore } from '@/stores/auth-store'; +import en from '@/locales/en/common.json'; + +// "Reply from the address it was sent to" explained catch-all matching, +// which belongs to the row below it. The row now says what it does and +// when it helps. + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'en', +})); + +vi.mock('../settings-section', async (importOriginal) => ({ + ...(await importOriginal()), + SettingsSection: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +describe('ComposingSettings wording', () => { + beforeEach(() => { + useSettingsStore.getState().resetToDefaults(); + useAuthStore.setState({ client: null }); + }); + + it('describes replying from the received address without the catch-all note', () => { + render(); + expect(screen.getByText('reply_from_received.description')).toBeTruthy(); + expect(screen.queryByText('auto_select_reply_identity.description')).toBeNull(); + expect(en.compose_words.reply_from_received.description).not.toMatch(/catch-all|Received Address Matching/i); + }); + + it('calls the send delay "Undo send"', () => { + render(); + expect(screen.getByText('undo_send.label')).toBeTruthy(); + expect(screen.queryByText('send_delay.label')).toBeNull(); + expect(en.compose_words.undo_send.label).toBe('Undo send'); + }); +}); diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx index b4be22dc7..c804790bc 100644 --- a/components/settings/composing-settings.tsx +++ b/components/settings/composing-settings.tsx @@ -104,7 +104,7 @@ export function ComposingSettings() { {!isSettingHidden('autoSelectReplyIdentity') && ( - + updateSetting('autoSelectReplyIdentity', checked)} diff --git a/lib/settings-schema.ts b/lib/settings-schema.ts index 5773e2048..0c1df52b5 100644 --- a/lib/settings-schema.ts +++ b/lib/settings-schema.ts @@ -272,7 +272,7 @@ const ENTRIES: SettingSchemaEntry[] = [ opts(`${EB}mention_adds_recipient.`, ['to', 'cc', 'off'])), bool('rtlEditingSupport', 'composing', `${EB}rtl_editing.label`), bool('quickReplyEnabled', 'composing', `${EB}quick_reply.label`), - bool('autoSelectReplyIdentity', 'composing', `${EB}auto_select_reply_identity.label`), + bool('autoSelectReplyIdentity', 'composing', 'compose_words.reply_from_received.label'), // The page shows it only while the composer may rewrite From. choice('replyIdentityMatch', 'composing', `${EB}reply_identity_match.label`, opts(`${EB}reply_identity_match.`, ['exact', 'domain']), { gate: 'customFromEnabled' }), diff --git a/lib/settings-search.ts b/lib/settings-search.ts index b1d8ea701..5ad03149a 100644 --- a/lib/settings-search.ts +++ b/lib/settings-search.ts @@ -250,7 +250,7 @@ export const tabSearchEntries: Record = { { label: 'proofreading.settings.writing_language' }, { label: 'settings.composing_page.sections.replying' }, { label: 'settings.email_behavior.quick_reply.label' }, - { label: 'settings.email_behavior.auto_select_reply_identity.label' }, + { label: 'compose_words.reply_from_received.label' }, { label: 'settings.email_behavior.signature_position.label' }, { label: 'settings.email_behavior.signature_new_mail.label' }, { label: 'settings.email_behavior.signature_reply_forward.label' }, diff --git a/locales/ar/common.json b/locales/ar/common.json index aa4b6e34b..3554362e3 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "التراجع عن الإرسال", "description": "يمنحك بضع ثوانٍ لاسترجاع الرسالة بعد الضغط على إرسال." + }, + "reply_from_received": { + "label": "الرد من العنوان المستلَم إليه", + "description": "الرد من العنوان الذي أُرسلت إليه الرسالة. مفيد إذا كانت لديك عدة عناوين." } }, "plugin_list": { diff --git a/locales/ca/common.json b/locales/ca/common.json index a70f2df8d..3c756c200 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "Desfés l'enviament", "description": "Us dona uns segons per recuperar un missatge després de prémer Envia." + }, + "reply_from_received": { + "label": "Respon des de l'adreça de recepció", + "description": "Respon des de l'adreça a la qual es va enviar el correu. Útil si teniu diverses adreces." } }, "plugin_list": { diff --git a/locales/cs/common.json b/locales/cs/common.json index 9275aea8c..20bd5cb05 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "Vrátit odeslání", "description": "Dává vám několik sekund na to, abyste zprávu po stisknutí Odeslat vzali zpět." + }, + "reply_from_received": { + "label": "Automaticky vybírat adresu pro odpověď", + "description": "Odpovídat z adresy, na kterou zpráva přišla. Hodí se, pokud máte více adres." } }, "plugin_list": { diff --git a/locales/da/common.json b/locales/da/common.json index cd5076687..1c2118e3a 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "Fortryd afsendelse", "description": "Giver dig et par sekunder til at tage en besked tilbage, efter du har trykket på Send." + }, + "reply_from_received": { + "label": "Svar fra modtaget adresse", + "description": "Svar fra den adresse, mailen blev sendt til. Nyttigt, hvis du har flere adresser." } }, "plugin_list": { diff --git a/locales/de/common.json b/locales/de/common.json index d881a451c..77323e258 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "Senden rückgängig machen", "description": "Gibt Ihnen ein paar Sekunden, um eine Nachricht nach dem Klick auf Senden zurückzuholen." + }, + "reply_from_received": { + "label": "Antwortadresse automatisch wählen", + "description": "Die Antwort geht von der Adresse aus, an die die E-Mail ging. Nützlich, wenn Sie mehrere Adressen haben." } }, "plugin_list": { diff --git a/locales/en/common.json b/locales/en/common.json index 287ef7a97..465dca018 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "Undo send", "description": "Gives you a few seconds to take a message back after you press Send." + }, + "reply_from_received": { + "label": "Reply from the address it was sent to", + "description": "Reply from the address the mail was sent to. Useful if you have several addresses." } }, "plugin_list": { diff --git a/locales/es/common.json b/locales/es/common.json index 8004e3917..650e646a1 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "Deshacer envío", "description": "Le da unos segundos para recuperar un mensaje después de pulsar Enviar." + }, + "reply_from_received": { + "label": "Seleccionar dirección de respuesta automáticamente", + "description": "Responder desde la dirección a la que se envió el correo. Útil si tiene varias direcciones." } }, "plugin_list": { diff --git a/locales/et/common.json b/locales/et/common.json index 4455842c1..8224fd630 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "Saatmise tühistamine", "description": "Annab sulle pärast Saada vajutamist mõne sekundi, et kiri tagasi võtta." + }, + "reply_from_received": { + "label": "Vasta kirja saanud aadressilt", + "description": "Vasta aadressilt, kuhu kiri saadeti. Kasulik, kui sul on mitu aadressi." } }, "plugin_list": { diff --git a/locales/fa/common.json b/locales/fa/common.json index e7e78d762..fff0e36aa 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "لغو ارسال", "description": "پس از زدن ارسال، چند ثانیه فرصت می‌دهد تا پیام را پس بگیرید." + }, + "reply_from_received": { + "label": "پاسخ از آدرس دریافتی", + "description": "پاسخ از نشانی‌ای که نامه به آن فرستاده شده بود. اگر چند نشانی دارید مفید است." } }, "plugin_list": { diff --git a/locales/fr/common.json b/locales/fr/common.json index 86156651d..58293cf34 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "Annuler l'envoi", "description": "Vous laisse quelques secondes pour reprendre un message après avoir cliqué sur Envoyer." + }, + "reply_from_received": { + "label": "Sélection automatique de l'adresse de réponse", + "description": "Répondre depuis l'adresse à laquelle le message a été envoyé. Utile si vous avez plusieurs adresses." } }, "plugin_list": { diff --git a/locales/he/common.json b/locales/he/common.json index 6953652ce..8d94cfcb4 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6389,6 +6389,10 @@ "undo_send": { "label": "ביטול שליחה", "description": "נותן לך כמה שניות להחזיר הודעה אחרי שלחצת על שלח." + }, + "reply_from_received": { + "label": "בחירה אוטומטית בכתובת תשובה", + "description": "השב מהכתובת שאליה נשלח הדואר. שימושי אם יש לך כמה כתובות." } }, "plugin_list": { diff --git a/locales/hu/common.json b/locales/hu/common.json index 534eec4cb..458d6c725 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "Küldés visszavonása", "description": "A Küldés megnyomása után néhány másodpercet ad az üzenet visszavonására." + }, + "reply_from_received": { + "label": "Válasz a fogadott címről", + "description": "Válasz arról a címről, amelyre a levél érkezett. Hasznos, ha több címe van." } }, "plugin_list": { diff --git a/locales/it/common.json b/locales/it/common.json index f7cdeb69d..bd0ad832a 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "Annulla invio", "description": "Ti dà qualche secondo per riprendere un messaggio dopo aver premuto Invia." + }, + "reply_from_received": { + "label": "Seleziona automaticamente l'indirizzo di risposta", + "description": "Rispondi dall'indirizzo a cui è stata inviata l'email. Utile se hai più indirizzi." } }, "plugin_list": { diff --git a/locales/ja/common.json b/locales/ja/common.json index 6352c0c2e..7fcdb4d04 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "送信取り消し", "description": "送信を押した後、数秒間メッセージを取り消せます。" + }, + "reply_from_received": { + "label": "返信元アドレスを自動選択", + "description": "メールの宛先になったアドレスから返信します。複数のアドレスがある場合に便利です。" } }, "plugin_list": { diff --git a/locales/ko/common.json b/locales/ko/common.json index 3be04c536..a43e71e92 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "보내기 취소", "description": "보내기를 누른 후 몇 초 동안 메시지를 되돌릴 수 있습니다." + }, + "reply_from_received": { + "label": "답장 시 보내는 사람 자동 선택", + "description": "메일을 받은 주소로 답장합니다. 주소가 여러 개일 때 유용합니다." } }, "plugin_list": { diff --git a/locales/lv/common.json b/locales/lv/common.json index 2d99614c6..44a79e5a9 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "Atsaukt sūtīšanu", "description": "Dod jums dažas sekundes, lai pēc Sūtīt nospiešanas paņemtu ziņojumu atpakaļ." + }, + "reply_from_received": { + "label": "Automātiski izvēlēties atbildes adresi", + "description": "Atbildēt no adreses, uz kuru vēstule tika nosūtīta. Noder, ja jums ir vairākas adreses." } }, "plugin_list": { diff --git a/locales/mn/common.json b/locales/mn/common.json index e633fd898..1173ac93b 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "Илгээлтийг буцаах", "description": "Илгээх товчийг дарсны дараа мессежээ буцаах хэдэн секунд өгнө." + }, + "reply_from_received": { + "label": "Хүлээн авсан хаягаас хариу бичих", + "description": "Захидал илгээгдсэн хаягаас хариу бичнэ. Олон хаягтай бол хэрэгтэй." } }, "plugin_list": { diff --git a/locales/nb/common.json b/locales/nb/common.json index c1d848c1b..09160a027 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "Angre sending", "description": "Gir deg noen sekunder til å ta tilbake en melding etter at du har trykket Send." + }, + "reply_from_received": { + "label": "Svar fra mottatt adresse", + "description": "Svar fra adressen e-posten ble sendt til. Nyttig hvis du har flere adresser." } }, "plugin_list": { diff --git a/locales/nl/common.json b/locales/nl/common.json index edaf8141a..83c019f2e 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "Verzenden ongedaan maken", "description": "Geeft je een paar seconden om een bericht terug te halen nadat je op Verzenden hebt gedrukt." + }, + "reply_from_received": { + "label": "Antwoordadres automatisch selecteren", + "description": "Antwoord vanaf het adres waar de mail naartoe is gestuurd. Handig als je meerdere adressen hebt." } }, "plugin_list": { diff --git a/locales/pl/common.json b/locales/pl/common.json index 65ad39388..9d4185978 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "Cofnij wysyłkę", "description": "Daje kilka sekund na wycofanie wiadomości po naciśnięciu Wyślij." + }, + "reply_from_received": { + "label": "Automatycznie wybieraj adres odpowiedzi", + "description": "Odpowiadaj z adresu, na który wysłano wiadomość. Przydatne, jeśli masz kilka adresów." } }, "plugin_list": { diff --git a/locales/pt/common.json b/locales/pt/common.json index a61acd6ee..05c89d668 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "Desfazer envio", "description": "Dá alguns segundos para você recuperar uma mensagem depois de clicar em Enviar." + }, + "reply_from_received": { + "label": "Selecionar automaticamente o endereço de resposta", + "description": "Responder do endereço para o qual o e-mail foi enviado. Útil se você tiver vários endereços." } }, "plugin_list": { diff --git a/locales/ro/common.json b/locales/ro/common.json index 1728249f2..b07b5a184 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "Anulează trimiterea", "description": "Îți oferă câteva secunde să retragi un mesaj după ce apeși Trimite." + }, + "reply_from_received": { + "label": "Răspunde de la adresa de la care a fost primit mesajul", + "description": "Răspunde de la adresa la care a fost trimis e-mailul. Util dacă ai mai multe adrese." } }, "plugin_list": { diff --git a/locales/ru/common.json b/locales/ru/common.json index e1e631ac4..ad41eefb2 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "Отмена отправки", "description": "Даёт несколько секунд, чтобы вернуть письмо после нажатия «Отправить»." + }, + "reply_from_received": { + "label": "Автоматически выбирать адрес для ответа", + "description": "Отвечать с адреса, на который пришло письмо. Удобно, если у вас несколько адресов." } }, "plugin_list": { diff --git a/locales/sk/common.json b/locales/sk/common.json index 68f9fc879..ec18d119b 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "Vrátiť odoslanie", "description": "Dáva vám niekoľko sekúnd na to, aby ste správu po stlačení Odoslať vzali späť." + }, + "reply_from_received": { + "label": "Odpovedať z prijatej adresy", + "description": "Odpovedať z adresy, na ktorú prišla správa. Hodí sa, ak máte viac adries." } }, "plugin_list": { diff --git a/locales/th/common.json b/locales/th/common.json index c172e5bd5..a46a03907 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -3309,6 +3309,10 @@ "undo_send": { "label": "ยกเลิกการส่ง", "description": "ให้เวลาคุณสองสามวินาทีเพื่อเรียกข้อความกลับหลังจากกดส่ง" + }, + "reply_from_received": { + "label": "ตอบกลับจากที่อยู่ที่ได้รับ", + "description": "ตอบกลับจากที่อยู่ที่อีเมลถูกส่งมา มีประโยชน์หากคุณมีหลายที่อยู่" } }, "plugin_list": { diff --git a/locales/tr/common.json b/locales/tr/common.json index de9c5d653..55f8ac366 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "Göndermeyi geri al", "description": "Gönder'e bastıktan sonra iletiyi geri almanız için birkaç saniye verir." + }, + "reply_from_received": { + "label": "Yanıt Adresini Otomatik Seç", + "description": "Postanın gönderildiği adresten yanıtlayın. Birden çok adresiniz varsa kullanışlıdır." } }, "plugin_list": { diff --git a/locales/uk/common.json b/locales/uk/common.json index 9c8f5634f..7d672778a 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "Скасувати надсилання", "description": "Дає кілька секунд, щоб повернути лист після натискання «Надіслати»." + }, + "reply_from_received": { + "label": "Автоматичний вибір адреси для відповіді", + "description": "Відповідати з адреси, на яку надійшов лист. Зручно, якщо у вас кілька адрес." } }, "plugin_list": { diff --git a/locales/vi/common.json b/locales/vi/common.json index f03718003..f48c8f8e7 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6400,6 +6400,10 @@ "undo_send": { "label": "Hoàn tác gửi", "description": "Cho bạn vài giây để lấy lại thư sau khi nhấn Gửi." + }, + "reply_from_received": { + "label": "Trả lời bằng địa chỉ nhận thư", + "description": "Trả lời từ địa chỉ mà thư đã được gửi đến. Hữu ích nếu bạn có nhiều địa chỉ." } }, "plugin_list": { diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index f10bfecdd..3ea326a1e 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "取消傳送", "description": "按下傳送後,給您幾秒鐘撤回郵件。" + }, + "reply_from_received": { + "label": "使用收件地址回覆", + "description": "從郵件寄達的地址回覆。適合擁有多個地址時使用。" } }, "plugin_list": { diff --git a/locales/zh/common.json b/locales/zh/common.json index ba262f0c1..937b1ba57 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6388,6 +6388,10 @@ "undo_send": { "label": "撤销发送", "description": "点击发送后,给您几秒钟时间撤回邮件。" + }, + "reply_from_received": { + "label": "自动选择回复地址", + "description": "从邮件发往的地址回复。适合拥有多个地址的情况。" } }, "plugin_list": { From 4ec1c128f6d45cc8bd426486433b074fe2829ff6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:36:09 +0100 Subject: [PATCH 09/34] fix: warn about template fill-ins like [Name] before sending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lone capitalised word in brackets now counts as a fill-in, so two cases of ordinary text changed: the cross-language case uses lowercase "[votre] [ваше]", and the broken word-list case uses "[Your name]", so both still test what they were written for. --- lib/__tests__/placeholder-check.test.ts | 20 ++++++++++++++++++-- lib/placeholder-check.ts | 7 ++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/lib/__tests__/placeholder-check.test.ts b/lib/__tests__/placeholder-check.test.ts index 9e3d52e3a..8cd351235 100644 --- a/lib/__tests__/placeholder-check.test.ts +++ b/lib/__tests__/placeholder-check.test.ts @@ -31,6 +31,22 @@ describe('findUnfilledPlaceholders', () => { expect(findUnfilledPlaceholders('', 'Treffpunkt: [ÜBERGABEORT]')).toEqual(['[ÜBERGABEORT]']); }); + // A template's "Hi [Name]" went out unchanged: only capitals and + // "your/insert" prompts counted. + it('finds capitalised words in brackets that a template left to fill in', () => { + expect(findUnfilledPlaceholders('', ['Hi [Name],', '[Step] is done. Regards, [First Name] at [Company]. [Date]'].join('\n'), EN)).toEqual([ + '[Name]', + '[Step]', + '[First Name]', + '[Company]', + '[Date]', + ]); + }); + + it('leaves longer bracketed phrases and links alone', () => { + expect(findUnfilledPlaceholders('', 'See [Chapter Four Part Two] and [Name](https://example.com).', EN)).toEqual([]); + }); + it('finds fill-ins that ask the sender to write something', () => { expect(findUnfilledPlaceholders('', 'Best regards,\n[Your Name]\n[Insert date]', EN)).toEqual([ '[Your Name]', @@ -94,7 +110,7 @@ describe('findUnfilledPlaceholders', () => { it('skips word lists that are not lists of strings', () => { const broken = [{ before: 'your' }, { before: [1, '', '-', null] }, null, { after: {} }] as unknown as PlaceholderWords[]; - expect(findUnfilledPlaceholders('', '[Your Name] [TIME]', broken)).toEqual(['[TIME]']); + expect(findUnfilledPlaceholders('', '[Your name] [TIME]', broken)).toEqual(['[TIME]']); }); it('finds the circles that stand in for a name in Japanese and Korean', () => { @@ -104,7 +120,7 @@ describe('findUnfilledPlaceholders', () => { it('leaves bracketed words and headings in other languages alone', () => { const text = [ '【重要】お知らせ 【通知】', - '[Votre] [Ваше] [您的]', + '[votre] [ваше] [您的]', 'Le [ton] juste. Vote [tu].', 'Siehe [Anlage 2] und [Kapitel 3].', 'Termin: ○ und ◯.', diff --git a/lib/placeholder-check.ts b/lib/placeholder-check.ts index a428ef62a..87f8a0f11 100644 --- a/lib/placeholder-check.ts +++ b/lib/placeholder-check.ts @@ -10,6 +10,11 @@ const TEMPLATE_PLACEHOLDER = /\{\{\s*[\w.-]+\s*\}\}/g; // Markdown link. const CAPS_BRACKET = /\[[\p{Lu}\d][\p{Lu}\d _./-]{0,28}[\p{Lu}\d]\](?!\()/gu; +// [Name], [Company], [First Name]: one to three capitalised words, the way +// templates name a fill-in. A lowercase word ([Yours truly]) or a number +// ([Anlage 2]) makes it ordinary text. +const TITLE_BRACKET = /\[\p{Lu}[\p{Ll}'’-]+(?: \p{Lu}[\p{Ll}'’-]+){0,2}\](?!\()/gu; + /** * The words that mark a fill-in in one language. They live in each locale * file under email_composer.placeholder_words, so a new translation brings @@ -113,7 +118,7 @@ function collect(text: string, patterns: RegExp[]): Match[] { * to check (see PlaceholderWords). */ export function findUnfilledPlaceholders(subject: string, bodyText: string, words: PlaceholderWords[] = []): string[] { - const bodyPatterns = [TEMPLATE_PLACEHOLDER, CAPS_BRACKET, CIRCLE_MARKER]; + const bodyPatterns = [TEMPLATE_PLACEHOLDER, CAPS_BRACKET, TITLE_BRACKET, CIRCLE_MARKER]; const prompt = promptBracketPattern(words); const unfinished = unfinishedWordPattern(words); if (prompt) bodyPatterns.push(prompt); 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 10/34] 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 317f3b40dda5959e017027def292abfe0439c577 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:42:47 +0100 Subject: [PATCH 11/34] fix: suggest recipients whose name starts with what you type first --- stores/__tests__/contact-store.test.ts | 41 +++++++++++++++++ stores/contact-store.ts | 62 ++++++++++++++++++-------- 2 files changed, 85 insertions(+), 18 deletions(-) diff --git a/stores/__tests__/contact-store.test.ts b/stores/__tests__/contact-store.test.ts index f52aa4e11..e16eb1a09 100644 --- a/stores/__tests__/contact-store.test.ts +++ b/stores/__tests__/contact-store.test.ts @@ -419,6 +419,47 @@ describe('contact-store', () => { expect(results).toEqual([{ name: 'Jane Doe', email: 'jane.doe@example.com' }]); }); + // "an" offered Daniel Okafor and Greta Hoffmann before Anna Becker. + it('should put names and addresses that start with the query first', () => { + const person = (id: string, given: string, surname: string, address: string) => makeContact({ + id, + name: { components: [{ kind: 'given', value: given }, { kind: 'surname', value: surname }], isOrdered: true }, + emails: { e0: { address } }, + }); + useContactStore.setState({ + contacts: [ + person('d', 'Daniel', 'Okafor', 'daniel@example.com'), + person('g', 'Greta', 'Hoffmann', 'greta@example.com'), + person('a', 'Anna', 'Becker', 'anna@example.com'), + person('h', 'Hiro', 'Tanaka', 'hiro@example.com'), + person('m', 'Mia', 'Schulz', 'mia.anders@example.com'), + ], + directoryPrincipals: [], + recentRecipients: [{ name: '', email: 'brandt@example.com' }, { name: 'Andreas Wolf', email: 'wolf@example.com' }], + }); + const results = useContactStore.getState().getAutocomplete('an'); + expect(results.map((r) => r.email)).toEqual([ + 'anna@example.com', + 'mia.anders@example.com', + 'wolf@example.com', + 'daniel@example.com', + 'greta@example.com', + 'hiro@example.com', + 'brandt@example.com', + ]); + }); + + it('should not lose a name that starts with the query behind ten that only contain it', () => { + const contacts = Array.from({ length: 12 }, (_, i) => + makeContact({ id: `d${i}`, name: { components: [{ kind: 'given', value: `Dan${i}` }], isOrdered: true }, emails: { e0: { address: `dan${i}@example.com` } } }), + ); + contacts.push(makeContact({ id: 'anna', name: { components: [{ kind: 'given', value: 'Anna' }], isOrdered: true }, emails: { e0: { address: 'anna@example.com' } } })); + useContactStore.setState({ contacts, directoryPrincipals: [], recentRecipients: [] }); + const results = useContactStore.getState().getAutocomplete('an'); + expect(results).toHaveLength(10); + expect(results[0].email).toBe('anna@example.com'); + }); + it('should repair a recent recipient whose address lost its closing bracket', () => { useContactStore.setState({ contacts: [], diff --git a/stores/contact-store.ts b/stores/contact-store.ts index 924dfc99a..78414be6b 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -41,6 +41,27 @@ function sameIdSet(a: Set, b: Set): boolean { /** One compose-autocomplete suggestion: a person, or a contact group. */ type RecipientSuggestion = { name: string; email: string; group?: { id: string; memberCount: number } }; +/** How many suggestions the compose autocomplete offers. */ +const MAX_SUGGESTIONS = 10; + +/** + * How well `query` (lowercase) matches any of `texts`: 0 at the start of a + * word ("an" in "Anna" or "an@x"), 1 inside a word ("an" in "Daniel"), + * -1 not at all. Word starts come first in the suggestions. + */ +function matchRank(query: string, ...texts: (string | undefined)[]): number { + let rank = -1; + for (const text of texts) { + if (!text) continue; + const lower = text.toLowerCase(); + for (let at = lower.indexOf(query); at !== -1; at = lower.indexOf(query, at + 1)) { + if (at === 0 || !/[\p{L}\p{N}]/u.test(lower[at - 1])) return 0; + rank = 1; + } + } + return rank; +} + /** * Reduces every suggestion to a clean display name plus a bare address and * collapses the ones that resolve to the same address. @@ -925,17 +946,26 @@ export const useContactStore = create()( const lower = query.toLowerCase(); const results: RecipientSuggestion[] = []; + // Every match is collected and ranked: a word that starts with the + // query ("an": Anna, an@x) before one that only contains it + // (Daniel), each kind in the order contacts, directory, recent. + const ranks = new Map(); + const add = (suggestion: RecipientSuggestion, rank: number) => { + results.push(suggestion); + ranks.set(suggestion, rank); + }; for (const contact of contacts) { if (contact.kind === 'group') { // Suggest the group itself as a single entry (Outlook-style); // the composer turns it into one chip carrying the members. const groupName = getContactDisplayName(contact); - if (groupName && groupName.toLowerCase().includes(lower)) { + const rank = matchRank(lower, groupName); + if (rank >= 0) { const memberCount = get().getGroupMembers(contact.id) .filter(m => getContactPrimaryEmail(m).trim()).length; if (memberCount > 0) { - results.push({ name: groupName, email: '', group: { id: contact.id, memberCount } }); + add({ name: groupName, email: '', group: { id: contact.id, memberCount } }, rank); } } continue; @@ -946,15 +976,9 @@ export const useContactStore = create()( for (const emailEntry of emails) { if (!emailEntry.address) continue; - if ( - name.toLowerCase().includes(lower) || - emailEntry.address.toLowerCase().includes(lower) - ) { - results.push({ name, email: emailEntry.address }); - } + const rank = matchRank(lower, name, emailEntry.address); + if (rank >= 0) add({ name, email: emailEntry.address }, rank); } - - if (results.length >= 10) break; } // Augment with directory principals (other users on the server, RFC 9670). @@ -963,7 +987,6 @@ export const useContactStore = create()( if (directoryPrincipals.length > 0) { const seen = new Set(results.map(r => r.email.toLowerCase())); for (const p of directoryPrincipals) { - if (results.length >= 10) break; const addr = p.email.toLowerCase(); if (seen.has(addr)) { const betterName = p.description || p.name; @@ -975,10 +998,11 @@ export const useContactStore = create()( } continue; } - if (p.name.toLowerCase().includes(lower) || addr.includes(lower) || (p.description && p.description.toLowerCase().includes(lower))) { + const rank = matchRank(lower, p.name, addr, p.description); + if (rank >= 0) { const displayName = p.description || p.name; const name = displayName !== p.email ? displayName : ''; - results.push({ name, email: p.email }); + add({ name, email: p.email }, rank); seen.add(addr); } } @@ -990,20 +1014,22 @@ export const useContactStore = create()( // duplicates). These carry the display name from the message, so match // on name or address. const { recentRecipients } = get(); - if (recentRecipients.length > 0 && results.length < 10) { + if (recentRecipients.length > 0) { const seenRecent = new Set(results.map(r => r.email.toLowerCase())); for (const rec of recentRecipients) { - if (results.length >= 10) break; const addr = rec.email.toLowerCase(); if (seenRecent.has(addr)) continue; - if (addr.includes(lower) || (rec.name && rec.name.toLowerCase().includes(lower))) { - results.push({ name: rec.name, email: rec.email }); + const rank = matchRank(lower, addr, rec.name); + if (rank >= 0) { + add({ name: rec.name, email: rec.email }, rank); seenRecent.add(addr); } } } - return normalizeSuggestions(results); + // Array.prototype.sort is stable, so each rank keeps the order above. + const ranked = [...results].sort((a, b) => (ranks.get(a) ?? 1) - (ranks.get(b) ?? 1)); + return normalizeSuggestions(ranked).slice(0, MAX_SUGGESTIONS); }, getGroups: () => { From 7d2859e064b0ca2d33650ccc3b7b6f05a31ffdd6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:44:56 +0100 Subject: [PATCH 12/34] feat: keep undo send in the pop-out or quick compose window until the time is over --- .../__tests__/popout-compose-app.test.tsx | 88 ++++++++++ components/popout/popout-compose-app.tsx | 162 +++++++++++++++++- 2 files changed, 244 insertions(+), 6 deletions(-) diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx index 6ceedf1fa..d92e90439 100644 --- a/components/popout/__tests__/popout-compose-app.test.tsx +++ b/components/popout/__tests__/popout-compose-app.test.tsx @@ -4,6 +4,7 @@ import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-ch import { openPopoutChannel, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel'; import { createComposeHandoff, rememberComposePayload, rememberedComposePayload } from '@/lib/popout/compose-handoff'; import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; import { useIdentityStore } from '@/stores/identity-store'; import { usePluginStore } from '@/stores/plugin-store'; import { emailHooks } from '@/lib/plugin-hooks'; @@ -547,3 +548,90 @@ describe('after this page crashed', () => { expect(rememberedComposePayload('h2')).toBeNull(); }); }); + +describe('a send held for undo', () => { + const closeSelf = vi.fn(); + const MAILBOXES = [{ id: 'mDrafts', role: 'drafts' }, { id: 'mSent', role: 'sent' }]; + const startDraft = { + id: 'd0', subject: 'Before sending', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ name: 'Me', email: 'me@example.org' }], + keywords: { $draft: true }, mailboxIds: { mDrafts: true }, + bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [], + }; + const restoredDraft = { + id: 'e1', subject: 'Back again', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ name: 'Me', email: 'me@example.org' }], + keywords: { $draft: true }, mailboxIds: { mDrafts: true }, + bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [], + }; + let originalEmail: ReturnType; + let sendAt: string; + let cancelUndoSend: ReturnType; + + beforeEach(() => { + closeSelf.mockClear(); + originalEmail = useEmailStore.getState(); + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { closeSelf, setRestore: vi.fn() } } as never; + useIdentityStore.setState({ identities: [{ id: 'id-1', email: 'me@example.org', name: 'Me' }] } as never); + identityMocks.multi = { enabled: false, allIdentities: [], groups: [] }; + const client = { getEmail: vi.fn(async (id: string) => (id === 'e1' ? restoredDraft : startDraft)), getMailboxes: vi.fn(async () => MAILBOXES) }; + useAuthStore.setState({ client: client as never, getClientForAccount: (id: string) => (id === 'alice' ? client : undefined) } as never); + cancelUndoSend = vi.fn(async () => restoredDraft); + useEmailStore.setState({ + sendEmail: vi.fn(async () => { + useEmailStore.setState({ pendingUndoSend: { submissionId: 'sub1', emailId: 'e1', identityId: 'id-1', sendAt, isSmime: false } }); + return { scheduled: true, emailSubmissionId: 'sub1', emailId: 'e1', sendAt }; + }), + cancelUndoSend, + } as never); + }); + + afterEach(() => { + useEmailStore.setState(originalEmail); + window.bulwarkDesktop = undefined; + }); + + type Sending = { onSend: (data: object) => Promise; onClose: () => void }; + const DATA = { to: ['a@example.org'], cc: [], bcc: [], subject: 'Hi', body: 'Text', localAccountId: 'alice' }; + + /** Opens the window, sends from it and lets the composer close, as it does after a send. */ + async function sendFromWindow(seconds: number) { + sendAt = new Date(Date.now() + seconds * 1000).toISOString(); + window.history.replaceState(null, '', '/en/popout/compose?draft=d0&account=alice'); + render(); + await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Before sending')); + const props = composer as unknown as Sending; + await act(async () => { await props.onSend(DATA); }); + act(() => props.onClose()); + } + + it('stays open with "Sending in" and Undo, and does not hand the undo to the main window', async () => { + await sendFromWindow(5); + expect(screen.getByTestId('popout-sending').textContent).toContain('sending_in'); + expect(screen.getByRole('button', { name: 'undo_send' })).toBeInTheDocument(); + expect(closeSelf).not.toHaveBeenCalled(); + const sent = heard.find((message) => message.type === 'sent') as Extract | undefined; + expect(sent?.notice.pendingUndoSend ?? null).toBeNull(); + }); + + it('closes once the undo time is over', async () => { + await sendFromWindow(1.2); + expect(closeSelf).not.toHaveBeenCalled(); + await waitFor(() => expect(closeSelf).toHaveBeenCalled(), { timeout: 4000 }); + }); + + it('Undo takes the send back and opens the message again in this window', async () => { + await sendFromWindow(30); + fireEvent.click(screen.getByRole('button', { name: 'undo_send' })); + await waitFor(() => expect(cancelUndoSend).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ submissionId: 'sub1', localAccountId: 'alice' }))); + await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Back again')); + expect(closeSelf).not.toHaveBeenCalled(); + expect(new URLSearchParams(window.location.search).get('draft')).toBe('e1'); + }); + + it('a message scheduled for later still goes to the main window, and the window closes', async () => { + await sendFromWindow(3600); + expect(screen.queryByTestId('popout-sending')).toBeNull(); + expect(closeSelf).toHaveBeenCalled(); + const sent = heard.find((message) => message.type === 'sent') as Extract | undefined; + expect(sent?.notice.pendingUndoSend?.submissionId).toBe('sub1'); + }); +}); diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx index 4b9f058a8..e29b8810d 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer"; import { ErrorBoundary, ComposerErrorFallback } from "@/components/error"; @@ -13,8 +13,9 @@ import { toast } from "@/stores/toast-store"; import { formatRejectedRecipients } from "@/lib/jmap/client"; import { debug } from "@/lib/debug"; import { composerSendOptions } from "@/lib/outgoing/composer-send-options"; -import { announceSent } from "@/lib/plugin-after-send"; -import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watchMainWindow, type RescuedDraft } from "@/lib/popout/channel"; +import { announceSent, cancelAnnouncedSend, releaseAnnouncedSend } from "@/lib/plugin-after-send"; +import { cancelFailureToast } from "@/lib/send-failure-message"; +import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watchMainWindow, type PopoutSentNotice, type RescuedDraft } from "@/lib/popout/channel"; import { acknowledgeComposeHandoff, forgetComposePayload, receiveComposeHandoff, rememberComposePayload, rememberedComposePayload, watchHandoffCancel } from "@/lib/popout/compose-handoff"; import type { ComposePopoutPayload } from "@/lib/popout/compose-payload"; import { returnComposerToMain, type ReturnResult } from "@/lib/popout/return-to-main"; @@ -30,6 +31,7 @@ import { emailHooks } from "@/lib/plugin-hooks"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; import { useIdentityStore } from "@/stores/identity-store"; import type { Email } from "@/lib/jmap/types"; +import { Send } from "@/components/icons"; import { PopoutNotice, PopoutShell, PopoutSkeleton } from "./popout-shell"; /** A warning with no main window left to show it stays this long before the window goes. */ @@ -49,6 +51,91 @@ const MAIN_CHECK_MS = 1000; /** How long a reloaded window waits to hear what became of the text it handed over on the way out. */ const RESCUE_ANSWER_MS = 2000; +/** A send held this long or less is an undo window ("Sending in 5 s"), not a message scheduled for later. */ +const UNDO_WINDOW_MS = 120_000; +/** Undo goes this long before the server lets the message go, as in the main window. */ +const UNDO_MARGIN_MS = 1000; +/** What the plugins are told when the hold runs out gets this long before the window goes. */ +const RELEASE_WAIT_MS = 1000; + +type HeldSend = NonNullable; + +/** A draft to open again in this window, after Undo brought the message back to Drafts. */ +interface ReopenTarget { + draftId: string; + accountId: string; + ownerAccountId?: string; +} + +/** + * A send held for undo, shown in the window it was sent from: that's where the + * user is looking, and quick compose is used with the main window out of sight. + * The window stays until the hold runs out, then goes. + */ +function SendingWithUndo({ held, onUndone, onGone }: { + held: HeldSend; + /** The send was taken back; the message, back in Drafts, when there is one to open. */ + onUndone: (restored: Email | null) => void; + onGone: () => void; +}) { + const tViewer = useTranslations("email_viewer"); + const [now, setNow] = useState(() => Date.now()); + const [busy, setBusy] = useState(false); + const sendAt = Date.parse(held.sendAt); + const over = !Number.isFinite(sendAt) || now >= sendAt - UNDO_MARGIN_MS; + + useEffect(() => { + if (over) return; + const timer = setInterval(() => setNow(Date.now()), 250); + return () => clearInterval(timer); + }, [over]); + + // Too late to undo: the plugins hear of the send now, and the window goes. + useEffect(() => { + if (!over || busy) return; + releaseAnnouncedSend(held.submissionId); + const timer = setTimeout(onGone, RELEASE_WAIT_MS); + return () => clearTimeout(timer); + }, [over, busy, held.submissionId, onGone]); + + const undo = async () => { + const auth = useAuthStore.getState(); + // Never another login: the submission id names another submission there. + const client = held.localAccountId ? auth.getClientForAccount(held.localAccountId) : auth.client; + if (!client) { + toast.error(tViewer("undo_send_signed_out")); + return; + } + setBusy(true); + let restored: Email | null; + try { + restored = await useEmailStore.getState().cancelUndoSend(client, held); + } catch (error) { + debug.error("Failed to undo the send:", error); + const { title, message } = cancelFailureToast(error, tViewer, tViewer("undo_send_failed")); + toast.error(title, message ? { message } : undefined); + setBusy(false); + return; + } + // Never left the server: plugins don't hear about it. + cancelAnnouncedSend(held.submissionId); + onUndone(held.isSmime ? null : restored); + }; + + const seconds = Math.max(0, Math.ceil((sendAt - now) / 1000)); + return ( +
+
+ ); +} + function readLaunch(): { handoffId: string | null; accountId?: string; draftId?: string; ownerAccountId?: string } { if (typeof window === "undefined") return { handoffId: null }; const search = new URLSearchParams(window.location.search); @@ -214,7 +301,12 @@ function NewMessageGate({ onReady }: { onReady: (payload: ComposePopoutPayload) } /** The composer alone, in its own window: sending, autosave and discard go through the shared stores, as in a Pro tab. */ -function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; handoffId: string | null }) { +function PopoutComposer({ payload, handoffId, onReopen }: { + payload: ComposePopoutPayload; + handoffId: string | null; + /** Undo brought the message back to Drafts: open it again here. */ + onReopen: (target: ReopenTarget) => void; +}) { const t = useTranslations(); const tQuick = useTranslations("desktopQuickCompose"); const client = useAuthStore((s) => s.client); @@ -267,6 +359,16 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; const accountId = data.localAccountId ?? useAuthStore.getState().activeAccountId; const pending = useEmailStore.getState().pendingUndoSend; + const withLogin = pending ? { ...pending, localAccountId: pending.localAccountId ?? accountId ?? undefined } : null; + // An undo window stays here, where Send was pressed: the window shows it + // and closes when it runs out. A message scheduled for later goes to the + // main window as before. + const remaining = withLogin ? Date.parse(withLogin.sendAt) - Date.now() : NaN; + const keepHere = result.scheduled && withLogin && Number.isFinite(remaining) && remaining <= UNDO_WINDOW_MS ? withLogin : null; + if (keepHere) { + heldRef.current = keepHere; + useEmailStore.setState({ pendingUndoSend: null }); + } const rejectedRecipients = result.rejectedRecipients?.length ? formatRejectedRecipients(result.rejectedRecipients) : undefined; const taken = await announceSentToMainWindow({ accountId, @@ -274,7 +376,7 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; queued: !!result.queued, filingError: !!result.filingError, rejectedRecipients, - pendingUndoSend: pending ? { ...pending, localAccountId: pending.localAccountId ?? accountId ?? undefined } : null, + pendingUndoSend: keepHere ? null : withLogin, }); // No main window to say so, and this is the only notice these get (#1123). if (!taken && (result.filingError || rejectedRecipients)) { @@ -301,11 +403,36 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; } }, [client, t]); + // A send held for undo, kept in this window until it runs out. + const heldRef = useRef(null); + const [held, setHeld] = useState(null); + const handleClose = useCallback(() => { + announceMailChanged(useAuthStore.getState().activeAccountId); + if (heldRef.current) { + setHeld(heldRef.current); + return; + } + closePopoutWindow(); + }, []); + + const heldGone = useCallback(() => { announceMailChanged(useAuthStore.getState().activeAccountId); closePopoutWindow(); }, []); + const heldUndone = useCallback((restored: Email | null) => { + const sent = heldRef.current; + heldRef.current = null; + const login = sent?.localAccountId ?? useAuthStore.getState().activeAccountId; + announceMailChanged(login); + if (!restored || !login) { + closePopoutWindow(); + return; + } + onReopen({ draftId: restored.id, accountId: login, ownerAccountId: sent?.submissionAccountId }); + }, [onReopen]); + const [mainWindow] = useState(watchMainWindow); useEffect(() => () => mainWindow.stop(), [mainWindow]); @@ -373,6 +500,8 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; }, }), [handoffId, mainWindow, payload]); + if (held) return ; + return ( (named && draftId && !rememberedComposePayload(named) ? null : named)); const [payload, setPayload] = useState(null); + // After Undo: the message back in Drafts, opened again in a new composer here. + const [reopen, setReopen] = useState(null); + const [generation, setGeneration] = useState(0); + const handleReopen = useCallback((target: ReopenTarget) => { + // A reload starts from the draft, as after a save. + const url = new URL(window.location.href); + url.searchParams.delete("handoff"); + url.searchParams.set("draft", target.draftId); + url.searchParams.set("account", target.accountId); + if (target.ownerAccountId) url.searchParams.set("owner", target.ownerAccountId); + else url.searchParams.delete("owner"); + window.history.replaceState(null, "", `${url.pathname}${url.search}`); + setPayload(null); + setReopen(target); + }, []); + const reopened = useCallback((next: ComposePopoutPayload) => { + setReopen(null); + setGeneration((n) => n + 1); + setPayload(next); + }, []); useEffect(() => { if (!handoffId) return; @@ -438,7 +587,8 @@ export function PopoutComposeApp() { return ( - {payload ? + {payload ? + : reopen ? : handoffId ? : draftId ? : } From 0c5ba654c6c3b17e8d01d113186cda3259948f08 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:56:08 +0100 Subject: [PATCH 13/34] fix: show the name only on recipient chips, with the address on hover The autocomplete, chip-drag and paste tests found chips by their old "Name (address)" text; they now find them by the "Name
" tooltip, which is where the address went. --- .../__tests__/recipient-autocomplete.test.tsx | 8 +++--- .../__tests__/recipient-chip-drag.test.tsx | 4 +-- .../email/__tests__/recipient-paste.test.tsx | 27 ++++++++++++++----- components/email/email-composer.tsx | 17 ++++++++---- 4 files changed, 38 insertions(+), 18 deletions(-) diff --git a/components/email/__tests__/recipient-autocomplete.test.tsx b/components/email/__tests__/recipient-autocomplete.test.tsx index af1feb66d..be289b5a7 100644 --- a/components/email/__tests__/recipient-autocomplete.test.tsx +++ b/components/email/__tests__/recipient-autocomplete.test.tsx @@ -243,7 +243,7 @@ describe('recipient autocomplete', () => { fireEvent.keyDown(input, { key: 'Enter' }); - expect(screen.getByText('User B (userb@example.org)')).toBeInTheDocument(); + expect(screen.getByTitle('User B ')).toBeInTheDocument(); expect(screen.queryByText('user')).not.toBeInTheDocument(); }); @@ -255,7 +255,7 @@ describe('recipient autocomplete', () => { fireEvent.keyDown(input, { key: 'Tab' }); - expect(screen.getByText('User C (userc@example.org)')).toBeInTheDocument(); + expect(screen.getByTitle('User C ')).toBeInTheDocument(); }); it('keeps a complete typed address that no suggestion matches exactly', async () => { @@ -268,7 +268,7 @@ describe('recipient autocomplete', () => { fireEvent.keyDown(input, { key: 'Enter' }); expect(screen.getByText('userb@example.or')).toBeInTheDocument(); - expect(screen.queryByText('User B (userb@example.org)')).not.toBeInTheDocument(); + expect(screen.queryByTitle('User B ')).not.toBeInTheDocument(); }); it('starts the arrow keys from the highlighted suggestion', async () => { @@ -280,6 +280,6 @@ describe('recipient autocomplete', () => { fireEvent.keyDown(input, { key: 'ArrowDown' }); fireEvent.keyDown(input, { key: 'Enter' }); - expect(screen.getByText('User C (userc@example.org)')).toBeInTheDocument(); + expect(screen.getByTitle('User C ')).toBeInTheDocument(); }); }); diff --git a/components/email/__tests__/recipient-chip-drag.test.tsx b/components/email/__tests__/recipient-chip-drag.test.tsx index 058916079..c9971480e 100644 --- a/components/email/__tests__/recipient-chip-drag.test.tsx +++ b/components/email/__tests__/recipient-chip-drag.test.tsx @@ -249,8 +249,8 @@ describe('RecipientChipInput drag and drop', () => { it('keeps a display name with a comma in a single chip (array model)', async () => { render(, ' }} />); - // One chip, displayed as "Doo, John (john@doo.org)" — not split on the comma. - const chip = await screen.findByText('Doo, John (john@doo.org)'); + // One chip, "Doo, John" with the address in its tooltip — not split on the comma. + const chip = await screen.findByTitle('Doo, John '); const chipSpan = chip.closest('[draggable]') as HTMLElement; expect(chipSpan).not.toBeNull(); diff --git a/components/email/__tests__/recipient-paste.test.tsx b/components/email/__tests__/recipient-paste.test.tsx index 3d8a80ca9..911ce6768 100644 --- a/components/email/__tests__/recipient-paste.test.tsx +++ b/components/email/__tests__/recipient-paste.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import { fireEvent } from '@testing-library/dom'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import React from 'react'; @@ -239,18 +239,31 @@ describe('RecipientChipInput paste', () => { const input = toInput(); paste(input, '"Alice Smith ", "Alex Smith "'); - // Chips render as "Name (email)" when a display name is present. - expect(screen.getByText('Alice Smith (alice@x.com)')).toBeInTheDocument(); - expect(screen.getByText('Alex Smith (alex@x.com)')).toBeInTheDocument(); + // A named chip has its address in the tooltip. + expect(screen.getByTitle('Alice Smith ')).toBeInTheDocument(); + expect(screen.getByTitle('Alex Smith ')).toBeInTheDocument(); expect(input.value).toBe(''); }); + // "Maximilian Alexander von Hoh…": the chip showed "Name (address)" and + // cut both. It shows the name; the address is in the tooltip, and screen + // readers still hear it. + it('shows the name only on a named chip', () => { + render(); + paste(toInput(), 'Maximilian Alexander von Hohenberg '); + + const label = screen.getByTitle('Maximilian Alexander von Hohenberg '); + expect(within(label).getByText('Maximilian Alexander von Hohenberg')).toHaveClass('truncate'); + expect(within(label).queryByText(/max\.hohenberg@example\.org/, { ignore: '.sr-only' })).toBeNull(); + expect(label.closest('[draggable]')?.textContent).toBe('Maximilian Alexander von Hohenberg (max.hohenberg@example.org)'); + }); + it('keeps a `Name ` pair as a single named chip', () => { render(); const input = toInput(); paste(input, 'John Doe , jane@y.com'); - expect(screen.getByText('John Doe (j@x.com)')).toBeInTheDocument(); + expect(screen.getByTitle('John Doe ')).toBeInTheDocument(); expect(screen.getByText('jane@y.com')).toBeInTheDocument(); expect(input.value).toBe(''); }); @@ -260,7 +273,7 @@ describe('RecipientChipInput paste', () => { const input = toInput(); paste(input, '"Doe, John" ; bob@z.com'); - expect(screen.getByText('Doe, John (j@x.com)')).toBeInTheDocument(); + expect(screen.getByTitle('Doe, John ')).toBeInTheDocument(); expect(screen.getByText('bob@z.com')).toBeInTheDocument(); expect(input.value).toBe(''); }); @@ -295,7 +308,7 @@ describe('RecipientChipInput paste', () => { const input = toInput(); paste(input, '"VIP" , not-an-email, b@y.com'); - expect(screen.getByText('VIP (vip@x.com)')).toBeInTheDocument(); + expect(screen.getByTitle('VIP ')).toBeInTheDocument(); expect(screen.getByText('b@y.com')).toBeInTheDocument(); expect(input.value).toBe('not-an-email'); }); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 3409f77db..9dcb154ca 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -6248,11 +6248,16 @@ export function RecipientChipInput({ } }, [editingChip]); - // Format a recipient for display in a chip / context menu + // A chip shows the name only (the address when there is none); the full + // "Name
" is in its tooltip and at the top of its menu. const formatChipDisplay = (r: Recipient): string => r.group ? `${r.name || 'Group'} (${r.group.members.length})` - : r.name && r.name !== r.email ? `${r.name} (${r.email})` : r.email; + : r.name && r.name !== r.email ? r.name : r.email; + const formatChipFull = (r: Recipient): string => + r.group + ? formatChipDisplay(r) + : r.name && r.name !== r.email ? `${r.name} <${r.email}>` : r.email; // Handle saving an edited chip const handleSaveEdit = (newValue: string) => { @@ -6645,10 +6650,12 @@ export function RecipientChipInput({ ) : ( m.email).join(', ') : undefined} + title={chip.group ? chip.group.members.map(m => m.email).join(', ') : formatChipFull(chip)} > {chip.group && } {chipDisplay} + {/* Screen readers still hear the address the chip no longer shows. */} + {!chip.group && chipDisplay !== chip.email && {` (${chip.email})`}} {isInvalid && {`, ${t('invalid_recipient')}`}} )} @@ -6745,8 +6752,8 @@ export function RecipientChipInput({ > {contextMenu.data && ( <> -
- {formatChipDisplay(contextMenu.data.recipient)} +
+ {formatChipFull(contextMenu.data.recipient)}
{!contextMenu.data.recipient.group && ( 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 14/34] 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?.()) && ( ); } diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 9dcb154ca..3b0003861 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -41,7 +41,9 @@ import { Reply, Forward, BookUser, + Lock, } from "@/components/icons"; +import { useRouter } from "@/i18n/navigation"; import { ComposerPluginMenuItems, ComposerPluginToolbarButtons, ComposerToolbarMenu, ComposerToolbarMenuItem, ComposerToolbarMenuCheckItem } from "@/components/email/composer-toolbar-menu"; import { ComposerToolbarMenuRadioItem } from "@/components/email/composer-toolbar-menu"; import type { MessagePriority } from "@/lib/message-priority"; @@ -603,6 +605,9 @@ export function EmailComposer({ const tOffline = useTranslations('offline'); const tOfflineNotes = useTranslations('offlineConflicts'); const tSendGuards = useTranslations('send_guards'); + const tWords = useTranslations('compose_words'); + const router = useRouter(); + const showAdvancedSettings = useSettingsStore((state) => state.showAdvancedSettings); const timeFormat = useSettingsStore((state) => state.timeFormat); const plainTextModeDefault = useSettingsStore((state) => state.plainTextMode); // Per-message format (#1022). Seeded from the "plain text only" setting - @@ -5632,16 +5637,31 @@ export function EmailComposer({ {t('option_dsn')} )} - {composerClient?.supportsSubmissionExtension?.('REQUIRETLS') && ( + {/* REQUIRETLS only secures the way there, so it is an advanced + option; one already ticked stays visible to untick. */} + {composerClient?.supportsSubmissionExtension?.('REQUIRETLS') && (showAdvancedSettings || requireTls) && ( setRequireTls(v => !v)} - description={t('option_require_tls_hint')} + description={tWords('require_tls_hint')} testId="composer-require-tls-toggle" > - {t('option_require_tls')} + {tWords('require_tls')} )} + {/* Encrypting is a crypto plugin's job (S/MIME, PGP). Without + one, say where to get it rather than offer nothing. */} + {emailHooks.onComposeSend.size === 0 && isFeatureEnabled('pluginsEnabled') && !ownWindow && ( + { close(); router.push('/settings/addons'); }} + testId="composer-encrypt-setup" + > + {tWords('encrypt')} + + )}
diff --git a/locales/ar/common.json b/locales/ar/common.json index 3554362e3..ed6682970 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "الرد من العنوان المستلَم إليه", "description": "الرد من العنوان الذي أُرسلت إليه الرسالة. مفيد إذا كانت لديك عدة عناوين." - } + }, + "require_tls": "التسليم عبر اتصال آمن فقط", + "require_tls_hint": "الرسالة نفسها غير مشفّرة. تُرَد إذا لم يتمكن خادم من استخدام TLS.", + "encrypt": "تشفير…", + "encrypt_hint": "يتطلب إضافة S/MIME أو PGP. يفتح الإعدادات." }, "plugin_list": { "filters": "المرشحات", diff --git a/locales/ca/common.json b/locales/ca/common.json index 3c756c200..7836a4f0e 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "Respon des de l'adreça de recepció", "description": "Respon des de l'adreça a la qual es va enviar el correu. Útil si teniu diverses adreces." - } + }, + "require_tls": "Lliura només per una connexió segura", + "require_tls_hint": "El missatge en si no està xifrat. Es retorna si un servidor no pot fer servir TLS.", + "encrypt": "Xifra…", + "encrypt_hint": "Cal un complement S/MIME o PGP. Obre la configuració." }, "plugin_list": { "filters": "Filtres", diff --git a/locales/cs/common.json b/locales/cs/common.json index 20bd5cb05..301fbdacc 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "Automaticky vybírat adresu pro odpověď", "description": "Odpovídat z adresy, na kterou zpráva přišla. Hodí se, pokud máte více adres." - } + }, + "require_tls": "Doručit jen přes zabezpečené spojení", + "require_tls_hint": "Samotná zpráva šifrovaná není. Vrátí se, pokud server nemůže použít TLS.", + "encrypt": "Šifrovat…", + "encrypt_hint": "Vyžaduje doplněk S/MIME nebo PGP. Otevře Nastavení." }, "plugin_list": { "filters": "Filtry", diff --git a/locales/da/common.json b/locales/da/common.json index 1c2118e3a..fd0a954df 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "Svar fra modtaget adresse", "description": "Svar fra den adresse, mailen blev sendt til. Nyttigt, hvis du har flere adresser." - } + }, + "require_tls": "Lever kun via en sikker forbindelse", + "require_tls_hint": "Selve beskeden er ikke krypteret. Den sendes retur, hvis en server ikke kan bruge TLS.", + "encrypt": "Krypter…", + "encrypt_hint": "Kræver en S/MIME- eller PGP-tilføjelse. Åbner Indstillinger." }, "plugin_list": { "filters": "Filtre", diff --git a/locales/de/common.json b/locales/de/common.json index 77323e258..03b98526d 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "Antwortadresse automatisch wählen", "description": "Die Antwort geht von der Adresse aus, an die die E-Mail ging. Nützlich, wenn Sie mehrere Adressen haben." - } + }, + "require_tls": "Nur über eine sichere Verbindung zustellen", + "require_tls_hint": "Die Nachricht selbst wird nicht verschlüsselt. Sie kommt zurück, wenn ein Server kein TLS nutzen kann.", + "encrypt": "Verschlüsseln…", + "encrypt_hint": "Dafür brauchen Sie eine S/MIME- oder PGP-Erweiterung. Öffnet die Einstellungen." }, "plugin_list": { "filters": "Filter", diff --git a/locales/en/common.json b/locales/en/common.json index 465dca018..4138b9b32 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "Reply from the address it was sent to", "description": "Reply from the address the mail was sent to. Useful if you have several addresses." - } + }, + "require_tls": "Only deliver over a secure connection", + "require_tls_hint": "The message itself isn't encrypted. It bounces if a server can't use TLS.", + "encrypt": "Encrypt…", + "encrypt_hint": "Needs an S/MIME or PGP add-on. Opens Settings." }, "plugin_list": { "filters": "Filters", diff --git a/locales/es/common.json b/locales/es/common.json index 650e646a1..fda797ee8 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "Seleccionar dirección de respuesta automáticamente", "description": "Responder desde la dirección a la que se envió el correo. Útil si tiene varias direcciones." - } + }, + "require_tls": "Entregar solo por una conexión segura", + "require_tls_hint": "El mensaje en sí no se cifra. Se devuelve si un servidor no puede usar TLS.", + "encrypt": "Cifrar…", + "encrypt_hint": "Necesita un complemento S/MIME o PGP. Abre la Configuración." }, "plugin_list": { "filters": "Filtros", diff --git a/locales/et/common.json b/locales/et/common.json index 8224fd630..5793da0ee 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "Vasta kirja saanud aadressilt", "description": "Vasta aadressilt, kuhu kiri saadeti. Kasulik, kui sul on mitu aadressi." - } + }, + "require_tls": "Toimeta kohale ainult turvalise ühenduse kaudu", + "require_tls_hint": "Kiri ise ei ole krüpteeritud. See tuleb tagasi, kui server ei saa TLS-i kasutada.", + "encrypt": "Krüpteeri…", + "encrypt_hint": "Vajab S/MIME või PGP lisa. Avab seaded." }, "plugin_list": { "filters": "Filtrid", diff --git a/locales/fa/common.json b/locales/fa/common.json index fff0e36aa..730598745 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "پاسخ از آدرس دریافتی", "description": "پاسخ از نشانی‌ای که نامه به آن فرستاده شده بود. اگر چند نشانی دارید مفید است." - } + }, + "require_tls": "تحویل فقط از راه اتصال امن", + "require_tls_hint": "خود پیام رمزگذاری نمی‌شود. اگر سروری نتواند از TLS استفاده کند، برگشت می‌خورد.", + "encrypt": "رمزگذاری…", + "encrypt_hint": "به افزونهٔ S/MIME یا PGP نیاز دارد. تنظیمات را باز می‌کند." }, "plugin_list": { "filters": "فیلترها", diff --git a/locales/fr/common.json b/locales/fr/common.json index 58293cf34..f76c76c4f 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "Sélection automatique de l'adresse de réponse", "description": "Répondre depuis l'adresse à laquelle le message a été envoyé. Utile si vous avez plusieurs adresses." - } + }, + "require_tls": "Livrer uniquement par une connexion sécurisée", + "require_tls_hint": "Le message lui-même n'est pas chiffré. Il est renvoyé si un serveur ne peut pas utiliser TLS.", + "encrypt": "Chiffrer…", + "encrypt_hint": "Nécessite une extension S/MIME ou PGP. Ouvre les Paramètres." }, "plugin_list": { "filters": "Filtres", diff --git a/locales/he/common.json b/locales/he/common.json index 8d94cfcb4..3662a919d 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6393,7 +6393,11 @@ "reply_from_received": { "label": "בחירה אוטומטית בכתובת תשובה", "description": "השב מהכתובת שאליה נשלח הדואר. שימושי אם יש לך כמה כתובות." - } + }, + "require_tls": "מסירה רק דרך חיבור מאובטח", + "require_tls_hint": "ההודעה עצמה אינה מוצפנת. היא תוחזר אם שרת לא יכול להשתמש ב-TLS.", + "encrypt": "הצפנה…", + "encrypt_hint": "דורש תוסף S/MIME או PGP. פותח את ההגדרות." }, "plugin_list": { "filters": "מסננים", diff --git a/locales/hu/common.json b/locales/hu/common.json index 458d6c725..7dedc7a49 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "Válasz a fogadott címről", "description": "Válasz arról a címről, amelyre a levél érkezett. Hasznos, ha több címe van." - } + }, + "require_tls": "Kézbesítés csak biztonságos kapcsolaton át", + "require_tls_hint": "Maga az üzenet nincs titkosítva. Visszapattan, ha egy szerver nem tud TLS-t használni.", + "encrypt": "Titkosítás…", + "encrypt_hint": "S/MIME vagy PGP bővítmény kell hozzá. Megnyitja a Beállításokat." }, "plugin_list": { "filters": "Szűrők", diff --git a/locales/it/common.json b/locales/it/common.json index bd0ad832a..ad285a749 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "Seleziona automaticamente l'indirizzo di risposta", "description": "Rispondi dall'indirizzo a cui è stata inviata l'email. Utile se hai più indirizzi." - } + }, + "require_tls": "Consegna solo tramite una connessione sicura", + "require_tls_hint": "Il messaggio in sé non è cifrato. Viene respinto se un server non può usare TLS.", + "encrypt": "Cifra…", + "encrypt_hint": "Serve un componente aggiuntivo S/MIME o PGP. Apre le Impostazioni." }, "plugin_list": { "filters": "Filtri", diff --git a/locales/ja/common.json b/locales/ja/common.json index 7fcdb4d04..c5f44ee3e 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "返信元アドレスを自動選択", "description": "メールの宛先になったアドレスから返信します。複数のアドレスがある場合に便利です。" - } + }, + "require_tls": "安全な接続でのみ配信", + "require_tls_hint": "メッセージ自体は暗号化されません。サーバーが TLS を使えない場合は返送されます。", + "encrypt": "暗号化…", + "encrypt_hint": "S/MIME または PGP のアドオンが必要です。設定を開きます。" }, "plugin_list": { "filters": "フィルター", diff --git a/locales/ko/common.json b/locales/ko/common.json index a43e71e92..d7a56c111 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "답장 시 보내는 사람 자동 선택", "description": "메일을 받은 주소로 답장합니다. 주소가 여러 개일 때 유용합니다." - } + }, + "require_tls": "보안 연결로만 배달", + "require_tls_hint": "메시지 자체는 암호화되지 않습니다. 서버가 TLS를 사용할 수 없으면 반송됩니다.", + "encrypt": "암호화…", + "encrypt_hint": "S/MIME 또는 PGP 추가 기능이 필요합니다. 설정을 엽니다." }, "plugin_list": { "filters": "필터", diff --git a/locales/lv/common.json b/locales/lv/common.json index 44a79e5a9..bcfadf592 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "Automātiski izvēlēties atbildes adresi", "description": "Atbildēt no adreses, uz kuru vēstule tika nosūtīta. Noder, ja jums ir vairākas adreses." - } + }, + "require_tls": "Piegādāt tikai pa drošu savienojumu", + "require_tls_hint": "Pats ziņojums nav šifrēts. Tas tiek atgriezts, ja serveris nevar izmantot TLS.", + "encrypt": "Šifrēt…", + "encrypt_hint": "Nepieciešams S/MIME vai PGP papildinājums. Atver iestatījumus." }, "plugin_list": { "filters": "Filtri", diff --git a/locales/mn/common.json b/locales/mn/common.json index 1173ac93b..8d03df472 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "Хүлээн авсан хаягаас хариу бичих", "description": "Захидал илгээгдсэн хаягаас хариу бичнэ. Олон хаягтай бол хэрэгтэй." - } + }, + "require_tls": "Зөвхөн аюулгүй холболтоор хүргэх", + "require_tls_hint": "Мессеж өөрөө шифрлэгдэхгүй. Сервер TLS ашиглаж чадахгүй бол буцаагдана.", + "encrypt": "Шифрлэх…", + "encrypt_hint": "S/MIME эсвэл PGP нэмэлт хэрэгтэй. Тохиргоог нээнэ." }, "plugin_list": { "filters": "Шүүлтүүр", diff --git a/locales/nb/common.json b/locales/nb/common.json index 09160a027..589082828 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "Svar fra mottatt adresse", "description": "Svar fra adressen e-posten ble sendt til. Nyttig hvis du har flere adresser." - } + }, + "require_tls": "Lever bare over en sikker tilkobling", + "require_tls_hint": "Selve meldingen er ikke kryptert. Den sendes i retur hvis en server ikke kan bruke TLS.", + "encrypt": "Krypter…", + "encrypt_hint": "Krever et S/MIME- eller PGP-tillegg. Åpner Innstillinger." }, "plugin_list": { "filters": "Filtre", diff --git a/locales/nl/common.json b/locales/nl/common.json index 83c019f2e..3fd8ddc41 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "Antwoordadres automatisch selecteren", "description": "Antwoord vanaf het adres waar de mail naartoe is gestuurd. Handig als je meerdere adressen hebt." - } + }, + "require_tls": "Alleen via een beveiligde verbinding bezorgen", + "require_tls_hint": "Het bericht zelf is niet versleuteld. Het komt terug als een server geen TLS kan gebruiken.", + "encrypt": "Versleutelen…", + "encrypt_hint": "Vereist een S/MIME- of PGP-add-on. Opent Instellingen." }, "plugin_list": { "filters": "Filters", diff --git a/locales/pl/common.json b/locales/pl/common.json index 9d4185978..1db35978a 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "Automatycznie wybieraj adres odpowiedzi", "description": "Odpowiadaj z adresu, na który wysłano wiadomość. Przydatne, jeśli masz kilka adresów." - } + }, + "require_tls": "Doręczaj tylko przez bezpieczne połączenie", + "require_tls_hint": "Sama wiadomość nie jest szyfrowana. Zostanie zwrócona, jeśli serwer nie może użyć TLS.", + "encrypt": "Zaszyfruj…", + "encrypt_hint": "Wymaga dodatku S/MIME lub PGP. Otwiera Ustawienia." }, "plugin_list": { "filters": "Filtry", diff --git a/locales/pt/common.json b/locales/pt/common.json index 05c89d668..24db7fd80 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "Selecionar automaticamente o endereço de resposta", "description": "Responder do endereço para o qual o e-mail foi enviado. Útil se você tiver vários endereços." - } + }, + "require_tls": "Entregar só por uma conexão segura", + "require_tls_hint": "A mensagem em si não é cifrada. Ela volta se um servidor não puder usar TLS.", + "encrypt": "Cifrar…", + "encrypt_hint": "Precisa de um extra S/MIME ou PGP. Abre as Configurações." }, "plugin_list": { "filters": "Filtros", diff --git a/locales/ro/common.json b/locales/ro/common.json index b07b5a184..6b46ce602 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "Răspunde de la adresa de la care a fost primit mesajul", "description": "Răspunde de la adresa la care a fost trimis e-mailul. Util dacă ai mai multe adrese." - } + }, + "require_tls": "Livrează doar printr-o conexiune sigură", + "require_tls_hint": "Mesajul în sine nu este criptat. Este returnat dacă un server nu poate folosi TLS.", + "encrypt": "Criptează…", + "encrypt_hint": "Necesită un supliment S/MIME sau PGP. Deschide Setările." }, "plugin_list": { "filters": "Filtre", diff --git a/locales/ru/common.json b/locales/ru/common.json index ad41eefb2..4ea47ede2 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "Автоматически выбирать адрес для ответа", "description": "Отвечать с адреса, на который пришло письмо. Удобно, если у вас несколько адресов." - } + }, + "require_tls": "Доставлять только по защищённому соединению", + "require_tls_hint": "Само письмо не шифруется. Оно вернётся, если сервер не может использовать TLS.", + "encrypt": "Зашифровать…", + "encrypt_hint": "Нужно дополнение S/MIME или PGP. Открывает настройки." }, "plugin_list": { "filters": "Фильтры", diff --git a/locales/sk/common.json b/locales/sk/common.json index ec18d119b..c532d76e1 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "Odpovedať z prijatej adresy", "description": "Odpovedať z adresy, na ktorú prišla správa. Hodí sa, ak máte viac adries." - } + }, + "require_tls": "Doručiť len cez zabezpečené spojenie", + "require_tls_hint": "Samotná správa nie je šifrovaná. Vráti sa, ak server nemôže použiť TLS.", + "encrypt": "Šifrovať…", + "encrypt_hint": "Vyžaduje doplnok S/MIME alebo PGP. Otvorí Nastavenia." }, "plugin_list": { "filters": "Filtre", diff --git a/locales/th/common.json b/locales/th/common.json index a46a03907..9a5932b1a 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -3313,7 +3313,11 @@ "reply_from_received": { "label": "ตอบกลับจากที่อยู่ที่ได้รับ", "description": "ตอบกลับจากที่อยู่ที่อีเมลถูกส่งมา มีประโยชน์หากคุณมีหลายที่อยู่" - } + }, + "require_tls": "ส่งผ่านการเชื่อมต่อที่ปลอดภัยเท่านั้น", + "require_tls_hint": "ตัวข้อความเองไม่ได้ถูกเข้ารหัส ข้อความจะถูกตีกลับหากเซิร์ฟเวอร์ใช้ TLS ไม่ได้", + "encrypt": "เข้ารหัส…", + "encrypt_hint": "ต้องใช้ส่วนเสริม S/MIME หรือ PGP จะเปิดการตั้งค่า" }, "plugin_list": { "filters": "ตัวกรอง", diff --git a/locales/tr/common.json b/locales/tr/common.json index 55f8ac366..38db522bf 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "Yanıt Adresini Otomatik Seç", "description": "Postanın gönderildiği adresten yanıtlayın. Birden çok adresiniz varsa kullanışlıdır." - } + }, + "require_tls": "Yalnızca güvenli bağlantı üzerinden teslim et", + "require_tls_hint": "İletinin kendisi şifrelenmez. Bir sunucu TLS kullanamazsa geri döner.", + "encrypt": "Şifrele…", + "encrypt_hint": "S/MIME veya PGP eklentisi gerekir. Ayarlar'ı açar." }, "plugin_list": { "filters": "Filtreler", diff --git a/locales/uk/common.json b/locales/uk/common.json index 7d672778a..946065a5f 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "Автоматичний вибір адреси для відповіді", "description": "Відповідати з адреси, на яку надійшов лист. Зручно, якщо у вас кілька адрес." - } + }, + "require_tls": "Доставляти лише захищеним з'єднанням", + "require_tls_hint": "Сам лист не шифрується. Він повернеться, якщо сервер не може використати TLS.", + "encrypt": "Зашифрувати…", + "encrypt_hint": "Потрібен додаток S/MIME або PGP. Відкриває налаштування." }, "plugin_list": { "filters": "Фільтри", diff --git a/locales/vi/common.json b/locales/vi/common.json index f48c8f8e7..0919080c0 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6404,7 +6404,11 @@ "reply_from_received": { "label": "Trả lời bằng địa chỉ nhận thư", "description": "Trả lời từ địa chỉ mà thư đã được gửi đến. Hữu ích nếu bạn có nhiều địa chỉ." - } + }, + "require_tls": "Chỉ gửi qua kết nối an toàn", + "require_tls_hint": "Bản thân thư không được mã hóa. Thư sẽ bị trả lại nếu máy chủ không dùng được TLS.", + "encrypt": "Mã hóa…", + "encrypt_hint": "Cần tiện ích S/MIME hoặc PGP. Mở Cài đặt." }, "plugin_list": { "filters": "Bộ lọc", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 3ea326a1e..1234d09e6 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "使用收件地址回覆", "description": "從郵件寄達的地址回覆。適合擁有多個地址時使用。" - } + }, + "require_tls": "僅透過安全連線投遞", + "require_tls_hint": "郵件本身不會加密。如果伺服器無法使用 TLS,郵件會被退回。", + "encrypt": "加密…", + "encrypt_hint": "需要 S/MIME 或 PGP 附加元件。將開啟設定。" }, "plugin_list": { "filters": "篩選", diff --git a/locales/zh/common.json b/locales/zh/common.json index 937b1ba57..fd354b278 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6392,7 +6392,11 @@ "reply_from_received": { "label": "自动选择回复地址", "description": "从邮件发往的地址回复。适合拥有多个地址的情况。" - } + }, + "require_tls": "仅通过安全连接投递", + "require_tls_hint": "邮件本身不会加密。如果服务器无法使用 TLS,邮件会被退回。", + "encrypt": "加密…", + "encrypt_hint": "需要 S/MIME 或 PGP 附加组件。将打开设置。" }, "plugin_list": { "filters": "筛选", From 1b13b4ce88b8ccd0a949b6f5a36d805169ee3056 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:12:24 +0100 Subject: [PATCH 16/34] feat: show Offline in the composer and keep the message when Send can't go out --- .../__tests__/composer-offline-send.test.tsx | 289 ++++++++++++++++++ .../__tests__/composer-own-window.test.tsx | 21 +- components/email/email-composer.tsx | 67 +++- hooks/use-browser-online.ts | 21 ++ locales/ar/common.json | 7 +- locales/ca/common.json | 7 +- locales/cs/common.json | 7 +- locales/da/common.json | 7 +- locales/de/common.json | 7 +- locales/en/common.json | 7 +- locales/es/common.json | 7 +- locales/et/common.json | 7 +- locales/fa/common.json | 7 +- locales/fr/common.json | 7 +- locales/he/common.json | 7 +- locales/hu/common.json | 7 +- locales/it/common.json | 7 +- locales/ja/common.json | 7 +- locales/ko/common.json | 7 +- locales/lv/common.json | 7 +- locales/mn/common.json | 7 +- locales/nb/common.json | 7 +- locales/nl/common.json | 7 +- locales/pl/common.json | 7 +- locales/pt/common.json | 7 +- locales/ro/common.json | 7 +- locales/ru/common.json | 7 +- locales/sk/common.json | 7 +- locales/th/common.json | 7 +- locales/tr/common.json | 7 +- locales/uk/common.json | 7 +- locales/vi/common.json | 7 +- locales/zh-TW/common.json | 7 +- locales/zh/common.json | 7 +- 34 files changed, 572 insertions(+), 36 deletions(-) create mode 100644 components/email/__tests__/composer-offline-send.test.tsx create mode 100644 hooks/use-browser-online.ts diff --git a/components/email/__tests__/composer-offline-send.test.tsx b/components/email/__tests__/composer-offline-send.test.tsx new file mode 100644 index 000000000..5c7b5f425 --- /dev/null +++ b/components/email/__tests__/composer-offline-send.test.tsx @@ -0,0 +1,289 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; + +// Writing offline. The footer says "Offline" next to Send. With "Keep mail on +// this device" on, Send puts the message in the outbox as before. Without it +// nothing can keep the message safely for later: Send says so, keeps the +// text, and points at the setting. It used to fail with "Failed to send". + +const offline = vi.hoisted(() => ({ active: false, allowed: true })); +vi.mock('@/lib/offline/offline-mode', async (importOriginal) => ({ + ...(await importOriginal()), + offlineActive: () => offline.active, + offlineAllowed: () => offline.allowed, +})); + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: ({ collapseToolbarOnMobile }: { collapseToolbarOnMobile?: boolean }) => + React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-collapsed': String(!!collapseToolbarOnMobile) }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/ui/avatar', () => ({ Avatar: () => 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 }), +})); + +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 }; +}); + +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: () => {}, + }; + 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 }; +}); + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: () => {}, success: () => {}, warning: () => {} }, +})); + +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 })); + +const DATA = { + to: 'bob@example.com', + cc: '', + bcc: '', + subject: 'Lunch', + body: '

Hello there

', + showCc: false, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: null, +}; + +let online = true; +beforeEach(() => { + online = true; + offline.active = false; + offline.allowed = true; + Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => online }); +}); +afterEach(() => { + online = true; +}); + +function goOffline() { + online = false; + act(() => { window.dispatchEvent(new Event('offline')); }); +} +function goOnline() { + online = true; + act(() => { window.dispatchEvent(new Event('online')); }); +} + +const footer = () => document.querySelector('[data-composer-footer]')!; + +describe('the composer while offline', () => { + it('says "Offline" next to Send, and stops once the network is back', () => { + render(); + expect(screen.queryByTestId('composer-offline')).toBeNull(); + goOffline(); + const label = screen.getByTestId('composer-offline'); + expect(footer()).toContainElement(label); + expect(label.textContent).toContain('offline'); + expect(label.textContent).toContain('offline_hint'); + goOnline(); + expect(screen.queryByTestId('composer-offline')).toBeNull(); + }); + + it('without offline mode, Send keeps the message and says why, with the way to the setting', async () => { + const onSend = vi.fn(); + render(); + goOffline(); + fireEvent.click(screen.getByTestId('composer-send')); + const notice = await screen.findByTestId('composer-offline-notice'); + expect(notice.textContent).toContain('offline_send_unsaved'); + const link = screen.getByRole('link', { name: 'settings.keep_label' }); + expect(link.getAttribute('href')).toMatch(/\/settings\/backup\/offline$/); + expect(link).toHaveAttribute('target', '_blank'); + expect(onSend).not.toHaveBeenCalled(); + expect(screen.getByTestId('email-composer')).toBeInTheDocument(); + + goOnline(); + expect(screen.queryByTestId('composer-offline-notice')).toBeNull(); + }); + + it('says the message is in Drafts only when the server has it', async () => { + render(); + goOffline(); + fireEvent.click(screen.getByTestId('composer-send')); + expect((await screen.findByTestId('composer-offline-notice')).textContent).toContain('offline_send_saved'); + }); + + it('Ctrl+Enter takes the same way', async () => { + const onSend = vi.fn(); + render(); + goOffline(); + fireEvent.keyDown(screen.getByTestId('composer-send'), { key: 'Enter', ctrlKey: true }); + await screen.findByTestId('composer-offline-notice'); + expect(onSend).not.toHaveBeenCalled(); + }); + + it('leaves out the link where this device may not keep mail', async () => { + offline.allowed = false; + render(); + goOffline(); + fireEvent.click(screen.getByTestId('composer-send')); + await screen.findByTestId('composer-offline-notice'); + expect(screen.queryByRole('link', { name: 'settings.keep_label' })).toBeNull(); + }); + + it('with offline mode on, Send goes ahead: the outbox keeps it until the network is back', async () => { + offline.active = true; + const onSend = vi.fn(); + render(); + goOffline(); + expect(screen.getByTestId('composer-offline').textContent).toContain('offline_hint_outbox'); + fireEvent.click(screen.getByTestId('composer-send')); + await waitFor(() => expect(onSend).toHaveBeenCalled()); + expect(screen.queryByTestId('composer-offline-notice')).toBeNull(); + }); +}); diff --git a/components/email/__tests__/composer-own-window.test.tsx b/components/email/__tests__/composer-own-window.test.tsx index 199eee835..ab200fb56 100644 --- a/components/email/__tests__/composer-own-window.test.tsx +++ b/components/email/__tests__/composer-own-window.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { NextIntlClientProvider } from 'next-intl'; import { EmailComposer, type ComposerDraftData, type ComposerPopOut } from '../email-composer'; @@ -364,3 +364,22 @@ describe('a composer that is all its window shows', () => { expect(creations).toHaveLength(saved); }); }); + +describe('back online', () => { + it('saves what could not be saved while offline, so "saved in Drafts once you are back online" holds', async () => { + let onLine = true; + vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine); + rejectSave = true; + mount({}); + onLine = false; + act(() => { window.dispatchEvent(new Event('offline')); }); + fireEvent.change(subjectField(), { target: { value: 'Written on a train' } }); + await waitFor(() => expect(creations).toHaveLength(1), { timeout: 4000 }); + + rejectSave = false; + onLine = true; + act(() => { window.dispatchEvent(new Event('online')); }); + await waitFor(() => expect(creations).toHaveLength(2)); + expect(creations[1].subject).toBe('Written on a train'); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index dbbe94385..aee9fbe6b 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -41,6 +41,7 @@ import { Reply, Forward, BookUser, + CloudOff, } from "@/components/icons"; import { ComposerPluginMenuItems, ComposerPluginToolbarButtons, ComposerToolbarMenu, ComposerToolbarMenuItem, ComposerToolbarMenuCheckItem } from "@/components/email/composer-toolbar-menu"; import { ComposerToolbarMenuRadioItem } from "@/components/email/composer-toolbar-menu"; @@ -95,6 +96,9 @@ import type { DirectoryEntry } from "@/stores/contact-store"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import { isStandInId } from "@/lib/jmap/stand-in-ids"; import { noteDraftReplacedElsewhere } from "@/lib/offline/queue"; +import { offlineActive, offlineAllowed } from "@/lib/offline/offline-mode"; +import { useBrowserOnline } from "@/hooks/use-browser-online"; +import { appPath, buildSettingsPath } from "@/lib/deep-links"; import type { FileNode } from "@/lib/jmap/types"; import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { renderTemplateWithPlugins, notifyTemplateApplied } from "@/stores/template-store"; @@ -4626,6 +4630,35 @@ export function EmailComposer({ }; }); + // Offline. With "Keep mail on this device" on, a send waits in the outbox + // and goes out once the server answers again. Without it there is nowhere + // safe to keep a message for later: Send says so and the text stays here. + const tComposeWindows = useTranslations('compose_windows'); + const browserOnline = useBrowserOnline(); + const [offlineSendNotice, setOfflineSendNotice] = useState<'saved' | 'unsaved' | null>(null); + const sendOrSayOffline = () => { + if (typeof navigator !== 'undefined' && navigator.onLine === false && !offlineActive(composingAccountId ?? null)) { + const saved = !!draftIdRef.current && changeVersionRef.current === savedVersionRef.current && !saveFailedRef.current; + setOfflineSendNotice(saved ? 'saved' : 'unsaved'); + announceStatus(tComposeWindows(saved ? 'offline_send_saved' : 'offline_send_unsaved')); + return; + } + handleSend(); + }; + // Back online: the notice goes, and what couldn't be saved meanwhile is saved now. + const wasOnlineRef = useRef(browserOnline); + useEffect(() => { + const cameBack = browserOnline && !wasOnlineRef.current; + wasOnlineRef.current = browserOnline; + if (!cameBack) return; + setOfflineSendNotice(null); + if (saveCloseAttemptRef.current || sentRef.current || isSendingRef.current || !isDirtyRef.current) return; + if (changeVersionRef.current === savedVersionRef.current && !saveFailedRef.current) return; + void saveDraftRef.current({ auto: true }); + }, [browserOnline]); + // Only asked while offline: it reads the organisation's policy and the store's support. + const offlineHint = browserOnline ? '' : tComposeWindows(offlineActive(composingAccountId ?? null) ? 'offline_hint_outbox' : 'offline_hint'); + const handleComposerKeyDown = (e: React.KeyboardEvent) => { if (e.defaultPrevented) return; // Keys from a dialog the composer opened (Insert link, ...) reach here @@ -4694,7 +4727,7 @@ export function EmailComposer({ } e.preventDefault(); - handleSend(); + sendOrSayOffline(); return; } @@ -4864,7 +4897,7 @@ export function EmailComposer({ {phoneLayout && (composerClient?.hasDelayedSend() || hasSendMenuActions ? (
) : ( + + + + } + > +
+ + { + 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": "我的模板", From 242d0bacfce5227b7ea569a74106227b0ccf8ad8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:16:24 +0100 Subject: [PATCH 18/34] fix: keep toasts at the bottom instead of over the composer's close button --- components/ui/__tests__/toast-layout.test.tsx | 19 +++++++++++++++++++ components/ui/toast.tsx | 18 +++++++++++++----- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/components/ui/__tests__/toast-layout.test.tsx b/components/ui/__tests__/toast-layout.test.tsx index a421af676..335d03634 100644 --- a/components/ui/__tests__/toast-layout.test.tsx +++ b/components/ui/__tests__/toast-layout.test.tsx @@ -208,6 +208,25 @@ describe('toasts and the focused element', () => { expect(toastContainer()).not.toHaveAttribute('data-dodging'); }); + // With the focus in the composer's text or the quick reply box, "Email + // sent" and "Sending in 5 seconds" moved to the top and covered the + // composer's close button and the reader's toolbar. A large field the + // stack only overlaps at a corner keeps the toasts at the bottom. + it('stays at the bottom over a corner of a large focused field', () => { + const h = window.innerHeight; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.hasAttribute('data-toast-container')) return rect(h - 100, 600, 360, 80); + if (this.id === 'body') return rect(h - 400, 300, 700, 360); + return rect(10, 10, 80, 36); + }); + render(<>