+
+ );
+}
diff --git a/lib/desktop/__tests__/shortcut-keys.test.ts b/lib/desktop/__tests__/shortcut-keys.test.ts
new file mode 100644
index 000000000..f2f345138
--- /dev/null
+++ b/lib/desktop/__tests__/shortcut-keys.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest';
+import { acceleratorFromKeyEvent, describeAccelerator, type ShortcutKeyEvent } from '../shortcut-keys';
+
+const press = (key: string, code: string, modifiers: Partial = {}): ShortcutKeyEvent => ({
+ key,
+ code,
+ ctrlKey: false,
+ altKey: false,
+ shiftKey: false,
+ metaKey: false,
+ ...modifiers,
+});
+
+describe('describeAccelerator', () => {
+ it('names the keys as each OS does', () => {
+ expect(describeAccelerator('CommandOrControl+Alt+N', 'win32')).toBe('Ctrl+Alt+N');
+ expect(describeAccelerator('CommandOrControl+Alt+N', 'linux')).toBe('Ctrl+Alt+N');
+ expect(describeAccelerator('CommandOrControl+Alt+N', 'darwin')).toBe('⌥⌘N');
+ expect(describeAccelerator('Super+Shift+F9', 'win32')).toBe('Win+Shift+F9');
+ expect(describeAccelerator('Control+Shift+CommandOrControl+B', 'darwin')).toBe('⌃⇧⌘B');
+ });
+
+ it('reads what a policy may write', () => {
+ expect(describeAccelerator('Ctrl+Alt+M', 'win32')).toBe('Ctrl+Alt+M');
+ });
+});
+
+describe('acceleratorFromKeyEvent', () => {
+ it('turns a press into what the shell takes', () => {
+ expect(acceleratorFromKeyEvent(press('n', 'KeyN', { ctrlKey: true, altKey: true }), 'win32')).toBe('CommandOrControl+Alt+N');
+ expect(acceleratorFromKeyEvent(press('m', 'KeyM', { metaKey: true, altKey: true }), 'darwin')).toBe('CommandOrControl+Alt+M');
+ expect(acceleratorFromKeyEvent(press('7', 'Digit7', { ctrlKey: true, shiftKey: true }), 'linux')).toBe('CommandOrControl+Shift+7');
+ });
+
+ it('uses the letter on the key, whatever the layout puts where', () => {
+ expect(acceleratorFromKeyEvent(press('z', 'KeyY', { ctrlKey: true, altKey: true }), 'win32')).toBe('CommandOrControl+Alt+Z');
+ });
+
+ it('falls back on the key itself when a modifier changed the character', () => {
+ expect(acceleratorFromKeyEvent(press('˜', 'KeyN', { metaKey: true, altKey: true }), 'darwin')).toBe('CommandOrControl+Alt+N');
+ });
+
+ it('keeps Ctrl on a Mac apart from Cmd, and the Windows key apart from Ctrl', () => {
+ expect(acceleratorFromKeyEvent(press('n', 'KeyN', { ctrlKey: true, altKey: true }), 'darwin')).toBe('Control+Alt+N');
+ expect(acceleratorFromKeyEvent(press('n', 'KeyN', { metaKey: true, altKey: true }), 'win32')).toBe('Super+Alt+N');
+ });
+
+ it('needs two modifiers, so no key is taken away from every other app', () => {
+ expect(acceleratorFromKeyEvent(press('c', 'KeyC', { ctrlKey: true }), 'win32')).toBeNull();
+ expect(acceleratorFromKeyEvent(press('c', 'KeyC'), 'win32')).toBeNull();
+ });
+
+ it('takes one modifier on a function key, and none is still not enough', () => {
+ expect(acceleratorFromKeyEvent(press('F9', 'F9', { ctrlKey: true }), 'win32')).toBe('CommandOrControl+F9');
+ expect(acceleratorFromKeyEvent(press('F9', 'F9'), 'win32')).toBeNull();
+ });
+
+ it('waits while only modifiers are down', () => {
+ expect(acceleratorFromKeyEvent(press('Control', 'ControlLeft', { ctrlKey: true }), 'win32')).toBeNull();
+ expect(acceleratorFromKeyEvent(press('Alt', 'AltLeft', { ctrlKey: true, altKey: true }), 'win32')).toBeNull();
+ });
+});
diff --git a/lib/desktop/languages.ts b/lib/desktop/languages.ts
index aabfdeb01..6d37508f1 100644
--- a/lib/desktop/languages.ts
+++ b/lib/desktop/languages.ts
@@ -50,6 +50,7 @@ export function shellLabels(t: (key: string, values?: Record) =>
menuWindow: t('desktopUi.menu.window'),
menuHelp: t('desktopUi.menu.help'),
menuNewMessage: t('email_composer.new_message'),
+ menuQuickCompose: t('desktopQuickCompose.menu'),
menuNewWindow: t('desktopUi.menu.new_window'),
menuPrint: t('desktopUi.menu.print'),
menuSavePdf: t('desktopUi.menu.save_pdf'),
diff --git a/lib/desktop/policy-schema.ts b/lib/desktop/policy-schema.ts
index ee00aec1f..87d0645ec 100644
--- a/lib/desktop/policy-schema.ts
+++ b/lib/desktop/policy-schema.ts
@@ -825,7 +825,7 @@ export const POLICY_CONSUMERS: Record = {
AppLockTimeoutMinutes: { repo: 'webmail', file: 'lib/app-lock/policy.ts' },
TelemetryAllowed: { repo: 'shell', file: 'src/main/features/privacy.ts' },
CrashReportsAllowed: { repo: 'shell', file: 'src/main/features/privacy.ts' },
- QuickComposeShortcut: { pendingConsumer: 'step 7, quick compose' },
+ QuickComposeShortcut: { repo: 'shell', file: 'src/main/features/os-quick-compose.ts' },
AutoSelectCertificateForUrls: { pendingConsumer: 'step 7, client certificates' },
KeystoreAllowedOrigins: { repo: 'shell', file: 'src/main/features/policies.ts' },
};
diff --git a/lib/desktop/shortcut-keys.ts b/lib/desktop/shortcut-keys.ts
new file mode 100644
index 000000000..66dc5bd3e
--- /dev/null
+++ b/lib/desktop/shortcut-keys.ts
@@ -0,0 +1,84 @@
+/**
+ * Global shortcuts as the shell takes them (Electron accelerators) and as people read them.
+ */
+
+/** Ctrl+Alt+N, Cmd+Opt+N. The shell's default too: src/main/features/os-rules.ts. */
+export const QUICK_COMPOSE_DEFAULT = 'CommandOrControl+Alt+N';
+
+const MAC_SYMBOLS: Record = {
+ control: '⌃',
+ ctrl: '⌃',
+ alt: '⌥',
+ option: '⌥',
+ altgr: '⌥',
+ shift: '⇧',
+ commandorcontrol: '⌘',
+ cmdorctrl: '⌘',
+ command: '⌘',
+ cmd: '⌘',
+ super: '⌘',
+ meta: '⌘',
+};
+
+const OTHER_NAMES: Record = {
+ control: 'Ctrl',
+ ctrl: 'Ctrl',
+ commandorcontrol: 'Ctrl',
+ cmdorctrl: 'Ctrl',
+ command: 'Ctrl',
+ cmd: 'Ctrl',
+ alt: 'Alt',
+ option: 'Alt',
+ altgr: 'AltGr',
+ shift: 'Shift',
+};
+
+/** The order a Mac shows modifiers in. */
+const MAC_ORDER = ['⌃', '⌥', '⇧', '⌘'];
+
+export function describeAccelerator(accelerator: string, platform: string | undefined): string {
+ const parts = accelerator.split('+');
+ const key = parts.pop() ?? '';
+ const named = parts.map((part) => part.toLowerCase());
+ if (platform === 'darwin') {
+ const symbols = named.map((part) => MAC_SYMBOLS[part] ?? part);
+ return [...symbols].sort((a, b) => MAC_ORDER.indexOf(a) - MAC_ORDER.indexOf(b)).join('') + key;
+ }
+ const superKey = platform === 'win32' ? 'Win' : 'Super';
+ return [...named.map((part) => OTHER_NAMES[part] ?? superKey), key].join('+');
+}
+
+export interface ShortcutKeyEvent {
+ key: string;
+ code: string;
+ ctrlKey: boolean;
+ altKey: boolean;
+ shiftKey: boolean;
+ metaKey: boolean;
+}
+
+/** The key's own letter where the layout gives one: Alt on a Mac turns N into a dead key. */
+function keyName(event: ShortcutKeyEvent): string | null {
+ if (/^[a-zA-Z0-9]$/.test(event.key)) return event.key.toUpperCase();
+ const code = /^(?:Key([A-Z])|Digit(\d)|(F\d{1,2}))$/.exec(event.code);
+ return code ? (code[1] ?? code[2] ?? code[3]) : null;
+}
+
+/**
+ * A key press as a shortcut, or null while it can't be one. Two modifiers, since a global
+ * Ctrl+C would take that key away from every other app. A function key is rare enough for one.
+ */
+export function acceleratorFromKeyEvent(event: ShortcutKeyEvent, platform: string | undefined): string | null {
+ const key = keyName(event);
+ if (!key) return null;
+ const mac = platform === 'darwin';
+ const modifiers = [
+ ...((mac ? event.metaKey : event.ctrlKey) ? ['CommandOrControl'] : []),
+ ...(mac && event.ctrlKey ? ['Control'] : []),
+ ...(!mac && event.metaKey ? ['Super'] : []),
+ ...(event.altKey ? ['Alt'] : []),
+ ...(event.shiftKey ? ['Shift'] : []),
+ ];
+ if (modifiers.length < (/^F\d/.test(key) ? 1 : 2)) return null;
+ return [...modifiers, key].join('+');
+}
diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts
index a8331c347..262ebe937 100644
--- a/lib/desktop/types.ts
+++ b/lib/desktop/types.ts
@@ -48,6 +48,8 @@ export interface DesktopWindowsBridge {
setRestoreEnabled?: (enabled: boolean) => void;
/** Signed in, with a session to lend: windows from last time can open. */
sessionReady?: () => void;
+ /** Nobody is signed in any more: a pop-out would find no session. */
+ sessionEnded?: () => void;
/** The warm spare's page, see components/popout/popout-spare-app.tsx. */
spare?: {
ready: () => void;
@@ -458,6 +460,19 @@ export interface DesktopOsSettings {
quietHoursEnd: string;
/** "Show Bulwark", an Electron accelerator. Null: off. */
showShortcut: string | null;
+ /** Quick compose, an Electron accelerator. Null: off. Unset: nobody chose, so the policy's or the built-in one. */
+ quickComposeShortcut?: string | null;
+}
+
+export interface DesktopQuickComposeStatus {
+ /** What's in force after policy and the user's choice. Null: off. */
+ shortcut: string | null;
+ /** Really held. False with a shortcut: another app has it. */
+ active: boolean;
+ /** Set by the `QuickComposeShortcut` policy. */
+ locked: boolean;
+ /** What turning it on again gives. */
+ builtIn: string | null;
}
export interface DesktopOsStatus {
@@ -465,6 +480,7 @@ export interface DesktopOsStatus {
pausedUntil: number | null;
/** The shortcut in settings is really held. Another app can have it. */
shortcutActive: boolean;
+ quickCompose?: DesktopQuickComposeStatus;
}
export interface DesktopOsFile {
diff --git a/lib/popout/__tests__/return-to-main.test.ts b/lib/popout/__tests__/return-to-main.test.ts
new file mode 100644
index 000000000..e3d99241e
--- /dev/null
+++ b/lib/popout/__tests__/return-to-main.test.ts
@@ -0,0 +1,119 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel';
+import { registerComposeFlush } from '@/lib/compose-flush-registry';
+import { openPopoutChannel, type PopoutMessage } from '../channel';
+import { answerWhenComposerOpens, dropReturnedComposer, offerReturnedComposer, onReturnedComposer, onReturnedComposerElsewhere, returnComposerToMain, takeReturnedComposer } from '../return-to-main';
+
+// The way a composer goes back from its own window. The window lets go of its text only when
+// the main window says the composer is there, so every other outcome has to say so too.
+
+const PAYLOAD = { mode: 'compose' as const, initialData: { subject: 'Hello' } as never };
+
+beforeEach(() => {
+ vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
+ FakeBroadcastChannel.reset();
+});
+
+afterEach(() => {
+ takeReturnedComposer();
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+});
+
+/** Plays the main window's end of the channel. */
+function mainWindow(onReturn: (message: Extract, post: (m: PopoutMessage) => void) => void) {
+ const heard: PopoutMessage[] = [];
+ const channel = openPopoutChannel((message) => {
+ heard.push(message);
+ if (message.type === 'composer-return') onReturn(message, (m) => channel?.post(m));
+ });
+ return heard;
+}
+
+describe('from the pop-out', () => {
+ it('sends the draft and its login, and goes by what the main window answers', async () => {
+ const seen: unknown[] = [];
+ mainWindow((message, post) => {
+ seen.push([message.accountId, message.payload]);
+ post({ type: 'composer-return-answer', returnId: message.returnId, status: 'shown' });
+ });
+ await expect(returnComposerToMain(PAYLOAD, 'alice')).resolves.toBe('shown');
+ expect(seen).toEqual([['alice', PAYLOAD]]);
+ });
+
+ it('hears that the main window is busy', async () => {
+ mainWindow((message, post) => post({ type: 'composer-return-answer', returnId: message.returnId, status: 'busy' }));
+ await expect(returnComposerToMain(PAYLOAD, 'alice')).resolves.toBe('busy');
+ });
+
+ it('ignores an answer about another composer', async () => {
+ mainWindow((_message, post) => post({ type: 'composer-return-answer', returnId: 'someone-else', status: 'shown' }));
+ await expect(returnComposerToMain(PAYLOAD, 'alice', 60)).resolves.toBe('no-answer');
+ });
+
+ it('with no answer it keeps the text and calls the move off, so no second copy turns up', async () => {
+ const heard = mainWindow(() => {});
+ await expect(returnComposerToMain(PAYLOAD, 'alice', 30)).resolves.toBe('no-answer');
+ await vi.waitFor(() => expect(heard.map((message) => message.type)).toEqual(['composer-return', 'composer-return-cancel']));
+ });
+});
+
+describe('in the main window', () => {
+ const returned = (id: string, answer = vi.fn()) => ({ returnId: id, payload: PAYLOAD, answer });
+
+ it('tells the mail page, and only the mail page, when it is there', () => {
+ const mail = vi.fn();
+ const elsewhere = vi.fn();
+ const stops = [onReturnedComposer(mail), onReturnedComposerElsewhere(elsewhere)];
+ offerReturnedComposer(returned('a'));
+ expect(mail).toHaveBeenCalledOnce();
+ expect(elsewhere).not.toHaveBeenCalled();
+ for (const stop of stops) stop();
+ });
+
+ it('with no mail page it asks for one, and the composer waits for the page to mount', () => {
+ const elsewhere = vi.fn();
+ const stop = onReturnedComposerElsewhere(elsewhere);
+ offerReturnedComposer(returned('a'));
+ expect(elsewhere).toHaveBeenCalledOnce();
+ expect(takeReturnedComposer()?.returnId).toBe('a');
+ expect(takeReturnedComposer()).toBeNull();
+ stop();
+ });
+
+ it('a second one while the first waits is told busy and takes nothing over', () => {
+ const second = vi.fn();
+ offerReturnedComposer(returned('a'));
+ offerReturnedComposer(returned('b', second));
+ expect(second).toHaveBeenCalledWith('busy');
+ expect(takeReturnedComposer()?.returnId).toBe('a');
+ });
+
+ it('forgets one the pop-out gave up on, and no other', () => {
+ offerReturnedComposer(returned('a'));
+ dropReturnedComposer('b');
+ expect(takeReturnedComposer()?.returnId).toBe('a');
+ offerReturnedComposer(returned('c'));
+ dropReturnedComposer('c');
+ expect(takeReturnedComposer()).toBeNull();
+ });
+
+ it('says shown only when a composer has really opened', () => {
+ const answer = vi.fn();
+ answerWhenComposerOpens(returned('a', answer));
+ expect(answer).not.toHaveBeenCalled();
+ const stop = registerComposeFlush(async () => {});
+ expect(answer).toHaveBeenCalledWith('shown');
+ stop();
+ });
+
+ it('says nothing if no composer opens, so the pop-out stays with the text', () => {
+ vi.useFakeTimers();
+ const answer = vi.fn();
+ answerWhenComposerOpens(returned('a', answer), 100);
+ vi.advanceTimersByTime(200);
+ const stop = registerComposeFlush(async () => {});
+ expect(answer).not.toHaveBeenCalled();
+ stop();
+ });
+});
diff --git a/lib/popout/channel.ts b/lib/popout/channel.ts
index eab983baf..a466cae2f 100644
--- a/lib/popout/channel.ts
+++ b/lib/popout/channel.ts
@@ -96,6 +96,9 @@ export type PopoutMessage =
| { type: 'draft-rescue'; draft: RescuedDraft }
| { type: 'rescue-query'; rescueKey: string }
| { type: 'rescue-answer'; rescueKey: string; draftId: string | null }
+ | { type: 'composer-return'; returnId: string; accountId?: string; payload: ComposePopoutPayload }
+ | { type: 'composer-return-answer'; returnId: string; status: 'shown' | 'busy' }
+ | { type: 'composer-return-cancel'; returnId: string }
| { type: 'main-ping' }
| { type: 'main-pong'; windowId: string; accountIds: string[] }
| { type: 'main-gone'; windowId: string }
diff --git a/lib/popout/return-to-main.ts b/lib/popout/return-to-main.ts
new file mode 100644
index 000000000..58e4ac331
--- /dev/null
+++ b/lib/popout/return-to-main.ts
@@ -0,0 +1,100 @@
+/**
+ * Moves a composer from its own window back into the main window: the way back of
+ * compose-handoff.ts. The pop-out has saved by now and keeps its copy until the main
+ * window says the composer is on screen there. A main window already busy with a message
+ * says so and takes nothing, because a composer that is open holds someone's text.
+ */
+
+import { onComposerOpened } from '@/lib/compose-flush-registry';
+import { newPopoutId, openPopoutChannel } from './channel';
+import type { ComposePopoutPayload } from './compose-payload';
+
+/** Longer than a route change to the mail page takes; then the pop-out keeps the draft. */
+export const RETURN_WAIT_MS = 20_000;
+const OPEN_WAIT_MS = RETURN_WAIT_MS - 2000;
+
+export type ReturnAnswer = 'shown' | 'busy';
+export type ReturnResult = ReturnAnswer | 'no-answer';
+
+/** From a pop-out: true once the composer is on screen in the main window. */
+export function returnComposerToMain(payload: ComposePopoutPayload, accountId?: string | null, waitMs = RETURN_WAIT_MS): Promise {
+ return new Promise((resolve) => {
+ const returnId = newPopoutId();
+ const finish = (result: ReturnResult) => {
+ clearTimeout(timer);
+ channel?.close();
+ resolve(result);
+ };
+ const channel = openPopoutChannel((message) => {
+ if (message.type === 'composer-return-answer' && message.returnId === returnId) finish(message.status);
+ });
+ const timer = setTimeout(() => {
+ // It might still turn up there, next to the copy that stays here.
+ channel?.post({ type: 'composer-return-cancel', returnId });
+ finish('no-answer');
+ }, waitMs);
+ channel?.post({ type: 'composer-return', returnId, accountId: accountId ?? undefined, payload });
+ });
+}
+
+export interface ReturnedComposer {
+ returnId: string;
+ payload: ComposePopoutPayload;
+ answer: (status: ReturnAnswer) => void;
+}
+
+let waiting: ReturnedComposer | null = null;
+const mailListeners = new Set<() => void>();
+const elsewhereListeners = new Set<() => void>();
+
+/**
+ * In the main window. The mail page picks it up, or, with no mail page on screen, whoever
+ * can go there does and the page picks it up as it mounts.
+ */
+export function offerReturnedComposer(returned: ReturnedComposer): void {
+ if (waiting) {
+ returned.answer('busy');
+ return;
+ }
+ waiting = returned;
+ for (const listener of [...(mailListeners.size > 0 ? mailListeners : elsewhereListeners)]) listener();
+}
+
+export function takeReturnedComposer(): ReturnedComposer | null {
+ const returned = waiting;
+ waiting = null;
+ return returned;
+}
+
+/** The pop-out gave up waiting and keeps the draft. */
+export function dropReturnedComposer(returnId: string): void {
+ if (waiting?.returnId === returnId) waiting = null;
+}
+
+/** The mail page, for as long as it's there. */
+export function onReturnedComposer(listener: () => void): () => void {
+ mailListeners.add(listener);
+ return () => {
+ mailListeners.delete(listener);
+ };
+}
+
+/** Whoever can bring up the mail page. */
+export function onReturnedComposerElsewhere(listener: () => void): () => void {
+ elsewhereListeners.add(listener);
+ return () => {
+ elsewhereListeners.delete(listener);
+ };
+}
+
+/** Lets the pop-out go once a composer has really opened here. Call before opening it. */
+export function answerWhenComposerOpens(returned: ReturnedComposer, waitMs = OPEN_WAIT_MS): void {
+ const finish = () => {
+ clearTimeout(timer);
+ stop();
+ returned.answer('shown');
+ };
+ const stop = onComposerOpened(finish);
+ // No answer means the pop-out stays with the text: nothing is lost by saying nothing.
+ const timer = setTimeout(stop, waitMs);
+}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index c759d9601..0286c1826 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "تعذّر نسخ الملف.",
"reply_lost": "تعذّر الاحتفاظ بالرد الذي كتبته. افتح الرسالة وأجب من هناك."
},
+ "desktopQuickCompose": {
+ "label": "كتابة سريعة",
+ "menu": "كتابة سريعة",
+ "description": "يفتح {keys} نافذة صغيرة لرسالة جديدة من أي تطبيق آخر.",
+ "description_off": "يفتح نافذة صغيرة لرسالة جديدة من أي تطبيق آخر.",
+ "taken": "يستخدم تطبيق آخر {keys}، لذلك لا يعمل هنا. اختر مفاتيح أخرى أو أغلق ذلك التطبيق.",
+ "change": "تغيير المفاتيح: {keys}",
+ "recording": "اضغط المفاتيح…",
+ "recording_hint": "اضغط مع الاستمرار على مفتاحين من Ctrl وAlt وShift ومفتاح النظام، ثم اضغط حرفًا أو رقمًا. يلغي Esc العملية.",
+ "back_to_main": "نقل إلى النافذة الرئيسية",
+ "back_busy": "توجد رسالة مفتوحة بالفعل في النافذة الرئيسية. أغلقها ثم حاول مرة أخرى.",
+ "back_no_answer": "لم تستجب النافذة الرئيسية. تبقى رسالتك هنا."
+ },
"desktopReminders": {
"generic_title": "تذكير",
"snooze": "تأجيل {count} د",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 9ca9c8032..a2e03b233 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "No s'ha pogut copiar el fitxer.",
"reply_lost": "No s'ha pogut conservar la resposta que heu escrit. Obriu el missatge i responeu des d'allà."
},
+ "desktopQuickCompose": {
+ "label": "Redacció ràpida",
+ "menu": "Redacció ràpida",
+ "description": "{keys} obre una finestra petita per a un missatge nou, des de qualsevol altra aplicació.",
+ "description_off": "Obre una finestra petita per a un missatge nou, des de qualsevol altra aplicació.",
+ "taken": "Una altra aplicació fa servir {keys}, així que aquí no fa res. Tria altres tecles o tanca aquella aplicació.",
+ "change": "Canvia les tecles: {keys}",
+ "recording": "Prem les tecles…",
+ "recording_hint": "Mantén premudes dues de Ctrl, Alt, Maj o la tecla del sistema, i prem una lletra o un número. Esc cancel·la.",
+ "back_to_main": "Mou a la finestra principal",
+ "back_busy": "La finestra principal ja té un missatge obert. Tanca'l i torna-ho a provar.",
+ "back_no_answer": "La finestra principal no ha respost. El teu missatge es queda aquí."
+ },
"desktopReminders": {
"generic_title": "Recordatori",
"snooze": "Posposa {count} min",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 5585c00c0..62e597a7d 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "Soubor se nepodařilo zkopírovat.",
"reply_lost": "Napsanou odpověď se nepodařilo zachovat. Otevřete zprávu a odpovězte v ní."
},
+ "desktopQuickCompose": {
+ "label": "Rychlá zpráva",
+ "menu": "Rychlá zpráva",
+ "description": "{keys} otevře malé okno pro novou zprávu z jakékoli jiné aplikace.",
+ "description_off": "Otevře malé okno pro novou zprávu z jakékoli jiné aplikace.",
+ "taken": "{keys} používá jiná aplikace, takže tady nic neudělá. Vyberte jiné klávesy nebo tu aplikaci zavřete.",
+ "change": "Změnit klávesy: {keys}",
+ "recording": "Stiskněte klávesy…",
+ "recording_hint": "Podržte dvě z kláves Ctrl, Alt, Shift nebo systémovou klávesu a stiskněte písmeno nebo číslici. Esc zruší.",
+ "back_to_main": "Přesunout do hlavního okna",
+ "back_busy": "V hlavním okně je už otevřená zpráva. Zavřete ji a zkuste to znovu.",
+ "back_no_answer": "Hlavní okno neodpovědělo. Vaše zpráva zůstává tady."
+ },
"desktopReminders": {
"generic_title": "Připomenutí",
"snooze": "Odložit o {count} min",
diff --git a/locales/da/common.json b/locales/da/common.json
index 9fa9629c6..5fde34c0c 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "Filen kunne ikke kopieres.",
"reply_lost": "Dit skrevne svar kunne ikke gemmes. Åbn beskeden, og svar der."
},
+ "desktopQuickCompose": {
+ "label": "Hurtig skrivning",
+ "menu": "Hurtig skrivning",
+ "description": "{keys} åbner et lille vindue til en ny besked, fra enhver anden app.",
+ "description_off": "Åbner et lille vindue til en ny besked, fra enhver anden app.",
+ "taken": "En anden app bruger {keys}, så her sker der ikke noget. Vælg andre taster, eller luk den app.",
+ "change": "Skift tasterne: {keys}",
+ "recording": "Tryk på tasterne…",
+ "recording_hint": "Hold to af Ctrl, Alt, Shift eller systemtasten nede, og tryk på et bogstav eller tal. Esc annullerer.",
+ "back_to_main": "Flyt til hovedvinduet",
+ "back_busy": "Hovedvinduet har allerede en besked åben. Luk den, og prøv igen.",
+ "back_no_answer": "Hovedvinduet svarede ikke. Din besked bliver her."
+ },
"desktopReminders": {
"generic_title": "Påmindelse",
"snooze": "Udsæt {count} min.",
diff --git a/locales/de/common.json b/locales/de/common.json
index d1bc0fa61..1e29b8892 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "Die Datei konnte nicht kopiert werden.",
"reply_lost": "Ihre eingegebene Antwort konnte nicht übernommen werden. Öffnen Sie die Nachricht und antworten Sie dort."
},
+ "desktopQuickCompose": {
+ "label": "Schnell schreiben",
+ "menu": "Schnell schreiben",
+ "description": "{keys} öffnet ein kleines Fenster für eine neue Nachricht, aus jeder anderen App.",
+ "description_off": "Öffnet ein kleines Fenster für eine neue Nachricht, aus jeder anderen App.",
+ "taken": "Eine andere App verwendet {keys}, daher passiert hier nichts. Wählen Sie andere Tasten, oder schließen Sie diese App.",
+ "change": "Tasten ändern: {keys}",
+ "recording": "Tasten drücken…",
+ "recording_hint": "Halten Sie zwei der Tasten Strg, Alt, Umschalt oder die Systemtaste, und drücken Sie einen Buchstaben oder eine Zahl. Esc bricht ab.",
+ "back_to_main": "In das Hauptfenster verschieben",
+ "back_busy": "Im Hauptfenster ist bereits eine Nachricht geöffnet. Schließen Sie diese, und versuchen Sie es dann erneut.",
+ "back_no_answer": "Das Hauptfenster hat nicht geantwortet. Ihre Nachricht bleibt hier."
+ },
"desktopReminders": {
"generic_title": "Erinnerung",
"snooze": "{count} Min. schlummern",
diff --git a/locales/en/common.json b/locales/en/common.json
index b7b372d47..860e4d930 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "The file could not be copied.",
"reply_lost": "Your typed reply could not be kept. Open the message and reply there."
},
+ "desktopQuickCompose": {
+ "label": "Quick compose",
+ "menu": "Quick compose",
+ "description": "{keys} opens a small window for a new message, from any other app.",
+ "description_off": "Opens a small window for a new message, from any other app.",
+ "taken": "Another app is using {keys}, so it does nothing here. Pick other keys, or close that app.",
+ "change": "Change the keys: {keys}",
+ "recording": "Press the keys…",
+ "recording_hint": "Hold two of Ctrl, Alt, Shift or the system key, and press a letter or number. Esc cancels.",
+ "back_to_main": "Move to the main window",
+ "back_busy": "The main window already has a message open. Close that one, then try again.",
+ "back_no_answer": "The main window did not answer. Your message stays here."
+ },
"desktopReminders": {
"generic_title": "Reminder",
"snooze": "Snooze {count} min",
diff --git a/locales/es/common.json b/locales/es/common.json
index a6af887c6..f9b9a5628 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "No se pudo copiar el archivo.",
"reply_lost": "No se pudo conservar la respuesta que escribiste. Abre el mensaje y responde desde allí."
},
+ "desktopQuickCompose": {
+ "label": "Redacción rápida",
+ "menu": "Redacción rápida",
+ "description": "{keys} abre una ventana pequeña para un mensaje nuevo, desde cualquier otra aplicación.",
+ "description_off": "Abre una ventana pequeña para un mensaje nuevo, desde cualquier otra aplicación.",
+ "taken": "Otra aplicación está usando {keys}, así que aquí no hace nada. Elige otras teclas o cierra esa aplicación.",
+ "change": "Cambiar las teclas: {keys}",
+ "recording": "Pulsa las teclas…",
+ "recording_hint": "Mantén pulsadas dos de Ctrl, Alt, Mayús o la tecla del sistema, y pulsa una letra o un número. Esc cancela.",
+ "back_to_main": "Mover a la ventana principal",
+ "back_busy": "La ventana principal ya tiene un mensaje abierto. Ciérralo y vuelve a intentarlo.",
+ "back_no_answer": "La ventana principal no respondió. Tu mensaje se queda aquí."
+ },
"desktopReminders": {
"generic_title": "Recordatorio",
"snooze": "Posponer {count} min",
diff --git a/locales/et/common.json b/locales/et/common.json
index 303262a33..332513a58 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "Faili ei saanud kopeerida.",
"reply_lost": "Kirjutatud vastust ei saanud säilitada. Ava kiri ja vasta seal."
},
+ "desktopQuickCompose": {
+ "label": "Kiirkiri",
+ "menu": "Kiirkiri",
+ "description": "{keys} avab väikese akna uue kirja jaoks, ükskõik millisest teisest rakendusest.",
+ "description_off": "Avab väikese akna uue kirja jaoks, ükskõik millisest teisest rakendusest.",
+ "taken": "{keys} on teise rakenduse kasutuses, seega siin ei juhtu midagi. Valige teised klahvid või sulgege see rakendus.",
+ "change": "Muuda klahve: {keys}",
+ "recording": "Vajutage klahve…",
+ "recording_hint": "Hoidke all kahte klahvi Ctrl, Alt, Shift või süsteemiklahv ja vajutage tähte või numbrit. Esc tühistab.",
+ "back_to_main": "Teisalda põhiaknasse",
+ "back_busy": "Põhiaknas on juba kiri avatud. Sulgege see ja proovige uuesti.",
+ "back_no_answer": "Põhiaken ei vastanud. Teie kiri jääb siia."
+ },
"desktopReminders": {
"generic_title": "Meeldetuletus",
"snooze": "Lükka {count} min edasi",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 9a1f39794..bb300382d 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "رونوشت پرونده ممکن نشد.",
"reply_lost": "پاسخی که نوشتید نگه داشته نشد. پیام را باز کنید و از آنجا پاسخ دهید."
},
+ "desktopQuickCompose": {
+ "label": "نوشتن سریع",
+ "menu": "نوشتن سریع",
+ "description": "{keys} از هر برنامه دیگری یک پنجره کوچک برای پیام جدید باز میکند.",
+ "description_off": "از هر برنامه دیگری یک پنجره کوچک برای پیام جدید باز میکند.",
+ "taken": "برنامه دیگری از {keys} استفاده میکند، پس اینجا کاری انجام نمیدهد. کلیدهای دیگری انتخاب کنید یا آن برنامه را ببندید.",
+ "change": "تغییر کلیدها: {keys}",
+ "recording": "کلیدها را فشار دهید…",
+ "recording_hint": "دو کلید از Ctrl، Alt، Shift یا کلید سیستم را نگه دارید و یک حرف یا عدد را فشار دهید. Esc لغو میکند.",
+ "back_to_main": "انتقال به پنجره اصلی",
+ "back_busy": "پنجره اصلی از قبل یک پیام باز دارد. آن را ببندید و دوباره امتحان کنید.",
+ "back_no_answer": "پنجره اصلی پاسخ نداد. پیام شما اینجا میماند."
+ },
"desktopReminders": {
"generic_title": "یادآوری",
"snooze": "تعویق {count} دقیقه",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index fd43c5da3..0607b76f5 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "Le fichier n'a pas pu être copié.",
"reply_lost": "Votre réponse saisie n'a pas pu être conservée. Ouvrez le message et répondez depuis celui-ci."
},
+ "desktopQuickCompose": {
+ "label": "Rédaction rapide",
+ "menu": "Rédaction rapide",
+ "description": "{keys} ouvre une petite fenêtre pour un nouveau message, depuis n'importe quelle autre application.",
+ "description_off": "Ouvre une petite fenêtre pour un nouveau message, depuis n'importe quelle autre application.",
+ "taken": "Une autre application utilise {keys}, ce raccourci n'a donc aucun effet ici. Choisissez d'autres touches ou fermez cette application.",
+ "change": "Changer les touches : {keys}",
+ "recording": "Appuyez sur les touches…",
+ "recording_hint": "Maintenez deux touches parmi Ctrl, Alt, Maj ou la touche système, puis appuyez sur une lettre ou un chiffre. Échap annule.",
+ "back_to_main": "Déplacer vers la fenêtre principale",
+ "back_busy": "La fenêtre principale a déjà un message ouvert. Fermez-le, puis réessayez.",
+ "back_no_answer": "La fenêtre principale n'a pas répondu. Votre message reste ici."
+ },
"desktopReminders": {
"generic_title": "Rappel",
"snooze": "Reporter de {count} min",
diff --git a/locales/he/common.json b/locales/he/common.json
index 10bb0aacf..8d3b0f19e 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -6959,6 +6959,19 @@
"copy_failed": "לא ניתן להעתיק את הקובץ.",
"reply_lost": "לא ניתן היה לשמור את התשובה שהקלדת. פתחו את ההודעה והשיבו משם."
},
+ "desktopQuickCompose": {
+ "label": "כתיבה מהירה",
+ "menu": "כתיבה מהירה",
+ "description": "{keys} פותח חלון קטן להודעה חדשה מכל אפליקציה אחרת.",
+ "description_off": "פותח חלון קטן להודעה חדשה מכל אפליקציה אחרת.",
+ "taken": "אפליקציה אחרת משתמשת ב-{keys}, ולכן כאן זה לא עושה כלום. בחרו מקשים אחרים או סגרו את האפליקציה הזו.",
+ "change": "שינוי המקשים: {keys}",
+ "recording": "לחצו על המקשים…",
+ "recording_hint": "החזיקו שניים מהמקשים Ctrl, Alt, Shift או מקש המערכת, ולחצו על אות או מספר. Esc מבטל.",
+ "back_to_main": "העברה לחלון הראשי",
+ "back_busy": "בחלון הראשי כבר פתוחה הודעה. סגרו אותה ונסו שוב.",
+ "back_no_answer": "החלון הראשי לא ענה. ההודעה שלכם נשארת כאן."
+ },
"desktopReminders": {
"generic_title": "תזכורת",
"snooze": "דחייה ב-{count} דק'",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 50702f8fb..a6d0e473f 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "A fájlt nem sikerült másolni.",
"reply_lost": "A beírt válasz nem maradt meg. Nyissa meg az üzenetet, és válaszoljon ott."
},
+ "desktopQuickCompose": {
+ "label": "Gyors levélírás",
+ "menu": "Gyors levélírás",
+ "description": "A(z) {keys} kis ablakot nyit egy új üzenethez, bármely másik alkalmazásból.",
+ "description_off": "Kis ablakot nyit egy új üzenethez, bármely másik alkalmazásból.",
+ "taken": "A(z) {keys} billentyűket egy másik alkalmazás használja, így itt nem történik semmi. Válasszon más billentyűket, vagy zárja be azt az alkalmazást.",
+ "change": "Billentyűk módosítása: {keys}",
+ "recording": "Nyomja meg a billentyűket…",
+ "recording_hint": "Tartson lenyomva kettőt a Ctrl, Alt, Shift vagy a rendszerbillentyű közül, és nyomjon meg egy betűt vagy számot. Az Esc megszakítja.",
+ "back_to_main": "Áthelyezés a főablakba",
+ "back_busy": "A főablakban már nyitva van egy üzenet. Zárja be, majd próbálja újra.",
+ "back_no_answer": "A főablak nem válaszolt. Az üzenet itt marad."
+ },
"desktopReminders": {
"generic_title": "Emlékeztető",
"snooze": "Halasztás {count} perccel",
diff --git a/locales/it/common.json b/locales/it/common.json
index 7ff4479d1..4915051fd 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "Impossibile copiare il file.",
"reply_lost": "Non è stato possibile conservare la risposta digitata. Apri il messaggio e rispondi da lì."
},
+ "desktopQuickCompose": {
+ "label": "Scrittura rapida",
+ "menu": "Scrittura rapida",
+ "description": "{keys} apre una piccola finestra per un nuovo messaggio, da qualsiasi altra app.",
+ "description_off": "Apre una piccola finestra per un nuovo messaggio, da qualsiasi altra app.",
+ "taken": "Un'altra app sta usando {keys}, quindi qui non succede nulla. Scegli altri tasti o chiudi quell'app.",
+ "change": "Cambia i tasti: {keys}",
+ "recording": "Premi i tasti…",
+ "recording_hint": "Tieni premuti due tra Ctrl, Alt, Maiusc o il tasto di sistema e premi una lettera o un numero. Esc annulla.",
+ "back_to_main": "Sposta nella finestra principale",
+ "back_busy": "La finestra principale ha già un messaggio aperto. Chiudilo e riprova.",
+ "back_no_answer": "La finestra principale non ha risposto. Il messaggio resta qui."
+ },
"desktopReminders": {
"generic_title": "Promemoria",
"snooze": "Rimanda di {count} min",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 3eda5c005..38753d6d5 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "ファイルをコピーできませんでした。",
"reply_lost": "入力した返信を保持できませんでした。メッセージを開いて返信してください。"
},
+ "desktopQuickCompose": {
+ "label": "クイック作成",
+ "menu": "クイック作成",
+ "description": "{keys} で、ほかのどのアプリからでも新規メッセージ用の小さなウィンドウを開きます。",
+ "description_off": "ほかのどのアプリからでも新規メッセージ用の小さなウィンドウを開きます。",
+ "taken": "{keys} は別のアプリが使用中のため、ここでは動作しません。別のキーを選ぶか、そのアプリを閉じてください。",
+ "change": "キーを変更: {keys}",
+ "recording": "キーを押してください…",
+ "recording_hint": "Ctrl、Alt、Shift、システムキーのうち 2 つを押したまま、文字か数字を押します。Esc でキャンセルします。",
+ "back_to_main": "メインウィンドウに移動",
+ "back_busy": "メインウィンドウで別のメッセージが開いています。それを閉じてからもう一度お試しください。",
+ "back_no_answer": "メインウィンドウから応答がありません。メッセージはここに残ります。"
+ },
"desktopReminders": {
"generic_title": "リマインダー",
"snooze": "{count} 分後に再通知",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 355bdbb3f..a1c9cd1ba 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "파일을 복사할 수 없습니다.",
"reply_lost": "입력한 답장을 유지할 수 없습니다. 메시지를 열어 답장하세요."
},
+ "desktopQuickCompose": {
+ "label": "빠른 작성",
+ "menu": "빠른 작성",
+ "description": "{keys}를 누르면 다른 앱에서도 새 메시지용 작은 창이 열립니다.",
+ "description_off": "다른 앱에서도 새 메시지용 작은 창을 엽니다.",
+ "taken": "다른 앱이 {keys}를 사용 중이라 여기서는 작동하지 않습니다. 다른 키를 고르거나 그 앱을 닫으세요.",
+ "change": "키 변경: {keys}",
+ "recording": "키를 누르세요…",
+ "recording_hint": "Ctrl, Alt, Shift, 시스템 키 중 두 개를 누른 채 문자나 숫자를 누르세요. Esc를 누르면 취소됩니다.",
+ "back_to_main": "기본 창으로 이동",
+ "back_busy": "기본 창에 이미 열린 메시지가 있습니다. 그 메시지를 닫고 다시 시도하세요.",
+ "back_no_answer": "기본 창이 응답하지 않았습니다. 메시지는 여기에 그대로 있습니다."
+ },
"desktopReminders": {
"generic_title": "알림",
"snooze": "{count}분 후 다시 알림",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 2f5d9e297..3e3c3940c 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "Failu neizdevās nokopēt.",
"reply_lost": "Ievadīto atbildi neizdevās saglabāt. Atveriet ziņojumu un atbildiet tur."
},
+ "desktopQuickCompose": {
+ "label": "Ātrā rakstīšana",
+ "menu": "Ātrā rakstīšana",
+ "description": "{keys} atver nelielu logu jaunai ziņai no jebkuras citas lietotnes.",
+ "description_off": "Atver nelielu logu jaunai ziņai no jebkuras citas lietotnes.",
+ "taken": "{keys} izmanto cita lietotne, tāpēc šeit nekas nenotiek. Izvēlieties citus taustiņus vai aizveriet šo lietotni.",
+ "change": "Mainīt taustiņus: {keys}",
+ "recording": "Nospiediet taustiņus…",
+ "recording_hint": "Turiet nospiestus divus no Ctrl, Alt, Shift vai sistēmas taustiņa un nospiediet burtu vai ciparu. Esc atceļ.",
+ "back_to_main": "Pārvietot uz galveno logu",
+ "back_busy": "Galvenajā logā jau ir atvērta ziņa. Aizveriet to un mēģiniet vēlreiz.",
+ "back_no_answer": "Galvenais logs neatbildēja. Jūsu ziņa paliek šeit."
+ },
"desktopReminders": {
"generic_title": "Atgādinājums",
"snooze": "Atlikt par {count} min",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index cf0de6ec1..5174fbb58 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "Файлыг хуулж чадсангүй.",
"reply_lost": "Бичсэн хариуг хадгалж чадсангүй. Захиаг нээгээд тэндээс хариулна уу."
},
+ "desktopQuickCompose": {
+ "label": "Хурдан бичих",
+ "menu": "Хурдан бичих",
+ "description": "{keys} нь өөр ямар ч аппаас шинэ мессежийн жижиг цонх нээнэ.",
+ "description_off": "Өөр ямар ч аппаас шинэ мессежийн жижиг цонх нээнэ.",
+ "taken": "{keys}-г өөр апп ашиглаж байгаа тул энд ажиллахгүй. Өөр товч сонгох эсвэл тэр аппыг хаана уу.",
+ "change": "Товчийг өөрчлөх: {keys}",
+ "recording": "Товчнуудыг дарна уу…",
+ "recording_hint": "Ctrl, Alt, Shift эсвэл системийн товчноос хоёрыг дарж байгаад үсэг эсвэл тоо дарна уу. Esc цуцална.",
+ "back_to_main": "Үндсэн цонх руу зөөх",
+ "back_busy": "Үндсэн цонхонд аль хэдийн мессеж нээлттэй байна. Түүнийг хаагаад дахин оролдоно уу.",
+ "back_no_answer": "Үндсэн цонх хариу өгсөнгүй. Таны мессеж энд үлдэнэ."
+ },
"desktopReminders": {
"generic_title": "Сануулга",
"snooze": "{count} минут хойшлуулах",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index c434676eb..6507dd6a4 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "Filen kunne ikke kopieres.",
"reply_lost": "Svaret du skrev, kunne ikke beholdes. Åpne meldingen og svar der."
},
+ "desktopQuickCompose": {
+ "label": "Rask skriving",
+ "menu": "Rask skriving",
+ "description": "{keys} åpner et lite vindu for en ny melding, fra hvilken som helst annen app.",
+ "description_off": "Åpner et lite vindu for en ny melding, fra hvilken som helst annen app.",
+ "taken": "En annen app bruker {keys}, så her skjer det ingenting. Velg andre taster, eller lukk den appen.",
+ "change": "Endre tastene: {keys}",
+ "recording": "Trykk på tastene…",
+ "recording_hint": "Hold nede to av Ctrl, Alt, Shift eller systemtasten, og trykk på en bokstav eller et tall. Esc avbryter.",
+ "back_to_main": "Flytt til hovedvinduet",
+ "back_busy": "Hovedvinduet har allerede en melding åpen. Lukk den, og prøv igjen.",
+ "back_no_answer": "Hovedvinduet svarte ikke. Meldingen din blir her."
+ },
"desktopReminders": {
"generic_title": "Påminnelse",
"snooze": "Utsett {count} min",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 3afb0250b..63650e1fd 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "Het bestand kon niet worden gekopieerd.",
"reply_lost": "Je getypte antwoord kon niet worden bewaard. Open het bericht en antwoord daar."
},
+ "desktopQuickCompose": {
+ "label": "Snel opstellen",
+ "menu": "Snel opstellen",
+ "description": "{keys} opent een klein venster voor een nieuw bericht, vanuit elke andere app.",
+ "description_off": "Opent een klein venster voor een nieuw bericht, vanuit elke andere app.",
+ "taken": "Een andere app gebruikt {keys}, dus hier gebeurt niets. Kies andere toetsen of sluit die app.",
+ "change": "Toetsen wijzigen: {keys}",
+ "recording": "Druk op de toetsen…",
+ "recording_hint": "Houd twee van Ctrl, Alt, Shift of de systeemtoets vast en druk op een letter of cijfer. Esc annuleert.",
+ "back_to_main": "Naar het hoofdvenster verplaatsen",
+ "back_busy": "In het hoofdvenster staat al een bericht open. Sluit dat en probeer het opnieuw.",
+ "back_no_answer": "Het hoofdvenster gaf geen antwoord. Je bericht blijft hier."
+ },
"desktopReminders": {
"generic_title": "Herinnering",
"snooze": "{count} min uitstellen",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 003c9b529..92d0ea07e 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "Nie udało się skopiować pliku.",
"reply_lost": "Nie udało się zachować wpisanej odpowiedzi. Otwórz wiadomość i odpowiedz w niej."
},
+ "desktopQuickCompose": {
+ "label": "Szybka wiadomość",
+ "menu": "Szybka wiadomość",
+ "description": "{keys} otwiera małe okno nowej wiadomości z dowolnej innej aplikacji.",
+ "description_off": "Otwiera małe okno nowej wiadomości z dowolnej innej aplikacji.",
+ "taken": "Inna aplikacja używa {keys}, więc tutaj nic się nie dzieje. Wybierz inne klawisze lub zamknij tę aplikację.",
+ "change": "Zmień klawisze: {keys}",
+ "recording": "Naciśnij klawisze…",
+ "recording_hint": "Przytrzymaj dwa z klawiszy Ctrl, Alt, Shift lub klawisz systemowy i naciśnij literę albo cyfrę. Esc anuluje.",
+ "back_to_main": "Przenieś do głównego okna",
+ "back_busy": "W głównym oknie jest już otwarta wiadomość. Zamknij ją i spróbuj ponownie.",
+ "back_no_answer": "Główne okno nie odpowiedziało. Wiadomość zostaje tutaj."
+ },
"desktopReminders": {
"generic_title": "Przypomnienie",
"snooze": "Odłóż o {count} min",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 4cfe8b1e4..5fde5c117 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "Não foi possível copiar o ficheiro.",
"reply_lost": "Não foi possível guardar a resposta escrita. Abra a mensagem e responda a partir dela."
},
+ "desktopQuickCompose": {
+ "label": "Escrita rápida",
+ "menu": "Escrita rápida",
+ "description": "{keys} abre uma janela pequena para uma nova mensagem, a partir de qualquer outra aplicação.",
+ "description_off": "Abre uma janela pequena para uma nova mensagem, a partir de qualquer outra aplicação.",
+ "taken": "Outra aplicação está a usar {keys}, por isso aqui não faz nada. Escolha outras teclas ou feche essa aplicação.",
+ "change": "Alterar as teclas: {keys}",
+ "recording": "Prima as teclas…",
+ "recording_hint": "Mantenha premidas duas de Ctrl, Alt, Shift ou a tecla do sistema e prima uma letra ou um número. Esc cancela.",
+ "back_to_main": "Mover para a janela principal",
+ "back_busy": "A janela principal já tem uma mensagem aberta. Feche-a e tente de novo.",
+ "back_no_answer": "A janela principal não respondeu. A sua mensagem fica aqui."
+ },
"desktopReminders": {
"generic_title": "Lembrete",
"snooze": "Adiar {count} min",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 572f9b856..8379d6fde 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "Fișierul nu a putut fi copiat.",
"reply_lost": "Răspunsul scris nu a putut fi păstrat. Deschide mesajul și răspunde de acolo."
},
+ "desktopQuickCompose": {
+ "label": "Scriere rapidă",
+ "menu": "Scriere rapidă",
+ "description": "{keys} deschide o fereastră mică pentru un mesaj nou, din orice altă aplicație.",
+ "description_off": "Deschide o fereastră mică pentru un mesaj nou, din orice altă aplicație.",
+ "taken": "Altă aplicație folosește {keys}, așa că aici nu face nimic. Alege alte taste sau închide aplicația respectivă.",
+ "change": "Schimbă tastele: {keys}",
+ "recording": "Apasă tastele…",
+ "recording_hint": "Ține apăsate două dintre Ctrl, Alt, Shift sau tasta de sistem și apasă o literă sau o cifră. Esc anulează.",
+ "back_to_main": "Mută în fereastra principală",
+ "back_busy": "Fereastra principală are deja un mesaj deschis. Închide-l și încearcă din nou.",
+ "back_no_answer": "Fereastra principală nu a răspuns. Mesajul rămâne aici."
+ },
"desktopReminders": {
"generic_title": "Memento",
"snooze": "Amână {count} min",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 2fdb08106..b04b64f93 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "Не удалось скопировать файл.",
"reply_lost": "Не удалось сохранить введённый ответ. Откройте письмо и ответьте из него."
},
+ "desktopQuickCompose": {
+ "label": "Быстрое письмо",
+ "menu": "Быстрое письмо",
+ "description": "{keys} открывает маленькое окно для нового письма из любого другого приложения.",
+ "description_off": "Открывает маленькое окно для нового письма из любого другого приложения.",
+ "taken": "{keys} занято другим приложением, поэтому здесь сочетание не работает. Выберите другие клавиши или закройте это приложение.",
+ "change": "Изменить клавиши: {keys}",
+ "recording": "Нажмите клавиши…",
+ "recording_hint": "Удерживайте две из клавиш Ctrl, Alt, Shift или системную клавишу и нажмите букву или цифру. Esc отменяет.",
+ "back_to_main": "Переместить в главное окно",
+ "back_busy": "В главном окне уже открыто письмо. Закройте его и попробуйте снова.",
+ "back_no_answer": "Главное окно не ответило. Ваше письмо остаётся здесь."
+ },
"desktopReminders": {
"generic_title": "Напоминание",
"snooze": "Отложить на {count} мин",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index d8a42ccfb..9dca3e961 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "Súbor sa nepodarilo skopírovať.",
"reply_lost": "Napísanú odpoveď sa nepodarilo zachovať. Otvorte správu a odpovedzte v nej."
},
+ "desktopQuickCompose": {
+ "label": "Rýchla správa",
+ "menu": "Rýchla správa",
+ "description": "{keys} otvorí malé okno pre novú správu z akejkoľvek inej aplikácie.",
+ "description_off": "Otvorí malé okno pre novú správu z akejkoľvek inej aplikácie.",
+ "taken": "{keys} používa iná aplikácia, takže tu nič neurobí. Vyberte iné klávesy alebo tú aplikáciu zatvorte.",
+ "change": "Zmeniť klávesy: {keys}",
+ "recording": "Stlačte klávesy…",
+ "recording_hint": "Podržte dva z klávesov Ctrl, Alt, Shift alebo systémový kláves a stlačte písmeno alebo číslicu. Esc zruší.",
+ "back_to_main": "Presunúť do hlavného okna",
+ "back_busy": "V hlavnom okne je už otvorená správa. Zatvorte ju a skúste to znova.",
+ "back_no_answer": "Hlavné okno neodpovedalo. Vaša správa zostáva tu."
+ },
"desktopReminders": {
"generic_title": "Pripomienka",
"snooze": "Odložiť o {count} min",
diff --git a/locales/th/common.json b/locales/th/common.json
index af4128993..6fa6e6864 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -6966,6 +6966,19 @@
"copy_failed": "คัดลอกไฟล์ไม่ได้",
"reply_lost": "ไม่สามารถเก็บคำตอบที่พิมพ์ไว้ได้ เปิดข้อความแล้วตอบจากที่นั่น"
},
+ "desktopQuickCompose": {
+ "label": "เขียนด่วน",
+ "menu": "เขียนด่วน",
+ "description": "{keys} เปิดหน้าต่างเล็กสำหรับข้อความใหม่จากแอปอื่นใดก็ได้",
+ "description_off": "เปิดหน้าต่างเล็กสำหรับข้อความใหม่จากแอปอื่นใดก็ได้",
+ "taken": "แอปอื่นกำลังใช้ {keys} อยู่ จึงไม่มีผลที่นี่ เลือกปุ่มอื่นหรือปิดแอปนั้น",
+ "change": "เปลี่ยนปุ่ม: {keys}",
+ "recording": "กดปุ่ม…",
+ "recording_hint": "กดค้างสองปุ่มจาก Ctrl, Alt, Shift หรือปุ่มระบบ แล้วกดตัวอักษรหรือตัวเลข กด Esc เพื่อยกเลิก",
+ "back_to_main": "ย้ายไปหน้าต่างหลัก",
+ "back_busy": "หน้าต่างหลักมีข้อความเปิดอยู่แล้ว ปิดข้อความนั้นแล้วลองอีกครั้ง",
+ "back_no_answer": "หน้าต่างหลักไม่ตอบสนอง ข้อความของคุณยังอยู่ที่นี่"
+ },
"desktopReminders": {
"generic_title": "การแจ้งเตือน",
"snooze": "เลื่อน {count} นาที",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index c74a4ae59..825d2610a 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "Dosya kopyalanamadı.",
"reply_lost": "Yazdığınız yanıt korunamadı. İletiyi açıp oradan yanıtlayın."
},
+ "desktopQuickCompose": {
+ "label": "Hızlı ileti",
+ "menu": "Hızlı ileti",
+ "description": "{keys}, başka herhangi bir uygulamadan yeni ileti için küçük bir pencere açar.",
+ "description_off": "Başka herhangi bir uygulamadan yeni ileti için küçük bir pencere açar.",
+ "taken": "{keys} başka bir uygulama tarafından kullanılıyor, bu yüzden burada bir şey yapmaz. Başka tuşlar seçin veya o uygulamayı kapatın.",
+ "change": "Tuşları değiştir: {keys}",
+ "recording": "Tuşlara basın…",
+ "recording_hint": "Ctrl, Alt, Shift veya sistem tuşundan ikisini basılı tutun ve bir harfe ya da rakama basın. Esc iptal eder.",
+ "back_to_main": "Ana pencereye taşı",
+ "back_busy": "Ana pencerede zaten açık bir ileti var. Onu kapatın ve yeniden deneyin.",
+ "back_no_answer": "Ana pencere yanıt vermedi. İletiniz burada kalıyor."
+ },
"desktopReminders": {
"generic_title": "Anımsatıcı",
"snooze": "{count} dk ertele",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 43227c05e..d48126eeb 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "Не вдалося скопіювати файл.",
"reply_lost": "Не вдалося зберегти введену відповідь. Відкрийте лист і дайте відповідь у ньому."
},
+ "desktopQuickCompose": {
+ "label": "Швидкий лист",
+ "menu": "Швидкий лист",
+ "description": "{keys} відкриває маленьке вікно для нового листа з будь-якого іншого застосунку.",
+ "description_off": "Відкриває маленьке вікно для нового листа з будь-якого іншого застосунку.",
+ "taken": "{keys} використовує інший застосунок, тому тут нічого не відбувається. Виберіть інші клавіші або закрийте той застосунок.",
+ "change": "Змінити клавіші: {keys}",
+ "recording": "Натисніть клавіші…",
+ "recording_hint": "Утримуйте дві з клавіш Ctrl, Alt, Shift або системну клавішу й натисніть літеру чи цифру. Esc скасовує.",
+ "back_to_main": "Перемістити в головне вікно",
+ "back_busy": "У головному вікні вже відкрито лист. Закрийте його й спробуйте ще раз.",
+ "back_no_answer": "Головне вікно не відповіло. Ваш лист залишається тут."
+ },
"desktopReminders": {
"generic_title": "Нагадування",
"snooze": "Відкласти на {count} хв",
diff --git a/locales/vi/common.json b/locales/vi/common.json
index 8c6ef8e6f..7f9cfc113 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -6970,6 +6970,19 @@
"copy_failed": "Không thể sao chép tệp.",
"reply_lost": "Không thể giữ câu trả lời bạn đã nhập. Hãy mở thư và trả lời ở đó."
},
+ "desktopQuickCompose": {
+ "label": "Soạn nhanh",
+ "menu": "Soạn nhanh",
+ "description": "{keys} mở một cửa sổ nhỏ để viết thư mới, từ bất kỳ ứng dụng nào khác.",
+ "description_off": "Mở một cửa sổ nhỏ để viết thư mới, từ bất kỳ ứng dụng nào khác.",
+ "taken": "Một ứng dụng khác đang dùng {keys}, nên phím tắt này không có tác dụng ở đây. Hãy chọn phím khác hoặc đóng ứng dụng đó.",
+ "change": "Đổi phím: {keys}",
+ "recording": "Nhấn các phím…",
+ "recording_hint": "Giữ hai trong số Ctrl, Alt, Shift hoặc phím hệ thống, rồi nhấn một chữ cái hoặc chữ số. Esc để hủy.",
+ "back_to_main": "Chuyển sang cửa sổ chính",
+ "back_busy": "Cửa sổ chính đang mở một thư. Hãy đóng thư đó rồi thử lại.",
+ "back_no_answer": "Cửa sổ chính không phản hồi. Thư của bạn vẫn ở đây."
+ },
"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 1028578b1..76d7acbb8 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "無法複製檔案。",
"reply_lost": "無法保留您輸入的回覆。請開啟郵件並在其中回覆。"
},
+ "desktopQuickCompose": {
+ "label": "快速撰寫",
+ "menu": "快速撰寫",
+ "description": "{keys} 可從任何其他應用程式開啟用於新郵件的小視窗。",
+ "description_off": "從任何其他應用程式開啟用於新郵件的小視窗。",
+ "taken": "另一個應用程式正在使用 {keys},所以在這裡沒有作用。請選擇其他按鍵,或關閉該應用程式。",
+ "change": "變更按鍵:{keys}",
+ "recording": "請按下按鍵…",
+ "recording_hint": "按住 Ctrl、Alt、Shift 或系統鍵中的兩個,再按一個字母或數字。按 Esc 取消。",
+ "back_to_main": "移到主視窗",
+ "back_busy": "主視窗已有開啟的郵件。請先關閉它,然後再試一次。",
+ "back_no_answer": "主視窗沒有回應。您的郵件仍在這裡。"
+ },
"desktopReminders": {
"generic_title": "提醒",
"snooze": "{count} 分鐘後再提醒",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 641f7a2cf..ed930a567 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -6958,6 +6958,19 @@
"copy_failed": "无法复制文件。",
"reply_lost": "无法保留你输入的回复。请打开邮件并在其中回复。"
},
+ "desktopQuickCompose": {
+ "label": "快速撰写",
+ "menu": "快速撰写",
+ "description": "{keys} 可从任何其他应用打开一个用于新邮件的小窗口。",
+ "description_off": "从任何其他应用打开一个用于新邮件的小窗口。",
+ "taken": "另一个应用正在使用 {keys},所以在这里不起作用。请选择其他按键,或关闭那个应用。",
+ "change": "更改按键:{keys}",
+ "recording": "请按下按键…",
+ "recording_hint": "按住 Ctrl、Alt、Shift 或系统键中的两个,再按一个字母或数字。按 Esc 取消。",
+ "back_to_main": "移到主窗口",
+ "back_busy": "主窗口中已有打开的邮件。请先关闭它,然后重试。",
+ "back_no_answer": "主窗口没有响应。您的邮件仍在这里。"
+ },
"desktopReminders": {
"generic_title": "提醒",
"snooze": "{count} 分钟后再提醒",
From d0a636416d96958078a497a85b2fe2da6ca2deca Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 23:35:23 +0100
Subject: [PATCH 3/5] docs: say where quick compose opens its window in the
pop-out notes
---
lib/popout/open.ts | 4 ++--
lib/popout/return-to-main.ts | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/lib/popout/open.ts b/lib/popout/open.ts
index 3cee94b3d..d3c6785dc 100644
--- a/lib/popout/open.ts
+++ b/lib/popout/open.ts
@@ -1,6 +1,6 @@
/**
- * Opens pop-out windows: one message, or a composer. Shared with the desktop
- * app's quick compose, which only has to call openComposerWindow.
+ * Opens pop-out windows: one message, or a composer. The desktop app's quick
+ * compose opens the same address from the shell.
*/
import { appPath } from '@/lib/deep-links';
diff --git a/lib/popout/return-to-main.ts b/lib/popout/return-to-main.ts
index 58e4ac331..710b5314a 100644
--- a/lib/popout/return-to-main.ts
+++ b/lib/popout/return-to-main.ts
@@ -16,7 +16,7 @@ const OPEN_WAIT_MS = RETURN_WAIT_MS - 2000;
export type ReturnAnswer = 'shown' | 'busy';
export type ReturnResult = ReturnAnswer | 'no-answer';
-/** From a pop-out: true once the composer is on screen in the main window. */
+/** From a pop-out: `shown` once the composer is on screen in the main window. */
export function returnComposerToMain(payload: ComposePopoutPayload, accountId?: string | null, waitMs = RETURN_WAIT_MS): Promise {
return new Promise((resolve) => {
const returnId = newPopoutId();
From 889f6ffbd1b7dde106b5d48df4b5ce62b2f7d752 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 00:56:09 +0100
Subject: [PATCH 4/5] fix: fail requests under way when the desktop app closes
its connections, and forget client certificates per account
---
lib/__tests__/jmap-connections-reset.test.ts | 129 ++++++++++++++++++
lib/desktop/connections-reset.ts | 26 ++++
lib/desktop/keystore-grants.ts | 16 ++-
lib/desktop/types.ts | 12 +-
lib/jmap/client.ts | 32 ++++-
.../__tests__/auth-store-logout-hooks.test.ts | 28 +++-
.../auth-store-token-renewal-timeout.test.ts | 118 ++++++++++++++++
stores/auth-store.ts | 37 ++++-
8 files changed, 375 insertions(+), 23 deletions(-)
create mode 100644 lib/__tests__/jmap-connections-reset.test.ts
create mode 100644 lib/desktop/connections-reset.ts
create mode 100644 stores/__tests__/auth-store-token-renewal-timeout.test.ts
diff --git a/lib/__tests__/jmap-connections-reset.test.ts b/lib/__tests__/jmap-connections-reset.test.ts
new file mode 100644
index 000000000..e2de4acdc
--- /dev/null
+++ b/lib/__tests__/jmap-connections-reset.test.ts
@@ -0,0 +1,129 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { JMAPClient, RequestTimeoutError } from '../jmap/client';
+import { UploadXHR, uploadClient } from './helpers/upload-xhr';
+
+/**
+ * The desktop app closed its connections under the page. A request under way then never settles
+ * by itself, so all that ends it is being told.
+ */
+
+const session = {
+ capabilities: { 'urn:ietf:params:jmap:core': {} },
+ accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} } },
+ primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' },
+ apiUrl: 'https://mail.example.com/jmap/api',
+ downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
+ uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
+ eventSourceUrl: 'https://mail.example.com/jmap/eventsource',
+};
+
+let reset: () => void;
+
+function withBridge(): void {
+ const listeners = new Set<() => void>();
+ reset = () => {
+ for (const listener of listeners) listener();
+ };
+ (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = {
+ version: 1,
+ certificates: {
+ onConnectionsReset: (listener: () => void) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ },
+ };
+}
+
+/** Takes the request, and nothing ever comes back. Only an abort ends it. */
+const silent = (_url: unknown, init?: RequestInit) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => reject(init.signal?.reason ?? new DOMException('aborted', 'AbortError')));
+ });
+
+/** The one all of the client's calls go through. */
+const requestOf = (client: JMAPClient) => (client as unknown as { request(calls: unknown[]): Promise }).request.bind(client);
+
+async function connectedClient(): Promise {
+ const client = new JMAPClient('https://mail.example.com', 'alice', 'secret');
+ vi.mocked(fetch).mockImplementationOnce(async () => new Response(JSON.stringify(session), { status: 200, headers: { 'Content-Type': 'application/json' } }));
+ await client.connect();
+ return client;
+}
+
+beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn());
+});
+
+afterEach(() => {
+ delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop;
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+describe('when the desktop app closes its connections', () => {
+ it('a send under way fails as "may have sent", at once, and isn\'t sent again', async () => {
+ withBridge();
+ const client = await connectedClient();
+ vi.mocked(fetch).mockClear();
+ vi.mocked(fetch).mockImplementation(silent);
+ const send = requestOf(client)([['EmailSubmission/set', { accountId: 'acct-1', create: { s: {} } }, '0']]);
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
+ reset();
+ await expect(send).rejects.toBeInstanceOf(RequestTimeoutError);
+ await new Promise((resolve) => setTimeout(resolve, 1200));
+ expect(fetch, 'the client retries other failures of a read after 1 s: never this').toHaveBeenCalledTimes(1);
+ });
+
+ it('a read under way fails too, and nothing waits for its deadline', async () => {
+ withBridge();
+ const client = await connectedClient();
+ vi.mocked(fetch).mockClear();
+ vi.mocked(fetch).mockImplementation(silent);
+ const read = requestOf(client)([['Mailbox/get', { accountId: 'acct-1' }, '0']]);
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
+ reset();
+ await expect(read).rejects.toBeInstanceOf(RequestTimeoutError);
+ });
+
+ it('a body still being read is caught as well', async () => {
+ withBridge();
+ const client = await connectedClient();
+ vi.mocked(fetch).mockClear();
+ let signal: AbortSignal | undefined;
+ vi.mocked(fetch).mockImplementation(async (_url, init) => {
+ signal = init?.signal ?? undefined;
+ return new Response('{}', { status: 200 });
+ });
+ await requestOf(client)([['Mailbox/get', { accountId: 'acct-1' }, '0']]).catch(() => {});
+ expect(signal?.aborted).toBe(false);
+ reset();
+ expect(signal?.reason).toBeInstanceOf(RequestTimeoutError);
+ });
+
+ it('an upload under way fails the same way, not five minutes later', async () => {
+ withBridge();
+ UploadXHR.instances = [];
+ vi.stubGlobal('XMLHttpRequest', UploadXHR);
+ const upload = uploadClient().uploadBlob(new File(['x'], 'a.txt', { type: 'text/plain' }), { onProgress: () => {} });
+ await vi.waitFor(() => expect(UploadXHR.instances).toHaveLength(1));
+ reset();
+ await expect(upload).rejects.toBeInstanceOf(RequestTimeoutError);
+ expect(UploadXHR.instances[0].abort).toHaveBeenCalledOnce();
+ expect(UploadXHR.instances).toHaveLength(1);
+ });
+
+ it('nothing changes in a browser, where a deadline is all there is', async () => {
+ const client = await connectedClient();
+ vi.mocked(fetch).mockClear();
+ vi.useFakeTimers();
+ vi.mocked(fetch).mockImplementation(silent);
+ const read = requestOf(client)([['Mailbox/get', { accountId: 'acct-1' }, '0']]);
+ const failed = expect(read).rejects.toBeInstanceOf(RequestTimeoutError);
+ await vi.advanceTimersByTimeAsync(0);
+ expect(vi.getTimerCount(), 'only the deadline').toBe(1);
+ await vi.advanceTimersByTimeAsync(10 * 60_000);
+ await failed;
+ });
+});
diff --git a/lib/desktop/connections-reset.ts b/lib/desktop/connections-reset.ts
new file mode 100644
index 000000000..71dee5752
--- /dev/null
+++ b/lib/desktop/connections-reset.ts
@@ -0,0 +1,26 @@
+import { getDesktopBridge } from './bridge';
+
+/**
+ * The desktop app's connections were closed under the page: at the last sign-out with a client
+ * certificate in use, when a policy changed, or when Chromium's network process crashed. A
+ * request under way then never settles, as Electron's request proxy doesn't pass the loss on. So
+ * main says so, and whoever holds a request gives up on it. Nothing happens in a browser.
+ */
+
+let wiredTo: unknown = null;
+const listeners = new Set<() => void>();
+
+/** Null where there's nothing to listen to: in a browser, or on an older shell. */
+export function onConnectionsReset(listener: () => void): (() => void) | null {
+ const bridge = getDesktopBridge();
+ const subscribe = bridge?.certificates?.onConnectionsReset;
+ if (!subscribe) return null;
+ if (wiredTo !== bridge) {
+ wiredTo = bridge;
+ subscribe(() => {
+ for (const each of [...listeners]) each();
+ });
+ }
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
diff --git a/lib/desktop/keystore-grants.ts b/lib/desktop/keystore-grants.ts
index bed4f3d8f..a16f2baf3 100644
--- a/lib/desktop/keystore-grants.ts
+++ b/lib/desktop/keystore-grants.ts
@@ -11,12 +11,18 @@ export function clearKeystoreGrants(): void {
}
/**
- * Forgets which client certificate goes to which server, with what the app keeps of it in
- * memory, so the next account at this computer gets asked. Nothing happens in a browser or on
- * a shell without the call.
+ * Forgets which client certificate goes to which server. An account's own sign-out takes what
+ * was chosen under it. The last sign-out takes everything, with what the app keeps of it in
+ * memory, so the next person at this computer gets asked. That restarts the app's connections,
+ * so an account switch never comes here. Nothing happens in a browser or on a shell without the call.
*/
-export function clearDesktopCertificateChoices(): void {
- void Promise.resolve(getDesktopBridge()?.certificates?.clearChoices?.()).catch(() => {});
+export function clearDesktopCertificateChoices(scope: { account?: string; last?: boolean }): void {
+ void Promise.resolve(getDesktopBridge()?.certificates?.clearChoices?.(scope)).catch(() => {});
+}
+
+/** Which account a certificate chosen from now on belongs to. */
+export function noteDesktopCertificateAccount(account: string | null): void {
+ void Promise.resolve(getDesktopBridge()?.certificates?.setAccount?.(account)).catch(() => {});
}
/**
diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts
index a5a4073f9..65a51d161 100644
--- a/lib/desktop/types.ts
+++ b/lib/desktop/types.ts
@@ -324,8 +324,16 @@ export interface DesktopKeystoreBridge {
}
export interface DesktopCertificatesBridge {
- /** Forgets the client certificates chosen for servers, for the next account. Only narrows. */
- clearChoices: () => Promise;
+ /**
+ * Forgets client certificates chosen for servers. `account`: those chosen while that account was
+ * active, at its own sign-out. `last`: all of them, and what the app keeps of them in memory, at
+ * the sign-out that leaves nobody. Only narrows.
+ */
+ clearChoices: (scope: { account?: string; last?: boolean }) => Promise;
+ /** The account a choice made from now on belongs to. */
+ setAccount: (account: string | null) => Promise;
+ /** The app's connections were closed: a request under way will never settle by itself. */
+ onConnectionsReset: (listener: () => void) => () => void;
}
export interface DesktopPolicyBridge {
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index 77bd2eaec..2ac9dd25f 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -1,5 +1,6 @@
import { recordJmapCall, reportJmapFailure } from "@/lib/error-reporting/client";
import { getDesktopBridge } from '@/lib/desktop/bridge';
+import { onConnectionsReset } from '@/lib/desktop/connections-reset';
import { desktopJmapBase, desktopJmapOrigin, isDesktopProxiedServer, withDesktopBlobType } from '@/lib/desktop/jmap-route';
import { advertisesMailboxMetadata, bulwarkEntries, escapeMetadataPathSegment, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataPatchError, MetadataRefusedError, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata";
import { IncompleteDeleteError } from "./delete-error";
@@ -1855,14 +1856,23 @@ export class JMAPClient implements IJMAPClient {
// "we gave up" from "the caller cancelled", and callers must be able to tell
// those apart (a cancelled send is not a failed send).
let timedOut = false;
- const timer = setTimeout(() => {
+ const giveUp = () => {
timedOut = true;
- controller.abort();
- }, timeoutMs);
+ // The reason goes on to a body still being read after this returned.
+ controller.abort(new RequestTimeoutError(timeoutMs));
+ };
+ const timer = setTimeout(giveUp, timeoutMs);
+ // The desktop app closed its connections, so this will never settle by itself. It's what a
+ // deadline running out means: the request may have reached the server, only the answer is
+ // lost, and it isn't replayed (authenticatedFetch).
+ const stopWatching = onConnectionsReset(giveUp);
try {
- return await fetch(url, { ...withJmapCredentials(init), headers, signal: controller.signal });
+ const response = await fetch(url, { ...withJmapCredentials(init), headers, signal: controller.signal });
+ if (stopWatching) setTimeout(stopWatching, timeoutMs);
+ return response;
} catch (error) {
+ stopWatching?.();
if (timedOut) throw new RequestTimeoutError(timeoutMs);
throw error;
} finally {
@@ -6363,6 +6373,7 @@ export class JMAPClient implements IJMAPClient {
const cleanup = () => {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
+ stopWatchingConnections?.();
xhr.onload = xhr.onerror = xhr.onabort = xhr.ontimeout = null;
xhr.upload.onprogress = null;
};
@@ -6379,6 +6390,7 @@ export class JMAPClient implements IJMAPClient {
};
const onAbort = () => abortWith(new DOMException('Upload aborted', 'AbortError'));
const onTimeout = () => abortWith(new RequestTimeoutError(JMAPClient.TRANSFER_TIMEOUT_MS));
+ const stopWatchingConnections = onConnectionsReset(onTimeout);
const resetWatchdog = () => {
clearTimeout(timer);
// XHR uses this budget for inactivity, unlike fetch's deadline until
@@ -11670,6 +11682,8 @@ export class JMAPClient implements IJMAPClient {
* Checking on the way back in makes the recovery immediate rather than
* leaving the app on a socket that will never deliver another byte.
*/
+ private stopWatchingConnections: (() => void) | null = null;
+
private recycleStaleSSE(): void {
if (this.intentionallyDisconnected) return;
if (!this.sseAbortController) return;
@@ -11704,6 +11718,14 @@ export class JMAPClient implements IJMAPClient {
}
};
window.addEventListener('online', this.onlineHandler);
+ // A stream under way when the desktop app closes its connections would stay silent.
+ this.stopWatchingConnections = onConnectionsReset(() => {
+ if (this.intentionallyDisconnected || !this.sseAbortController) return;
+ this.stopSSEPingMonitor();
+ this.sseAbortController.abort();
+ this.sseAbortController = null;
+ this.scheduleSSEReconnect();
+ });
}
}
@@ -11716,6 +11738,8 @@ export class JMAPClient implements IJMAPClient {
window.removeEventListener('online', this.onlineHandler);
this.onlineHandler = null;
}
+ this.stopWatchingConnections?.();
+ this.stopWatchingConnections = null;
}
onConnectionChange(callback: (connected: boolean) => void): void {
diff --git a/stores/__tests__/auth-store-logout-hooks.test.ts b/stores/__tests__/auth-store-logout-hooks.test.ts
index 3e5e53f8d..afdad8ad2 100644
--- a/stores/__tests__/auth-store-logout-hooks.test.ts
+++ b/stores/__tests__/auth-store-logout-hooks.test.ts
@@ -79,27 +79,43 @@ describe('sign-out with plugins', () => {
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, certificates: { clearChoices } };
};
- it('are forgotten at sign-out', async () => {
+ it('all of them at the sign-out that leaves nobody', async () => {
const clearChoices = vi.fn().mockResolvedValue(undefined);
withBridge(clearChoices);
await useAuthStore.getState().logout();
expect(clearChoices).toHaveBeenCalledTimes(1);
+ expect(clearChoices.mock.calls[0][0]).toMatchObject({ last: true });
});
- it('when all accounts sign out', async () => {
+ it('all of them when all accounts sign out', async () => {
const clearChoices = vi.fn().mockResolvedValue(undefined);
withBridge(clearChoices);
await useAuthStore.getState().logoutAll();
- expect(clearChoices).toHaveBeenCalled();
+ expect(clearChoices).toHaveBeenCalledWith({ last: true });
});
- it('and when switching to another account', () => {
+ it('only its own while another account stays', async () => {
const clearChoices = vi.fn().mockResolvedValue(undefined);
withBridge(clearChoices);
+ const accounts = useAccountStore.getState();
+ const entry = (id: string) => ({ id, label: id, serverUrl: 'https://mail.example.test', username: id, authMode: 'basic' as const, rememberMe: true, displayName: id, email: id, lastLoginAt: 1, isConnected: true, hasError: false, isDefault: false, cookieSlot: id === 'a1' ? 0 : 1, avatarColor: '#000' });
+ useAccountStore.setState({ ...accounts, accounts: [entry('a1'), entry('a2')], activeAccountId: 'a1' });
+ useAuthStore.setState({ isAuthenticated: true, activeAccountId: 'a1' });
+ await useAuthStore.getState().logout();
+ expect(clearChoices).toHaveBeenCalledWith({ account: 'a1', last: false });
+ expect(clearChoices.mock.calls.some(([scope]) => scope.last === true)).toBe(false);
+ });
+
+ it('nothing when switching to another account or searching across them', () => {
+ const clearChoices = vi.fn().mockResolvedValue(undefined);
+ const setAccount = vi.fn().mockResolvedValue(undefined);
+ (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, certificates: { clearChoices, setAccount } };
useAuthStore.setState({ isAuthenticated: true, activeAccountId: 'a1' });
- expect(clearChoices).not.toHaveBeenCalled();
useAuthStore.setState({ activeAccountId: 'a2' });
- expect(clearChoices).toHaveBeenCalledTimes(1);
+ useAuthStore.setState({ activeAccountId: 'a1' });
+ expect(clearChoices).not.toHaveBeenCalled();
+ expect(setAccount.mock.calls.at(-1)).toEqual(['a1']);
+ expect(setAccount).toHaveBeenCalledWith('a2');
});
it('signs out all the same on a shell without the call, or one that fails', async () => {
diff --git a/stores/__tests__/auth-store-token-renewal-timeout.test.ts b/stores/__tests__/auth-store-token-renewal-timeout.test.ts
new file mode 100644
index 000000000..14a24176a
--- /dev/null
+++ b/stores/__tests__/auth-store-token-renewal-timeout.test.ts
@@ -0,0 +1,118 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+/**
+ * A token renewal holds the token lock for every window. A request that never answers, as one
+ * caught when the desktop app closes its connections, can't be left to hold it for good.
+ */
+
+const SERVER = 'https://mail.example.test';
+
+function account(id: string, slot: number) {
+ return {
+ id,
+ label: id,
+ serverUrl: SERVER,
+ username: `${id}@example.test`,
+ authMode: 'oauth' as const,
+ rememberMe: true,
+ displayName: id,
+ email: `${id}@example.test`,
+ lastLoginAt: 1,
+ isConnected: false,
+ hasError: false,
+ isDefault: true,
+ cookieSlot: slot,
+ avatarColor: '#000',
+ };
+}
+
+describe('a token renewal that gets no answer', { timeout: 30_000 }, () => {
+ const held = new Set();
+ let renewals = 0;
+
+ beforeEach(() => {
+ vi.resetModules();
+ held.clear();
+ renewals = 0;
+ Object.defineProperty(navigator, 'locks', {
+ configurable: true,
+ value: {
+ request: async (name: string, options: { ifAvailable?: boolean }, callback: (lock: unknown) => unknown) => {
+ if (options.ifAvailable && held.has(name)) return callback(null);
+ held.add(name);
+ try {
+ return await callback({});
+ } finally {
+ held.delete(name);
+ }
+ },
+ },
+ });
+ vi.stubGlobal('fetch', vi.fn((url: unknown, init?: RequestInit) => {
+ if (String(url).includes('/api/auth/token') && init?.method === 'PUT') {
+ renewals += 1;
+ return new Promise((_resolve, reject) => {
+ init.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
+ });
+ }
+ return Promise.resolve(new Response('{}', { status: 200 }));
+ }));
+ });
+
+ afterEach(() => {
+ delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop;
+ delete (navigator as { locks?: unknown }).locks;
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ async function storeWithAccount() {
+ const { useAuthStore } = await import('../auth-store');
+ const { useAccountStore } = await import('../account-store');
+ useAccountStore.setState({ accounts: [account('A', 0)], activeAccountId: 'A', defaultAccountId: 'A' });
+ useAuthStore.setState({ isAuthenticated: true, isLoading: false, serverUrl: SERVER, username: 'A@example.test', activeAccountId: 'A', authMode: 'oauth' });
+ // After the imports: they don't finish on a stopped clock.
+ vi.useFakeTimers();
+ return useAuthStore;
+ }
+
+ it('lets go of the lock after its time, and counts as an outage', async () => {
+ const store = await storeWithAccount();
+ const done = vi.fn();
+ void store.getState().refreshAccessToken({ accountId: 'A' }).then(done);
+ await vi.advanceTimersByTimeAsync(0);
+ expect(renewals).toBe(1);
+ expect(held.size, 'it holds the lock while it runs').toBe(1);
+ await vi.advanceTimersByTimeAsync(29_000);
+ expect(done).not.toHaveBeenCalled();
+ await vi.advanceTimersByTimeAsync(1_000);
+ expect(held.size).toBe(0);
+ expect(done).toHaveBeenCalledWith(null);
+ expect(store.getState().isAuthenticated, 'nobody is signed out for it').toBe(true);
+ });
+
+ it('lets go at once when the desktop app says its connections went', async () => {
+ let reset = () => {};
+ (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = {
+ version: 1,
+ certificates: {
+ setAccount: async () => {},
+ onConnectionsReset: (listener: () => void) => {
+ reset = listener;
+ return () => {};
+ },
+ },
+ };
+ const store = await storeWithAccount();
+ const done = vi.fn();
+ void store.getState().refreshAccessToken({ accountId: 'A' }).then(done);
+ await vi.advanceTimersByTimeAsync(0);
+ expect(held.size).toBe(1);
+ reset();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(held.size).toBe(0);
+ expect(done).toHaveBeenCalledWith(null);
+ });
+});
diff --git a/stores/auth-store.ts b/stores/auth-store.ts
index 0fd01dfd7..7953d72a2 100644
--- a/stores/auth-store.ts
+++ b/stores/auth-store.ts
@@ -18,7 +18,8 @@ import { generateAccountId, MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } from '@/lib/browser-navigation';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { getDesktopBridge } from '@/lib/desktop/bridge';
-import { clearDesktopCertificateChoices, clearDesktopCrashFiles, clearKeystoreGrants } from '@/lib/desktop/keystore-grants';
+import { clearDesktopCertificateChoices, clearDesktopCrashFiles, clearKeystoreGrants, noteDesktopCertificateAccount } from '@/lib/desktop/keystore-grants';
+import { onConnectionsReset } from '@/lib/desktop/connections-reset';
import { DESKTOP_CLIENT_ID_KEY } from '@/lib/desktop/browser-sign-in';
import { isEmbedded, notifyParent } from '@/lib/iframe-bridge';
import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager';
@@ -359,6 +360,26 @@ function trackSessionWrite(slot: number, run: (signal: AbortSignal) => Promis
return request;
}
+/** It holds the token lock, for every window: a request that never answers can't be left to hold it. */
+const TOKEN_RENEWAL_TIMEOUT_MS = 30_000;
+
+function renewalSignal(outer: AbortSignal): { signal: AbortSignal; done: () => void } {
+ const controller = new AbortController();
+ const stop = () => controller.abort();
+ if (outer.aborted) stop();
+ else outer.addEventListener('abort', stop, { once: true });
+ const timer = setTimeout(stop, TOKEN_RENEWAL_TIMEOUT_MS);
+ const stopWatching = onConnectionsReset(stop);
+ return {
+ signal: controller.signal,
+ done: () => {
+ clearTimeout(timer);
+ outer.removeEventListener('abort', stop);
+ stopWatching?.();
+ },
+ };
+}
+
const TIMED_OUT = Symbol('timed out');
function withTimeout(promise: Promise, ms: number): Promise {
@@ -649,8 +670,10 @@ function fetchSlotAccessToken(slot: number, opts: { force?: boolean; remember?:
const force = opts.force && !contended;
if (!IS_LITE) {
const query = `${force ? '&force=true' : ''}${opts.remember === false ? '&remember=false' : ''}`;
- return apiFetch(`/api/auth/token?slot=${slot}${query}`, { method: 'PUT', signal })
- .then((res) => noteSessionAnswer(res, slot));
+ const renewal = renewalSignal(signal);
+ return apiFetch(`/api/auth/token?slot=${slot}${query}`, { method: 'PUT', signal: renewal.signal })
+ .then((res) => noteSessionAnswer(res, slot))
+ .finally(renewal.done);
}
// Like the route's cookie cache: a restore resumes with the token it had,
// since a renewal before the refresh token's `nbf` is refused (#552).
@@ -2544,7 +2567,7 @@ export const useAuthStore = create()(
useSettingsStore.getState().disableSync();
clearKeystoreGrants();
- clearDesktopCertificateChoices();
+ clearDesktopCertificateChoices({ account: accountId ?? undefined, last: useAccountStore.getState().accounts.length === 0 });
clearDesktopCrashFiles();
void authHooks.onAfterLogout.emit({
accountId: accountId ?? 'all',
@@ -2636,6 +2659,7 @@ export const useAuthStore = create()(
// mail never passed a question, so it waits for their next sign-in.
accountStore.removeAccount(remaining.id, 'evicted');
}
+ clearDesktopCertificateChoices({ last: true });
}
// Full logout. The credentials are cleared before the page state:
@@ -2680,6 +2704,7 @@ export const useAuthStore = create()(
evictAccount(accountId);
forgetAccountRows(accountId, get().client);
accountStore.removeAccount(accountId, reason);
+ clearDesktopCertificateChoices({ account: accountId });
forgetPrincipals(accountId);
clearPluginStorageForAccount(accountId);
},
@@ -2726,7 +2751,7 @@ export const useAuthStore = create()(
}
clearKeystoreGrants();
- clearDesktopCertificateChoices();
+ clearDesktopCertificateChoices({ last: true });
clearDesktopCrashFiles();
void authHooks.onAfterLogout.emit({
accountId: 'all'
@@ -3874,9 +3899,9 @@ useAuthStore.subscribe((state, prev) => {
const ready = state.isAuthenticated && !!state.client && !!state.activeAccountId;
const wasReady = prev.isAuthenticated && !!prev.client && !!prev.activeAccountId;
if (ready && !wasReady) void authHooks.onAuthReady.emit({ accountId: state.activeAccountId });
+ if (state.activeAccountId !== prev.activeAccountId) noteDesktopCertificateAccount(state.activeAccountId);
if (state.activeAccountId && prev.activeAccountId && state.activeAccountId !== prev.activeAccountId) {
clearKeystoreGrants();
- clearDesktopCertificateChoices();
clearDesktopCrashFiles();
void authHooks.onAccountSwitch.emit({ accountId: state.activeAccountId, previousAccountId: prev.activeAccountId });
}
From 71aaadef502398eb12214b1d5cdaf016f939916a Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 01:31:43 +0100
Subject: [PATCH 5/5] fix: run plugins' compose check in quick compose, keep a
composer with one owner, and bring it back after a crash
The first version of quick compose skipped `onBeforeCompose`, a composer
moved back could end up open in both windows, a reply moved back under
Pro flagged the selected message, and a crashed pop-out came back blank.
- A new message in a pop-out runs `onBeforeCompose` with the same result
as the main window: a plugin that stops it closes the window.
- The way back has two steps, so a pop-out that gave up can't leave a
second owner. A handoff behind a plugin's gate makes it say busy.
- A Pro tab made from a composer moved back answers its own message.
- Each save writes `draft`, `account` and `owner` into the pop-out's
address, which a reload after a crash starts from. What was typed since
the last save, up to the 2 s autosave delay, is still lost.
- The recorder refuses keys that type a character on this keyboard, and
what the shell refuses from a page, and says why.
---
.../desktop/__tests__/desktop-host.test.tsx | 2 +-
.../__tests__/composer-own-window.test.tsx | 2 +
components/email/email-composer.tsx | 10 +-
.../mail-app-compose-handoff.test.tsx | 123 +++++++++++++-
components/mail/mail-app.tsx | 32 +++-
.../__tests__/popout-compose-app.test.tsx | 152 +++++++++++++++++-
.../popout/__tests__/popout-host.test.tsx | 15 ++
components/popout/popout-compose-app.tsx | 80 +++++++--
components/popout/popout-host.tsx | 6 +-
.../desktop-quick-compose-setting.test.tsx | 40 +++++
.../desktop-quick-compose-setting.tsx | 55 +++++--
lib/desktop/__tests__/shortcut-keys.test.ts | 91 ++++++++---
lib/desktop/shortcut-keys.ts | 84 ++++++++--
lib/popout/__tests__/return-to-main.test.ts | 110 +++++++++----
lib/popout/channel.ts | 2 +
lib/popout/compose-handoff.ts | 12 ++
lib/popout/return-to-main.ts | 71 ++++++--
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 +-
47 files changed, 893 insertions(+), 174 deletions(-)
diff --git a/components/desktop/__tests__/desktop-host.test.tsx b/components/desktop/__tests__/desktop-host.test.tsx
index 42a6d0198..61f8a3b8d 100644
--- a/components/desktop/__tests__/desktop-host.test.tsx
+++ b/components/desktop/__tests__/desktop-host.test.tsx
@@ -333,7 +333,7 @@ describe('quick compose and the way back', () => {
fakeBridge();
render();
push.mockClear();
- offerReturnedComposer({ returnId: 'r1', payload: { mode: 'compose' }, answer: () => {} });
+ offerReturnedComposer({ returnId: 'r1', payload: { mode: 'compose' }, answer: () => {}, sendReady: () => {} });
expect(push).toHaveBeenCalledWith('/');
});
});
diff --git a/components/email/__tests__/composer-own-window.test.tsx b/components/email/__tests__/composer-own-window.test.tsx
index 03f84e9b5..199eee835 100644
--- a/components/email/__tests__/composer-own-window.test.tsx
+++ b/components/email/__tests__/composer-own-window.test.tsx
@@ -337,6 +337,8 @@ describe('a composer that is all its window shows', () => {
fireEvent.change(subjectField(), { target: { value: 'Autosaved' } });
await waitFor(() => expect(creations).toHaveLength(1), { timeout: 4000 });
await waitFor(() => expect(onDraftSaved).toHaveBeenCalled());
+ // Says where it lives: a reload after a crash has only the address to go by.
+ expect(onDraftSaved.mock.calls[0][1]).toEqual(expect.objectContaining({ localAccountId: expect.anything() }));
expect(unload().defaultPrevented).toBe(false);
expect(creations).toHaveLength(1);
});
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index c6cb3e6b8..753926c86 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -402,8 +402,12 @@ interface EmailComposerProps {
ownWindow?: {
/** The state as the window unloads, for a reload to start from. */
onUnloadState?: (data: ComposerDraftData) => void;
- /** A save reached the server, for other windows showing the Drafts folder. */
- onDraftSaved?: (draftId: string) => void;
+ /**
+ * A save reached the server, for other windows showing the Drafts folder and for a
+ * reload to find it. `accountId`: the account it lives in, when not the own one.
+ * `localAccountId`: the login it was saved through.
+ */
+ onDraftSaved?: (draftId: string, where: { accountId?: string; localAccountId?: string }) => void;
/**
* Hands a draft to another window to save as this one closes: a request
* started now wouldn't survive. False when that can't be done safely.
@@ -3155,7 +3159,7 @@ export function EmailComposer({
// Only the save the user asked for is read out: autosave would say it
// every minute, and the save before sending is followed by "Sent".
if (opts.announce && id && !saveFailedRef.current) announceStatus(t('draft_saved'));
- if (id && !saveFailedRef.current) ownWindowRef.current?.onDraftSaved?.(id);
+ if (id && !saveFailedRef.current) ownWindowRef.current?.onDraftSaved?.(id, { accountId: draftAccountIdRef.current, localAccountId: draftLocalAccountIdRef.current });
return id;
})();
inflightSaveRef.current = promise;
diff --git a/components/mail/__tests__/mail-app-compose-handoff.test.tsx b/components/mail/__tests__/mail-app-compose-handoff.test.tsx
index 665f2e04a..11ad54128 100644
--- a/components/mail/__tests__/mail-app-compose-handoff.test.tsx
+++ b/components/mail/__tests__/mail-app-compose-handoff.test.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { act, 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 type { Email, Mailbox } from '@/lib/jmap/types';
import { useAuthStore } from '@/stores/auth-store';
@@ -9,6 +9,10 @@ import { usePluginStore } from '@/stores/plugin-store';
import { useThemeStore } from '@/stores/theme-store';
import { handOffComposeTab, useProTabStore } from '@/stores/pro-tab-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
+import { emailHooks } from '@/lib/plugin-hooks';
+import { handOffNewMessage } from '@/lib/launch-handoff';
+import { commitReturnedComposer, dropReturnedComposer, offerReturnedComposer, takeReturnedComposer } from '@/lib/popout/return-to-main';
+import { EmbeddedContext } from '@/hooks/use-is-embedded';
// A reply crossing between a Pro compose tab and the standard composer answers
// the message it was started from. The message selected in the mail list can be
@@ -18,6 +22,7 @@ const seen = vi.hoisted(() => ({ sourceEmailId: undefined as string | undefined
vi.mock('@/components/email/email-composer', async () => {
const { useEffect, useRef, useState } = await import('react');
+ const { registerComposeFlush } = await import('@/lib/compose-flush-registry');
return {
EmailComposer: ({ initialData, onSaveState, onSend, sourceEmailId }: {
initialData?: { subject?: string; mode?: string } | null;
@@ -26,6 +31,7 @@ vi.mock('@/components/email/email-composer', async () => {
sourceEmailId?: string;
}) => {
seen.sourceEmailId = sourceEmailId;
+ useEffect(() => registerComposeFlush(async () => {}), []);
const [subject, setSubject] = useState(initialData?.subject ?? '');
const initial = useRef(subject);
const latest = useRef(subject);
@@ -172,3 +178,118 @@ describe('a reply handed from a Pro compose tab to the standard composer', () =>
expect(tab?.composeData?.sourceEmailSource).toEqual(SOURCE_OF_A);
});
});
+
+describe('a composer coming back from its own window', () => {
+ const REPLY = { mode: 'reply' as const, sourceEmailId: 'a', sourceEmailSource: SOURCE_OF_A, initialData: { subject: 'Re: A', mode: 'reply' } as never };
+
+ afterEach(() => {
+ takeReturnedComposer();
+ cleanup();
+ useProTabStore.setState({ tabs: [], activeTabId: null } as never);
+ });
+
+ function comeBack(id: string, payload: Parameters[0]['payload'] = REPLY, ready: 'commit' | 'silent' = 'commit') {
+ const answer = vi.fn();
+ act(() => offerReturnedComposer({ returnId: id, payload, answer, sendReady: () => { if (ready === 'commit') commitReturnedComposer(id); } }));
+ return answer;
+ }
+
+ const showsMail = async () => {
+ await screen.findByText('message list');
+ await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); });
+ };
+
+ it('opens with its text, says so, and answers its own message', async () => {
+ render();
+ await showsMail();
+ const answer = comeBack('r1');
+ expect(await screen.findByLabelText('stub subject')).toHaveProperty('value', 'Re: A');
+ await waitFor(() => expect(answer).toHaveBeenCalledWith('shown'));
+ expect(seen.sourceEmailId).toBe('a');
+ });
+
+ it('under Mountain View too', async () => {
+ useSettingsStore.setState({ interfaceLayout: 'mountain-view' } as never);
+ render();
+ await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); });
+ const answer = comeBack('r1');
+ expect(await screen.findByLabelText('stub subject')).toHaveProperty('value', 'Re: A');
+ await waitFor(() => expect(answer).toHaveBeenCalledWith('shown'));
+ expect(seen.sourceEmailId).toBe('a');
+ useSettingsStore.setState({ interfaceLayout: 'default' } as never);
+ });
+
+ it('under Pro it becomes a tab that answers its own message, not the one selected in the list', async () => {
+ render();
+ await showsMail();
+ comeBack('r1');
+ await waitFor(() => expect(useProTabStore.getState().tabs.some((tab) => tab.kind === 'compose')).toBe(true));
+ const tab = useProTabStore.getState().tabs.find((candidate) => candidate.kind === 'compose');
+ expect(tab?.composeData?.initialData?.subject).toBe('Re: A');
+ expect(tab?.composeData?.sourceEmailId).toBe('a');
+ expect(tab?.composeData?.sourceEmailSource).toEqual(SOURCE_OF_A);
+ });
+
+ it('a new message moved back under Pro answers nothing, whatever is selected', async () => {
+ render();
+ await showsMail();
+ comeBack('r1', { mode: 'compose', initialData: { subject: 'New', mode: 'compose' } as never });
+ await waitFor(() => expect(useProTabStore.getState().tabs.some((tab) => tab.kind === 'compose')).toBe(true));
+ const tab = useProTabStore.getState().tabs.find((candidate) => candidate.kind === 'compose');
+ expect(tab?.composeData?.sourceEmailId).toBeNull();
+ expect(tab?.composeData?.sourceEmailSource).toBeNull();
+ });
+
+ it('with one open already it says busy and leaves that one be', async () => {
+ render();
+ await showsMail();
+ comeBack('r1');
+ const first = await screen.findByLabelText('stub subject');
+ fireEvent.change(first, { target: { value: 'Being written' } });
+ const answer = comeBack('r2', { mode: 'compose', initialData: { subject: 'Second', mode: 'compose' } as never });
+ await waitFor(() => expect(answer).toHaveBeenCalledWith('busy'));
+ expect(screen.getByLabelText('stub subject')).toHaveProperty('value', 'Being written');
+ });
+
+ it('a pop-out that gave up as this took it: nothing opens, so the draft has one owner', async () => {
+ render();
+ await showsMail();
+ const answer = comeBack('r1', REPLY, 'silent');
+ await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); });
+ act(() => dropReturnedComposer('r1'));
+ await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); });
+ expect(screen.queryByLabelText('stub subject')).toBeNull();
+ expect(answer).not.toHaveBeenCalledWith('shown');
+ });
+
+ it('a new message held up by a slow plugin gate says busy, and nothing replaces what comes after', async () => {
+ let letThrough: (value: unknown) => void = () => {};
+ const held = emailHooks.onBeforeCompose.register('test', ((options: unknown) => new Promise((resolve) => { letThrough = () => resolve(options); })) as never);
+ try {
+ render();
+ await showsMail();
+ act(() => handOffNewMessage());
+ await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); });
+ const answer = comeBack('r1');
+ await waitFor(() => expect(answer).toHaveBeenCalledWith('busy'));
+ expect(screen.queryByLabelText('stub subject')).toBeNull();
+ await act(async () => { letThrough(undefined); await new Promise((resolve) => setTimeout(resolve, 20)); });
+ expect(await screen.findByLabelText('stub subject')).toHaveProperty('value', '');
+ } finally {
+ held.dispose();
+ }
+ });
+
+ it('a handoff arriving while it is being claimed waits for it, and takes nothing over', async () => {
+ render();
+ await showsMail();
+ const answer = comeBack('r1', REPLY, 'silent');
+ await act(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); });
+ act(() => handOffNewMessage());
+ await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); });
+ expect(screen.queryByLabelText('stub subject')).toBeNull();
+ act(() => commitReturnedComposer('r1'));
+ expect(await screen.findByLabelText('stub subject')).toHaveProperty('value', 'Re: A');
+ await waitFor(() => expect(answer).toHaveBeenCalledWith('shown'));
+ });
+});
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index f49a38da8..0a37a0835 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -1497,6 +1497,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
} : undefined);
+ const handedOff = handedOffSource?.session === composerSessionId ? handedOffSource : null;
const effectiveMode = pendingDraft?.mode ?? composerMode;
const title = buildComposeTabTitle({
mode: effectiveMode,
@@ -1515,8 +1516,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
initialData: pendingDraft,
prefill: composerPrefill,
initialFiles: composerInitialFiles,
- sourceEmailId: selectedEmail?.id ?? null,
- sourceEmailSource: selectedEmail ? emailWriteSource(selectedEmail, client) : null,
+ // A composer handed over answers its own message, not the one selected in the list.
+ sourceEmailId: handedOff ? handedOff.emailId ?? null : selectedEmail?.id ?? null,
+ sourceEmailSource: handedOff ? handedOff.source ?? null : selectedEmail ? emailWriteSource(selectedEmail, client) : null,
title,
});
@@ -1718,6 +1720,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// got what it holds. Under Pro a composer moves to its own tab, so nothing
// waits long.
const handoffOpeningRef = useRef(false);
+ const returnOpeningRef = useRef(false);
const handoffToldWaitingRef = useRef(new Set());
const composerInUseRef = useRef(false);
const composerInUse = showComposer || pendingMailtoAccountChoice !== null
@@ -1749,7 +1752,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
};
const openNext = () => {
- if (handoffOpeningRef.current) return;
+ if (handoffOpeningRef.current || returnOpeningRef.current) return;
if (composerInUseRef.current) {
const waiting = pendingHandoffs().find((handoff) => handoff.kind === 'compose');
if (waiting && !handoffToldWaitingRef.current.has(waiting.id)) {
@@ -1773,13 +1776,26 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}, [isAuthenticated, client, proTakingOver, routeOutsideDraft, withComposeGate, isMobile, composerInUse, handoffSettled]);
// A composer coming back from its own window. It has saved, and keeps its copy until
- // the composer is open here. One already open holds someone's text, so that says no.
+ // the composer is open here. One already open holds someone's text, and so may one on its
+ // way behind a plugin's onBeforeCompose, so either says no.
useEffect(() => {
if (!isAuthenticated || !client || proTakingOver) return;
- const adopt = () => {
+ const adopt = async () => {
const returned = takeReturnedComposer();
if (!returned) return;
- if (composerInUseRef.current) {
+ const busy = () => composerInUseRef.current || handoffOpeningRef.current;
+ if (busy()) {
+ returned.answer('busy');
+ return;
+ }
+ // Holds back a handoff until this has gone one way or the other.
+ returnOpeningRef.current = true;
+ const go = await returned.claim().finally(() => {
+ returnOpeningRef.current = false;
+ setHandoffSettled((n) => n + 1);
+ });
+ if (!go) return;
+ if (busy()) {
returned.answer('busy');
return;
}
@@ -1792,8 +1808,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setShowComposer(true);
if (isMobile) setActiveView('viewer');
};
- adopt();
- return onReturnedComposer(adopt);
+ void adopt();
+ return onReturnedComposer(() => void adopt());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated, client, proTakingOver, isMobile, composerSessionId]);
diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx
index 11a3bb07a..6ceedf1fa 100644
--- a/components/popout/__tests__/popout-compose-app.test.tsx
+++ b/components/popout/__tests__/popout-compose-app.test.tsx
@@ -2,7 +2,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel';
import { openPopoutChannel, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel';
-import { createComposeHandoff, rememberComposePayload } from '@/lib/popout/compose-handoff';
+import { createComposeHandoff, rememberComposePayload, rememberedComposePayload } from '@/lib/popout/compose-handoff';
import { useAuthStore } from '@/stores/auth-store';
import { useIdentityStore } from '@/stores/identity-store';
import { usePluginStore } from '@/stores/plugin-store';
@@ -328,7 +328,9 @@ describe('back into the main window', () => {
function mainWindowAnswers(status: 'shown' | 'busy' | null, accountIds = ['alice']) {
const channel = openPopoutChannel((message) => {
if (message.type === 'main-ping') channel?.post({ type: 'main-pong', windowId: 'main', accountIds });
- if (message.type === 'composer-return' && status) channel?.post({ type: 'composer-return-answer', returnId: message.returnId, status });
+ if (message.type === 'composer-return' && status === 'busy') channel?.post({ type: 'composer-return-answer', returnId: message.returnId, status });
+ if (message.type === 'composer-return' && status === 'shown') channel?.post({ type: 'composer-return-ready', returnId: message.returnId });
+ if (message.type === 'composer-return-commit' && status) channel?.post({ type: 'composer-return-answer', returnId: message.returnId, status });
})!;
return channel;
}
@@ -346,7 +348,7 @@ describe('back into the main window', () => {
window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { focus } } as never;
const main = mainWindowAnswers('shown');
const props = await openComposer();
- await waitFor(() => expect((composer as unknown as { popOut?: unknown }).popOut).toBeTruthy());
+ await waitFor(() => expect((composer as unknown as { popOut?: unknown }).popOut).toBeTruthy(), { timeout: 5000 });
expect((composer as unknown as { popOutToMainLabel: string }).popOutToMainLabel).toBe('back_to_main');
expect(props).toBeTruthy();
main.close();
@@ -377,12 +379,13 @@ describe('back into the main window', () => {
window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { focus } } as never;
const main = mainWindowAnswers('shown');
await openComposer();
- await waitFor(() => expect((composer as unknown as { popOut?: unknown }).popOut).toBeTruthy());
+ await waitFor(() => expect((composer as unknown as { popOut?: unknown }).popOut).toBeTruthy(), { timeout: 5000 });
const target = (composer as unknown as { popOut: () => { handOver: (state: object) => Promise } }).popOut();
const state = { mode: 'reply', subject: 'Back', draftId: 'd9', draftLocalAccountId: 'carol' };
await act(async () => {
await expect(target.handOver(state)).resolves.toBe(true);
});
+ expect(heard.map((message) => message.type)).toContain('composer-return-commit');
const sent = heard.find((message) => message.type === 'composer-return') as Extract;
expect(sent.payload).toMatchObject({ mode: 'reply', initialData: state });
// The draft's own login, which isn't always the window's.
@@ -395,7 +398,7 @@ describe('back into the main window', () => {
window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { focus } } as never;
const main = mainWindowAnswers('busy');
await openComposer();
- await waitFor(() => expect((composer as unknown as { popOut?: unknown }).popOut).toBeTruthy());
+ await waitFor(() => expect((composer as unknown as { popOut?: unknown }).popOut).toBeTruthy(), { timeout: 5000 });
const target = (composer as unknown as { popOut: () => { handOver: (state: object) => Promise; notShownMessage: () => string } }).popOut();
await act(async () => {
await expect(target.handOver({ mode: 'compose' })).resolves.toBe(false);
@@ -405,3 +408,142 @@ describe('back into the main window', () => {
main.close();
});
});
+
+describe('a new message, with nothing handed over', () => {
+ const hooks: Array<() => void> = [];
+ const listen = (handler: (...args: never[]) => unknown) => {
+ const held = emailHooks.onBeforeCompose.register('test', handler as never);
+ hooks.push(() => held.dispose());
+ };
+
+ beforeEach(() => {
+ window.history.replaceState(null, '', '/en/popout/compose');
+ usePluginStore.setState({ initializePlugins: vi.fn(async () => {}) } as never);
+ useIdentityStore.setState({ identities: [{ id: 'id-1', email: 'me@example.org', name: 'Me' }] } as never);
+ });
+
+ afterEach(() => {
+ for (const stop of hooks.splice(0)) stop();
+ window.bulwarkDesktop = undefined;
+ });
+
+ type Prefill = { prefill?: { to?: string[]; subject?: string } | null };
+
+ it('with no plugin listening, opens empty', async () => {
+ render();
+ await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy());
+ expect((composer as unknown as Prefill).prefill ?? null).toBeNull();
+ });
+
+ it('a plugin that fills in fields fills them in, as in the main window', async () => {
+ const seen: unknown[] = [];
+ listen((options: unknown) => {
+ seen.push(options);
+ return { ...(options as object), to: ['from-plugin@example.org'], subject: 'From a plugin' };
+ });
+ render();
+ await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy());
+ expect((composer as unknown as Prefill).prefill).toEqual({ to: ['from-plugin@example.org'], subject: 'From a plugin' });
+ expect(seen).toHaveLength(1);
+ });
+
+ it('a plugin that stops it stops it: no composer, and the window goes', async () => {
+ const closeSelf = vi.fn();
+ window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { closeSelf } } as never;
+ listen(() => false);
+ render();
+ await waitFor(() => expect(closeSelf).toHaveBeenCalled());
+ expect(screen.queryByTestId('composer')).toBeNull();
+ });
+
+ it('waits for plugins that are still starting before it asks them', async () => {
+ let started: () => void = () => {};
+ usePluginStore.setState({ initializePlugins: vi.fn(() => new Promise((resolve) => { started = resolve; })) } as never);
+ render();
+ await settle();
+ expect(screen.queryByTestId('composer')).toBeNull();
+ listen(() => false);
+ const closeSelf = vi.fn();
+ window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { closeSelf } } as never;
+ await act(async () => started());
+ await waitFor(() => expect(closeSelf).toHaveBeenCalled());
+ expect(screen.queryByTestId('composer')).toBeNull();
+ });
+
+ it('a draft or a handoff isn\'t asked about again: it was at its start', async () => {
+ const hook = vi.fn(() => false);
+ listen(hook);
+ const handoff = createComposeHandoff()!;
+ void handoff.complete(PAYLOAD);
+ openAt(handoff.id);
+ await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy());
+ expect(hook).not.toHaveBeenCalled();
+ });
+});
+
+describe('after this page crashed', () => {
+ const LOGIN = 'alice@example.org@mail.example.org';
+ const MAILBOXES = [{ id: 'mDrafts', role: 'drafts' }];
+ const draft = {
+ id: 'd7', subject: 'Saved before the crash', to: [], 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 search = () => new URLSearchParams(window.location.search);
+
+ beforeEach(() => {
+ useIdentityStore.setState({ identities: [{ id: 'id-1', email: 'me@example.org', name: 'Me' }] } as never);
+ usePluginStore.setState({ initializePlugins: vi.fn(async () => {}) } as never);
+ identityMocks.multi = { enabled: false, allIdentities: [], groups: [] };
+ });
+
+ it('each save puts the draft, its login and its account in the address, on a window that started empty', async () => {
+ window.history.replaceState(null, '', '/en/popout/compose');
+ render();
+ await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy());
+ const own = (composer as unknown as { ownWindow: { onDraftSaved: (id: string, where: object) => void } }).ownWindow;
+ act(() => own.onDraftSaved('d1', { localAccountId: LOGIN, accountId: 'shared' }));
+ expect([search().get('draft'), search().get('account'), search().get('owner')]).toEqual(['d1', LOGIN, 'shared']);
+ act(() => own.onDraftSaved('d2', { localAccountId: LOGIN }));
+ expect([search().get('draft'), search().get('account'), search().get('owner')]).toEqual(['d2', LOGIN, null]);
+ });
+
+ it('comes back on the saved draft, not blank', async () => {
+ const getEmail = vi.fn(async () => draft);
+ const client = { getEmail, getMailboxes: vi.fn(async () => MAILBOXES) };
+ useAuthStore.setState({ getClientForAccount: (id: string) => (id === LOGIN ? client : undefined) } as never);
+ window.history.replaceState(null, '', `/en/popout/compose?draft=d7&account=${encodeURIComponent(LOGIN)}`);
+ render();
+ await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Saved before the crash'));
+ expect(getEmail).toHaveBeenCalledWith('d7', undefined);
+ });
+
+ it('with a handoff in the address too, the saved draft wins: nothing was remembered, so the page died', async () => {
+ const getEmail = vi.fn(async () => draft);
+ const client = { getEmail, getMailboxes: vi.fn(async () => MAILBOXES) };
+ useAuthStore.setState({ getClientForAccount: (id: string) => (id === LOGIN ? client : undefined) } as never);
+ window.history.replaceState(null, '', `/en/popout/compose?handoff=gone&draft=d7&account=${encodeURIComponent(LOGIN)}`);
+ render();
+ await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Saved before the crash'));
+ });
+
+ it('an ordinary reload goes on from what it remembered as it unloaded, which is newer than the draft', async () => {
+ const getEmail = vi.fn(async () => draft);
+ const client = { getEmail, getMailboxes: vi.fn(async () => MAILBOXES) };
+ useAuthStore.setState({ getClientForAccount: (id: string) => (id === LOGIN ? client : undefined) } as never);
+ rememberComposePayload('h1', { mode: 'compose', initialData: { subject: 'Typed since the save' } as never });
+ window.history.replaceState(null, '', `/en/popout/compose?handoff=h1&draft=d7&account=${encodeURIComponent(LOGIN)}`);
+ render();
+ // A reload first hears what became of the text it handed over on the way out.
+ await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Typed since the save'), { timeout: 5000 });
+ expect(getEmail).not.toHaveBeenCalled();
+ });
+
+ it('a save forgets what was remembered, so a later crash can\'t bring back older text', async () => {
+ rememberComposePayload('h2', { mode: 'compose', initialData: { subject: 'Older' } as never });
+ window.history.replaceState(null, '', '/en/popout/compose?handoff=h2');
+ render();
+ await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy(), { timeout: 5000 });
+ act(() => (composer as unknown as { ownWindow: { onDraftSaved: (id: string, where: object) => void } }).ownWindow.onDraftSaved('d3', {}));
+ expect(rememberedComposePayload('h2')).toBeNull();
+ });
+});
diff --git a/components/popout/__tests__/popout-host.test.tsx b/components/popout/__tests__/popout-host.test.tsx
index 04b6bcae7..e8a0dd03e 100644
--- a/components/popout/__tests__/popout-host.test.tsx
+++ b/components/popout/__tests__/popout-host.test.tsx
@@ -407,6 +407,11 @@ describe('a composer coming back', () => {
const returned = takeReturnedComposer();
expect(returned?.payload).toEqual(PAYLOAD);
expect(lockRequests).toContain('bulwark-popout-return:r1');
+ const claim = returned!.claim();
+ await settle();
+ expect(heard).toContainEqual({ type: 'composer-return-ready', returnId: 'r1' });
+ popout.post({ type: 'composer-return-commit', returnId: 'r1' });
+ await expect(claim).resolves.toBe(true);
returned?.answer('shown');
await settle();
expect(heard).toContainEqual({ type: 'composer-return-answer', returnId: 'r1', status: 'shown' });
@@ -433,6 +438,16 @@ describe('a composer coming back', () => {
}
});
+ it('a call-off after the mail page took it stops the page opening it', async () => {
+ render();
+ await settle();
+ popout.post({ type: 'composer-return', returnId: 'r1', accountId: 'alice', payload: PAYLOAD });
+ await settle();
+ const claim = takeReturnedComposer()!.claim();
+ popout.post({ type: 'composer-return-cancel', returnId: 'r1' });
+ await expect(claim).resolves.toBe(false);
+ });
+
it('goes when the pop-out calls it off', async () => {
render();
await settle();
diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx
index caf93da18..4b9f058a8 100644
--- a/components/popout/popout-compose-app.tsx
+++ b/components/popout/popout-compose-app.tsx
@@ -15,13 +15,14 @@ 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 { acknowledgeComposeHandoff, receiveComposeHandoff, rememberComposePayload, rememberedComposePayload, watchHandoffCancel } from "@/lib/popout/compose-handoff";
+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";
import { closePopoutWindow } from "@/lib/popout/window-role";
import { hasDesktopWindows } from "@/lib/popout/support";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import { Button } from "@/components/ui/button";
+import { composeOptionsFor, runBeforeCompose } from "@/lib/plugin-compose-gate";
import { composerDataFromDraft, isRestorableDraft } from "@/lib/draft-composer-data";
import { noteOpenDraft } from "@/lib/desktop/window-restore";
import { findDraftIdentityIdForLogin } from "@/lib/draft-source";
@@ -39,6 +40,9 @@ type SendData = Parameters>[0];
const EMPTY_PAYLOAD: ComposePopoutPayload = { mode: "compose" };
+/** How long a new message waits for plugins to start, so that their onBeforeCompose gets its say. */
+const PLUGINS_WAIT_MS = 5000;
+
/** How often the window looks whether a main window holding its login is still there. */
const MAIN_CHECK_MS = 1000;
@@ -167,6 +171,48 @@ function RestoredDraft({ draftId, accountId, ownerAccountId, onReady }: {
return ;
}
+/**
+ * A new message with nothing handed over, from the desktop app's shortcut or File menu. Plugins
+ * get the same say as in the main window: stop it, and nothing opens; or fill in fields.
+ */
+function NewMessageGate({ onReady }: { onReady: (payload: ComposePopoutPayload) => void }) {
+ const t = useTranslations();
+ const tWindows = useTranslations("pwaWindows");
+
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ await Promise.race([
+ Promise.resolve(usePluginStore.getState().initializePlugins()).catch(() => {}),
+ new Promise((resolve) => setTimeout(resolve, PLUGINS_WAIT_MS)),
+ ]);
+ if (cancelled) return;
+ if (emailHooks.onBeforeCompose.size === 0) {
+ onReady(EMPTY_PAYLOAD);
+ return;
+ }
+ const prefill = await runBeforeCompose(composeOptionsFor("compose", {
+ ownEmails: useIdentityStore.getState().identities.map((identity) => identity.email).filter((email): email is string => Boolean(email)),
+ replyPrefix: t("email_composer.prefix.reply"),
+ forwardPrefix: t("email_composer.prefix.forward"),
+ }));
+ if (cancelled) return;
+ if (!prefill) {
+ closePopoutWindow();
+ return;
+ }
+ onReady(Object.keys(prefill).length > 0 ? { ...EMPTY_PAYLOAD, prefill } : EMPTY_PAYLOAD);
+ })();
+ return () => {
+ cancelled = true;
+ };
+ // Once, as the window opens.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return ;
+}
+
/** 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 }) {
const t = useTranslations();
@@ -282,12 +328,13 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload;
result = await returnComposerToMain(
{ ...payload, mode: state.mode, replyTo: state.replyTo, initialData: state, initialDraftText: undefined, prefill: null },
state.draftLocalAccountId ?? useAuthStore.getState().activeAccountId,
+ () => mainWindow.alive(),
);
if (result === "shown") getDesktopBridge()?.windows?.focus?.();
return result === "shown";
},
};
- }, [payload, tQuick]);
+ }, [mainWindow, payload, tQuick]);
// The window that opened this one lets go of its copy now. Not without a
// session: this one couldn't save or send the text.
@@ -310,14 +357,19 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload;
onUnloadState: (state: ComposerDraftData) => {
if (handoffId) rememberComposePayload(handoffId, { ...payload, initialData: state, prefill: null });
},
- onDraftSaved: (draftId: string) => {
- announceMailChanged(useAuthStore.getState().activeAccountId);
- // A save makes a new id, and this address is what a reload starts from.
+ onDraftSaved: (draftId: string, where: { accountId?: string; localAccountId?: string } = {}) => {
+ const active = useAuthStore.getState().activeAccountId;
+ announceMailChanged(active);
+ // A save makes a new id, and this address is what a reload starts from: also the
+ // shell's reload after this page crashed, when nothing else of it survives.
const url = new URL(window.location.href);
- if (url.searchParams.has("draft")) {
- url.searchParams.set("draft", draftId);
- window.history.replaceState(null, "", `${url.pathname}${url.search}`);
- }
+ url.searchParams.set("draft", draftId);
+ const login = where.localAccountId ?? url.searchParams.get("account") ?? active;
+ if (login) url.searchParams.set("account", login);
+ if (where.accountId) url.searchParams.set("owner", where.accountId);
+ else url.searchParams.delete("owner");
+ window.history.replaceState(null, "", `${url.pathname}${url.search}`);
+ if (handoffId) forgetComposePayload(handoffId);
},
}), [handoffId, mainWindow, payload]);
@@ -348,8 +400,11 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload;
/** `/popout/compose`: a draft handed over by another window, or a new message. */
export function PopoutComposeApp() {
const t = useTranslations("pwaWindows");
- const [{ handoffId, accountId, draftId, ownerAccountId }] = useState(readLaunch);
- const [payload, setPayload] = useState(handoffId || draftId ? null : EMPTY_PAYLOAD);
+ const [{ handoffId: named, accountId, draftId, ownerAccountId }] = useState(readLaunch);
+ // Saved on the server and nothing remembered: this page crashed or was killed, and the
+ // draft is the newest there is. An ordinary reload remembers as it unloads.
+ const [handoffId] = useState(() => (named && draftId && !rememberedComposePayload(named) ? null : named));
+ const [payload, setPayload] = useState(null);
useEffect(() => {
if (!handoffId) return;
@@ -384,8 +439,9 @@ export function PopoutComposeApp() {
return (
{payload ?
+ : handoffId ?
: draftId ?
- : }
+ : }
);
}
diff --git a/components/popout/popout-host.tsx b/components/popout/popout-host.tsx
index 1fda7b9ad..4239c73b6 100644
--- a/components/popout/popout-host.tsx
+++ b/components/popout/popout-host.tsx
@@ -10,7 +10,7 @@ import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { newPopoutId, openPopoutChannel, type PopoutChannel, type PopoutMessage, type PopoutSentNotice, type RescuedDraft, type SharedAccountSession } from "@/lib/popout/channel";
import { keepRescuedDraft } from "@/lib/popout/rescued-drafts";
-import { dropReturnedComposer, offerReturnedComposer } from "@/lib/popout/return-to-main";
+import { commitReturnedComposer, dropReturnedComposer, offerReturnedComposer } from "@/lib/popout/return-to-main";
import { credentialFingerprint, sealFor } from "@/lib/popout/sealed";
import { changesAnyType, subscribePushStateChanges } from "@/lib/push-events";
@@ -182,10 +182,14 @@ export function PopoutHost() {
returnId,
payload,
answer: (status) => channel?.post({ type: "composer-return-answer", returnId, status }),
+ sendReady: () => channel?.post({ type: "composer-return-ready", returnId }),
});
});
break;
}
+ case "composer-return-commit":
+ commitReturnedComposer(message.returnId);
+ break;
case "composer-return-cancel":
dropReturnedComposer(message.returnId);
break;
diff --git a/components/settings/__tests__/desktop-quick-compose-setting.test.tsx b/components/settings/__tests__/desktop-quick-compose-setting.test.tsx
index 9da13feda..b9f430022 100644
--- a/components/settings/__tests__/desktop-quick-compose-setting.test.tsx
+++ b/components/settings/__tests__/desktop-quick-compose-setting.test.tsx
@@ -72,6 +72,46 @@ describe('DesktopQuickComposeSetting', () => {
expect(screen.queryByTestId('quick-compose-recording')).toBeNull();
});
+ it('refuses keys this keyboard types a character with, and says so', () => {
+ const onChange = show({});
+ fireEvent.click(screen.getByTestId('quick-compose-keys'));
+ const recorder = screen.getByTestId('quick-compose-recording');
+ fireEvent.keyDown(recorder, { key: '€', code: 'KeyE', ctrlKey: true, altKey: true });
+ expect(onChange).not.toHaveBeenCalled();
+ expect(screen.getByText('types_character')).toBeTruthy();
+ // Still recording: the next try can be better.
+ fireEvent.keyDown(recorder, { key: 'Enter', code: 'Enter', ctrlKey: true, altKey: true });
+ expect(onChange).toHaveBeenCalledWith('CommandOrControl+Alt+Enter');
+ });
+
+ it('refuses keys the system or every app uses, and says so', () => {
+ const onChange = show({});
+ fireEvent.click(screen.getByTestId('quick-compose-keys'));
+ fireEvent.keyDown(screen.getByTestId('quick-compose-recording'), { key: 'V', code: 'KeyV', ctrlKey: true, shiftKey: true });
+ expect(onChange).not.toHaveBeenCalled();
+ expect(screen.getByText('reserved')).toBeTruthy();
+ });
+
+ it('refuses keys this keyboard types a character with, and says so', () => {
+ const onChange = show({});
+ fireEvent.click(screen.getByTestId('quick-compose-keys'));
+ const recorder = screen.getByTestId('quick-compose-recording');
+ fireEvent.keyDown(recorder, { key: '€', code: 'KeyE', ctrlKey: true, altKey: true });
+ expect(onChange).not.toHaveBeenCalled();
+ expect(screen.getByText('types_character')).toBeTruthy();
+ // Still recording: the next try can be better.
+ fireEvent.keyDown(recorder, { key: 'Enter', code: 'Enter', ctrlKey: true, altKey: true });
+ expect(onChange).toHaveBeenCalledWith('CommandOrControl+Alt+Enter');
+ });
+
+ it('refuses keys the system or every app uses, and says so', () => {
+ const onChange = show({});
+ fireEvent.click(screen.getByTestId('quick-compose-keys'));
+ fireEvent.keyDown(screen.getByTestId('quick-compose-recording'), { key: 'V', code: 'KeyV', ctrlKey: true, shiftKey: true });
+ expect(onChange).not.toHaveBeenCalled();
+ expect(screen.getByText('reserved')).toBeTruthy();
+ });
+
it('Esc leaves things as they were', () => {
const onChange = show({});
fireEvent.click(screen.getByTestId('quick-compose-keys'));
diff --git a/components/settings/desktop-quick-compose-setting.tsx b/components/settings/desktop-quick-compose-setting.tsx
index b6766dedb..733463094 100644
--- a/components/settings/desktop-quick-compose-setting.tsx
+++ b/components/settings/desktop-quick-compose-setting.tsx
@@ -1,12 +1,26 @@
"use client";
-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
-import { acceleratorFromKeyEvent, describeAccelerator, QUICK_COMPOSE_DEFAULT } from "@/lib/desktop/shortcut-keys";
+import { describeAccelerator, QUICK_COMPOSE_DEFAULT, readShortcutPress } from "@/lib/desktop/shortcut-keys";
import type { DesktopQuickComposeStatus } from "@/lib/desktop/types";
import { SettingItem, ToggleSwitch } from "./settings-section";
+interface KeyboardMap {
+ get: (code: string) => string | undefined;
+}
+
+/** What each key gives alone on this keyboard. Not every browser has it, so it can be null. */
+async function readKeyboardMap(): Promise {
+ try {
+ const keyboard = (navigator as unknown as { keyboard?: { getLayoutMap?: () => Promise } }).keyboard;
+ return (await keyboard?.getLayoutMap?.()) ?? null;
+ } catch {
+ return null;
+ }
+}
+
/**
* The shortcut for a small composer window, on the desktop settings. What holds it is the
* shell's business: this shows what it says, including that another app has the keys.
@@ -18,10 +32,30 @@ export function DesktopQuickComposeSetting({ status, platform, onChange }: {
}) {
const t = useTranslations("desktopQuickCompose");
const [recording, setRecording] = useState(false);
+ const [refused, setRefused] = useState<"types-character" | "reserved" | null>(null);
+ const keyboardMap = useRef(null);
const { shortcut, active, locked } = status;
const keys = shortcut ? describeAccelerator(shortcut, platform) : "";
- const description = recording ? t("recording_hint")
+ useEffect(() => {
+ if (!recording) return;
+ let stale = false;
+ void readKeyboardMap().then((map) => {
+ if (!stale) keyboardMap.current = map;
+ });
+ return () => {
+ stale = true;
+ };
+ }, [recording]);
+
+ const stopRecording = () => {
+ setRecording(false);
+ setRefused(null);
+ };
+
+ const description = refused === "types-character" ? t("types_character")
+ : refused === "reserved" ? t("reserved")
+ : recording ? t("recording_hint")
: !shortcut ? t("description_off")
: active ? t("description", { keys })
: t("taken", { keys });
@@ -36,18 +70,21 @@ export function DesktopQuickComposeSetting({ status, platform, onChange }: {
autoFocus
className="text-xs h-7"
data-testid="quick-compose-recording"
- onBlur={() => setRecording(false)}
+ onBlur={stopRecording}
onKeyDown={(event) => {
event.preventDefault();
event.stopPropagation();
if (event.key === "Escape") {
- setRecording(false);
+ stopRecording();
return;
}
- const next = acceleratorFromKeyEvent(event, platform);
- if (!next) return;
- setRecording(false);
- onChange(next);
+ const result = readShortcutPress(event, platform, keyboardMap.current?.get(event.code));
+ if ("accelerator" in result) {
+ stopRecording();
+ onChange(result.accelerator);
+ } else if (result.refused !== "incomplete") {
+ setRefused(result.refused);
+ }
}}
>
{t("recording")}
diff --git a/lib/desktop/__tests__/shortcut-keys.test.ts b/lib/desktop/__tests__/shortcut-keys.test.ts
index f2f345138..423225010 100644
--- a/lib/desktop/__tests__/shortcut-keys.test.ts
+++ b/lib/desktop/__tests__/shortcut-keys.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { acceleratorFromKeyEvent, describeAccelerator, type ShortcutKeyEvent } from '../shortcut-keys';
+import { describeAccelerator, readShortcutPress, shortcutProblem, type ShortcutKeyEvent } from '../shortcut-keys';
const press = (key: string, code: string, modifiers: Partial = {}): ShortcutKeyEvent => ({
key,
@@ -11,9 +11,11 @@ const press = (key: string, code: string, modifiers: Partial =
...modifiers,
});
+const CTRL_ALT = { ctrlKey: true, altKey: true };
+
describe('describeAccelerator', () => {
it('names the keys as each OS does', () => {
- expect(describeAccelerator('CommandOrControl+Alt+N', 'win32')).toBe('Ctrl+Alt+N');
+ expect(describeAccelerator('CommandOrControl+Alt+Enter', 'win32')).toBe('Ctrl+Alt+Enter');
expect(describeAccelerator('CommandOrControl+Alt+N', 'linux')).toBe('Ctrl+Alt+N');
expect(describeAccelerator('CommandOrControl+Alt+N', 'darwin')).toBe('⌥⌘N');
expect(describeAccelerator('Super+Shift+F9', 'win32')).toBe('Win+Shift+F9');
@@ -25,38 +27,85 @@ describe('describeAccelerator', () => {
});
});
-describe('acceleratorFromKeyEvent', () => {
+describe('readShortcutPress', () => {
it('turns a press into what the shell takes', () => {
- expect(acceleratorFromKeyEvent(press('n', 'KeyN', { ctrlKey: true, altKey: true }), 'win32')).toBe('CommandOrControl+Alt+N');
- expect(acceleratorFromKeyEvent(press('m', 'KeyM', { metaKey: true, altKey: true }), 'darwin')).toBe('CommandOrControl+Alt+M');
- expect(acceleratorFromKeyEvent(press('7', 'Digit7', { ctrlKey: true, shiftKey: true }), 'linux')).toBe('CommandOrControl+Shift+7');
+ expect(readShortcutPress(press('n', 'KeyN', CTRL_ALT), 'win32')).toEqual({ accelerator: 'CommandOrControl+Alt+N' });
+ expect(readShortcutPress(press('j', 'KeyJ', { metaKey: true, altKey: true }), 'darwin')).toEqual({ accelerator: 'CommandOrControl+Alt+J' });
+ // Cmd+Opt+M minimises every window on a Mac. Elsewhere those keys are free.
+ expect(readShortcutPress(press('m', 'KeyM', { metaKey: true, altKey: true }), 'darwin')).toEqual({ refused: 'reserved' });
+ expect(readShortcutPress(press('m', 'KeyM', CTRL_ALT), 'win32')).toEqual({ accelerator: 'CommandOrControl+Alt+M' });
+ expect(readShortcutPress(press('7', 'Digit7', { ctrlKey: true, shiftKey: true }), 'linux')).toEqual({ accelerator: 'CommandOrControl+Shift+7' });
+ expect(readShortcutPress(press('Enter', 'Enter', CTRL_ALT), 'win32')).toEqual({ accelerator: 'CommandOrControl+Alt+Enter' });
});
it('uses the letter on the key, whatever the layout puts where', () => {
- expect(acceleratorFromKeyEvent(press('z', 'KeyY', { ctrlKey: true, altKey: true }), 'win32')).toBe('CommandOrControl+Alt+Z');
+ expect(readShortcutPress(press('z', 'KeyY', CTRL_ALT), 'win32')).toEqual({ accelerator: 'CommandOrControl+Alt+Z' });
});
- it('falls back on the key itself when a modifier changed the character', () => {
- expect(acceleratorFromKeyEvent(press('˜', 'KeyN', { metaKey: true, altKey: true }), 'darwin')).toBe('CommandOrControl+Alt+N');
+ it('falls back on the key itself when Cmd+Opt changed the character', () => {
+ expect(readShortcutPress(press('˜', 'KeyN', { metaKey: true, altKey: true }), 'darwin')).toEqual({ accelerator: 'CommandOrControl+Alt+N' });
});
- it('keeps Ctrl on a Mac apart from Cmd, and the Windows key apart from Ctrl', () => {
- expect(acceleratorFromKeyEvent(press('n', 'KeyN', { ctrlKey: true, altKey: true }), 'darwin')).toBe('Control+Alt+N');
- expect(acceleratorFromKeyEvent(press('n', 'KeyN', { metaKey: true, altKey: true }), 'win32')).toBe('Super+Alt+N');
+ it('keeps Ctrl on a Mac apart from Cmd', () => {
+ expect(readShortcutPress(press('n', 'KeyN', { metaKey: true, ctrlKey: true }), 'darwin')).toEqual({ accelerator: 'CommandOrControl+Control+N' });
});
- it('needs two modifiers, so no key is taken away from every other app', () => {
- expect(acceleratorFromKeyEvent(press('c', 'KeyC', { ctrlKey: true }), 'win32')).toBeNull();
- expect(acceleratorFromKeyEvent(press('c', 'KeyC'), 'win32')).toBeNull();
+ for (const [name, event, plainKey] of [
+ ['AltGr+N on a Czech keyboard, which types }', press('}', 'KeyN', CTRL_ALT), 'n'],
+ ['AltGr+E on a German keyboard, which types €', press('€', 'KeyE', CTRL_ALT), 'e'],
+ ['AltGr+N on a Polish keyboard, which types ń', press('ń', 'KeyN', CTRL_ALT), 'n'],
+ ['AltGr+Shift+N on a Polish keyboard, which types Ń', press('Ń', 'KeyN', { ...CTRL_ALT, shiftKey: true }), 'n'],
+ ['AltGr+2 on a German keyboard, which types ²', press('²', 'Digit2', CTRL_ALT), '2'],
+ ['a dead key', press('Dead', 'KeyE', CTRL_ALT), 'e'],
+ ['the same with no keyboard map to go by', press('}', 'KeyN', CTRL_ALT), undefined],
+ ['Option+Shift on a Mac, which types', press('Ñ', 'KeyN', { altKey: true, shiftKey: true }), 'n'],
+ ] as const) {
+ it(`refuses ${name}`, () => {
+ expect(readShortcutPress(event, name.includes('Mac') ? 'darwin' : 'win32', plainKey)).toEqual({ refused: 'types-character' });
+ });
+ }
+
+ it('takes Ctrl+Alt on a key that only gives its own letter there, as on a Russian keyboard', () => {
+ expect(readShortcutPress(press('т', 'KeyN', CTRL_ALT), 'win32', 'т')).toEqual({ accelerator: 'CommandOrControl+Alt+N' });
});
- it('takes one modifier on a function key, and none is still not enough', () => {
- expect(acceleratorFromKeyEvent(press('F9', 'F9', { ctrlKey: true }), 'win32')).toBe('CommandOrControl+F9');
- expect(acceleratorFromKeyEvent(press('F9', 'F9'), 'win32')).toBeNull();
+ it('a function key and Enter type nothing', () => {
+ expect(readShortcutPress(press('F9', 'F9', CTRL_ALT), 'win32')).toEqual({ accelerator: 'CommandOrControl+Alt+F9' });
+ expect(readShortcutPress(press('Enter', 'Enter', CTRL_ALT), 'win32')).toEqual({ accelerator: 'CommandOrControl+Alt+Enter' });
});
- it('waits while only modifiers are down', () => {
- expect(acceleratorFromKeyEvent(press('Control', 'ControlLeft', { ctrlKey: true }), 'win32')).toBeNull();
- expect(acceleratorFromKeyEvent(press('Alt', 'AltLeft', { ctrlKey: true, altKey: true }), 'win32')).toBeNull();
+ for (const [name, event] of [
+ ['Ctrl+C', press('c', 'KeyC', { ctrlKey: true })],
+ ['Ctrl+V', press('v', 'KeyV', { ctrlKey: true })],
+ ['a letter alone', press('c', 'KeyC')],
+ ['Shift+A', press('A', 'KeyA', { shiftKey: true })],
+ ['Alt+Shift+A', press('A', 'KeyA', { altKey: true, shiftKey: true })],
+ ['Alt+F4', press('F4', 'F4', { altKey: true })],
+ ['Ctrl+F9', press('F9', 'F9', { ctrlKey: true })],
+ ['a function key alone', press('F9', 'F9')],
+ ['Ctrl down on its own', press('Control', 'ControlLeft', { ctrlKey: true })],
+ ['Ctrl and Alt down on their own', press('Alt', 'AltLeft', CTRL_ALT)],
+ ] as const) {
+ it(`waits on ${name}: too few modifiers`, () => {
+ expect(readShortcutPress(event, 'win32')).toEqual({ refused: 'incomplete' });
+ });
+ }
+
+ for (const [name, event, platform] of [
+ ['Ctrl+Shift+V, paste in every terminal', press('V', 'KeyV', { ctrlKey: true, shiftKey: true }), 'win32'],
+ ['the Windows key with Ctrl+Shift+S', press('S', 'KeyS', { metaKey: true, shiftKey: true, ctrlKey: true }), 'win32'],
+ ['Cmd+Shift+4, a Mac screenshot', press('4', 'Digit4', { metaKey: true, shiftKey: true }), 'darwin'],
+ ['Cmd+Ctrl+Q, a Mac lock', press('q', 'KeyQ', { metaKey: true, ctrlKey: true }), 'darwin'],
+ ] as const) {
+ it(`refuses ${name}`, () => {
+ expect(readShortcutPress(event, platform)).toEqual({ refused: 'reserved' });
+ });
+ }
+});
+
+describe('shortcutProblem', () => {
+ it('counts Ctrl and CommandOrControl as one key off a Mac, and two on one', () => {
+ expect(shortcutProblem('CommandOrControl+Control+N', 'win32')).toBe('incomplete');
+ expect(shortcutProblem('CommandOrControl+Control+N', 'darwin')).toBeNull();
});
});
diff --git a/lib/desktop/shortcut-keys.ts b/lib/desktop/shortcut-keys.ts
index 66dc5bd3e..323fa048d 100644
--- a/lib/desktop/shortcut-keys.ts
+++ b/lib/desktop/shortcut-keys.ts
@@ -1,8 +1,13 @@
/**
* Global shortcuts as the shell takes them (Electron accelerators) and as people read them.
+ * `shortcutProblem` is the shell's own rule (`isAccelerator` in src/main/features/os-rules.ts),
+ * so the settings page can say why before the shell silently refuses.
*/
-/** Ctrl+Alt+N, Cmd+Opt+N. The shell's default too: src/main/features/os-rules.ts. */
+/**
+ * What turning quick compose on gives when the shell doesn't say. On Windows the shell
+ * answers Ctrl+Alt+Enter: Ctrl+Alt+N is AltGr+N, which types a character on many layouts.
+ */
export const QUICK_COMPOSE_DEFAULT = 'CommandOrControl+Alt+N';
const MAC_SYMBOLS: Record = {
@@ -57,28 +62,83 @@ export interface ShortcutKeyEvent {
metaKey: boolean;
}
+/**
+ * What a system or every app already uses, past the rule of two modifiers. Same list as the
+ * shell's. `cmd` is Cmd on a Mac and Ctrl elsewhere.
+ */
+const RESERVED = new Set(['cmd+shift+A', 'cmd+shift+C', 'cmd+shift+V', 'cmd+shift+X', 'cmd+shift+Z']);
+const RESERVED_ON_MAC = new Set([
+ 'cmd+shift+3', 'cmd+shift+4', 'cmd+shift+5', 'cmd+shift+Q',
+ 'cmd+ctrl+Q', 'cmd+ctrl+F',
+ 'alt+cmd+D', 'alt+cmd+H', 'alt+cmd+M',
+]);
+
+export type ShortcutPress =
+ | { accelerator: string }
+ /** Still pressing, or too few modifiers. */
+ | { refused: 'incomplete' }
+ /** This keyboard types a character with these keys, which would stop working in every app. */
+ | { refused: 'types-character' }
+ /** The system or every app uses these. */
+ | { refused: 'reserved' };
+
/** The key's own letter where the layout gives one: Alt on a Mac turns N into a dead key. */
function keyName(event: ShortcutKeyEvent): string | null {
if (/^[a-zA-Z0-9]$/.test(event.key)) return event.key.toUpperCase();
- const code = /^(?:Key([A-Z])|Digit(\d)|(F\d{1,2}))$/.exec(event.code);
- return code ? (code[1] ?? code[2] ?? code[3]) : null;
+ const code = /^(?:Key([A-Z])|Digit(\d)|(F\d{1,2})|(Enter))$/.exec(event.code);
+ return code ? (code[1] ?? code[2] ?? code[3] ?? code[4]) : null;
}
/**
- * A key press as a shortcut, or null while it can't be one. Two modifiers, since a global
- * Ctrl+C would take that key away from every other app. A function key is rare enough for one.
+ * AltGr is Ctrl+Alt to Windows, so a press with both reports the character it types: `}` for
+ * AltGr+N on a Czech keyboard. A letter of the layout's own is no such thing: Ctrl+Alt+N on a
+ * Russian keyboard reports т, which is that key anyway. `plainKey` is what the key gives
+ * alone, from the keyboard map; without one, anything but a plain letter or digit counts.
*/
-export function acceleratorFromKeyEvent(event: ShortcutKeyEvent, platform: string | undefined): string | null {
- const key = keyName(event);
- if (!key) return null;
+function typesCharacter(event: ShortcutKeyEvent, platform: string | undefined, plainKey: string | undefined): boolean {
const mac = platform === 'darwin';
- const modifiers = [
+ const altGrShaped = mac ? event.altKey && !event.metaKey : event.ctrlKey && event.altKey;
+ if (!altGrShaped) return false;
+ if (event.key === 'Dead') return true;
+ if ([...event.key].length !== 1) return false;
+ if (/^[a-zA-Z0-9]$/.test(event.key)) return false;
+ return !plainKey || event.key.toLowerCase() !== plainKey.toLowerCase();
+}
+
+/** The shell's rule, for an accelerator in this file's own form. */
+export function shortcutProblem(accelerator: string, platform: string | undefined): 'incomplete' | 'reserved' | null {
+ const parts = accelerator.split('+');
+ const key = parts.pop() ?? '';
+ const mac = platform === 'darwin';
+ const modifiers = new Set(parts.map((part) => {
+ const name = part.toLowerCase();
+ if (name === 'commandorcontrol') return 'cmd';
+ if (name === 'control') return mac ? 'ctrl' : 'cmd';
+ return name;
+ }));
+ if (modifiers.has('super')) return 'reserved';
+ if (modifiers.size < 2 || !(modifiers.has('cmd') || modifiers.has('ctrl'))) return 'incomplete';
+ const named = `${[...modifiers].sort().join('+')}+${key}`;
+ return RESERVED.has(named) || (mac && RESERVED_ON_MAC.has(named)) ? 'reserved' : null;
+}
+
+/**
+ * A key press as a shortcut, or why not. Two modifiers with Ctrl or Cmd among them, since a
+ * global Ctrl+C or Alt+F4 would take that key away from every other app.
+ */
+export function readShortcutPress(event: ShortcutKeyEvent, platform: string | undefined, plainKey?: string): ShortcutPress {
+ if (typesCharacter(event, platform, plainKey)) return { refused: 'types-character' };
+ const key = keyName(event);
+ if (!key) return { refused: 'incomplete' };
+ const mac = platform === 'darwin';
+ const accelerator = [
...((mac ? event.metaKey : event.ctrlKey) ? ['CommandOrControl'] : []),
...(mac && event.ctrlKey ? ['Control'] : []),
...(!mac && event.metaKey ? ['Super'] : []),
...(event.altKey ? ['Alt'] : []),
...(event.shiftKey ? ['Shift'] : []),
- ];
- if (modifiers.length < (/^F\d/.test(key) ? 1 : 2)) return null;
- return [...modifiers, key].join('+');
+ key,
+ ].join('+');
+ const problem = shortcutProblem(accelerator, platform);
+ return problem ? { refused: problem } : { accelerator };
}
diff --git a/lib/popout/__tests__/return-to-main.test.ts b/lib/popout/__tests__/return-to-main.test.ts
index e3d99241e..848679b20 100644
--- a/lib/popout/__tests__/return-to-main.test.ts
+++ b/lib/popout/__tests__/return-to-main.test.ts
@@ -2,12 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel';
import { registerComposeFlush } from '@/lib/compose-flush-registry';
import { openPopoutChannel, type PopoutMessage } from '../channel';
-import { answerWhenComposerOpens, dropReturnedComposer, offerReturnedComposer, onReturnedComposer, onReturnedComposerElsewhere, returnComposerToMain, takeReturnedComposer } from '../return-to-main';
+import { answerWhenComposerOpens, commitReturnedComposer, dropReturnedComposer, offerReturnedComposer, onReturnedComposer, onReturnedComposerElsewhere, returnComposerToMain, takeReturnedComposer } from '../return-to-main';
// The way a composer goes back from its own window. The window lets go of its text only when
-// the main window says the composer is there, so every other outcome has to say so too.
+// the main window says the composer is there, and at no moment may both windows hold it.
const PAYLOAD = { mode: 'compose' as const, initialData: { subject: 'Hello' } as never };
+const alive = () => true;
beforeEach(() => {
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
@@ -21,45 +22,83 @@ afterEach(() => {
});
/** Plays the main window's end of the channel. */
-function mainWindow(onReturn: (message: Extract, post: (m: PopoutMessage) => void) => void) {
+function mainWindow(onMessage: (message: PopoutMessage, post: (m: PopoutMessage) => void) => void) {
const heard: PopoutMessage[] = [];
const channel = openPopoutChannel((message) => {
heard.push(message);
- if (message.type === 'composer-return') onReturn(message, (m) => channel?.post(m));
+ onMessage(message, (m) => channel?.post(m));
});
return heard;
}
describe('from the pop-out', () => {
- it('sends the draft and its login, and goes by what the main window answers', async () => {
+ it('sends the draft and its login, commits when the main window is ready, and goes by its answer', async () => {
const seen: unknown[] = [];
- mainWindow((message, post) => {
- seen.push([message.accountId, message.payload]);
- post({ type: 'composer-return-answer', returnId: message.returnId, status: 'shown' });
+ const heard = mainWindow((message, post) => {
+ if (message.type === 'composer-return') {
+ seen.push([message.accountId, message.payload]);
+ post({ type: 'composer-return-ready', returnId: message.returnId });
+ }
+ if (message.type === 'composer-return-commit') post({ type: 'composer-return-answer', returnId: message.returnId, status: 'shown' });
});
- await expect(returnComposerToMain(PAYLOAD, 'alice')).resolves.toBe('shown');
+ await expect(returnComposerToMain(PAYLOAD, 'alice', alive)).resolves.toBe('shown');
expect(seen).toEqual([['alice', PAYLOAD]]);
+ expect(heard.map((message) => message.type)).toEqual(['composer-return', 'composer-return-commit']);
});
it('hears that the main window is busy', async () => {
- mainWindow((message, post) => post({ type: 'composer-return-answer', returnId: message.returnId, status: 'busy' }));
- await expect(returnComposerToMain(PAYLOAD, 'alice')).resolves.toBe('busy');
+ mainWindow((message, post) => {
+ if (message.type === 'composer-return') post({ type: 'composer-return-answer', returnId: message.returnId, status: 'busy' });
+ });
+ await expect(returnComposerToMain(PAYLOAD, 'alice', alive)).resolves.toBe('busy');
});
- it('ignores an answer about another composer', async () => {
- mainWindow((_message, post) => post({ type: 'composer-return-answer', returnId: 'someone-else', status: 'shown' }));
- await expect(returnComposerToMain(PAYLOAD, 'alice', 60)).resolves.toBe('no-answer');
+ it('ignores what is said about another composer', async () => {
+ const heard = mainWindow((message, post) => {
+ if (message.type !== 'composer-return') return;
+ post({ type: 'composer-return-answer', returnId: 'someone-else', status: 'shown' });
+ post({ type: 'composer-return-ready', returnId: 'someone-else' });
+ });
+ await expect(returnComposerToMain(PAYLOAD, 'alice', alive, 60)).resolves.toBe('no-answer');
+ expect(heard.map((message) => message.type)).not.toContain('composer-return-commit');
});
it('with no answer it keeps the text and calls the move off, so no second copy turns up', async () => {
const heard = mainWindow(() => {});
- await expect(returnComposerToMain(PAYLOAD, 'alice', 30)).resolves.toBe('no-answer');
+ await expect(returnComposerToMain(PAYLOAD, 'alice', alive, 30)).resolves.toBe('no-answer');
await vi.waitFor(() => expect(heard.map((message) => message.type)).toEqual(['composer-return', 'composer-return-cancel']));
});
+
+ it('once committed it doesn\'t give up on a slow main window, which has the text by then', async () => {
+ let answer: (() => void) | null = null;
+ mainWindow((message, post) => {
+ if (message.type === 'composer-return') post({ type: 'composer-return-ready', returnId: message.returnId });
+ if (message.type === 'composer-return-commit') answer = () => post({ type: 'composer-return-answer', returnId: message.returnId, status: 'shown' });
+ });
+ const result = returnComposerToMain(PAYLOAD, 'alice', alive, 30);
+ let settled = false;
+ void result.then(() => { settled = true; });
+ await new Promise((resolve) => setTimeout(resolve, 120));
+ expect(settled).toBe(false);
+ expect(answer).not.toBeNull();
+ answer!();
+ await expect(result).resolves.toBe('shown');
+ });
+
+ it('once committed it keeps the text if the main window goes away', async () => {
+ mainWindow((message, post) => {
+ if (message.type === 'composer-return') post({ type: 'composer-return-ready', returnId: message.returnId });
+ });
+ let there = true;
+ const result = returnComposerToMain(PAYLOAD, 'alice', () => there, 30);
+ await new Promise((resolve) => setTimeout(resolve, 60));
+ there = false;
+ await expect(result).resolves.toBe('no-answer');
+ });
});
describe('in the main window', () => {
- const returned = (id: string, answer = vi.fn()) => ({ returnId: id, payload: PAYLOAD, answer });
+ const returned = (id: string, answer = vi.fn(), sendReady = vi.fn()) => ({ returnId: id, payload: PAYLOAD, answer, sendReady });
it('tells the mail page, and only the mail page, when it is there', () => {
const mail = vi.fn();
@@ -98,22 +137,39 @@ describe('in the main window', () => {
expect(takeReturnedComposer()).toBeNull();
});
+ it('claiming asks the pop-out, and only its go ahead lets the page open anything', async () => {
+ const sendReady = vi.fn();
+ offerReturnedComposer(returned('a', vi.fn(), sendReady));
+ const taken = takeReturnedComposer()!;
+ expect(sendReady).not.toHaveBeenCalled();
+ const claim = taken.claim();
+ expect(sendReady).toHaveBeenCalledOnce();
+ commitReturnedComposer('a');
+ await expect(claim).resolves.toBe(true);
+ });
+
+ it('a pop-out that gave up as the page took it wins: nothing opens here', async () => {
+ offerReturnedComposer(returned('a'));
+ const taken = takeReturnedComposer()!;
+ const claim = taken.claim();
+ dropReturnedComposer('a');
+ await expect(claim).resolves.toBe(false);
+ });
+
+ it('silence after asking means no', async () => {
+ vi.useFakeTimers();
+ offerReturnedComposer(returned('a'));
+ const claim = takeReturnedComposer()!.claim();
+ await vi.advanceTimersByTimeAsync(6000);
+ await expect(claim).resolves.toBe(false);
+ });
+
it('says shown only when a composer has really opened', () => {
const answer = vi.fn();
- answerWhenComposerOpens(returned('a', answer));
+ answerWhenComposerOpens({ returnId: 'a', payload: PAYLOAD, answer, claim: async () => true });
expect(answer).not.toHaveBeenCalled();
const stop = registerComposeFlush(async () => {});
expect(answer).toHaveBeenCalledWith('shown');
stop();
});
-
- it('says nothing if no composer opens, so the pop-out stays with the text', () => {
- vi.useFakeTimers();
- const answer = vi.fn();
- answerWhenComposerOpens(returned('a', answer), 100);
- vi.advanceTimersByTime(200);
- const stop = registerComposeFlush(async () => {});
- expect(answer).not.toHaveBeenCalled();
- stop();
- });
});
diff --git a/lib/popout/channel.ts b/lib/popout/channel.ts
index a466cae2f..f2d64a312 100644
--- a/lib/popout/channel.ts
+++ b/lib/popout/channel.ts
@@ -97,6 +97,8 @@ export type PopoutMessage =
| { type: 'rescue-query'; rescueKey: string }
| { type: 'rescue-answer'; rescueKey: string; draftId: string | null }
| { type: 'composer-return'; returnId: string; accountId?: string; payload: ComposePopoutPayload }
+ | { type: 'composer-return-ready'; returnId: string }
+ | { type: 'composer-return-commit'; returnId: string }
| { type: 'composer-return-answer'; returnId: string; status: 'shown' | 'busy' }
| { type: 'composer-return-cancel'; returnId: string }
| { type: 'main-ping' }
diff --git a/lib/popout/compose-handoff.ts b/lib/popout/compose-handoff.ts
index aad0dee63..ec0fdf85b 100644
--- a/lib/popout/compose-handoff.ts
+++ b/lib/popout/compose-handoff.ts
@@ -115,6 +115,18 @@ export function rememberComposePayload(handoffId: string, payload: ComposePopout
}
}
+/**
+ * A newer version is on the server. What's remembered was written as the page last unloaded,
+ * and a page that crashes writes nothing, so it would bring back older text than the draft.
+ */
+export function forgetComposePayload(handoffId: string): void {
+ try {
+ sessionStorage.removeItem(STORAGE_PREFIX + handoffId);
+ } catch {
+ // Unavailable: there's nothing remembered either.
+ }
+}
+
/** The draft this window holds from before a reload. */
export function rememberedComposePayload(handoffId: string): ComposePopoutPayload | null {
try {
diff --git a/lib/popout/return-to-main.ts b/lib/popout/return-to-main.ts
index 710b5314a..b99f6d52a 100644
--- a/lib/popout/return-to-main.ts
+++ b/lib/popout/return-to-main.ts
@@ -3,6 +3,11 @@
* compose-handoff.ts. The pop-out has saved by now and keeps its copy until the main
* window says the composer is on screen there. A main window already busy with a message
* says so and takes nothing, because a composer that is open holds someone's text.
+ *
+ * Only one window may end up with the text, so the move has two steps. The main window says
+ * it's ready before it opens anything. The pop-out commits only if it hasn't given up, and
+ * from then on it waits for as long as a main window is there: giving up after that would
+ * leave the same draft open twice.
*/
import { onComposerOpened } from '@/lib/compose-flush-registry';
@@ -11,25 +16,42 @@ import type { ComposePopoutPayload } from './compose-payload';
/** Longer than a route change to the mail page takes; then the pop-out keeps the draft. */
export const RETURN_WAIT_MS = 20_000;
-const OPEN_WAIT_MS = RETURN_WAIT_MS - 2000;
+/** How long a main window that said it's ready waits to hear go ahead. */
+const COMMIT_WAIT_MS = 5000;
+/** How often a pop-out that committed looks whether a main window is still there. */
+const ALIVE_CHECK_MS = 1000;
export type ReturnAnswer = 'shown' | 'busy';
export type ReturnResult = ReturnAnswer | 'no-answer';
/** From a pop-out: `shown` once the composer is on screen in the main window. */
-export function returnComposerToMain(payload: ComposePopoutPayload, accountId?: string | null, waitMs = RETURN_WAIT_MS): Promise {
+export function returnComposerToMain(
+ payload: ComposePopoutPayload,
+ accountId: string | null | undefined,
+ mainStillThere: () => boolean,
+ waitMs = RETURN_WAIT_MS,
+): Promise {
return new Promise((resolve) => {
const returnId = newPopoutId();
+ let aliveTimer: ReturnType | null = null;
const finish = (result: ReturnResult) => {
clearTimeout(timer);
+ if (aliveTimer) clearInterval(aliveTimer);
channel?.close();
resolve(result);
};
const channel = openPopoutChannel((message) => {
- if (message.type === 'composer-return-answer' && message.returnId === returnId) finish(message.status);
+ if (!('returnId' in message) || message.returnId !== returnId) return;
+ if (message.type === 'composer-return-answer') finish(message.status);
+ if (message.type === 'composer-return-ready') {
+ clearTimeout(timer);
+ channel?.post({ type: 'composer-return-commit', returnId });
+ aliveTimer = setInterval(() => {
+ if (!mainStillThere()) finish('no-answer');
+ }, ALIVE_CHECK_MS);
+ }
});
const timer = setTimeout(() => {
- // It might still turn up there, next to the copy that stays here.
channel?.post({ type: 'composer-return-cancel', returnId });
finish('no-answer');
}, waitMs);
@@ -41,22 +63,38 @@ export interface ReturnedComposer {
returnId: string;
payload: ComposePopoutPayload;
answer: (status: ReturnAnswer) => void;
+ /** Asks the pop-out to go ahead. False: it has given up, and keeps the text. */
+ claim: () => Promise;
}
let waiting: ReturnedComposer | null = null;
const mailListeners = new Set<() => void>();
const elsewhereListeners = new Set<() => void>();
+const commits = new Map void>();
/**
* In the main window. The mail page picks it up, or, with no mail page on screen, whoever
* can go there does and the page picks it up as it mounts.
*/
-export function offerReturnedComposer(returned: ReturnedComposer): void {
+export function offerReturnedComposer(incoming: Omit & { sendReady: () => void }): void {
+ const { sendReady, ...returned } = incoming;
if (waiting) {
returned.answer('busy');
return;
}
- waiting = returned;
+ waiting = {
+ ...returned,
+ claim: () => new Promise((resolve) => {
+ const settle = (go: boolean) => {
+ clearTimeout(timer);
+ commits.delete(returned.returnId);
+ resolve(go);
+ };
+ const timer = setTimeout(() => settle(false), COMMIT_WAIT_MS);
+ commits.set(returned.returnId, settle);
+ sendReady();
+ }),
+ };
for (const listener of [...(mailListeners.size > 0 ? mailListeners : elsewhereListeners)]) listener();
}
@@ -66,9 +104,15 @@ export function takeReturnedComposer(): ReturnedComposer | null {
return returned;
}
+/** The pop-out goes ahead. */
+export function commitReturnedComposer(returnId: string): void {
+ commits.get(returnId)?.(true);
+}
+
/** The pop-out gave up waiting and keeps the draft. */
export function dropReturnedComposer(returnId: string): void {
if (waiting?.returnId === returnId) waiting = null;
+ commits.get(returnId)?.(false);
}
/** The mail page, for as long as it's there. */
@@ -87,14 +131,13 @@ export function onReturnedComposerElsewhere(listener: () => void): () => void {
};
}
-/** Lets the pop-out go once a composer has really opened here. Call before opening it. */
-export function answerWhenComposerOpens(returned: ReturnedComposer, waitMs = OPEN_WAIT_MS): void {
- const finish = () => {
- clearTimeout(timer);
+/**
+ * Lets the pop-out go once a composer has really opened here. Call before opening it. There's
+ * no time limit: the pop-out holds the text until then, and goes on holding it if this window goes.
+ */
+export function answerWhenComposerOpens(returned: ReturnedComposer): void {
+ const stop = onComposerOpened(() => {
stop();
returned.answer('shown');
- };
- const stop = onComposerOpened(finish);
- // No answer means the pop-out stays with the text: nothing is lost by saying nothing.
- const timer = setTimeout(stop, waitMs);
+ });
}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 0286c1826..14c6fe92c 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -6966,10 +6966,12 @@
"taken": "يستخدم تطبيق آخر {keys}، لذلك لا يعمل هنا. اختر مفاتيح أخرى أو أغلق ذلك التطبيق.",
"change": "تغيير المفاتيح: {keys}",
"recording": "اضغط المفاتيح…",
- "recording_hint": "اضغط مع الاستمرار على مفتاحين من Ctrl وAlt وShift ومفتاح النظام، ثم اضغط حرفًا أو رقمًا. يلغي Esc العملية.",
+ "recording_hint": "اضغط مع الاستمرار على Ctrl (أو Cmd في Mac) مع Alt أو Shift، ثم اضغط حرفًا أو رقمًا أو Enter أو مفتاح وظيفة. يلغي Esc العملية.",
"back_to_main": "نقل إلى النافذة الرئيسية",
"back_busy": "توجد رسالة مفتوحة بالفعل في النافذة الرئيسية. أغلقها ثم حاول مرة أخرى.",
- "back_no_answer": "لم تستجب النافذة الرئيسية. تبقى رسالتك هنا."
+ "back_no_answer": "لم تستجب النافذة الرئيسية. تبقى رسالتك هنا.",
+ "types_character": "تكتب هذه المفاتيح حرفًا على لوحة مفاتيحك، وسيتوقف عن العمل في كل التطبيقات. جرّب مفاتيح أخرى.",
+ "reserved": "يستخدم النظام أو معظم التطبيقات هذه المفاتيح. جرّب مفاتيح أخرى."
},
"desktopReminders": {
"generic_title": "تذكير",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index a2e03b233..9417b1114 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -6966,10 +6966,12 @@
"taken": "Una altra aplicació fa servir {keys}, així que aquí no fa res. Tria altres tecles o tanca aquella aplicació.",
"change": "Canvia les tecles: {keys}",
"recording": "Prem les tecles…",
- "recording_hint": "Mantén premudes dues de Ctrl, Alt, Maj o la tecla del sistema, i prem una lletra o un número. Esc cancel·la.",
+ "recording_hint": "Mantén premuda Ctrl (Cmd al Mac) amb Alt o Maj, i prem una lletra, un número, Retorn o una tecla de funció. Esc cancel·la.",
"back_to_main": "Mou a la finestra principal",
"back_busy": "La finestra principal ja té un missatge obert. Tanca'l i torna-ho a provar.",
- "back_no_answer": "La finestra principal no ha respost. El teu missatge es queda aquí."
+ "back_no_answer": "La finestra principal no ha respost. El teu missatge es queda aquí.",
+ "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."
},
"desktopReminders": {
"generic_title": "Recordatori",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 62e597a7d..7879ee394 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -6966,10 +6966,12 @@
"taken": "{keys} používá jiná aplikace, takže tady nic neudělá. Vyberte jiné klávesy nebo tu aplikaci zavřete.",
"change": "Změnit klávesy: {keys}",
"recording": "Stiskněte klávesy…",
- "recording_hint": "Podržte dvě z kláves Ctrl, Alt, Shift nebo systémovou klávesu a stiskněte písmeno nebo číslici. Esc zruší.",
+ "recording_hint": "Podržte Ctrl (na Macu Cmd) spolu s Alt nebo Shift a stiskněte písmeno, číslici, Enter nebo funkční klávesu. Esc zruší.",
"back_to_main": "Přesunout do hlavního okna",
"back_busy": "V hlavním okně je už otevřená zpráva. Zavřete ji a zkuste to znovu.",
- "back_no_answer": "Hlavní okno neodpovědělo. Vaše zpráva zůstává tady."
+ "back_no_answer": "Hlavní okno neodpovědělo. Vaše zpráva zůstává tady.",
+ "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."
},
"desktopReminders": {
"generic_title": "Připomenutí",
diff --git a/locales/da/common.json b/locales/da/common.json
index 5fde34c0c..4c5828344 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -6966,10 +6966,12 @@
"taken": "En anden app bruger {keys}, så her sker der ikke noget. Vælg andre taster, eller luk den app.",
"change": "Skift tasterne: {keys}",
"recording": "Tryk på tasterne…",
- "recording_hint": "Hold to af Ctrl, Alt, Shift eller systemtasten nede, og tryk på et bogstav eller tal. Esc annullerer.",
+ "recording_hint": "Hold Ctrl (Cmd på en Mac) nede sammen med Alt eller Shift, og tryk på et bogstav, tal, Enter eller en funktionstast. Esc annullerer.",
"back_to_main": "Flyt til hovedvinduet",
"back_busy": "Hovedvinduet har allerede en besked åben. Luk den, og prøv igen.",
- "back_no_answer": "Hovedvinduet svarede ikke. Din besked bliver her."
+ "back_no_answer": "Hovedvinduet svarede ikke. Din besked bliver her.",
+ "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."
},
"desktopReminders": {
"generic_title": "Påmindelse",
diff --git a/locales/de/common.json b/locales/de/common.json
index 1e29b8892..c17438139 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -6966,10 +6966,12 @@
"taken": "Eine andere App verwendet {keys}, daher passiert hier nichts. Wählen Sie andere Tasten, oder schließen Sie diese App.",
"change": "Tasten ändern: {keys}",
"recording": "Tasten drücken…",
- "recording_hint": "Halten Sie zwei der Tasten Strg, Alt, Umschalt oder die Systemtaste, und drücken Sie einen Buchstaben oder eine Zahl. Esc bricht ab.",
+ "recording_hint": "Halten Sie Strg (auf dem Mac Cmd) mit Alt oder Umschalt, und drücken Sie einen Buchstaben, eine Zahl, die Eingabetaste oder eine Funktionstaste. Esc bricht ab.",
"back_to_main": "In das Hauptfenster verschieben",
"back_busy": "Im Hauptfenster ist bereits eine Nachricht geöffnet. Schließen Sie diese, und versuchen Sie es dann erneut.",
- "back_no_answer": "Das Hauptfenster hat nicht geantwortet. Ihre Nachricht bleibt hier."
+ "back_no_answer": "Das Hauptfenster hat nicht geantwortet. Ihre Nachricht bleibt hier.",
+ "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."
},
"desktopReminders": {
"generic_title": "Erinnerung",
diff --git a/locales/en/common.json b/locales/en/common.json
index 860e4d930..56b60bae7 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -6966,10 +6966,12 @@
"taken": "Another app is using {keys}, so it does nothing here. Pick other keys, or close that app.",
"change": "Change the keys: {keys}",
"recording": "Press the keys…",
- "recording_hint": "Hold two of Ctrl, Alt, Shift or the system key, and press a letter or number. Esc cancels.",
+ "recording_hint": "Hold Ctrl (Cmd on a Mac) with Alt or Shift, and press a letter, number, Enter or function key. Esc cancels.",
"back_to_main": "Move to the main window",
"back_busy": "The main window already has a message open. Close that one, then try again.",
- "back_no_answer": "The main window did not answer. Your message stays here."
+ "back_no_answer": "The main window did not answer. Your message stays here.",
+ "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."
},
"desktopReminders": {
"generic_title": "Reminder",
diff --git a/locales/es/common.json b/locales/es/common.json
index f9b9a5628..23f563828 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -6966,10 +6966,12 @@
"taken": "Otra aplicación está usando {keys}, así que aquí no hace nada. Elige otras teclas o cierra esa aplicación.",
"change": "Cambiar las teclas: {keys}",
"recording": "Pulsa las teclas…",
- "recording_hint": "Mantén pulsadas dos de Ctrl, Alt, Mayús o la tecla del sistema, y pulsa una letra o un número. Esc cancela.",
+ "recording_hint": "Mantén pulsada Ctrl (Cmd en Mac) con Alt o Mayús, y pulsa una letra, un número, Intro o una tecla de función. Esc cancela.",
"back_to_main": "Mover a la ventana principal",
"back_busy": "La ventana principal ya tiene un mensaje abierto. Ciérralo y vuelve a intentarlo.",
- "back_no_answer": "La ventana principal no respondió. Tu mensaje se queda aquí."
+ "back_no_answer": "La ventana principal no respondió. Tu mensaje se queda aquí.",
+ "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."
},
"desktopReminders": {
"generic_title": "Recordatorio",
diff --git a/locales/et/common.json b/locales/et/common.json
index 332513a58..18d6eda06 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -6966,10 +6966,12 @@
"taken": "{keys} on teise rakenduse kasutuses, seega siin ei juhtu midagi. Valige teised klahvid või sulgege see rakendus.",
"change": "Muuda klahve: {keys}",
"recording": "Vajutage klahve…",
- "recording_hint": "Hoidke all kahte klahvi Ctrl, Alt, Shift või süsteemiklahv ja vajutage tähte või numbrit. Esc tühistab.",
+ "recording_hint": "Hoidke all Ctrl (Macis Cmd) koos Alt või Shiftiga ja vajutage tähte, numbrit, Enterit või funktsiooniklahvi. Esc tühistab.",
"back_to_main": "Teisalda põhiaknasse",
"back_busy": "Põhiaknas on juba kiri avatud. Sulgege see ja proovige uuesti.",
- "back_no_answer": "Põhiaken ei vastanud. Teie kiri jääb siia."
+ "back_no_answer": "Põhiaken ei vastanud. Teie kiri jääb siia.",
+ "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."
},
"desktopReminders": {
"generic_title": "Meeldetuletus",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index bb300382d..1794d84f7 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -6966,10 +6966,12 @@
"taken": "برنامه دیگری از {keys} استفاده میکند، پس اینجا کاری انجام نمیدهد. کلیدهای دیگری انتخاب کنید یا آن برنامه را ببندید.",
"change": "تغییر کلیدها: {keys}",
"recording": "کلیدها را فشار دهید…",
- "recording_hint": "دو کلید از Ctrl، Alt، Shift یا کلید سیستم را نگه دارید و یک حرف یا عدد را فشار دهید. Esc لغو میکند.",
+ "recording_hint": "Ctrl (در Mac کلید Cmd) را همراه با Alt یا Shift نگه دارید و یک حرف، عدد، Enter یا کلید تابعی را فشار دهید. Esc لغو میکند.",
"back_to_main": "انتقال به پنجره اصلی",
"back_busy": "پنجره اصلی از قبل یک پیام باز دارد. آن را ببندید و دوباره امتحان کنید.",
- "back_no_answer": "پنجره اصلی پاسخ نداد. پیام شما اینجا میماند."
+ "back_no_answer": "پنجره اصلی پاسخ نداد. پیام شما اینجا میماند.",
+ "types_character": "این کلیدها روی صفحهکلید شما نویسهای مینویسند و آن نویسه دیگر در هیچ برنامهای کار نمیکند. کلیدهای دیگری را امتحان کنید.",
+ "reserved": "سیستم یا بیشتر برنامهها از این کلیدها استفاده میکنند. کلیدهای دیگری را امتحان کنید."
},
"desktopReminders": {
"generic_title": "یادآوری",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 0607b76f5..64b417655 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -6966,10 +6966,12 @@
"taken": "Une autre application utilise {keys}, ce raccourci n'a donc aucun effet ici. Choisissez d'autres touches ou fermez cette application.",
"change": "Changer les touches : {keys}",
"recording": "Appuyez sur les touches…",
- "recording_hint": "Maintenez deux touches parmi Ctrl, Alt, Maj ou la touche système, puis appuyez sur une lettre ou un chiffre. Échap annule.",
+ "recording_hint": "Maintenez Ctrl (Cmd sur Mac) avec Alt ou Maj, puis appuyez sur une lettre, un chiffre, Entrée ou une touche de fonction. Échap annule.",
"back_to_main": "Déplacer vers la fenêtre principale",
"back_busy": "La fenêtre principale a déjà un message ouvert. Fermez-le, puis réessayez.",
- "back_no_answer": "La fenêtre principale n'a pas répondu. Votre message reste ici."
+ "back_no_answer": "La fenêtre principale n'a pas répondu. Votre message reste ici.",
+ "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."
},
"desktopReminders": {
"generic_title": "Rappel",
diff --git a/locales/he/common.json b/locales/he/common.json
index 8d3b0f19e..d017d845a 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -6967,10 +6967,12 @@
"taken": "אפליקציה אחרת משתמשת ב-{keys}, ולכן כאן זה לא עושה כלום. בחרו מקשים אחרים או סגרו את האפליקציה הזו.",
"change": "שינוי המקשים: {keys}",
"recording": "לחצו על המקשים…",
- "recording_hint": "החזיקו שניים מהמקשים Ctrl, Alt, Shift או מקש המערכת, ולחצו על אות או מספר. Esc מבטל.",
+ "recording_hint": "החזיקו Ctrl (Cmd ב-Mac) יחד עם Alt או Shift, ולחצו על אות, מספר, Enter או מקש פונקציה. Esc מבטל.",
"back_to_main": "העברה לחלון הראשי",
"back_busy": "בחלון הראשי כבר פתוחה הודעה. סגרו אותה ונסו שוב.",
- "back_no_answer": "החלון הראשי לא ענה. ההודעה שלכם נשארת כאן."
+ "back_no_answer": "החלון הראשי לא ענה. ההודעה שלכם נשארת כאן.",
+ "types_character": "המקשים האלה מקלידים תו במקלדת שלכם, והוא יפסיק לעבוד בכל האפליקציות. נסו מקשים אחרים.",
+ "reserved": "המערכת או רוב האפליקציות משתמשות במקשים האלה. נסו מקשים אחרים."
},
"desktopReminders": {
"generic_title": "תזכורת",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index a6d0e473f..9bc925f15 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -6966,10 +6966,12 @@
"taken": "A(z) {keys} billentyűket egy másik alkalmazás használja, így itt nem történik semmi. Válasszon más billentyűket, vagy zárja be azt az alkalmazást.",
"change": "Billentyűk módosítása: {keys}",
"recording": "Nyomja meg a billentyűket…",
- "recording_hint": "Tartson lenyomva kettőt a Ctrl, Alt, Shift vagy a rendszerbillentyű közül, és nyomjon meg egy betűt vagy számot. Az Esc megszakítja.",
+ "recording_hint": "Tartsa lenyomva a Ctrl (Macen a Cmd) billentyűt az Alt vagy a Shift billentyűvel, és nyomjon meg egy betűt, számot, az Entert vagy egy funkcióbillentyűt. Az Esc megszakítja.",
"back_to_main": "Áthelyezés a főablakba",
"back_busy": "A főablakban már nyitva van egy üzenet. Zárja be, majd próbálja újra.",
- "back_no_answer": "A főablak nem válaszolt. Az üzenet itt marad."
+ "back_no_answer": "A főablak nem válaszolt. Az üzenet itt marad.",
+ "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."
},
"desktopReminders": {
"generic_title": "Emlékeztető",
diff --git a/locales/it/common.json b/locales/it/common.json
index 4915051fd..a5a7ee134 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -6966,10 +6966,12 @@
"taken": "Un'altra app sta usando {keys}, quindi qui non succede nulla. Scegli altri tasti o chiudi quell'app.",
"change": "Cambia i tasti: {keys}",
"recording": "Premi i tasti…",
- "recording_hint": "Tieni premuti due tra Ctrl, Alt, Maiusc o il tasto di sistema e premi una lettera o un numero. Esc annulla.",
+ "recording_hint": "Tieni premuto Ctrl (Cmd su Mac) con Alt o Maiusc e premi una lettera, un numero, Invio o un tasto funzione. Esc annulla.",
"back_to_main": "Sposta nella finestra principale",
"back_busy": "La finestra principale ha già un messaggio aperto. Chiudilo e riprova.",
- "back_no_answer": "La finestra principale non ha risposto. Il messaggio resta qui."
+ "back_no_answer": "La finestra principale non ha risposto. Il messaggio resta qui.",
+ "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."
},
"desktopReminders": {
"generic_title": "Promemoria",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 38753d6d5..75e59590a 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -6966,10 +6966,12 @@
"taken": "{keys} は別のアプリが使用中のため、ここでは動作しません。別のキーを選ぶか、そのアプリを閉じてください。",
"change": "キーを変更: {keys}",
"recording": "キーを押してください…",
- "recording_hint": "Ctrl、Alt、Shift、システムキーのうち 2 つを押したまま、文字か数字を押します。Esc でキャンセルします。",
+ "recording_hint": "Ctrl (Mac では Cmd) と Alt または Shift を押したまま、文字、数字、Enter、ファンクションキーのいずれかを押します。Esc でキャンセルします。",
"back_to_main": "メインウィンドウに移動",
"back_busy": "メインウィンドウで別のメッセージが開いています。それを閉じてからもう一度お試しください。",
- "back_no_answer": "メインウィンドウから応答がありません。メッセージはここに残ります。"
+ "back_no_answer": "メインウィンドウから応答がありません。メッセージはここに残ります。",
+ "types_character": "このキーはお使いのキーボードで文字を入力します。割り当てると、すべてのアプリでその文字を入力できなくなります。別のキーをお試しください。",
+ "reserved": "このキーはシステムまたは多くのアプリが使用しています。別のキーをお試しください。"
},
"desktopReminders": {
"generic_title": "リマインダー",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index a1c9cd1ba..4fb216774 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -6966,10 +6966,12 @@
"taken": "다른 앱이 {keys}를 사용 중이라 여기서는 작동하지 않습니다. 다른 키를 고르거나 그 앱을 닫으세요.",
"change": "키 변경: {keys}",
"recording": "키를 누르세요…",
- "recording_hint": "Ctrl, Alt, Shift, 시스템 키 중 두 개를 누른 채 문자나 숫자를 누르세요. Esc를 누르면 취소됩니다.",
+ "recording_hint": "Ctrl(Mac에서는 Cmd)과 Alt 또는 Shift를 누른 채 문자, 숫자, Enter 또는 기능 키를 누르세요. Esc를 누르면 취소됩니다.",
"back_to_main": "기본 창으로 이동",
"back_busy": "기본 창에 이미 열린 메시지가 있습니다. 그 메시지를 닫고 다시 시도하세요.",
- "back_no_answer": "기본 창이 응답하지 않았습니다. 메시지는 여기에 그대로 있습니다."
+ "back_no_answer": "기본 창이 응답하지 않았습니다. 메시지는 여기에 그대로 있습니다.",
+ "types_character": "이 키는 사용 중인 키보드에서 문자를 입력하며, 지정하면 모든 앱에서 그 문자를 입력할 수 없게 됩니다. 다른 키를 사용해 보세요.",
+ "reserved": "시스템 또는 대부분의 앱이 이 키를 사용합니다. 다른 키를 사용해 보세요."
},
"desktopReminders": {
"generic_title": "알림",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 3e3c3940c..60bf27913 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -6966,10 +6966,12 @@
"taken": "{keys} izmanto cita lietotne, tāpēc šeit nekas nenotiek. Izvēlieties citus taustiņus vai aizveriet šo lietotni.",
"change": "Mainīt taustiņus: {keys}",
"recording": "Nospiediet taustiņus…",
- "recording_hint": "Turiet nospiestus divus no Ctrl, Alt, Shift vai sistēmas taustiņa un nospiediet burtu vai ciparu. Esc atceļ.",
+ "recording_hint": "Turiet nospiestu Ctrl (Mac datorā Cmd) kopā ar Alt vai Shift un nospiediet burtu, ciparu, Enter vai funkciju taustiņu. Esc atceļ.",
"back_to_main": "Pārvietot uz galveno logu",
"back_busy": "Galvenajā logā jau ir atvērta ziņa. Aizveriet to un mēģiniet vēlreiz.",
- "back_no_answer": "Galvenais logs neatbildēja. Jūsu ziņa paliek šeit."
+ "back_no_answer": "Galvenais logs neatbildēja. Jūsu ziņa paliek šeit.",
+ "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."
},
"desktopReminders": {
"generic_title": "Atgādinājums",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 5174fbb58..b2bc32574 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -6966,10 +6966,12 @@
"taken": "{keys}-г өөр апп ашиглаж байгаа тул энд ажиллахгүй. Өөр товч сонгох эсвэл тэр аппыг хаана уу.",
"change": "Товчийг өөрчлөх: {keys}",
"recording": "Товчнуудыг дарна уу…",
- "recording_hint": "Ctrl, Alt, Shift эсвэл системийн товчноос хоёрыг дарж байгаад үсэг эсвэл тоо дарна уу. Esc цуцална.",
+ "recording_hint": "Ctrl (Mac дээр Cmd)-г Alt эсвэл Shift-тэй хамт дарж байгаад үсэг, тоо, Enter эсвэл функц товч дарна уу. Esc цуцална.",
"back_to_main": "Үндсэн цонх руу зөөх",
"back_busy": "Үндсэн цонхонд аль хэдийн мессеж нээлттэй байна. Түүнийг хаагаад дахин оролдоно уу.",
- "back_no_answer": "Үндсэн цонх хариу өгсөнгүй. Таны мессеж энд үлдэнэ."
+ "back_no_answer": "Үндсэн цонх хариу өгсөнгүй. Таны мессеж энд үлдэнэ.",
+ "types_character": "Эдгээр товч таны гар дээр тэмдэгт бичдэг тул тэр тэмдэгт бүх аппад ажиллахаа болино. Өөр товч туршина уу.",
+ "reserved": "Систем эсвэл ихэнх апп эдгээр товчийг ашигладаг. Өөр товч туршина уу."
},
"desktopReminders": {
"generic_title": "Сануулга",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 6507dd6a4..45b3788b2 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -6966,10 +6966,12 @@
"taken": "En annen app bruker {keys}, så her skjer det ingenting. Velg andre taster, eller lukk den appen.",
"change": "Endre tastene: {keys}",
"recording": "Trykk på tastene…",
- "recording_hint": "Hold nede to av Ctrl, Alt, Shift eller systemtasten, og trykk på en bokstav eller et tall. Esc avbryter.",
+ "recording_hint": "Hold Ctrl (Cmd på Mac) nede sammen med Alt eller Shift, og trykk på en bokstav, et tall, Enter eller en funksjonstast. Esc avbryter.",
"back_to_main": "Flytt til hovedvinduet",
"back_busy": "Hovedvinduet har allerede en melding åpen. Lukk den, og prøv igjen.",
- "back_no_answer": "Hovedvinduet svarte ikke. Meldingen din blir her."
+ "back_no_answer": "Hovedvinduet svarte ikke. Meldingen din blir her.",
+ "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."
},
"desktopReminders": {
"generic_title": "Påminnelse",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 63650e1fd..ef726650a 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -6966,10 +6966,12 @@
"taken": "Een andere app gebruikt {keys}, dus hier gebeurt niets. Kies andere toetsen of sluit die app.",
"change": "Toetsen wijzigen: {keys}",
"recording": "Druk op de toetsen…",
- "recording_hint": "Houd twee van Ctrl, Alt, Shift of de systeemtoets vast en druk op een letter of cijfer. Esc annuleert.",
+ "recording_hint": "Houd Ctrl (Cmd op een Mac) met Alt of Shift vast en druk op een letter, cijfer, Enter of functietoets. Esc annuleert.",
"back_to_main": "Naar het hoofdvenster verplaatsen",
"back_busy": "In het hoofdvenster staat al een bericht open. Sluit dat en probeer het opnieuw.",
- "back_no_answer": "Het hoofdvenster gaf geen antwoord. Je bericht blijft hier."
+ "back_no_answer": "Het hoofdvenster gaf geen antwoord. Je bericht blijft hier.",
+ "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."
},
"desktopReminders": {
"generic_title": "Herinnering",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 92d0ea07e..393c5cff9 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -6966,10 +6966,12 @@
"taken": "Inna aplikacja używa {keys}, więc tutaj nic się nie dzieje. Wybierz inne klawisze lub zamknij tę aplikację.",
"change": "Zmień klawisze: {keys}",
"recording": "Naciśnij klawisze…",
- "recording_hint": "Przytrzymaj dwa z klawiszy Ctrl, Alt, Shift lub klawisz systemowy i naciśnij literę albo cyfrę. Esc anuluje.",
+ "recording_hint": "Przytrzymaj Ctrl (Cmd na Macu) razem z Alt lub Shift i naciśnij literę, cyfrę, Enter albo klawisz funkcyjny. Esc anuluje.",
"back_to_main": "Przenieś do głównego okna",
"back_busy": "W głównym oknie jest już otwarta wiadomość. Zamknij ją i spróbuj ponownie.",
- "back_no_answer": "Główne okno nie odpowiedziało. Wiadomość zostaje tutaj."
+ "back_no_answer": "Główne okno nie odpowiedziało. Wiadomość zostaje tutaj.",
+ "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."
},
"desktopReminders": {
"generic_title": "Przypomnienie",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 5fde5c117..2a0ad323f 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -6966,10 +6966,12 @@
"taken": "Outra aplicação está a usar {keys}, por isso aqui não faz nada. Escolha outras teclas ou feche essa aplicação.",
"change": "Alterar as teclas: {keys}",
"recording": "Prima as teclas…",
- "recording_hint": "Mantenha premidas duas de Ctrl, Alt, Shift ou a tecla do sistema e prima uma letra ou um número. Esc cancela.",
+ "recording_hint": "Mantenha premida Ctrl (Cmd no Mac) com Alt ou Shift e prima uma letra, um número, Enter ou uma tecla de função. Esc cancela.",
"back_to_main": "Mover para a janela principal",
"back_busy": "A janela principal já tem uma mensagem aberta. Feche-a e tente de novo.",
- "back_no_answer": "A janela principal não respondeu. A sua mensagem fica aqui."
+ "back_no_answer": "A janela principal não respondeu. A sua mensagem fica aqui.",
+ "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."
},
"desktopReminders": {
"generic_title": "Lembrete",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 8379d6fde..a989f3878 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -6966,10 +6966,12 @@
"taken": "Altă aplicație folosește {keys}, așa că aici nu face nimic. Alege alte taste sau închide aplicația respectivă.",
"change": "Schimbă tastele: {keys}",
"recording": "Apasă tastele…",
- "recording_hint": "Ține apăsate două dintre Ctrl, Alt, Shift sau tasta de sistem și apasă o literă sau o cifră. Esc anulează.",
+ "recording_hint": "Ține apăsată tasta Ctrl (Cmd pe Mac) cu Alt sau Shift și apasă o literă, o cifră, Enter sau o tastă funcțională. Esc anulează.",
"back_to_main": "Mută în fereastra principală",
"back_busy": "Fereastra principală are deja un mesaj deschis. Închide-l și încearcă din nou.",
- "back_no_answer": "Fereastra principală nu a răspuns. Mesajul rămâne aici."
+ "back_no_answer": "Fereastra principală nu a răspuns. Mesajul rămâne aici.",
+ "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."
},
"desktopReminders": {
"generic_title": "Memento",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index b04b64f93..3b5c2b31e 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -6966,10 +6966,12 @@
"taken": "{keys} занято другим приложением, поэтому здесь сочетание не работает. Выберите другие клавиши или закройте это приложение.",
"change": "Изменить клавиши: {keys}",
"recording": "Нажмите клавиши…",
- "recording_hint": "Удерживайте две из клавиш Ctrl, Alt, Shift или системную клавишу и нажмите букву или цифру. Esc отменяет.",
+ "recording_hint": "Удерживайте Ctrl (Cmd на Mac) вместе с Alt или Shift и нажмите букву, цифру, Enter или функциональную клавишу. Esc отменяет.",
"back_to_main": "Переместить в главное окно",
"back_busy": "В главном окне уже открыто письмо. Закройте его и попробуйте снова.",
- "back_no_answer": "Главное окно не ответило. Ваше письмо остаётся здесь."
+ "back_no_answer": "Главное окно не ответило. Ваше письмо остаётся здесь.",
+ "types_character": "На вашей клавиатуре эти клавиши вводят символ, и он перестал бы работать во всех приложениях. Попробуйте другие клавиши.",
+ "reserved": "Эти клавиши использует система или большинство приложений. Попробуйте другие клавиши."
},
"desktopReminders": {
"generic_title": "Напоминание",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 9dca3e961..bc9034d49 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -6966,10 +6966,12 @@
"taken": "{keys} používa iná aplikácia, takže tu nič neurobí. Vyberte iné klávesy alebo tú aplikáciu zatvorte.",
"change": "Zmeniť klávesy: {keys}",
"recording": "Stlačte klávesy…",
- "recording_hint": "Podržte dva z klávesov Ctrl, Alt, Shift alebo systémový kláves a stlačte písmeno alebo číslicu. Esc zruší.",
+ "recording_hint": "Podržte Ctrl (na Macu Cmd) spolu s Alt alebo Shift a stlačte písmeno, číslicu, Enter alebo funkčný kláves. Esc zruší.",
"back_to_main": "Presunúť do hlavného okna",
"back_busy": "V hlavnom okne je už otvorená správa. Zatvorte ju a skúste to znova.",
- "back_no_answer": "Hlavné okno neodpovedalo. Vaša správa zostáva tu."
+ "back_no_answer": "Hlavné okno neodpovedalo. Vaša správa zostáva tu.",
+ "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."
},
"desktopReminders": {
"generic_title": "Pripomienka",
diff --git a/locales/th/common.json b/locales/th/common.json
index 6fa6e6864..20734cddf 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -6974,10 +6974,12 @@
"taken": "แอปอื่นกำลังใช้ {keys} อยู่ จึงไม่มีผลที่นี่ เลือกปุ่มอื่นหรือปิดแอปนั้น",
"change": "เปลี่ยนปุ่ม: {keys}",
"recording": "กดปุ่ม…",
- "recording_hint": "กดค้างสองปุ่มจาก Ctrl, Alt, Shift หรือปุ่มระบบ แล้วกดตัวอักษรหรือตัวเลข กด Esc เพื่อยกเลิก",
+ "recording_hint": "กด Ctrl (Cmd บน Mac) ค้างไว้พร้อม Alt หรือ Shift แล้วกดตัวอักษร ตัวเลข Enter หรือปุ่มฟังก์ชัน กด Esc เพื่อยกเลิก",
"back_to_main": "ย้ายไปหน้าต่างหลัก",
"back_busy": "หน้าต่างหลักมีข้อความเปิดอยู่แล้ว ปิดข้อความนั้นแล้วลองอีกครั้ง",
- "back_no_answer": "หน้าต่างหลักไม่ตอบสนอง ข้อความของคุณยังอยู่ที่นี่"
+ "back_no_answer": "หน้าต่างหลักไม่ตอบสนอง ข้อความของคุณยังอยู่ที่นี่",
+ "types_character": "ปุ่มเหล่านี้พิมพ์อักขระบนแป้นพิมพ์ของคุณ และอักขระนั้นจะใช้ไม่ได้ในทุกแอป ลองใช้ปุ่มอื่น",
+ "reserved": "ระบบหรือแอปส่วนใหญ่ใช้ปุ่มเหล่านี้ ลองใช้ปุ่มอื่น"
},
"desktopReminders": {
"generic_title": "การแจ้งเตือน",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 825d2610a..40113ab7d 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -6966,10 +6966,12 @@
"taken": "{keys} başka bir uygulama tarafından kullanılıyor, bu yüzden burada bir şey yapmaz. Başka tuşlar seçin veya o uygulamayı kapatın.",
"change": "Tuşları değiştir: {keys}",
"recording": "Tuşlara basın…",
- "recording_hint": "Ctrl, Alt, Shift veya sistem tuşundan ikisini basılı tutun ve bir harfe ya da rakama basın. Esc iptal eder.",
+ "recording_hint": "Ctrl (Mac'te Cmd) ile Alt veya Shift tuşunu basılı tutun ve bir harfe, rakama, Enter'a ya da işlev tuşuna basın. Esc iptal eder.",
"back_to_main": "Ana pencereye taşı",
"back_busy": "Ana pencerede zaten açık bir ileti var. Onu kapatın ve yeniden deneyin.",
- "back_no_answer": "Ana pencere yanıt vermedi. İletiniz burada kalıyor."
+ "back_no_answer": "Ana pencere yanıt vermedi. İletiniz burada kalıyor.",
+ "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."
},
"desktopReminders": {
"generic_title": "Anımsatıcı",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index d48126eeb..caacf98bc 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -6966,10 +6966,12 @@
"taken": "{keys} використовує інший застосунок, тому тут нічого не відбувається. Виберіть інші клавіші або закрийте той застосунок.",
"change": "Змінити клавіші: {keys}",
"recording": "Натисніть клавіші…",
- "recording_hint": "Утримуйте дві з клавіш Ctrl, Alt, Shift або системну клавішу й натисніть літеру чи цифру. Esc скасовує.",
+ "recording_hint": "Утримуйте Ctrl (Cmd на Mac) разом з Alt або Shift і натисніть літеру, цифру, Enter або функціональну клавішу. Esc скасовує.",
"back_to_main": "Перемістити в головне вікно",
"back_busy": "У головному вікні вже відкрито лист. Закрийте його й спробуйте ще раз.",
- "back_no_answer": "Головне вікно не відповіло. Ваш лист залишається тут."
+ "back_no_answer": "Головне вікно не відповіло. Ваш лист залишається тут.",
+ "types_character": "На вашій клавіатурі ці клавіші вводять символ, і він перестав би працювати в усіх застосунках. Спробуйте інші клавіші.",
+ "reserved": "Ці клавіші використовує система або більшість застосунків. Спробуйте інші клавіші."
},
"desktopReminders": {
"generic_title": "Нагадування",
diff --git a/locales/vi/common.json b/locales/vi/common.json
index 7f9cfc113..d212fe083 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -6978,10 +6978,12 @@
"taken": "Một ứng dụng khác đang dùng {keys}, nên phím tắt này không có tác dụng ở đây. Hãy chọn phím khác hoặc đóng ứng dụng đó.",
"change": "Đổi phím: {keys}",
"recording": "Nhấn các phím…",
- "recording_hint": "Giữ hai trong số Ctrl, Alt, Shift hoặc phím hệ thống, rồi nhấn một chữ cái hoặc chữ số. Esc để hủy.",
+ "recording_hint": "Giữ Ctrl (Cmd trên Mac) cùng Alt hoặc Shift, rồi nhấn một chữ cái, chữ số, Enter hoặc phím chức năng. Esc để hủy.",
"back_to_main": "Chuyển sang cửa sổ chính",
"back_busy": "Cửa sổ chính đang mở một thư. Hãy đóng thư đó rồi thử lại.",
- "back_no_answer": "Cửa sổ chính không phản hồi. Thư của bạn vẫn ở đây."
+ "back_no_answer": "Cửa sổ chính không phản hồi. Thư của bạn vẫn ở đây.",
+ "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."
},
"desktopReminders": {
"generic_title": "Lời nhắc",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 76d7acbb8..afca2617f 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -6966,10 +6966,12 @@
"taken": "另一個應用程式正在使用 {keys},所以在這裡沒有作用。請選擇其他按鍵,或關閉該應用程式。",
"change": "變更按鍵:{keys}",
"recording": "請按下按鍵…",
- "recording_hint": "按住 Ctrl、Alt、Shift 或系統鍵中的兩個,再按一個字母或數字。按 Esc 取消。",
+ "recording_hint": "按住 Ctrl(Mac 上為 Cmd)和 Alt 或 Shift,再按一個字母、數字、Enter 或功能鍵。按 Esc 取消。",
"back_to_main": "移到主視窗",
"back_busy": "主視窗已有開啟的郵件。請先關閉它,然後再試一次。",
- "back_no_answer": "主視窗沒有回應。您的郵件仍在這裡。"
+ "back_no_answer": "主視窗沒有回應。您的郵件仍在這裡。",
+ "types_character": "這些鍵在您的鍵盤上會輸入一個字元,占用後該字元在所有應用程式中都無法輸入。請試試其他按鍵。",
+ "reserved": "系統或大多數應用程式正在使用這些鍵。請試試其他按鍵。"
},
"desktopReminders": {
"generic_title": "提醒",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index ed930a567..2e83c7ffa 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -6966,10 +6966,12 @@
"taken": "另一个应用正在使用 {keys},所以在这里不起作用。请选择其他按键,或关闭那个应用。",
"change": "更改按键:{keys}",
"recording": "请按下按键…",
- "recording_hint": "按住 Ctrl、Alt、Shift 或系统键中的两个,再按一个字母或数字。按 Esc 取消。",
+ "recording_hint": "按住 Ctrl(Mac 上为 Cmd)和 Alt 或 Shift,再按一个字母、数字、Enter 或功能键。按 Esc 取消。",
"back_to_main": "移到主窗口",
"back_busy": "主窗口中已有打开的邮件。请先关闭它,然后重试。",
- "back_no_answer": "主窗口没有响应。您的邮件仍在这里。"
+ "back_no_answer": "主窗口没有响应。您的邮件仍在这里。",
+ "types_character": "这些键在您的键盘上会输入一个字符,占用后该字符在所有应用中都无法输入。请试试其他按键。",
+ "reserved": "系统或大多数应用在使用这些键。请试试其他按键。"
},
"desktopReminders": {
"generic_title": "提醒",