{attachmentsForDisplay(attachments).map(({ att, index }) => {
diff --git a/lib/compose/__tests__/attachment-order.test.ts b/lib/compose/__tests__/attachment-order.test.ts
index d5337caea..de1376bed 100644
--- a/lib/compose/__tests__/attachment-order.test.ts
+++ b/lib/compose/__tests__/attachment-order.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { attachmentsForDisplay } from '../attachment-order';
describe('the order of the composer attachment chips', () => {
- it('puts failed, then uploading files first, and keeps the rest in the order added', () => {
+ it('puts failed files first, and keeps the rest, uploading or not, in the order added', () => {
const files = [
{ name: 'a.pdf' },
{ name: 'b.pdf', uploading: true },
@@ -11,8 +11,8 @@ describe('the order of the composer attachment chips', () => {
{ name: 'broken.png', error: 'failed' },
];
const shown = attachmentsForDisplay(files);
- expect(shown.map(({ att }) => att.name)).toEqual(['broken.png', 'b.pdf', 'big.zip', 'a.pdf', 'c.pdf']);
+ expect(shown.map(({ att }) => att.name)).toEqual(['broken.png', 'a.pdf', 'b.pdf', 'c.pdf', 'big.zip']);
// Each keeps its place in the composer's list, for Remove.
- expect(shown.map(({ index }) => index)).toEqual([4, 1, 3, 0, 2]);
+ expect(shown.map(({ index }) => index)).toEqual([4, 0, 1, 2, 3]);
});
});
diff --git a/lib/compose/attachment-order.ts b/lib/compose/attachment-order.ts
index 158d340d6..594011a40 100644
--- a/lib/compose/attachment-order.ts
+++ b/lib/compose/attachment-order.ts
@@ -1,13 +1,14 @@
/**
* The composer's attachment chips in the order they're shown: failed first,
- * then still uploading, then the rest, each group in the order added. The
- * list scrolls past about two rows, so the files that need an eye on them
- * are the ones always in view. `index` is the place in the composer's list.
+ * then the rest in the order added. A file still uploading keeps its place:
+ * moved to the front, it jumped to the end once done and went out of sight
+ * past the rows on screen (compose-p2-11). `index` is the place in the
+ * composer's list.
*/
export function attachmentsForDisplay
(
attachments: readonly T[],
): Array<{ att: T; index: number }> {
- const rank = (att: T) => (att.error ? 0 : att.uploading ? 1 : 2);
+ const rank = (att: T) => (att.error ? 0 : 1);
return attachments
.map((att, index) => ({ att, index }))
.sort((a, b) => rank(a.att) - rank(b.att) || a.index - b.index);
From 664ec3831d44640e29f44f44fc0a3a0fe02c0ea6 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 12:36:16 +0100
Subject: [PATCH 23/34] fix: open a recipient's menu when its chip is clicked
---
components/email/__tests__/composer-a11y.test.tsx | 15 +++++++++++++++
components/email/email-composer.tsx | 8 ++++++++
2 files changed, 23 insertions(+)
diff --git a/components/email/__tests__/composer-a11y.test.tsx b/components/email/__tests__/composer-a11y.test.tsx
index f71f24a0d..52783b107 100644
--- a/components/email/__tests__/composer-a11y.test.tsx
+++ b/components/email/__tests__/composer-a11y.test.tsx
@@ -394,6 +394,21 @@ describe('moving a recipient by keyboard (MO-07)', () => {
expect(liveRegion()).toHaveTextContent('recipient_moved:Anna Berg|cc');
});
+ // compose-p2-16: a click did nothing visible, and Escape then closed the composer.
+ it('opens the chip menu on a click, and Escape closes only the menu', async () => {
+ const onClose = vi.fn();
+ render( );
+ const chip = within(screen.getByRole('list', { name: 'to:' })).getByRole('listitem');
+ fireEvent.click(chip.querySelector('.truncate')!);
+
+ const move = await screen.findByTestId('recipient-move-bcc');
+ fireEvent.keyDown(move, { key: 'Escape' });
+
+ await waitFor(() => expect(screen.queryByTestId('recipient-move-bcc')).toBeNull());
+ expect(onClose).not.toHaveBeenCalled();
+ expect(screen.getAllByRole('listitem')).toHaveLength(1);
+ });
+
it('opens the chip menu with Shift+F10 too', async () => {
render( );
const chip = within(screen.getByRole('list', { name: 'to:' })).getByRole('listitem');
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index d0d922b13..615dfd910 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -6860,6 +6860,14 @@ export function RecipientChipInput({
!isEditing && "focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary"
)}
onContextMenu={isEditing ? undefined : (e) => handleContextMenu(e, i, chip)}
+ // A click opens the chip's menu too (address, Edit, Move to,
+ // Remove), as Gmail and Outlook do; it did nothing visible.
+ onClick={isEditing ? undefined : (e) => {
+ if (e.target instanceof Element && e.target.closest('button')) return;
+ // Not on to the field, whose click puts the caret in the input.
+ e.stopPropagation();
+ openChipMenu(i);
+ }}
data-invalid={isInvalid || undefined}
title={isInvalid ? t('invalid_recipient') : undefined}
role="listitem"
From af8c8a6ea4a97e64d2b7178e1f28b4eb23469a70 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 12:39:07 +0100
Subject: [PATCH 24/34] fix: keep toasts above the quick reply box
---
.../__tests__/viewer-quick-reply-words.test.tsx | 15 +++++++++++++++
components/email/email-viewer.tsx | 17 ++++++++++++++++-
components/ui/__tests__/toast-layout.test.tsx | 8 ++++++++
components/ui/toast.tsx | 3 +++
4 files changed, 42 insertions(+), 1 deletion(-)
diff --git a/components/email/__tests__/viewer-quick-reply-words.test.tsx b/components/email/__tests__/viewer-quick-reply-words.test.tsx
index f980a7c93..2262a4ba1 100644
--- a/components/email/__tests__/viewer-quick-reply-words.test.tsx
+++ b/components/email/__tests__/viewer-quick-reply-words.test.tsx
@@ -85,3 +85,18 @@ describe('quick reply wording', () => {
expect(onReply).toHaveBeenCalledWith('Thanks, will do');
});
});
+
+// compose-p2-17: the toasts lift over the box by its height (toast.tsx).
+describe('quick reply box and the toasts', () => {
+ it('marks the box and gives the page its height, and takes both back when it goes', async () => {
+ const height = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(96);
+ const { unmount } = render( );
+ await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
+
+ expect(document.querySelector('[data-quick-reply-box]')).not.toBeNull();
+ expect(document.documentElement.style.getPropertyValue('--quick-reply-height')).toBe('96px');
+ unmount();
+ expect(document.documentElement.style.getPropertyValue('--quick-reply-height')).toBe('');
+ height.mockRestore();
+ });
+});
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 80ffc1c4c..7cdfca9a8 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -3516,6 +3516,21 @@ export function EmailViewer({
// Not on spam: a reply confirms the address to the sender.
const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !replyComposerOpen && !isDraft && !isScheduled && !isBodyLoading && !isInJunkFolder
&& (!effectiveEmailContent.isHtml || iframeReady);
+ // Toasts sit above the quick reply box, as above a composer's footer
+ // (compose-p2-17): its height for toast.tsx, as it grows when focused.
+ useEffect(() => {
+ const box = quickReplyBoxRef.current;
+ if (!showQuickReply || !box || typeof ResizeObserver === 'undefined') return;
+ const root = document.documentElement;
+ const update = () => root.style.setProperty('--quick-reply-height', `${box.offsetHeight}px`);
+ update();
+ const observer = new ResizeObserver(update);
+ observer.observe(box);
+ return () => {
+ observer.disconnect();
+ root.style.removeProperty('--quick-reply-height');
+ };
+ }, [showQuickReply]);
// The plain-text body, where reader-above-quote mounts between text and quote.
const [plainBodyEl, setPlainBodyEl] = useState(null);
// Read aloud (browser speech): the body as shown, HTML frame or plain text.
@@ -6191,7 +6206,7 @@ ${printWatermark ? printWatermark.html : ''}
)}
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
- {showQuickReply && (
+ {showQuickReply && (
{
expect(container).toHaveClass('[html[data-composer-open]_&]:bottom-20');
});
+ // compose-p2-17: "Draft saved" covered the quick reply box.
+ it('sits over the quick reply box, by its height', () => {
+ render( );
+ const container = toastContainer();
+ expect(container).toHaveClass('[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]');
+ expect(container).toHaveClass('max-sm:[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]');
+ });
+
it('sits above a registered bottom bar on the desktop too, such as a form footer', () => {
render( );
const container = toastContainer();
diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx
index 33ebeab9b..8de8acca0 100644
--- a/components/ui/toast.tsx
+++ b/components/ui/toast.tsx
@@ -475,10 +475,13 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose:
// lifted over a registered bottom bar (a form's Save/Cancel footer)
// and over an open composer's footer.
"fixed bottom-[max(1.25rem,calc(var(--bottom-controls-height,0px)+0.75rem))] end-5 z-[99999] flex flex-col gap-2.5 [html[data-composer-open]_&]:bottom-20",
+ // And over the reader's quick reply box (compose-p2-17).
+ "[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]",
// Phone: full width with 12px margins, above the bottom controls
// (the tab bar, or whichever bar registered with useBottomControls).
"max-sm:inset-x-3 max-sm:bottom-[calc(var(--bottom-controls-height,3.75rem)+0.75rem)]",
"max-sm:[html[data-composer-open]_&]:bottom-[max(5rem,calc(var(--bottom-controls-height,3.75rem)+0.75rem))]",
+ "max-sm:[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]",
// Out of the way of the focused element: the top end corner.
dodge && "top-5 bottom-auto!",
held && "invisible"
From 4637f67b37b660c5193eb9690db3599f8ae6c8de Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 12:39:51 +0100
Subject: [PATCH 25/34] fix: draw the To field's focus ring whole
---
components/email/__tests__/composer-a11y.test.tsx | 6 ++++++
components/email/email-composer.tsx | 5 ++++-
2 files changed, 10 insertions(+), 1 deletion(-)
diff --git a/components/email/__tests__/composer-a11y.test.tsx b/components/email/__tests__/composer-a11y.test.tsx
index 52783b107..437bb596a 100644
--- a/components/email/__tests__/composer-a11y.test.tsx
+++ b/components/email/__tests__/composer-a11y.test.tsx
@@ -253,6 +253,12 @@ describe('recipient fields (SR-06, CV-02)', () => {
expect(screen.getByRole('list', { name: 'to:' })).toBeInTheDocument();
});
+ // compose-p2-18: the ring outside the input was cut by the field's scroller.
+ it("draws the To field's keyboard focus ring inside it", () => {
+ render( );
+ expect(screen.getByRole('combobox', { name: 'to:' })).toHaveClass('focus-inset');
+ });
+
it('labels the subject field', () => {
render( );
expect(screen.getByLabelText('subject_label')).toBe(screen.getByTestId('composer-subject'));
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 615dfd910..834bc7120 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -6984,7 +6984,10 @@ export function RecipientChipInput({
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onBlur={handleBlur}
- className="flex-1 min-w-[120px] border-0 outline-none h-7 text-sm bg-transparent text-foreground placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none"
+ // focus-inset: the keyboard focus ring goes inside the box. Drawn
+ // outside, the field's own scroller cut it to a line along the top
+ // and the end (compose-p2-18, a pop-out focuses To from a key).
+ className="focus-inset flex-1 min-w-[120px] border-0 outline-none h-7 text-sm bg-transparent text-foreground placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none"
role="combobox"
aria-expanded={activeAutoField === field && autocompleteResults.length > 0}
aria-autocomplete="list"
From 98f814fcedf417bc3b628f27acd518148932b98a Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 12:42:30 +0100
Subject: [PATCH 26/34] fix: open a mail link from another program from the
default account with no question, and drop "protocol link" from the account
picker
---
.../__tests__/compose-plugin-hooks.test.tsx | 24 +++++++++++++++++
components/mail/mail-app.tsx | 26 +++++++++----------
.../protocol/protocol-account-picker.tsx | 2 +-
lib/protocol-handlers/session.ts | 11 ++++++++
locales/ar/common.json | 1 -
locales/ca/common.json | 1 -
locales/cs/common.json | 1 -
locales/da/common.json | 1 -
locales/de/common.json | 1 -
locales/en/common.json | 1 -
locales/es/common.json | 1 -
locales/et/common.json | 1 -
locales/fa/common.json | 1 -
locales/fr/common.json | 1 -
locales/he/common.json | 1 -
locales/hu/common.json | 1 -
locales/it/common.json | 1 -
locales/ja/common.json | 1 -
locales/ko/common.json | 1 -
locales/lv/common.json | 1 -
locales/mn/common.json | 1 -
locales/nb/common.json | 1 -
locales/nl/common.json | 1 -
locales/pl/common.json | 1 -
locales/pt/common.json | 1 -
locales/ro/common.json | 1 -
locales/ru/common.json | 1 -
locales/sk/common.json | 1 -
locales/th/common.json | 1 -
locales/tr/common.json | 1 -
locales/uk/common.json | 1 -
locales/vi/common.json | 1 -
locales/zh-TW/common.json | 1 -
locales/zh/common.json | 1 -
34 files changed, 48 insertions(+), 45 deletions(-)
diff --git a/components/mail/__tests__/compose-plugin-hooks.test.tsx b/components/mail/__tests__/compose-plugin-hooks.test.tsx
index 8e0c18506..8645e3d3e 100644
--- a/components/mail/__tests__/compose-plugin-hooks.test.tsx
+++ b/components/mail/__tests__/compose-plugin-hooks.test.tsx
@@ -3,6 +3,7 @@ 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 { useAccountStore } from '@/stores/account-store';
import { useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
import { usePluginStore } from '@/stores/plugin-store';
@@ -222,6 +223,29 @@ describe('a mail link from another program in the desktop app', () => {
expect(screen.queryByText('stub composer')).not.toBeInTheDocument();
});
+ it('with two accounts it opens from the default one, in its own window, with no question', async () => {
+ const client = useAuthStore.getState().client;
+ useAuthStore.setState({ getAllConnectedClients: () => new Map([['login', client], ['second', client]]) as never });
+ useAccountStore.setState({
+ accounts: [
+ { id: 'login', email: 'me@example.com', isConnected: true, isDefault: false },
+ { id: 'second', email: 'work@example.com', isConnected: true, isDefault: true },
+ ],
+ defaultAccountId: 'second',
+ } as never);
+ try {
+ render( );
+ await fromOs();
+ await waitFor(() => expect(desktopWindows.handOver).toHaveBeenCalledTimes(1));
+ expect(desktopWindows.open).toHaveBeenCalledWith('second');
+ expect(screen.queryByText('select_account_title')).not.toBeInTheDocument();
+ // The main window keeps the account it shows.
+ expect(useAuthStore.getState().activeAccountId).toBe('login');
+ } finally {
+ useAccountStore.setState({ accounts: [], defaultAccountId: null } as never);
+ }
+ });
+
it('a link inside the app still opens here', async () => {
render( );
act(() => { requestInternalMailto('mailto:bob@example.com?subject=Hi'); });
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 8fd320a02..ca0f59b53 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -156,7 +156,7 @@ import { mailTitleContext } from "@/lib/tab-title";
import { usePluginStore } from "@/stores/plugin-store";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { canDownload } from "@/lib/device-policy";
-import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
+import { consumePendingMailto, mailLinkAccountId, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
import { setNotificationReplyOpener } from "@/lib/pwa-notify/page-actions";
import { setMailLinkOpener } from "@/lib/desktop/mail-links";
import { requestTabReply } from "@/lib/pwa-notify/tab-reply";
@@ -1716,7 +1716,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// A mail link from another program, in the desktop app: a compose window of its own,
// as Outlook and Thunderbird do, and the main window stays where it was. Plugins get
// their say first. False when it has to open here instead.
- const openMailtoInWindow = useCallback(async (pending: DraftRequest): Promise => {
+ const openMailtoInWindow = useCallback(async (pending: DraftRequest, accountId?: string): Promise => {
if (!pending.fromOs || !hasDesktopWindows() || !popoutsAvailable()) return false;
let prefill: ComposePrefill | null = null;
if (emailHooks.onBeforeCompose.size > 0) {
@@ -1729,7 +1729,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// A plugin said no: nothing opens, here or there.
if (!prefill) return true;
}
- const opened = openComposerWindow(useAuthStore.getState().activeAccountId);
+ // The account the link goes out from, in its own window: the main window keeps the one it shows.
+ const opened = openComposerWindow(accountId ?? useAuthStore.getState().activeAccountId);
if (!opened) return false;
void opened.handOver({
mode: 'compose',
@@ -1768,6 +1769,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}, [openMailtoInWindow, openMailtoHere]);
const openMailtoForAccount = useCallback(async (pending: DraftRequest, accountId: string) => {
+ if (await openMailtoInWindow(pending, accountId)) {
+ setPendingMailtoAccountChoice(null);
+ return;
+ }
setIsProtocolAccountSwitching(true);
try {
if (useAuthStore.getState().activeAccountId !== accountId) {
@@ -1778,7 +1783,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
} finally {
setIsProtocolAccountSwitching(false);
}
- }, [openMailtoDraft, switchAccount]);
+ }, [openMailtoDraft, openMailtoInWindow, switchAccount]);
// onMailtoIntercept: a plugin may stop a mailto: from opening the composer.
const mailtoAllowed = useCallback(async (pending: ParsedMailto & { href?: string }): Promise => {
@@ -1791,17 +1796,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
});
}, []);
- // A draft that comes from outside: which account it goes out from.
+ // A draft that comes from outside goes out from the default account, as in Outlook and
+ // Thunderbird, with no question: the composer's From changes it (desktop-p2-8).
const routeOutsideDraft = useCallback(async (pending: DraftRequest): Promise => {
- const protocolAccounts = getMailtoProtocolAccounts();
- if (protocolAccounts.length > 1) {
- // The question is asked in this window: bring it up.
- if (pending.fromOs) getDesktopBridge()?.windows?.focus?.();
- setPendingMailtoAccountChoice(pending);
- return;
- }
-
- const accountId = protocolAccounts[0]?.id ?? activeAccountId;
+ const accountId = mailLinkAccountId(getMailtoProtocolAccounts(), useAccountStore.getState().defaultAccountId, activeAccountId);
if (accountId) {
await openMailtoForAccount(pending, accountId);
return;
diff --git a/components/protocol/protocol-account-picker.tsx b/components/protocol/protocol-account-picker.tsx
index 9bc672a4a..bb594ce9e 100644
--- a/components/protocol/protocol-account-picker.tsx
+++ b/components/protocol/protocol-account-picker.tsx
@@ -167,7 +167,7 @@ export function ProtocolAccountPicker({
{t("switching_account")}
) : (
- {t("select_account_note")}
+
)}
void): () => void {
export function hasPendingWebcal(): boolean {
return hasPending(WEBCAL_KEY, isParsedWebcal);
}
+
+/**
+ * The account a mail link from outside goes out from, with no question: the one marked
+ * default, else the one in use, as Outlook and Thunderbird do. The composer's From can
+ * change it. Only signed-in accounts count.
+ */
+export function mailLinkAccountId(accounts: readonly { id: string }[], defaultId: string | null, activeId: string | null): string | null {
+ if (defaultId && accounts.some((account) => account.id === defaultId)) return defaultId;
+ if (activeId && accounts.some((account) => account.id === activeId)) return activeId;
+ return accounts[0]?.id ?? activeId ?? null;
+}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 999cafaf0..bb42ca4d2 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -245,7 +245,6 @@
"select_account_title": "اختر الحساب",
"select_mailto_account": "اختر الحساب الذي سيفتح رابط البريد هذا.",
"select_webcal_account": "اختر الحساب الذي سيفتح رابط التقويم هذا.",
- "select_account_note": "ينطبق هذا فقط على رابط البروتوكول هذا.",
"detail_to": "إلى",
"detail_subject": "الموضوع",
"detail_no_subject": "بلا موضوع",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 091cd601c..99ac2d96f 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -245,7 +245,6 @@
"select_account_title": "Trieu un compte",
"select_mailto_account": "Trieu quin compte ha d'obrir aquest enllaç de correu.",
"select_webcal_account": "Trieu quin compte ha d'obrir aquest enllaç de calendari.",
- "select_account_note": "Això només s'aplica a aquest enllaç de protocol.",
"detail_to": "Per a",
"detail_subject": "Assumpte",
"detail_no_subject": "Sense assumpte",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index b2ae541cf..0a27b0cb7 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -245,7 +245,6 @@
"select_account_title": "Vybrat účet",
"select_mailto_account": "Vyberte účet, ve kterém se má tento e-mailový odkaz otevřít.",
"select_webcal_account": "Vyberte účet, ve kterém se má tento kalendářový odkaz otevřít.",
- "select_account_note": "Tato volba platí jen pro tento odkaz protokolu.",
"detail_to": "Komu",
"detail_subject": "Předmět",
"detail_no_subject": "Bez předmětu",
diff --git a/locales/da/common.json b/locales/da/common.json
index 7d48f4b15..c53314e46 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -245,7 +245,6 @@
"select_account_title": "Vælg konto",
"select_mailto_account": "Vælg hvilken konto der skal åbne dette e-mail-link.",
"select_webcal_account": "Vælg hvilken konto der skal åbne dette kalenderlink.",
- "select_account_note": "Dette gælder kun for dette protokollink.",
"detail_to": "Til",
"detail_subject": "Emne",
"detail_no_subject": "Intet emne",
diff --git a/locales/de/common.json b/locales/de/common.json
index e1e33c934..f8ee36a9e 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -245,7 +245,6 @@
"select_account_title": "Account auswählen",
"select_mailto_account": "Wählen Sie aus, mit welchem Account dieser E-Mail-Link geöffnet werden soll.",
"select_webcal_account": "Wählen Sie aus, mit welchem Account dieser Kalender-Link geöffnet werden soll.",
- "select_account_note": "Diese Auswahl gilt nur für diesen Protokoll-Link.",
"detail_to": "An",
"detail_subject": "Betreff",
"detail_no_subject": "Ohne Betreff",
diff --git a/locales/en/common.json b/locales/en/common.json
index ae08611b3..e02c83940 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -245,7 +245,6 @@
"select_account_title": "Choose account",
"select_mailto_account": "Choose which account should open this email link.",
"select_webcal_account": "Choose which account should open this calendar link.",
- "select_account_note": "This only applies to this protocol link.",
"detail_to": "To",
"detail_subject": "Subject",
"detail_no_subject": "No subject",
diff --git a/locales/es/common.json b/locales/es/common.json
index e4c6f39e9..0742cc825 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -245,7 +245,6 @@
"select_account_title": "Elegir cuenta",
"select_mailto_account": "Elige qué cuenta debe abrir este enlace de correo.",
"select_webcal_account": "Elige qué cuenta debe abrir este enlace de calendario.",
- "select_account_note": "Esta selección solo se aplica a este enlace de protocolo.",
"detail_to": "Para",
"detail_subject": "Asunto",
"detail_no_subject": "Sin asunto",
diff --git a/locales/et/common.json b/locales/et/common.json
index 30ac1ee96..d1ba2e3cf 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -245,7 +245,6 @@
"select_account_title": "Vali konto",
"select_mailto_account": "Vali, milline konto peaks selle e-posti lingi avama.",
"select_webcal_account": "Vali, milline konto peaks selle kalendrilingi avama.",
- "select_account_note": "See kehtib ainult selle protokollilingi kohta.",
"detail_to": "Kellele",
"detail_subject": "Teema",
"detail_no_subject": "Teema puudub",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 117abe12f..8c695ba08 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -245,7 +245,6 @@
"select_account_title": "انتخاب حساب",
"select_mailto_account": "انتخاب کنید کدام حساب این لینک ایمیل را باز کند.",
"select_webcal_account": "انتخاب کنید کدام حساب این لینک تقویم را باز کند.",
- "select_account_note": "فقط برای این لینک پروتکل اعمال میشود.",
"detail_to": "به",
"detail_subject": "موضوع",
"detail_no_subject": "بدون موضوع",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 920bc103e..f969109d8 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -245,7 +245,6 @@
"select_account_title": "Choisir un compte",
"select_mailto_account": "Choisissez le compte qui doit ouvrir ce lien e-mail.",
"select_webcal_account": "Choisissez le compte qui doit ouvrir ce lien de calendrier.",
- "select_account_note": "Cette sélection s'applique uniquement à ce lien de protocole.",
"detail_to": "À",
"detail_subject": "Objet",
"detail_no_subject": "Sans objet",
diff --git a/locales/he/common.json b/locales/he/common.json
index 2ddbebc18..c85d839f1 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -5461,7 +5461,6 @@
"select_account_title": "בחר חשבון",
"select_mailto_account": "בחר איזה חשבון צריך לפתוח קישור דוא״ל זה.",
"select_webcal_account": "בחר איזה חשבון צריך לפתוח קישור לוח שנה זה.",
- "select_account_note": "זה חל רק על קישור פרוטוקול זה.",
"detail_to": "אל",
"detail_subject": "נושא",
"detail_no_subject": "אין נושא",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 40440385b..8abbef56a 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -245,7 +245,6 @@
"select_account_title": "Fiók kiválasztása",
"select_mailto_account": "Válaszd ki, melyik fiók nyissa meg ezt az e-mail hivatkozást.",
"select_webcal_account": "Válaszd ki, melyik fiók nyissa meg ezt a naptár hivatkozást.",
- "select_account_note": "Ez csak erre a protokoll hivatkozásra vonatkozik.",
"detail_to": "Címzett",
"detail_subject": "Tárgy",
"detail_no_subject": "Nincs tárgy",
diff --git a/locales/it/common.json b/locales/it/common.json
index 4c928e274..3b1459804 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -245,7 +245,6 @@
"select_account_title": "Scegli account",
"select_mailto_account": "Scegli quale account deve aprire questo link e-mail.",
"select_webcal_account": "Scegli quale account deve aprire questo link calendario.",
- "select_account_note": "Questa scelta si applica solo a questo link di protocollo.",
"detail_to": "A",
"detail_subject": "Oggetto",
"detail_no_subject": "Senza oggetto",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index a4788a73e..681b134a1 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -245,7 +245,6 @@
"select_account_title": "アカウントを選択",
"select_mailto_account": "このメールリンクを開くアカウントを選択してください。",
"select_webcal_account": "このカレンダーリンクを開くアカウントを選択してください。",
- "select_account_note": "この選択は、このプロトコル リンクにのみ適用されます。",
"detail_to": "宛先",
"detail_subject": "件名",
"detail_no_subject": "件名なし",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 8692e53c6..e5bded68b 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -245,7 +245,6 @@
"select_account_title": "계정 선택",
"select_mailto_account": "이 이메일 링크를 열 계정을 선택하세요.",
"select_webcal_account": "이 캘린더 링크를 열 계정을 선택하세요.",
- "select_account_note": "이 선택은 이 프로토콜 링크에만 적용됩니다.",
"detail_to": "받는 사람",
"detail_subject": "제목",
"detail_no_subject": "제목 없음",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 30ae51e41..0e3ad8adc 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -245,7 +245,6 @@
"select_account_title": "Izvēlieties kontu",
"select_mailto_account": "Izvēlieties, kurā kontā atvērt šo e-pasta saiti.",
"select_webcal_account": "Izvēlieties, kurā kontā atvērt šo kalendāra saiti.",
- "select_account_note": "Šī izvēle attiecas tikai uz šo protokola saiti.",
"detail_to": "Kam",
"detail_subject": "Temats",
"detail_no_subject": "Bez temata",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 677689faa..5d50c8e27 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -245,7 +245,6 @@
"select_account_title": "Бүртгэл сонгох",
"select_mailto_account": "Энэ имэйлийн холбоосыг аль бүртгэлээр нээхийг сонгоно уу.",
"select_webcal_account": "Энэ хуанлийн холбоосыг аль данс нээхийг сонгоно уу.",
- "select_account_note": "Энэ нь зөвхөн энэ протоколын холбоост хамаарна.",
"detail_to": "Хэнд",
"detail_subject": "Сэдэв",
"detail_no_subject": "Сэдэв байхгүй",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index bc849a939..b44ae533f 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -245,7 +245,6 @@
"select_account_title": "Velg konto",
"select_mailto_account": "Velg hvilken konto som skal åpne denne e-postlenken.",
"select_webcal_account": "Velg hvilken konto som skal åpne denne kalenderlenken.",
- "select_account_note": "Dette gjelder bare denne lenken.",
"detail_to": "Til",
"detail_subject": "Emne",
"detail_no_subject": "Uten emne",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 40c5b7aba..01d57dc4f 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -245,7 +245,6 @@
"select_account_title": "Account kiezen",
"select_mailto_account": "Kies welk account deze e-maillink moet openen.",
"select_webcal_account": "Kies welk account deze kalenderlink moet openen.",
- "select_account_note": "Deze keuze geldt alleen voor deze protocol-link.",
"detail_to": "Aan",
"detail_subject": "Onderwerp",
"detail_no_subject": "Geen onderwerp",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 977c2f348..4179fb56e 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -245,7 +245,6 @@
"select_account_title": "Wybierz konto",
"select_mailto_account": "Wybierz konto, które ma otworzyć ten link e-mail.",
"select_webcal_account": "Wybierz konto, które ma otworzyć ten link kalendarza.",
- "select_account_note": "Ten wybór dotyczy tylko tego linku protokołu.",
"detail_to": "Do",
"detail_subject": "Temat",
"detail_no_subject": "Bez tematu",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 4b3b27d99..83add1bb9 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -245,7 +245,6 @@
"select_account_title": "Escolher conta",
"select_mailto_account": "Escolha qual conta deve abrir este link de e-mail.",
"select_webcal_account": "Escolha qual conta deve abrir este link de calendário.",
- "select_account_note": "Esta escolha se aplica apenas a este link de protocolo.",
"detail_to": "Para",
"detail_subject": "Assunto",
"detail_no_subject": "Sem assunto",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 60c77c966..1e2ee6c78 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -245,7 +245,6 @@
"select_account_title": "Alegeți contul",
"select_mailto_account": "Alegeți contul în care să se deschidă acest link de e-mail.",
"select_webcal_account": "Alegeți contul care ar trebui să deschidă acest link către calendar.",
- "select_account_note": "Aceasta se aplică numai acestui link de protocol.",
"detail_to": "Către",
"detail_subject": "Subiect",
"detail_no_subject": "Fără subiect",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 3a0c9fcec..cc0949360 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -245,7 +245,6 @@
"select_account_title": "Выберите аккаунт",
"select_mailto_account": "Выберите, в каком аккаунте открыть эту ссылку электронной почты.",
"select_webcal_account": "Выберите, в каком аккаунте открыть эту ссылку календаря.",
- "select_account_note": "Этот выбор применяется только к этой ссылке протокола.",
"detail_to": "Кому",
"detail_subject": "Тема",
"detail_no_subject": "Без темы",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 502164d6d..7b1dee640 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -245,7 +245,6 @@
"select_account_title": "Vybrať účet",
"select_mailto_account": "Vyberte účet, v ktorom sa má tento e-mailový odkaz otvoriť.",
"select_webcal_account": "Vyberte účet, v ktorom sa má tento kalendárový odkaz otvoriť.",
- "select_account_note": "Toto platí iba pre tento odkaz protokolu.",
"detail_to": "Komu",
"detail_subject": "Predmet",
"detail_no_subject": "Bez predmetu",
diff --git a/locales/th/common.json b/locales/th/common.json
index 852be7f21..39eb53f8d 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -2701,7 +2701,6 @@
"select_account_title": "เลือกบัญชี",
"select_mailto_account": "เลือกบัญชีที่ต้องการให้เปิดลิงก์อีเมลนี้",
"select_webcal_account": "เลือกบัญชีที่ต้องการให้เปิดลิงก์ปฏิทินนี้",
- "select_account_note": "การตั้งค่านี้มีผลเฉพาะกับลิงก์โปรโตคอลนี้เท่านั้น",
"detail_to": "ถึง",
"detail_subject": "เรื่อง",
"detail_no_subject": "ไม่มีหัวข้อเรื่อง",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 8e246b38f..fb65d1d00 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -245,7 +245,6 @@
"select_account_title": "Hesap seç",
"select_mailto_account": "Bu e-posta bağlantısını hangi hesabın açacağını seçin.",
"select_webcal_account": "Bu takvim bağlantısını hangi hesabın açacağını seçin.",
- "select_account_note": "Bu seçim yalnızca bu protokol bağlantısı için geçerlidir.",
"detail_to": "Kime",
"detail_subject": "Konu",
"detail_no_subject": "Konu yok",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 52bf3725a..ea0209857 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -245,7 +245,6 @@
"select_account_title": "Виберіть акаунт",
"select_mailto_account": "Виберіть акаунт, у якому слід відкрити це посилання електронної пошти.",
"select_webcal_account": "Виберіть акаунт, у якому слід відкрити це посилання календаря.",
- "select_account_note": "Цей вибір застосовується лише до цього посилання протоколу.",
"detail_to": "Кому",
"detail_subject": "Тема",
"detail_no_subject": "Без теми",
diff --git a/locales/vi/common.json b/locales/vi/common.json
index a02cf42d1..b0545e068 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -245,7 +245,6 @@
"select_account_title": "Chọn tài khoản",
"select_mailto_account": "Chọn tài khoản nào sẽ mở liên kết email này.",
"select_webcal_account": "Chọn tài khoản nào sẽ mở liên kết lịch này.",
- "select_account_note": "Điều này chỉ áp dụng cho liên kết giao thức này.",
"detail_to": "Đến",
"detail_subject": "Tiêu đề",
"detail_no_subject": "Không có tiêu đề",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index afe32e3ed..2d3c8379f 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -245,7 +245,6 @@
"select_account_title": "選擇帳號",
"select_mailto_account": "選擇用於開啟此電子郵件連結的帳號。",
"select_webcal_account": "選擇用於開啟此行事曆連結的帳號。",
- "select_account_note": "此選擇僅適用於此協議連結。",
"detail_to": "收件者",
"detail_subject": "主題",
"detail_no_subject": "無主題",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index a5fd459b2..03a90fe73 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -245,7 +245,6 @@
"select_account_title": "选择账户",
"select_mailto_account": "选择用于打开此电子邮件链接的账户。",
"select_webcal_account": "选择用于打开此日历链接的账户。",
- "select_account_note": "此选择仅适用于此协议链接。",
"detail_to": "收件人",
"detail_subject": "主题",
"detail_no_subject": "无主题",
From b7686552a397f4bfa169504f564ade51231bf402 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 12:43:18 +0100
Subject: [PATCH 27/34] fix: translate File > Unlock in the desktop app's menus
---
lib/desktop/languages.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/lib/desktop/languages.ts b/lib/desktop/languages.ts
index cee9d4a84..d1a0ac6ae 100644
--- a/lib/desktop/languages.ts
+++ b/lib/desktop/languages.ts
@@ -146,6 +146,7 @@ export function shellLabels(t: (key: string, values?: Record) =>
trayNewMessage: t('email_composer.new_message'),
trayLockNow: t('appLock.lock_now'),
menuLockNow: t('appLock.lock_now'),
+ menuUnlock: t('appLock.unlock'),
trayUnread: t('email_list.sections.unread'),
trayPause: t('desktopMenuBar.tray_pause'),
trayPauseHour: t('desktopMenuBar.tray_pause_hour'),
From 7186f89de77c0e6af254b3c93526d2943fbf31c1 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 12:44:04 +0100
Subject: [PATCH 28/34] test: check the desktop menus get File > Unlock in the
app's language
---
lib/desktop/__tests__/shell-labels.test.ts | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/lib/desktop/__tests__/shell-labels.test.ts b/lib/desktop/__tests__/shell-labels.test.ts
index 563c194d4..57b874146 100644
--- a/lib/desktop/__tests__/shell-labels.test.ts
+++ b/lib/desktop/__tests__/shell-labels.test.ts
@@ -37,6 +37,11 @@ describe('the labels the desktop shell gets', () => {
}
});
+ it('names File > Unlock, which the menus offer while the app is locked', () => {
+ expect(labelsFor('de').labels.menuUnlock).toBe('Entsperren');
+ expect(labelsFor('en').labels.menuUnlock).toBe('Unlock');
+ });
+
it.each(LOCALES)('%s: leaves the shell its placeholders to fill in', (locale) => {
const { labels } = labelsFor(locale);
expect(labels.contextAddToDictionary).toContain('{word}');
From 07b6cae9e84888798544abaf9c7ef37cf7a6282f Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 12:50:52 +0100
Subject: [PATCH 29/34] fix: call the unread count the taskbar icon's in the
desktop app, and write "Not sent:" with a colon
---
.../__tests__/notification-settings.test.tsx | 23 +++++++++++++++++++
components/settings/notification-settings.tsx | 9 ++++++--
lib/__tests__/send-error-wording.test.ts | 14 +++++++++++
locales/ar/common.json | 8 ++++++-
locales/ca/common.json | 6 +++++
locales/cs/common.json | 6 +++++
locales/da/common.json | 8 ++++++-
locales/de/common.json | 6 +++++
locales/en/common.json | 8 ++++++-
locales/es/common.json | 6 +++++
locales/et/common.json | 8 ++++++-
locales/fa/common.json | 8 ++++++-
locales/fr/common.json | 6 +++++
locales/he/common.json | 8 ++++++-
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 | 8 ++++++-
locales/nb/common.json | 6 +++++
locales/nl/common.json | 8 ++++++-
locales/pl/common.json | 6 +++++
locales/pt/common.json | 6 +++++
locales/ro/common.json | 8 ++++++-
locales/ru/common.json | 6 +++++
locales/sk/common.json | 6 +++++
locales/th/common.json | 8 ++++++-
locales/tr/common.json | 8 ++++++-
locales/uk/common.json | 6 +++++
locales/vi/common.json | 6 +++++
locales/zh-TW/common.json | 6 +++++
locales/zh/common.json | 6 +++++
33 files changed, 235 insertions(+), 13 deletions(-)
create mode 100644 lib/__tests__/send-error-wording.test.ts
diff --git a/components/settings/__tests__/notification-settings.test.tsx b/components/settings/__tests__/notification-settings.test.tsx
index 0913b7204..157ac58a2 100644
--- a/components/settings/__tests__/notification-settings.test.tsx
+++ b/components/settings/__tests__/notification-settings.test.tsx
@@ -256,3 +256,26 @@ describe('NotificationSettings - push errors', () => {
expect(text).toBe('Relay register failed: 502');
});
});
+
+describe('NotificationSettings - the unread count in the desktop app', () => {
+ beforeEach(() => {
+ useSettingsStore.setState({ emailNotificationsEnabled: true });
+ useAuthStore.setState({ client: fakeClient, username: 'me@example.com' });
+ usePolicyStore.setState({ ...usePolicyStore.getState(), policy: {}, isSettingLocked: () => false, isSettingHidden: () => false } as never);
+ });
+ afterEach(() => {
+ delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
+ });
+
+ it('speaks of the taskbar icon there, not of a tab', async () => {
+ (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32' };
+ const { findByText, queryByText } = render( );
+ await findByText('unread_badge_label');
+ expect(queryByText('favicon_unread_badge.label')).toBeNull();
+ });
+
+ it('keeps the tab in a browser', async () => {
+ const { findByText } = render( );
+ await findByText('favicon_unread_badge.label');
+ });
+});
diff --git a/components/settings/notification-settings.tsx b/components/settings/notification-settings.tsx
index d54f08011..27a8562d9 100644
--- a/components/settings/notification-settings.tsx
+++ b/components/settings/notification-settings.tsx
@@ -33,6 +33,7 @@ import {
import { webPushErrorKey } from '@/lib/web-push-errors';
import type { PushDevice } from '@/lib/web-push';
import { IS_LITE } from '@/lib/lite';
+import { isDesktopApp } from '@/lib/desktop/bridge';
import { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode';
import {
resolveActiveRelayUrl,
@@ -55,6 +56,7 @@ type DevicesState =
export function NotificationSettings() {
const t = useTranslations('settings.notifications');
const tAppearance = useTranslations('settings.appearance');
+ const tDesktopP2 = useTranslations('uxp2_desktop');
const tUnread = useTranslations('account_unread');
const severalAccounts = useAccountStore((s) => s.accounts.length > 1);
const tThirdParty = useTranslations('third_party');
@@ -354,8 +356,11 @@ export function NotificationSettings() {
)}
- {/* The tab/app icon badge is a new-mail signal too; people look for it here. */}
-
+ {/* The tab/app icon badge is a new-mail signal too; people look for it here. The desktop app has no tab: the taskbar icon. */}
+
updateSetting('faviconUnreadBadge', checked)}
diff --git a/lib/__tests__/send-error-wording.test.ts b/lib/__tests__/send-error-wording.test.ts
new file mode 100644
index 000000000..24889f8dd
--- /dev/null
+++ b/lib/__tests__/send-error-wording.test.ts
@@ -0,0 +1,14 @@
+import { readdirSync, readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { describe, expect, it } from 'vitest';
+
+// "Not sent - the server rejected every recipient." read as a hyphen where a colon belongs
+// (usability pass 2, desktop-p2-18).
+const LOCALES = join(process.cwd(), 'locales');
+
+describe('the send error for refused recipients', () => {
+ it.each(readdirSync(LOCALES))('%s: no hyphen standing in for a dash or colon', (locale) => {
+ const messages = JSON.parse(readFileSync(join(LOCALES, locale, 'common.json'), 'utf8'));
+ expect(messages.email_composer.send_recipients_rejected).not.toContain(' - ');
+ });
+});
diff --git a/locales/ar/common.json b/locales/ar/common.json
index bb42ca4d2..234c2817d 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -1068,7 +1068,7 @@
"recipients_partly_refused": "رفض الخادم {recipients}. سيتلقى باقي المستلمين الرسالة.",
"send_offline": "يبدو أنك غير متصل. الرسالة ما زالت هنا، أرسلها مجددًا عند عودة الاتصال.",
"formatting": "التنسيق",
- "send_recipients_rejected": "لم تُرسل الرسالة - رفض الخادم جميع المستلمين.",
+ "send_recipients_rejected": "لم تُرسل الرسالة: رفض الخادم جميع المستلمين.",
"send_plugin_failed": "لم يُرسل - فشلت إضافة تتولى الإرسال أو استغرقت وقتًا طويلًا. تحقق من مجلد المرسل قبل الإرسال مرة أخرى.",
"send_some_recipients_rejected": "أُرسلت الرسالة، لكن ليس إلى هؤلاء المستلمين - رفضهم الخادم.",
"continue_draft": "متابعة المسودة",
@@ -7435,5 +7435,11 @@
"send_no_consent": "شغّل تقارير الأعطال أولًا.",
"send_no_url": "لم يُحدَّد عنوان، لذا لا توجد وجهة للإرسال. أدخل عنوانًا في الأعلى.",
"send_failed": "تعذّر إرسال تقرير العطل."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "عدد الرسائل غير المقروءة على أيقونة شريط المهام",
+ "unread_badge_description": "لترى البريد الجديد أثناء عملك في تطبيق آخر.",
+ "tip_compose_keys": "اضغط {keys} لكتابة رسالة جديدة",
+ "tip_palette_keys": "اضغط {keys} للعثور على أي أمر"
}
}
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 99ac2d96f..07c7e18f4 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -7435,5 +7435,11 @@
"send_no_consent": "Activa primer els informes d'errors greus.",
"send_no_url": "No hi ha cap adreça, així que no hi ha on enviar-ho. Escriu-ne una a dalt.",
"send_failed": "No s'ha pogut enviar l'informe d'error."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "Nombre de no llegits a la icona de la barra de tasques",
+ "unread_badge_description": "Per veure el correu nou mentre treballeu en una altra aplicació.",
+ "tip_compose_keys": "Premeu {keys} per escriure un missatge nou",
+ "tip_palette_keys": "Premeu {keys} per trobar qualsevol ordre"
}
}
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 0a27b0cb7..f288bd547 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -7435,5 +7435,11 @@
"send_no_consent": "Nejprve zapněte hlášení o pádech.",
"send_no_url": "Není nastavena žádná adresa, takže to nejde kam odeslat. Zadejte ji výše.",
"send_failed": "Hlášení o pádu se nepodařilo odeslat."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "Počet nepřečtených na ikoně na hlavním panelu",
+ "unread_badge_description": "Abyste viděli novou poštu, i když pracujete v jiné aplikaci.",
+ "tip_compose_keys": "Stiskněte {keys} a napište novou zprávu",
+ "tip_palette_keys": "Stiskněte {keys} a najděte jakýkoli příkaz"
}
}
diff --git a/locales/da/common.json b/locales/da/common.json
index c53314e46..6be693e13 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -1067,7 +1067,7 @@
"recipients_partly_refused": "Serveren afviste {recipients}. De øvrige modtagere får beskeden.",
"send_offline": "Du ser ud til at være offline. Beskeden er her stadig, send den igen, når du er online.",
"formatting": "Formatering",
- "send_recipients_rejected": "Ikke sendt - serveren afviste alle modtagere.",
+ "send_recipients_rejected": "Ikke sendt: serveren afviste alle modtagere.",
"send_plugin_failed": "Ikke sendt - et plugin, der står for afsendelsen, fejlede eller tog for lang tid. Tjek mappen Sendt, før du sender igen.",
"send_some_recipients_rejected": "Sendt, men ikke til disse modtagere - serveren afviste dem.",
"continue_draft": "Fortsæt kladde",
@@ -7435,5 +7435,11 @@
"send_no_consent": "Slå først nedbrudsrapporter til.",
"send_no_url": "Der er ikke angivet en adresse, så den kan ikke sendes nogen steder. Skriv en ovenfor.",
"send_failed": "Nedbrudsrapporten kunne ikke sendes."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "Antal ulæste på ikonet på proceslinjen",
+ "unread_badge_description": "Så du ser ny mail, mens du arbejder i en anden app.",
+ "tip_compose_keys": "Tryk på {keys} for at skrive en ny besked",
+ "tip_palette_keys": "Tryk på {keys} for at finde en kommando"
}
}
diff --git a/locales/de/common.json b/locales/de/common.json
index f8ee36a9e..ebe94d305 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -7435,5 +7435,11 @@
"send_no_consent": "Schalten Sie zuerst die Absturzberichte ein.",
"send_no_url": "Es ist keine Adresse eingetragen, daher gibt es kein Ziel. Geben Sie oben eine ein.",
"send_failed": "Der Absturzbericht konnte nicht gesendet werden."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "Anzahl ungelesener Nachrichten am Taskleistensymbol",
+ "unread_badge_description": "So sehen Sie neue Mail, während Sie in einer anderen App arbeiten.",
+ "tip_compose_keys": "Drücken Sie {keys}, um eine neue Nachricht zu schreiben",
+ "tip_palette_keys": "Drücken Sie {keys}, um jeden Befehl zu finden"
}
}
diff --git a/locales/en/common.json b/locales/en/common.json
index e02c83940..9a0bbf6e5 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -1068,7 +1068,7 @@
"submission_limit": "The server refused to send the message because a limit was reached: {reason}",
"recipients_refused": "Not sent: the server refused {recipients}.",
"recipients_partly_refused": "The server refused {recipients}. The other recipients get the message.",
- "send_recipients_rejected": "Not sent - the server rejected every recipient.",
+ "send_recipients_rejected": "Not sent: the server rejected every recipient.",
"cut": "Cut",
"copy": "Copy",
"send_plugin_failed": "Not sent - a plugin that handles sending failed or took too long. Check your Sent folder before sending again.",
@@ -7435,5 +7435,11 @@
"send_no_consent": "Turn on crash reports first.",
"send_no_url": "No address is set, so there's nowhere to send this. Enter one above.",
"send_failed": "The crash report couldn't be sent."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "Unread count on the taskbar icon",
+ "unread_badge_description": "So you see new mail while you work in another app.",
+ "tip_compose_keys": "Press {keys} to write a new message",
+ "tip_palette_keys": "Press {keys} to find any command"
}
}
diff --git a/locales/es/common.json b/locales/es/common.json
index 0742cc825..7457b8829 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -7435,5 +7435,11 @@
"send_no_consent": "Activa primero los informes de fallos.",
"send_no_url": "No hay ninguna dirección, así que no hay adónde enviarlo. Escribe una arriba.",
"send_failed": "No se ha podido enviar el informe de fallo."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "Número de no leídos en el icono de la barra de tareas",
+ "unread_badge_description": "Para ver el correo nuevo mientras trabaja en otra aplicación.",
+ "tip_compose_keys": "Pulse {keys} para escribir un mensaje nuevo",
+ "tip_palette_keys": "Pulse {keys} para buscar cualquier comando"
}
}
diff --git a/locales/et/common.json b/locales/et/common.json
index d1ba2e3cf..dca7f1fa3 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -1068,7 +1068,7 @@
"submission_limit": "Server keeldus kirja saatmast, sest piirang on täis: {reason}",
"recipients_refused": "Ei saadetud: server lükkas tagasi {recipients}.",
"recipients_partly_refused": "Server lükkas tagasi {recipients}. Teised adressaadid saavad kirja kätte.",
- "send_recipients_rejected": "Ei saadetud - server lükkas kõik adressaadid tagasi.",
+ "send_recipients_rejected": "Ei saadetud: server lükkas kõik adressaadid tagasi.",
"send_plugin_failed": "Ei saadetud - saatmist käsitlev pistikprogramm ebaõnnestus või võttis liiga kaua aega. Kontrolli enne uuesti saatmist kausta Saadetud.",
"send_some_recipients_rejected": "Saadetud, kuid mitte neile adressaatidele - server lükkas nad tagasi.",
"continue_draft": "Jätka mustandiga",
@@ -7435,5 +7435,11 @@
"send_no_consent": "Lülitage esmalt kokkujooksmise aruanded sisse.",
"send_no_url": "Aadressi pole määratud, seega pole seda kuhugi saata. Sisestage see ülal.",
"send_failed": "Kokkujooksmise aruannet ei õnnestunud saata."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "Lugemata kirjade arv tegumiriba ikoonil",
+ "unread_badge_description": "Nii näete uut kirja ka siis, kui töötate teises rakenduses.",
+ "tip_compose_keys": "Uue kirja kirjutamiseks vajutage {keys}",
+ "tip_palette_keys": "Mis tahes käsu leidmiseks vajutage {keys}"
}
}
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 8c695ba08..54da67fec 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -1068,7 +1068,7 @@
"recipients_partly_refused": "سرور {recipients} را رد کرد. سایر گیرندگان پیام را دریافت میکنند.",
"send_offline": "به نظر میرسد آفلاین هستید. پیام هنوز اینجاست، وقتی آنلاین شدید دوباره ارسالش کنید.",
"formatting": "قالببندی",
- "send_recipients_rejected": "ارسال نشد - سرور همه گیرندگان را رد کرد.",
+ "send_recipients_rejected": "ارسال نشد: سرور همه گیرندگان را رد کرد.",
"send_plugin_failed": "ارسال نشد - افزونهای که ارسال را انجام میدهد با خطا مواجه شد یا بیش از حد طول کشید. پیش از ارسال دوباره، پوشه ارسالشده را بررسی کنید.",
"send_some_recipients_rejected": "ارسال شد، اما نه برای این گیرندگان - سرور آنها را رد کرد.",
"continue_draft": "ادامه پیشنویس",
@@ -7435,5 +7435,11 @@
"send_no_consent": "ابتدا گزارشهای خرابی را روشن کنید.",
"send_no_url": "نشانیای تنظیم نشده، پس جایی برای ارسال نیست. در بالا یکی وارد کنید.",
"send_failed": "گزارش خرابی ارسال نشد."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "تعداد خواندهنشدهها روی نماد نوار وظیفه",
+ "unread_badge_description": "تا ایمیل جدید را هنگام کار در برنامهای دیگر ببینید.",
+ "tip_compose_keys": "برای نوشتن پیام جدید {keys} را فشار دهید",
+ "tip_palette_keys": "برای یافتن هر فرمانی {keys} را فشار دهید"
}
}
diff --git a/locales/fr/common.json b/locales/fr/common.json
index f969109d8..81bd26fdc 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -7435,5 +7435,11 @@
"send_no_consent": "Activez d'abord les rapports de plantage.",
"send_no_url": "Aucune adresse n'est définie, il n'y a donc nulle part où envoyer ce rapport. Saisissez-en une ci-dessus.",
"send_failed": "Le rapport de plantage n'a pas pu être envoyé."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "Nombre de non-lus sur l'icône de la barre des tâches",
+ "unread_badge_description": "Pour voir les nouveaux messages pendant que vous travaillez dans une autre application.",
+ "tip_compose_keys": "Appuyez sur {keys} pour écrire un nouveau message",
+ "tip_palette_keys": "Appuyez sur {keys} pour trouver n'importe quelle commande"
}
}
diff --git a/locales/he/common.json b/locales/he/common.json
index c85d839f1..6c17fa76d 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -991,7 +991,7 @@
"recipients_partly_refused": "השרת דחה את {recipients}. שאר הנמענים יקבלו את ההודעה.",
"send_offline": "נראה שאין חיבור לרשת. ההודעה עדיין כאן, שלחו אותה שוב כשתחזרו לרשת.",
"formatting": "עיצוב",
- "send_recipients_rejected": "לא נשלח - השרת דחה את כל הנמענים.",
+ "send_recipients_rejected": "לא נשלח: השרת דחה את כל הנמענים.",
"send_plugin_failed": "לא נשלח - תוסף שמטפל בשליחה נכשל או לקח יותר מדי זמן. בדקו את תיקיית דואר יוצא לפני שליחה חוזרת.",
"send_some_recipients_rejected": "נשלח, אך לא לנמענים האלה - השרת דחה אותם.",
"continue_draft": "המשך טיוטה",
@@ -7436,5 +7436,11 @@
"send_no_consent": "הפעילו קודם את דוחות הקריסה.",
"send_no_url": "לא הוגדרה כתובת, ולכן אין לאן לשלוח. הזינו כתובת למעלה.",
"send_failed": "לא ניתן היה לשלוח את דוח הקריסה."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "מספר ההודעות שלא נקראו על הסמל בשורת המשימות",
+ "unread_badge_description": "כדי לראות דואר חדש בזמן שאתם עובדים באפליקציה אחרת.",
+ "tip_compose_keys": "הקישו {keys} כדי לכתוב הודעה חדשה",
+ "tip_palette_keys": "הקישו {keys} כדי למצוא כל פקודה"
}
}
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 8abbef56a..5e63c0a50 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -7435,5 +7435,11 @@
"send_no_consent": "Először kapcsolja be az összeomlási jelentéseket.",
"send_no_url": "Nincs megadva cím, így nincs hová küldeni. Adjon meg egyet fent.",
"send_failed": "Az összeomlási jelentést nem sikerült elküldeni."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "Olvasatlan levelek száma a tálca ikonján",
+ "unread_badge_description": "Így akkor is látja az új leveleket, ha egy másik alkalmazásban dolgozik.",
+ "tip_compose_keys": "Új üzenet írásához nyomja meg: {keys}",
+ "tip_palette_keys": "Bármely parancs kereséséhez nyomja meg: {keys}"
}
}
diff --git a/locales/it/common.json b/locales/it/common.json
index 3b1459804..9e54ed7a5 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -7435,5 +7435,11 @@
"send_no_consent": "Attiva prima le segnalazioni di arresto anomalo.",
"send_no_url": "Non è impostato alcun indirizzo, quindi non c'è dove inviarla. Inseriscine uno qui sopra.",
"send_failed": "Impossibile inviare la segnalazione di arresto."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "Numero di messaggi non letti sull'icona della barra delle applicazioni",
+ "unread_badge_description": "Per vedere la nuova posta mentre lavori in un'altra app.",
+ "tip_compose_keys": "Premi {keys} per scrivere un nuovo messaggio",
+ "tip_palette_keys": "Premi {keys} per trovare qualsiasi comando"
}
}
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 681b134a1..aea6062c7 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -7435,5 +7435,11 @@
"send_no_consent": "先にクラッシュレポートをオンにしてください。",
"send_no_url": "アドレスが設定されていないため、送信先がありません。上で入力してください。",
"send_failed": "クラッシュレポートを送信できませんでした。"
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "タスクバーのアイコンに未読数を表示",
+ "unread_badge_description": "別のアプリで作業中でも新着メールがわかります。",
+ "tip_compose_keys": "{keys} を押して新しいメッセージを作成",
+ "tip_palette_keys": "{keys} を押してコマンドを検索"
}
}
diff --git a/locales/ko/common.json b/locales/ko/common.json
index e5bded68b..cc90a0ec9 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -7435,5 +7435,11 @@
"send_no_consent": "먼저 충돌 보고서를 켜세요.",
"send_no_url": "주소가 설정되지 않아 보낼 곳이 없습니다. 위에 입력하세요.",
"send_failed": "충돌 보고서를 보낼 수 없습니다."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "작업 표시줄 아이콘에 읽지 않은 수 표시",
+ "unread_badge_description": "다른 앱에서 작업하는 동안에도 새 메일을 확인할 수 있습니다.",
+ "tip_compose_keys": "{keys}를 눌러 새 메시지 쓰기",
+ "tip_palette_keys": "{keys}를 눌러 명령 찾기"
}
}
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 0e3ad8adc..444cc735b 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -7435,5 +7435,11 @@
"send_no_consent": "Vispirms ieslēdziet avāriju atskaites.",
"send_no_url": "Adrese nav iestatīta, tāpēc to nav kur sūtīt. Ievadiet to augstāk.",
"send_failed": "Avārijas atskaiti neizdevās nosūtīt."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "Nelasīto skaits uzdevumjoslas ikonā",
+ "unread_badge_description": "Lai redzētu jaunu pastu, kamēr strādājat citā lietotnē.",
+ "tip_compose_keys": "Nospiediet {keys}, lai rakstītu jaunu ziņojumu",
+ "tip_palette_keys": "Nospiediet {keys}, lai atrastu jebkuru komandu"
}
}
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 5d50c8e27..e2fe8c006 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -1068,7 +1068,7 @@
"recipients_partly_refused": "Сервер дараах хүлээн авагчдыг татгалзлаа: {recipients}. Бусад хүлээн авагчид мессежийг хүлээн авна.",
"send_offline": "Та офлайн байх шиг байна. Мессеж энд хадгалагдсан тул онлайн болмогц дахин илгээнэ үү.",
"formatting": "Формат",
- "send_recipients_rejected": "Илгээгдсэнгүй - сервер бүх хүлээн авагчийг татгалзсан.",
+ "send_recipients_rejected": "Илгээгдсэнгүй: сервер бүх хүлээн авагчийг татгалзсан.",
"send_plugin_failed": "Илгээгдсэнгүй - илгээлтийг гүйцэтгэдэг залгаас алдаа гаргасан эсвэл хэт удсан. Дахин илгээхээсээ өмнө Илгээсэн хавтсаа шалгана уу.",
"send_some_recipients_rejected": "Илгээсэн, гэхдээ эдгээр хүлээн авагчид хүрэхгүй - сервер тэднийг татгалзсан.",
"continue_draft": "Ноорог үргэлжлүүлэх",
@@ -7435,5 +7435,11 @@
"send_no_consent": "Эхлээд гэмтлийн тайланг асаана уу.",
"send_no_url": "Хаяг тохируулаагүй тул илгээх газаргүй. Дээр нэгийг оруулна уу.",
"send_failed": "Гэмтлийн тайланг илгээж чадсангүй."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "Ажлын самбарын дүрс дээрх уншаагүй тоо",
+ "unread_badge_description": "Өөр апп дээр ажиллаж байхдаа шинэ шуудангаа харахын тулд.",
+ "tip_compose_keys": "Шинэ зурвас бичихийн тулд {keys} дарна уу",
+ "tip_palette_keys": "Ямар ч тушаал олохын тулд {keys} дарна уу"
}
}
diff --git a/locales/nb/common.json b/locales/nb/common.json
index b44ae533f..0bac0e368 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -7435,5 +7435,11 @@
"send_no_consent": "Slå på krasjrapporter først.",
"send_no_url": "Ingen adresse er angitt, så den kan ikke sendes noe sted. Skriv inn en ovenfor.",
"send_failed": "Krasjrapporten kunne ikke sendes."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "Antall uleste på ikonet på oppgavelinjen",
+ "unread_badge_description": "Slik ser du ny e-post mens du jobber i en annen app.",
+ "tip_compose_keys": "Trykk på {keys} for å skrive en ny melding",
+ "tip_palette_keys": "Trykk på {keys} for å finne en kommando"
}
}
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 01d57dc4f..f5260ad4d 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -1072,7 +1072,7 @@
"recipients_partly_refused": "De server heeft {recipients} geweigerd. De andere ontvangers krijgen het bericht.",
"send_offline": "Je lijkt offline te zijn. Het bericht staat er nog; verstuur het opnieuw zodra je weer online bent.",
"formatting": "Opmaak",
- "send_recipients_rejected": "Niet verzonden - de server heeft alle ontvangers geweigerd.",
+ "send_recipients_rejected": "Niet verzonden: de server heeft alle ontvangers geweigerd.",
"send_plugin_failed": "Niet verzonden - een plug-in die het verzenden afhandelt, is mislukt of duurde te lang. Controleer je map Verzonden voordat je opnieuw verzendt.",
"send_some_recipients_rejected": "Verzonden, maar niet naar deze ontvangers - de server heeft ze geweigerd.",
"continue_draft": "Concept voortzetten",
@@ -7435,5 +7435,11 @@
"send_no_consent": "Zet eerst crashrapporten aan.",
"send_no_url": "Er is geen adres ingesteld, dus dit kan nergens heen. Vul hierboven een adres in.",
"send_failed": "Het crashrapport kon niet worden verzonden."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "Aantal ongelezen op het taakbalkpictogram",
+ "unread_badge_description": "Zo ziet u nieuwe mail terwijl u in een andere app werkt.",
+ "tip_compose_keys": "Druk op {keys} om een nieuw bericht te schrijven",
+ "tip_palette_keys": "Druk op {keys} om elke opdracht te vinden"
}
}
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 4179fb56e..674513fb6 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -7435,5 +7435,11 @@
"send_no_consent": "Najpierw włącz raporty o awariach.",
"send_no_url": "Nie ustawiono adresu, więc nie ma dokąd tego wysłać. Wpisz go powyżej.",
"send_failed": "Nie udało się wysłać raportu o awarii."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "Liczba nieprzeczytanych na ikonie na pasku zadań",
+ "unread_badge_description": "Aby widzieć nową pocztę, gdy pracujesz w innej aplikacji.",
+ "tip_compose_keys": "Naciśnij {keys}, aby napisać nową wiadomość",
+ "tip_palette_keys": "Naciśnij {keys}, aby znaleźć dowolne polecenie"
}
}
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 83add1bb9..0adbf5ecf 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -7435,5 +7435,11 @@
"send_no_consent": "Ligue primeiro os relatórios de falhas.",
"send_no_url": "Não há nenhum endereço definido, por isso não há para onde enviar. Introduza um acima.",
"send_failed": "Não foi possível enviar o relatório de falha."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "Número de não lidas no ícone da barra de tarefas",
+ "unread_badge_description": "Para ver novos e-mails enquanto trabalha noutra aplicação.",
+ "tip_compose_keys": "Prima {keys} para escrever uma nova mensagem",
+ "tip_palette_keys": "Prima {keys} para encontrar qualquer comando"
}
}
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 1e2ee6c78..34620836a 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -1068,7 +1068,7 @@
"recipients_partly_refused": "Serverul a respins {recipients}. Ceilalți destinatari primesc mesajul.",
"send_offline": "Se pare că ești offline. Mesajul este încă aici; trimite-l din nou când revii online.",
"formatting": "Formatare",
- "send_recipients_rejected": "Netrimis - serverul a respins toți destinatarii.",
+ "send_recipients_rejected": "Netrimis: serverul a respins toți destinatarii.",
"send_plugin_failed": "Nu a fost trimis - un plugin care se ocupă de trimitere a eșuat sau a durat prea mult. Verifică dosarul Trimise înainte de a trimite din nou.",
"send_some_recipients_rejected": "Trimis, dar nu și acestor destinatari - serverul i-a respins.",
"continue_draft": "Continuă schița",
@@ -7435,5 +7435,11 @@
"send_no_consent": "Porniți mai întâi rapoartele de blocare.",
"send_no_url": "Nu este setată nicio adresă, deci nu există unde să fie trimis. Introduceți una mai sus.",
"send_failed": "Raportul de blocare nu a putut fi trimis."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "Numărul de necitite pe pictograma din bara de activități",
+ "unread_badge_description": "Ca să vedeți e-mailurile noi în timp ce lucrați în altă aplicație.",
+ "tip_compose_keys": "Apăsați {keys} pentru a scrie un mesaj nou",
+ "tip_palette_keys": "Apăsați {keys} pentru a găsi orice comandă"
}
}
diff --git a/locales/ru/common.json b/locales/ru/common.json
index cc0949360..bee5bf63f 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -7435,5 +7435,11 @@
"send_no_consent": "Сначала включите отчёты о сбоях.",
"send_no_url": "Адрес не задан, поэтому отправлять некуда. Введите его выше.",
"send_failed": "Не удалось отправить отчёт о сбое."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "Число непрочитанных на значке на панели задач",
+ "unread_badge_description": "Чтобы видеть новую почту, пока вы работаете в другом приложении.",
+ "tip_compose_keys": "Нажмите {keys}, чтобы написать новое письмо",
+ "tip_palette_keys": "Нажмите {keys}, чтобы найти любую команду"
}
}
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 7b1dee640..c0583234e 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -7435,5 +7435,11 @@
"send_no_consent": "Najprv zapnite hlásenia o zlyhaniach.",
"send_no_url": "Nie je nastavená žiadna adresa, takže to nie je kam odoslať. Zadajte ju vyššie.",
"send_failed": "Hlásenie o zlyhaní sa nepodarilo odoslať."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "Počet neprečítaných na ikone na paneli úloh",
+ "unread_badge_description": "Aby ste videli novú poštu, aj keď pracujete v inej aplikácii.",
+ "tip_compose_keys": "Stlačte {keys} a napíšte novú správu",
+ "tip_palette_keys": "Stlačte {keys} a nájdite akýkoľvek príkaz"
}
}
diff --git a/locales/th/common.json b/locales/th/common.json
index 39eb53f8d..402cbc0c1 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -1448,7 +1448,7 @@
"submission_limit": "เซิร์ฟเวอร์ปฏิเสธการส่งข้อความเนื่องจากเกินขีดจำกัด: {reason}",
"recipients_refused": "ส่งไม่สำเร็จ: เซิร์ฟเวอร์ปฏิเสธผู้รับ {recipients}",
"recipients_partly_refused": "เซิร์ฟเวอร์ปฏิเสธผู้รับ {recipients} แต่ผู้รับรายอื่นจะยังคงได้รับข้อความ",
- "send_recipients_rejected": "ส่งไม่สำเร็จ - เซิร์ฟเวอร์ปฏิเสธผู้รับทุกคน",
+ "send_recipients_rejected": "ส่งไม่สำเร็จ: เซิร์ฟเวอร์ปฏิเสธผู้รับทุกคน",
"cut": "ตัด",
"copy": "คัดลอก",
"send_plugin_failed": "ส่งไม่สำเร็จ - ปลั๊กอินที่จัดการการส่งเกิดข้อผิดพลาด กรุณาตรวจสอบโฟลเดอร์ส่งแล้วก่อนส่งซ้ำ",
@@ -7443,5 +7443,11 @@
"send_no_consent": "เปิดรายงานข้อขัดข้องก่อน",
"send_no_url": "ไม่ได้ตั้งที่อยู่ จึงไม่มีปลายทางให้ส่ง ป้อนที่อยู่ด้านบน",
"send_failed": "ไม่สามารถส่งรายงานข้อขัดข้องได้"
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "จำนวนที่ยังไม่อ่านบนไอคอนในแถบงาน",
+ "unread_badge_description": "เพื่อให้เห็นอีเมลใหม่ขณะทำงานในแอปอื่น",
+ "tip_compose_keys": "กด {keys} เพื่อเขียนข้อความใหม่",
+ "tip_palette_keys": "กด {keys} เพื่อค้นหาคำสั่งใดก็ได้"
}
}
diff --git a/locales/tr/common.json b/locales/tr/common.json
index fb65d1d00..086c39ab6 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -1068,7 +1068,7 @@
"recipients_partly_refused": "Sunucu şu alıcıları reddetti: {recipients}. Diğer alıcılar iletiyi alır.",
"send_offline": "Çevrimdışı görünüyorsunuz. İleti hâlâ burada; yeniden çevrimiçi olduğunuzda tekrar gönderin.",
"formatting": "Biçimlendirme",
- "send_recipients_rejected": "Gönderilmedi - sunucu tüm alıcıları reddetti.",
+ "send_recipients_rejected": "Gönderilmedi: sunucu tüm alıcıları reddetti.",
"send_plugin_failed": "Gönderilmedi - gönderimi üstlenen bir eklenti başarısız oldu ya da çok uzun sürdü. Yeniden göndermeden önce Gönderilenler klasörünü kontrol edin.",
"send_some_recipients_rejected": "Gönderildi, ancak bu alıcılara değil - sunucu onları reddetti.",
"continue_draft": "Taslağa devam et",
@@ -7435,5 +7435,11 @@
"send_no_consent": "Önce kilitlenme raporlarını açın.",
"send_no_url": "Adres ayarlanmadı, bu yüzden gönderilecek yer yok. Yukarıya bir adres girin.",
"send_failed": "Kilitlenme raporu gönderilemedi."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "Görev çubuğu simgesinde okunmamış sayısı",
+ "unread_badge_description": "Başka bir uygulamada çalışırken yeni postayı görmeniz için.",
+ "tip_compose_keys": "Yeni bir ileti yazmak için {keys} tuşlarına basın",
+ "tip_palette_keys": "Herhangi bir komutu bulmak için {keys} tuşlarına basın"
}
}
diff --git a/locales/uk/common.json b/locales/uk/common.json
index ea0209857..9eefb2993 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -7435,5 +7435,11 @@
"send_no_consent": "Спочатку ввімкніть звіти про збої.",
"send_no_url": "Адресу не задано, тому надсилати нікуди. Введіть її вище.",
"send_failed": "Не вдалося надіслати звіт про збій."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "Кількість непрочитаних на значку на панелі завдань",
+ "unread_badge_description": "Щоб бачити нову пошту, поки ви працюєте в іншому застосунку.",
+ "tip_compose_keys": "Натисніть {keys}, щоб написати новий лист",
+ "tip_palette_keys": "Натисніть {keys}, щоб знайти будь-яку команду"
}
}
diff --git a/locales/vi/common.json b/locales/vi/common.json
index b0545e068..5b2b36233 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -7447,5 +7447,11 @@
"send_no_consent": "Hãy bật báo cáo sự cố trước.",
"send_no_url": "Chưa đặt địa chỉ nên không có nơi để gửi. Hãy nhập một địa chỉ ở trên.",
"send_failed": "Không thể gửi báo cáo sự cố."
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "Số thư chưa đọc trên biểu tượng thanh tác vụ",
+ "unread_badge_description": "Để bạn thấy thư mới khi đang làm việc trong ứng dụng khác.",
+ "tip_compose_keys": "Nhấn {keys} để viết thư mới",
+ "tip_palette_keys": "Nhấn {keys} để tìm bất kỳ lệnh nào"
}
}
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 2d3c8379f..7b7c0cdc1 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -7435,5 +7435,11 @@
"send_no_consent": "請先開啟當機報告。",
"send_no_url": "未設定地址,因此無處傳送。請在上方輸入一個。",
"send_failed": "無法傳送當機報告。"
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "在工作列圖示上顯示未讀數",
+ "unread_badge_description": "這樣您在其他應用程式中工作時也能看到新郵件。",
+ "tip_compose_keys": "按 {keys} 撰寫新郵件",
+ "tip_palette_keys": "按 {keys} 尋找任何指令"
}
}
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 03a90fe73..f5248c3f0 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -7435,5 +7435,11 @@
"send_no_consent": "请先开启崩溃报告。",
"send_no_url": "未设置地址,因此无处发送。请在上方输入一个。",
"send_failed": "无法发送崩溃报告。"
+ },
+ "uxp2_desktop": {
+ "unread_badge_label": "在任务栏图标上显示未读数",
+ "unread_badge_description": "这样您在其他应用中工作时也能看到新邮件。",
+ "tip_compose_keys": "按 {keys} 写新邮件",
+ "tip_palette_keys": "按 {keys} 查找任何命令"
}
}
From 2efbaed728370efdf0e340f5c6f4fc7e2dd16519 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 12:54:21 +0100
Subject: [PATCH 30/34] fix: give quick compose's Move to the main window an
arrow going in, not the exit full screen arrows
---
components/email/__tests__/composer-own-window.test.tsx | 9 +++++++++
components/email/email-composer.tsx | 4 +++-
2 files changed, 12 insertions(+), 1 deletion(-)
diff --git a/components/email/__tests__/composer-own-window.test.tsx b/components/email/__tests__/composer-own-window.test.tsx
index 2731b6a23..d6d312db5 100644
--- a/components/email/__tests__/composer-own-window.test.tsx
+++ b/components/email/__tests__/composer-own-window.test.tsx
@@ -112,6 +112,15 @@ function target(shown = true): ComposerPopOut & { handed: ComposerDraftData[]; c
const DRAFT: ComposerDraftData = { to: '', cc: '', bcc: '', subject: 'Old', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: null };
+describe('"Move to the main window" in quick compose', () => {
+ it('shows an arrow going in, not the arrows of leaving full screen', () => {
+ mount({ popOut: vi.fn(() => target()), popOutToMainLabel: 'Move to the main window' });
+ const svg = screen.getByTestId('composer-back-to-main').querySelector('svg');
+ expect(svg?.getAttribute('class')).toContain('tabler-icon-login');
+ expect(svg?.getAttribute('class')).not.toContain('minimize');
+ });
+});
+
describe('"Open in new window" on a composer', () => {
it('is not there without the host offering it: browsers stay as they are', () => {
mount({});
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index fc292094a..bbcff311d 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -38,6 +38,7 @@ import {
Minimize2,
Trash2,
ExternalLink,
+ LogIn,
ReplyAll,
Reply,
Forward,
@@ -4682,7 +4683,8 @@ export function EmailComposer({
title={popOutToMainLabel ?? tWindows('open_in_window')}
data-testid={popOutToMainLabel ? "composer-back-to-main" : "composer-open-in-window"}
>
- {popOutToMainLabel ? : }
+ {/* Into the main window: an arrow going in, not the inward arrows of leaving full screen. */}
+ {popOutToMainLabel ? : }
);
From 850ebce944465e36b1dca0a435e55e8356391657 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 12:57:24 +0100
Subject: [PATCH 31/34] fix: open a resumed reply draft under its message
---
.../mail/__tests__/mail-app-drafts.test.tsx | 2 ++
components/mail/mail-app.tsx | 16 +++++++++++-----
2 files changed, 13 insertions(+), 5 deletions(-)
diff --git a/components/mail/__tests__/mail-app-drafts.test.tsx b/components/mail/__tests__/mail-app-drafts.test.tsx
index 84411113b..980410488 100644
--- a/components/mail/__tests__/mail-app-drafts.test.tsx
+++ b/components/mail/__tests__/mail-app-drafts.test.tsx
@@ -144,6 +144,8 @@ describe('a message with a reply draft (compose-p2-6)', () => {
await screen.findByText('stub reply draft: my answer');
fireEvent.click(screen.getByText('stub reply'));
await waitFor(() => expect(screen.getByTestId('stub-composer')).toHaveAttribute('data-draft', 'd1'));
+ // Under the message it answers, as a new reply is, not over it.
+ expect(screen.getByTestId('stub-composer').closest('[data-reply-dock]')).not.toBeNull();
});
it('starts a new reply when the conversation has no reply to this message', async () => {
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 0492dba12..451e78a0c 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -306,6 +306,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const composerHostRef = useRef('pane');
// The compose session docked under its message (dockedReply).
const [dockedSession, setDockedSession] = useState(null);
+ // A reply draft opened again from its message (compose-p2-6): it docks too.
+ const [resumedReplySession, setResumedReplySession] = useState(null);
const showComposerRef = useRef(showComposer);
showComposerRef.current = showComposer;
// Fields a plugin filled in through onBeforeCompose for the composer about
@@ -2873,7 +2875,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
} : null;
}
if (!found) return false;
- await handleEditDraft(found.draft, found.source);
+ await handleEditDraft(found.draft, found.source, { underMessage: true });
return true;
};
@@ -2912,7 +2914,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
* undone or cancelled - the account it was sent from (a group or shared one
* for mail sent from its identity) and the login that sent it.
*/
- const handleEditDraft = async (email?: Email, source?: DraftSource) => {
+ // `underMessage`: a reply draft resumed from the message it answers docks
+ // under that message, as a new reply does (compose-p2-6).
+ const handleEditDraft = async (email?: Email, source?: DraftSource, { underMessage = false }: { underMessage?: boolean } = {}) => {
if (!client) return;
const draftCandidate = email && typeof email === 'object' && typeof email.id === 'string'
? email
@@ -2961,7 +2965,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Increment session ID to force the composer to remount with fresh state,
// even if it was already open (e.g. right-clicking a draft while composing).
- setComposerSessionId(id => id + 1);
+ const nextSession = composerSessionRef.current + 1;
+ setComposerSessionId(nextSession);
+ setResumedReplySession(underMessage ? nextSession : null);
setPendingDraft(composerDataFromDraft(draft, {
accountId,
localAccountId,
@@ -5053,7 +5059,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Decided as it opens and kept for the session: moving it would mount it
// anew when the message goes away (another folder opened meanwhile).
const dockCandidate = !mvShell && !isMobile && showComposer && !isEmbedded && !floatingComposer
- && (pendingDraft?.mode ?? composerMode) !== 'compose';
+ && ((pendingDraft?.mode ?? composerMode) !== 'compose' || resumedReplySession === composerSessionId);
const dockOnOpen = dockCandidate && Boolean(selectedEmail) && !selectedEmail?.keywords?.$draft;
if (dockOnOpen && dockedSession !== composerSessionId) setDockedSession(composerSessionId);
const dockedReply = dockCandidate && (dockOnOpen || dockedSession === composerSessionId);
@@ -6355,7 +6361,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
replyComposerOpen={dockedReply}
replyDraft={replyDraft && selectedEmail && replyDraft.emailId === selectedEmail.id ? {
preview: replyDraft.draft.preview,
- onEdit: () => void handleEditDraft(replyDraft.draft, replyDraft.source).catch(reportDraftOpenFailure),
+ onEdit: () => void handleEditDraft(replyDraft.draft, replyDraft.source, { underMessage: true }).catch(reportDraftOpenFailure),
onDiscard: discardReplyDraft,
} : null}
onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined}
From f05af5a5906e32eb152015b3b7a8450da53eab2e Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 12:57:25 +0100
Subject: [PATCH 32/34] fix: keep toasts above a reply draft's card
---
components/email/__tests__/viewer-reply-draft.test.tsx | 6 ++++++
components/email/email-viewer.tsx | 7 ++++---
2 files changed, 10 insertions(+), 3 deletions(-)
diff --git a/components/email/__tests__/viewer-reply-draft.test.tsx b/components/email/__tests__/viewer-reply-draft.test.tsx
index 52fba0c2e..b3dd05f39 100644
--- a/components/email/__tests__/viewer-reply-draft.test.tsx
+++ b/components/email/__tests__/viewer-reply-draft.test.tsx
@@ -68,6 +68,12 @@ describe('a reply draft under its message', () => {
expect(onDiscard).toHaveBeenCalledTimes(1);
});
+ // compose-p2-17: toasts lift over it as over the quick reply box.
+ it('counts as the quick reply box for the toasts', async () => {
+ await renderViewer({ preview: 'my answer', onEdit: vi.fn(), onDiscard: vi.fn() });
+ expect(screen.getByTestId('reply-draft')).toHaveAttribute('data-quick-reply-box');
+ });
+
it('keeps the quick reply box when there is none', async () => {
await renderViewer(null);
expect(screen.queryByTestId('reply-draft')).toBeNull();
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 655ea042b..91e56c5da 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -3527,8 +3527,9 @@ export function EmailViewer({
// Toasts sit above the quick reply box, as above a composer's footer
// (compose-p2-17): its height for toast.tsx, as it grows when focused.
useEffect(() => {
+ // The reply draft's card takes the box's place, and its ref.
const box = quickReplyBoxRef.current;
- if (!showQuickReply || !box || typeof ResizeObserver === 'undefined') return;
+ if (!(showQuickReply || showReplyDraft) || !box || typeof ResizeObserver === 'undefined') return;
const root = document.documentElement;
const update = () => root.style.setProperty('--quick-reply-height', `${box.offsetHeight}px`);
update();
@@ -3538,7 +3539,7 @@ export function EmailViewer({
observer.disconnect();
root.style.removeProperty('--quick-reply-height');
};
- }, [showQuickReply]);
+ }, [showQuickReply, showReplyDraft]);
// The plain-text body, where reader-above-quote mounts between text and quote.
const [plainBodyEl, setPlainBodyEl] = useState(null);
// Read aloud (browser speech): the body as shown, HTML frame or plain text.
@@ -6215,7 +6216,7 @@ ${printWatermark ? printWatermark.html : ''}
{/* The reply already started on this message, as Gmail shows it. */}
{showReplyDraft && replyDraft && (
-
+
From 6a2bedc442868d4b0fe6dd419074e09884276fcb Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 13:10:26 +0100
Subject: [PATCH 33/34] fix: name Ctrl+N and Ctrl+K in the desktop app's
welcome tips instead of single keys
---
components/ui/__tests__/welcome-banner.test.tsx | 13 +++++++++++++
components/ui/welcome-banner.tsx | 7 ++++++-
2 files changed, 19 insertions(+), 1 deletion(-)
diff --git a/components/ui/__tests__/welcome-banner.test.tsx b/components/ui/__tests__/welcome-banner.test.tsx
index 2ff6b50fb..3af02db83 100644
--- a/components/ui/__tests__/welcome-banner.test.tsx
+++ b/components/ui/__tests__/welcome-banner.test.tsx
@@ -27,6 +27,19 @@ describe('WelcomeBanner tips', () => {
expect(screen.getByText('tip_shortcuts')).toBeInTheDocument();
});
+ it('in the desktop app, names the keys it answers: Ctrl+N and Ctrl+K, not c', () => {
+ device.isMobile = false;
+ (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32' };
+ try {
+ render( );
+ expect(screen.getByText('tip_compose_keys')).toBeInTheDocument();
+ expect(screen.getByText('tip_palette_keys')).toBeInTheDocument();
+ expect(screen.queryByText('tip_compose')).toBeNull();
+ } finally {
+ delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
+ }
+ });
+
it('gives touch tips on a phone', () => {
device.isMobile = true;
render( );
diff --git a/components/ui/welcome-banner.tsx b/components/ui/welcome-banner.tsx
index 1b2a9b5cb..3c135ce11 100644
--- a/components/ui/welcome-banner.tsx
+++ b/components/ui/welcome-banner.tsx
@@ -9,6 +9,8 @@ import { useRouter } from "@/i18n/navigation";
import { useTour } from "@/components/tour/tour-provider";
import { useSettingsStore } from "@/stores/settings-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
+import { isDesktopApp } from "@/lib/desktop/bridge";
+import { formatShortcutCombo } from "@/lib/shortcut-keys";
const ONBOARDING_KEY = "onboarding_completed";
@@ -38,6 +40,7 @@ export function useWelcomeBannerDue(): boolean {
*/
export function WelcomeBanner({ variant = "list" }: { variant?: "list" | "pane" }) {
const t = useTranslations("welcome");
+ const tDesktop = useTranslations("uxp2_desktop");
const router = useRouter();
const { startTour } = useTour();
const onboardingCompleted = useSettingsStore((s) => s.onboardingCompleted);
@@ -113,7 +116,9 @@ export function WelcomeBanner({ variant = "list" }: { variant?: "list" | "pane"
{t("title")}
- {isMobile ? t("tip_compose_touch") : t("tip_compose")}
+ {/* The desktop app answers the keys Windows mail apps use, not single letters (desktop-p2-12). */}
+ {isMobile ? t("tip_compose_touch") : isDesktopApp() ? tDesktop("tip_compose_keys", { keys: formatShortcutCombo("Mod + N") }) : t("tip_compose")}
+ {!isMobile && isDesktopApp() && {tDesktop("tip_palette_keys", { keys: formatShortcutCombo("Mod + K") })} }
{!isMobile && {t("tip_shortcuts")} }
{isMobile ? t("tip_sidebar_touch") : t("tip_sidebar")}
{t("tip_settings")}
From 6ec867b4e08e11e438d819a6dd4159909acad086 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 13:16:44 +0100
Subject: [PATCH 34/34] fix: count a pop-out's undo send from the delay the
user picked
---
.../popout/__tests__/popout-compose-app.test.tsx | 10 ++++++++++
components/popout/popout-compose-app.tsx | 11 +++++++----
lib/outgoing/send-progress-toast.ts | 15 +++++++++++----
3 files changed, 28 insertions(+), 8 deletions(-)
diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx
index 737e95948..f1bfe6f70 100644
--- a/components/popout/__tests__/popout-compose-app.test.tsx
+++ b/components/popout/__tests__/popout-compose-app.test.tsx
@@ -7,6 +7,7 @@ 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 { useSettingsStore } from '@/stores/settings-store';
import { emailHooks } from '@/lib/plugin-hooks';
// The composer's window: when it tells the opening window to let go, and what
@@ -614,6 +615,15 @@ describe('a send held for undo', () => {
expect(sent?.notice.releasesAt).toBe(sendAt);
});
+ // The server's time runs past the delay: "Sending in 6 seconds" for 5 s.
+ it('never counts from more than the delay the user picked', async () => {
+ const before = useSettingsStore.getState().sendDelaySeconds;
+ useSettingsStore.setState({ sendDelaySeconds: 5 } as never);
+ await sendFromWindow(5.8);
+ expect(screen.getByTestId('popout-sending')).toHaveAttribute('data-seconds', '5');
+ useSettingsStore.setState({ sendDelaySeconds: before } as never);
+ });
+
// compose-24: the window sat with a greyed Send until the server answered.
it('says "Sending…" as soon as Send goes through, before the server answers', async () => {
let answer!: () => void;
diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx
index c8145e2d2..36e65f7c6 100644
--- a/components/popout/popout-compose-app.tsx
+++ b/components/popout/popout-compose-app.tsx
@@ -10,6 +10,8 @@ import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { usePluginStore } from "@/stores/plugin-store";
import { toast } from "@/stores/toast-store";
+import { useSettingsStore } from "@/stores/settings-store";
+import { secondsUntilSend } from "@/lib/outgoing/send-progress-toast";
import { formatRejectedRecipients } from "@/lib/jmap/client";
import { debug } from "@/lib/debug";
import { composerSendOptions } from "@/lib/outgoing/composer-send-options";
@@ -68,9 +70,9 @@ interface ReopenTarget {
}
/** "Sending…" or "Sent", where the composer was, with the send icon. */
-function SendStatus({ text, children }: { text: string; children?: React.ReactNode }) {
+function SendStatus({ text, seconds, children }: { text: string; seconds?: number; children?: React.ReactNode }) {
return (
-
+
{text}
{children}
@@ -134,11 +136,12 @@ function SendingWithUndo({ held, onUndone, onGone }: {
onUndone(held.isSmime ? null : restored);
};
- const seconds = Math.max(1, Math.ceil((sendAt - now) / 1000));
+ // As the main window's toast counts: never past the delay the user picked.
+ const seconds = secondsUntilSend(sendAt, now, useSettingsStore.getState().sendDelaySeconds);
// Once Undo is over, "Sent" for the moment before the window goes, as the
// main window's toast says it.
return (
-
+
{!over && (
void undo()} disabled={busy} data-testid="popout-undo-send">
{tViewer("undo_send")}
diff --git a/lib/outgoing/send-progress-toast.ts b/lib/outgoing/send-progress-toast.ts
index f0b3d6211..e4fda0f13 100644
--- a/lib/outgoing/send-progress-toast.ts
+++ b/lib/outgoing/send-progress-toast.ts
@@ -71,13 +71,20 @@ export interface UndoCountdownOptions {
* word that the message went. Stops by itself once the toast is gone (Undo,
* Send now, closed). Returns a stop for the caller.
*/
+/**
+ * Whole seconds until a held send goes, at least 1 and never more than the
+ * delay the user picked: the server's time runs a moment past it, which
+ * showed "Sending in 6 seconds" for a 5 s delay.
+ */
+export function secondsUntilSend(sendAt: number, now: number, maxSeconds?: number): number {
+ const left = Math.max(1, Math.ceil((sendAt - now) / 1000));
+ return maxSeconds && maxSeconds > 0 ? Math.min(left, maxSeconds) : left;
+}
+
export function runUndoSendCountdown(id: string, options: UndoCountdownOptions): () => void {
const now = options.now ?? Date.now;
const present = () => useToastStore.getState().toasts.some((t) => t.id === id);
- const secondsLeft = () => {
- const left = Math.max(1, Math.ceil((options.sendAt - now()) / 1000));
- return options.maxSeconds && options.maxSeconds > 0 ? Math.min(left, options.maxSeconds) : left;
- };
+ const secondsLeft = () => secondsUntilSend(options.sendAt, now(), options.maxSeconds);
let shown = -1;
const tick = () => {
if (!present()) return stop();