From 3da18cc2c03a0a05b92e295af790e741112f5922 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:25:03 +0100 Subject: [PATCH 1/3] feat: set the quick compose keys in the desktop settings and move a composer back into the main window --- .../desktop/__tests__/desktop-host.test.tsx | 28 +++- components/desktop/desktop-host.tsx | 16 +- components/email/email-composer.tsx | 17 ++- components/mail/mail-app.tsx | 26 ++++ .../__tests__/popout-compose-app.test.tsx | 94 ++++++++++++ .../popout/__tests__/popout-host.test.tsx | 53 +++++++ components/popout/popout-compose-app.tsx | 35 +++++ components/popout/popout-host.tsx | 18 +++ .../desktop-quick-compose-setting.test.tsx | 137 ++++++++++++++++++ components/settings/desktop-os-settings.tsx | 9 +- .../desktop-quick-compose-setting.tsx | 72 +++++++++ lib/desktop/__tests__/shortcut-keys.test.ts | 62 ++++++++ lib/desktop/languages.ts | 1 + lib/desktop/policy-schema.ts | 2 +- lib/desktop/shortcut-keys.ts | 84 +++++++++++ lib/desktop/types.ts | 16 ++ lib/popout/__tests__/return-to-main.test.ts | 119 +++++++++++++++ lib/popout/channel.ts | 3 + lib/popout/return-to-main.ts | 100 +++++++++++++ locales/ar/common.json | 13 ++ locales/ca/common.json | 13 ++ locales/cs/common.json | 13 ++ locales/da/common.json | 13 ++ locales/de/common.json | 13 ++ locales/en/common.json | 13 ++ locales/es/common.json | 13 ++ locales/et/common.json | 13 ++ locales/fa/common.json | 13 ++ locales/fr/common.json | 13 ++ locales/he/common.json | 13 ++ locales/hu/common.json | 13 ++ locales/it/common.json | 13 ++ locales/ja/common.json | 13 ++ locales/ko/common.json | 13 ++ locales/lv/common.json | 13 ++ locales/mn/common.json | 13 ++ locales/nb/common.json | 13 ++ locales/nl/common.json | 13 ++ locales/pl/common.json | 13 ++ locales/pt/common.json | 13 ++ locales/ro/common.json | 13 ++ locales/ru/common.json | 13 ++ locales/sk/common.json | 13 ++ locales/th/common.json | 13 ++ locales/tr/common.json | 13 ++ locales/uk/common.json | 13 ++ locales/vi/common.json | 13 ++ locales/zh-TW/common.json | 13 ++ locales/zh/common.json | 13 ++ 49 files changed, 1270 insertions(+), 12 deletions(-) create mode 100644 components/settings/__tests__/desktop-quick-compose-setting.test.tsx create mode 100644 components/settings/desktop-quick-compose-setting.tsx create mode 100644 lib/desktop/__tests__/shortcut-keys.test.ts create mode 100644 lib/desktop/shortcut-keys.ts create mode 100644 lib/popout/__tests__/return-to-main.test.ts create mode 100644 lib/popout/return-to-main.ts diff --git a/components/desktop/__tests__/desktop-host.test.tsx b/components/desktop/__tests__/desktop-host.test.tsx index 9f6da218b..42a6d0198 100644 --- a/components/desktop/__tests__/desktop-host.test.tsx +++ b/components/desktop/__tests__/desktop-host.test.tsx @@ -9,6 +9,7 @@ import { useToastStore } from '@/stores/toast-store'; import { useAuthStore } from '@/stores/auth-store'; import { useLocaleStore } from '@/stores/locale-store'; import { useOfflineStore } from '@/lib/offline/state'; +import { offerReturnedComposer, takeReturnedComposer } from '@/lib/popout/return-to-main'; const push = vi.fn(); vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push }) })); @@ -42,6 +43,7 @@ function fakeBridge() { setState: vi.fn(), setLanguages: vi.fn(), sessionReady: vi.fn(), + sessionEnded: vi.fn(), setRestoreEnabled: vi.fn(), setTheme: vi.fn(), openExternal: vi.fn(), @@ -55,7 +57,7 @@ function fakeBridge() { shell: { openExternal: parts.openExternal, setLanguages: parts.setLanguages }, spellcheck: { missing: async () => [], onMissing: (listener: (language: string) => void) => { missing = listener; return () => {}; }, allowDownload: parts.allowDownload }, clipboard: { copyFile: async () => true, clearFiles: parts.clearFiles }, - windows: { sessionReady: parts.sessionReady, setRestoreEnabled: parts.setRestoreEnabled }, + windows: { sessionReady: parts.sessionReady, sessionEnded: parts.sessionEnded, setRestoreEnabled: parts.setRestoreEnabled }, theme: { set: parts.setTheme, system: async () => ({ dark: false, highContrast: false }) }, lifecycle: { onFlushRequest: (handler: () => Promise) => { flush = handler; return () => {}; } }, }; @@ -311,3 +313,27 @@ describe('DesktopHost', () => { act(() => useOfflineStore.setState({ outbox: [] } as never)); }); }); + +describe('quick compose and the way back', () => { + afterEach(() => { + takeReturnedComposer(); + }); + + it('tells the shell when nobody is signed in any more, so a shortcut goes to the sign-in', async () => { + const shell = fakeBridge(); + useAuthStore.setState({ isAuthenticated: true }); + render(); + await waitFor(() => expect(shell.sessionReady).toHaveBeenCalled()); + expect(shell.sessionEnded).not.toHaveBeenCalled(); + act(() => useAuthStore.setState({ isAuthenticated: false })); + await waitFor(() => expect(shell.sessionEnded).toHaveBeenCalledOnce()); + }); + + it('goes to the mail page for a composer coming back, with none on screen', async () => { + fakeBridge(); + render(); + push.mockClear(); + offerReturnedComposer({ returnId: 'r1', payload: { mode: 'compose' }, answer: () => {} }); + expect(push).toHaveBeenCalledWith('/'); + }); +}); diff --git a/components/desktop/desktop-host.tsx b/components/desktop/desktop-host.tsx index 29aa70dd6..909ca657b 100644 --- a/components/desktop/desktop-host.tsx +++ b/components/desktop/desktop-host.tsx @@ -17,6 +17,7 @@ import type { DesktopSystemTheme } from "@/lib/desktop/types"; import { canDownloadAnywhere, canPrintAnywhere } from "@/lib/device-policy"; import { useOfflineStore } from "@/lib/offline/state"; import { openNewComposerWindow } from "@/lib/popout/open"; +import { onReturnedComposerElsewhere } from "@/lib/popout/return-to-main"; import { isPopoutWindow } from "@/lib/popout/window-role"; import { onBeforeSignOut, onFullSignOut } from "@/lib/sign-out-cleanup"; import { useAuthStore } from "@/stores/auth-store"; @@ -185,12 +186,21 @@ export function DesktopHost() { getDesktopBridge()?.windows?.setRestoreEnabled?.(restoreWindows); }, [restoreWindows]); - // Signed in: this window has a session to lend, for pop-outs from last time and links. + // Signed in: this window has a session to lend, for pop-outs from last time, links and + // quick compose. Signed out: they'd find nothing, and the shell shows this window instead. useEffect(() => { - if (!isAuthenticated || isPopoutWindow()) return; - getDesktopBridge()?.windows?.sessionReady?.(); + if (isPopoutWindow()) return; + const windows = getDesktopBridge()?.windows; + if (isAuthenticated) windows?.sessionReady?.(); + else windows?.sessionEnded?.(); }, [isAuthenticated]); + // A composer coming back from its own window, with no mail page here to take it. + useEffect(() => { + if (!getDesktopBridge()?.windows || isPopoutWindow()) return; + return onReturnedComposerElsewhere(() => routerRef.current.push("/")); + }, []); + // A quit asks for this: mail that hasn't left is worth a question. useEffect(() => { const lifecycle = getDesktopBridge()?.lifecycle; diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 03d657ade..c6cb3e6b8 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -318,6 +318,8 @@ export interface ComposerPopOut { /** True once the composer is on screen there. Until then this one keeps the text. */ handOver(data: ComposerDraftData): Promise; cancel(): void; + /** What to say when handOver answered false, if not that the new window was late. */ + notShownMessage?: () => string; } interface EmailComposerProps { @@ -390,6 +392,8 @@ interface EmailComposerProps { * hands the draft over under the same draft id, and closes here. */ popOut?: () => ComposerPopOut | null; + /** The move goes back to the main window: the button says that, and shows at any width. */ + popOutToMainLabel?: string; /** * This composer is all a window shows (lib/popout). It closes itself once a * message has gone. Closing the window with unsaved text hands the draft to @@ -561,6 +565,7 @@ export function EmailComposer({ onDiscardDraft, onSaveState, popOut, + popOutToMainLabel, ownWindow, onSubjectChange, className, @@ -4447,7 +4452,7 @@ export function EmailComposer({ // Not on screen there, so this is still the only copy of the text. if (!shown) { target.cancel(); - toast.error(tWindows('window_not_ready')); + toast.error(target.notShownMessage?.() ?? tWindows('window_not_ready')); return; } explicitCloseRef.current = true; @@ -4464,12 +4469,12 @@ export function EmailComposer({ type="button" onClick={(event) => { event.stopPropagation(); void handlePopOut(); }} disabled={isPoppingOut || isSending} - className={className} - aria-label={tWindows('open_in_window')} - title={tWindows('open_in_window')} - data-testid="composer-open-in-window" + className={popOutToMainLabel ? className.replace("hidden md:grid", "grid") : className} + aria-label={popOutToMainLabel ?? tWindows('open_in_window')} + title={popOutToMainLabel ?? tWindows('open_in_window')} + data-testid={popOutToMainLabel ? "composer-back-to-main" : "composer-open-in-window"} > - + {popOutToMainLabel ? : } ); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 9c69bd96e..f49a38da8 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -159,6 +159,7 @@ import { mailtoComposerDraft } from "./mailto-draft"; import { useOpenEmailInWindow, usePopoutsAvailable } from "@/hooks/use-popouts-available"; import { openComposerWindow } from "@/lib/popout/open"; import { onRescuedDraft, takeRescuedDraft } from "@/lib/popout/rescued-drafts"; +import { answerWhenComposerOpens, onReturnedComposer, takeReturnedComposer } from "@/lib/popout/return-to-main"; import { getQuoteBodies } from "@/lib/email-composer-utils"; import { emailHooks } from "@/lib/plugin-hooks"; import { afterRequestDispatch } from "@/lib/after-request-dispatch"; @@ -1771,6 +1772,31 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [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. + useEffect(() => { + if (!isAuthenticated || !client || proTakingOver) return; + const adopt = () => { + const returned = takeReturnedComposer(); + if (!returned) return; + if (composerInUseRef.current) { + returned.answer('busy'); + return; + } + const { payload } = returned; + answerWhenComposerOpens(returned); + setComposerSessionId((id) => id + 1); + setHandedOffSource({ session: composerSessionId + 1, emailId: payload.sourceEmailId ?? undefined, source: payload.sourceEmailSource ?? undefined }); + setPendingDraft(payload.initialData ?? null); + setComposerMode(payload.initialData?.mode ?? payload.mode); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); + }; + adopt(); + return onReturnedComposer(adopt); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAuthenticated, client, proTakingOver, isMobile, composerSessionId]); + useEffect(() => { if (!isAuthenticated || !client) return; diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx index 7fb96e09b..11a3bb07a 100644 --- a/components/popout/__tests__/popout-compose-app.test.tsx +++ b/components/popout/__tests__/popout-compose-app.test.tsx @@ -311,3 +311,97 @@ describe('PopoutComposeApp', () => { main.close(); }); }); + +describe('back into the main window', () => { + const focus = vi.fn(); + + beforeEach(() => { + focus.mockClear(); + window.history.replaceState(null, '', '/en/popout/compose'); + }); + + afterEach(() => { + window.bulwarkDesktop = undefined; + }); + + /** A main window that holds these logins, and answers a move as told. */ + 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 }); + })!; + return channel; + } + + const openComposer = async () => { + render(); + await settle(); + return composer as unknown as { + popOut?: () => { handOver: (state: object) => Promise; notShownMessage: () => string }; + popOutToMainLabel?: string; + }; + }; + + it('is offered while a main window holds the login', async () => { + 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()); + expect((composer as unknown as { popOutToMainLabel: string }).popOutToMainLabel).toBe('back_to_main'); + expect(props).toBeTruthy(); + main.close(); + }); + + it('is not offered with no main window, or one holding another login', async () => { + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { focus } } as never; + await openComposer(); + await settle(250); + expect((composer as unknown as { popOut?: unknown }).popOut).toBeUndefined(); + cleanup(); + const main = mainWindowAnswers('shown', ['bob']); + await openComposer(); + await settle(250); + expect((composer as unknown as { popOut?: unknown }).popOut).toBeUndefined(); + main.close(); + }); + + it('is not offered in a browser, which behaves as it did', async () => { + const main = mainWindowAnswers('shown'); + await openComposer(); + await settle(250); + expect((composer as unknown as { popOut?: unknown }).popOut).toBeUndefined(); + main.close(); + }); + + it('hands over what the composer holds, and brings the main window up once it is there', async () => { + 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()); + 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); + }); + 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. + expect(sent.accountId).toBe('carol'); + expect(focus).toHaveBeenCalledOnce(); + main.close(); + }); + + it('a busy main window keeps the text here, says why, and stays out of sight', async () => { + 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()); + 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); + }); + expect(target.notShownMessage()).toBe('back_busy'); + expect(focus).not.toHaveBeenCalled(); + main.close(); + }); +}); diff --git a/components/popout/__tests__/popout-host.test.tsx b/components/popout/__tests__/popout-host.test.tsx index 09f6824b8..04b6bcae7 100644 --- a/components/popout/__tests__/popout-host.test.tsx +++ b/components/popout/__tests__/popout-host.test.tsx @@ -4,6 +4,7 @@ import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-ch import { setLockedHere } from '@/lib/app-lock/state'; import { openPopoutChannel, requestWindowSession, watchMainWindow, askRescuedDraftId, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel'; import { takeRescuedDraft } from '@/lib/popout/rescued-drafts'; +import { takeReturnedComposer } from '@/lib/popout/return-to-main'; import { createSessionOpener, credentialFingerprint } from '@/lib/popout/sealed'; import { useAccountStore } from '@/stores/account-store'; import { useAuthStore } from '@/stores/auth-store'; @@ -390,3 +391,55 @@ describe('sealed answers', () => { await expect(other.open(answer.sealed)).rejects.toThrow(); }); }); + +describe('a composer coming back', () => { + const PAYLOAD = { mode: 'compose' as const, initialData: { subject: 'Back' } as never }; + + afterEach(() => { + takeReturnedComposer(); + }); + + it('is taken once, and the answer goes back to the window that sent it', async () => { + render(); + await settle(); + popout.post({ type: 'composer-return', returnId: 'r1', accountId: 'alice', payload: PAYLOAD }); + await settle(); + const returned = takeReturnedComposer(); + expect(returned?.payload).toEqual(PAYLOAD); + expect(lockRequests).toContain('bulwark-popout-return:r1'); + returned?.answer('shown'); + await settle(); + expect(heard).toContainEqual({ type: 'composer-return-answer', returnId: 'r1', status: 'shown' }); + }); + + it('is left to a window holding its login', async () => { + render(); + await settle(); + popout.post({ type: 'composer-return', returnId: 'r1', accountId: 'bob', payload: PAYLOAD }); + await settle(); + expect(takeReturnedComposer()).toBeNull(); + }); + + it('is refused while locked: nothing of it goes on screen', async () => { + render(); + await settle(); + setLockedHere(true); + try { + popout.post({ type: 'composer-return', returnId: 'r1', accountId: 'alice', payload: PAYLOAD }); + await settle(); + expect(takeReturnedComposer()).toBeNull(); + } finally { + setLockedHere(false); + } + }); + + it('goes when the pop-out calls it off', async () => { + render(); + await settle(); + popout.post({ type: 'composer-return', returnId: 'r1', accountId: 'alice', payload: PAYLOAD }); + await settle(); + popout.post({ type: 'composer-return-cancel', returnId: 'r1' }); + await settle(); + expect(takeReturnedComposer()).toBeNull(); + }); +}); diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx index 11e02e952..caf93da18 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -17,7 +17,10 @@ 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 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 { composerDataFromDraft, isRestorableDraft } from "@/lib/draft-composer-data"; import { noteOpenDraft } from "@/lib/desktop/window-restore"; @@ -36,6 +39,9 @@ type SendData = Parameters>[0]; const EMPTY_PAYLOAD: ComposePopoutPayload = { mode: "compose" }; +/** How often the window looks whether a main window holding its login is still there. */ +const MAIN_CHECK_MS = 1000; + /** How long a reloaded window waits to hear what became of the text it handed over on the way out. */ const RESCUE_ANSWER_MS = 2000; @@ -164,6 +170,7 @@ function RestoredDraft({ draftId, accountId, ownerAccountId, onReady }: { /** 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(); + const tQuick = useTranslations("desktopQuickCompose"); const client = useAuthStore((s) => s.client); const [sessionKey] = useState(() => `popout-${handoffId ?? Date.now().toString(36)}`); @@ -256,6 +263,32 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; const [mainWindow] = useState(watchMainWindow); useEffect(() => () => mainWindow.stop(), [mainWindow]); + // The way back into the main window, in the desktop app, while one holds this login. + const [mainHoldsLogin, setMainHoldsLogin] = useState(false); + useEffect(() => { + if (!hasDesktopWindows()) return; + const check = () => setMainHoldsLogin(mainWindow.alive(useAuthStore.getState().activeAccountId ?? undefined)); + check(); + const timer = setInterval(check, MAIN_CHECK_MS); + return () => clearInterval(timer); + }, [mainWindow]); + + const backToMain = useCallback(() => { + let result: ReturnResult = "no-answer"; + return { + cancel: () => {}, + notShownMessage: () => tQuick(result === "busy" ? "back_busy" : "back_no_answer"), + handOver: async (state: ComposerDraftData) => { + result = await returnComposerToMain( + { ...payload, mode: state.mode, replyTo: state.replyTo, initialData: state, initialDraftText: undefined, prefill: null }, + state.draftLocalAccountId ?? useAuthStore.getState().activeAccountId, + ); + if (result === "shown") getDesktopBridge()?.windows?.focus?.(); + return result === "shown"; + }, + }; + }, [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. useEffect(() => { @@ -304,6 +337,8 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; onClose={handleClose} onDiscardDraft={handleDiscardDraft} ownWindow={ownWindow} + popOut={mainHoldsLogin ? backToMain : undefined} + popOutToMainLabel={mainHoldsLogin ? tQuick("back_to_main") : undefined} className="min-h-0 flex-1" /> diff --git a/components/popout/popout-host.tsx b/components/popout/popout-host.tsx index 7cf2ab953..1fda7b9ad 100644 --- a/components/popout/popout-host.tsx +++ b/components/popout/popout-host.tsx @@ -10,6 +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 { credentialFingerprint, sealFor } from "@/lib/popout/sealed"; import { changesAnyType, subscribePushStateChanges } from "@/lib/push-events"; @@ -171,6 +172,23 @@ export function PopoutHost() { if (rescue) void rescue.then((draftId) => channel?.post({ type: "rescue-answer", rescueKey: message.rescueKey, draftId })); break; } + case "composer-return": { + const { returnId, accountId, payload } = message; + if (closing || isAppLockedNow()) break; + if (accountId && !shareableSessions().some((session) => session.accountId === accountId)) break; + // Several main windows: one takes it, and only that one answers. + claimOnce(`bulwark-popout-return:${returnId}`, () => { + offerReturnedComposer({ + returnId, + payload, + answer: (status) => channel?.post({ type: "composer-return-answer", returnId, status }), + }); + }); + break; + } + case "composer-return-cancel": + dropReturnedComposer(message.returnId); + break; case "main-ping": if (!closing) channel?.post({ type: "main-pong", windowId, accountIds: shareableSessions().map((session) => session.accountId) }); break; diff --git a/components/settings/__tests__/desktop-quick-compose-setting.test.tsx b/components/settings/__tests__/desktop-quick-compose-setting.test.tsx new file mode 100644 index 000000000..9da13feda --- /dev/null +++ b/components/settings/__tests__/desktop-quick-compose-setting.test.tsx @@ -0,0 +1,137 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { DesktopOsStatus, DesktopQuickComposeStatus } from '@/lib/desktop/types'; +import { DesktopOsSettings } from '../desktop-os-settings'; +import { DesktopQuickComposeSetting } from '../desktop-quick-compose-setting'; + +// The shell decides what's held. This page shows what it says, and that includes keys another +// app has: a shortcut that does nothing must not look as if it works. + +vi.mock('next-intl', async (importOriginal) => ({ + ...(await importOriginal()), + useTranslations: () => (key: string, values?: Record) => (values?.keys ? `${key}:${values.keys}` : key), +})); +vi.mock('../settings-section', async (importOriginal) => ({ + ...(await importOriginal()), + SettingsSection: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +const KEYS = 'CommandOrControl+Alt+N'; +const HELD: DesktopQuickComposeStatus = { shortcut: KEYS, active: true, locked: false, builtIn: KEYS }; + +afterEach(() => { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; +}); + +describe('DesktopQuickComposeSetting', () => { + const show = (status: Partial, platform = 'win32') => { + const onChange = vi.fn(); + render(); + return onChange; + }; + + it('names the keys while they work', () => { + show({}); + expect(screen.getByText('description:Ctrl+Alt+N')).toBeTruthy(); + expect(screen.getByTestId('quick-compose-keys').textContent).toBe('Ctrl+Alt+N'); + expect(screen.queryByText('taken:Ctrl+Alt+N')).toBeNull(); + }); + + it('says so when another app holds the keys', () => { + show({ active: false }); + expect(screen.getByText('taken:Ctrl+Alt+N')).toBeTruthy(); + expect(screen.queryByText('description:Ctrl+Alt+N')).toBeNull(); + }); + + it('turned off, it names no keys and no conflict', () => { + show({ shortcut: null, active: false }); + expect(screen.getByText('description_off')).toBeTruthy(); + expect(screen.queryByTestId('quick-compose-keys')).toBeNull(); + expect(screen.queryByText(/^taken/)).toBeNull(); + }); + + it('the switch turns it off, and on again with the usual keys', () => { + const onChange = show({}); + fireEvent.click(screen.getByRole('switch')); + expect(onChange).toHaveBeenLastCalledWith(null); + const again = vi.fn(); + render(); + fireEvent.click(screen.getAllByRole('switch')[1]); + expect(again).toHaveBeenLastCalledWith('CommandOrControl+Alt+M'); + }); + + it('takes new keys from a press, and ignores a press that can\'t be a shortcut', () => { + const onChange = show({}); + fireEvent.click(screen.getByTestId('quick-compose-keys')); + const recorder = screen.getByTestId('quick-compose-recording'); + fireEvent.keyDown(recorder, { key: 'c', code: 'KeyC', ctrlKey: true }); + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByTestId('quick-compose-recording')).toBeTruthy(); + fireEvent.keyDown(recorder, { key: 'm', code: 'KeyM', ctrlKey: true, altKey: true }); + expect(onChange).toHaveBeenCalledWith('CommandOrControl+Alt+M'); + expect(screen.queryByTestId('quick-compose-recording')).toBeNull(); + }); + + it('Esc leaves things as they were', () => { + const onChange = show({}); + fireEvent.click(screen.getByTestId('quick-compose-keys')); + fireEvent.keyDown(screen.getByTestId('quick-compose-recording'), { key: 'Escape', code: 'Escape' }); + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByTestId('quick-compose-keys')).toBeTruthy(); + }); + + it('a press while recording goes no further, or the app\'s own shortcuts would run', () => { + const heard = vi.fn(); + document.addEventListener('keydown', heard); + show({}); + fireEvent.click(screen.getByTestId('quick-compose-keys')); + fireEvent.keyDown(screen.getByTestId('quick-compose-recording'), { key: 'k', code: 'KeyK', ctrlKey: true, altKey: true }); + document.removeEventListener('keydown', heard); + expect(heard).not.toHaveBeenCalled(); + }); + + it('set by a policy, the row is locked and says who set it', () => { + show({ locked: true }); + expect(screen.getByRole('switch').closest('[inert]')).not.toBeNull(); + }); +}); + +describe('on the desktop settings page', () => { + function bridge(status: Partial) { + const full = { + settings: { closeToTray: true, startAtLogin: false, startMinimized: false, attentionOnNewMail: true, quietHoursEnabled: false, quietHoursStart: '22:00', quietHoursEnd: '07:00', showShortcut: null }, + pausedUntil: null, + shortcutActive: false, + ...status, + }; + const setSettings = vi.fn(async (patch: object) => ({ ...full, settings: { ...full.settings, ...patch }, quickCompose: { shortcut: null, active: false, locked: false, builtIn: KEYS } })); + (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { + version: 1, + platform: 'win32', + mode: 'standalone', + os: { + getStatus: async () => full, + setSettings, + defaultAppStatus: async () => ({ registered: false, mailto: false, webcal: false }), + onStatusChange: () => () => {}, + }, + }; + return setSettings; + } + + it('sends the choice to the shell, and shows what comes back', async () => { + const setSettings = bridge({ quickCompose: HELD }); + render(); + const keys = await screen.findByTestId('quick-compose-keys'); + fireEvent.click(keys.parentElement!.querySelector('[role="switch"]')!); + expect(setSettings).toHaveBeenCalledWith({ quickComposeShortcut: null }); + await screen.findByText('description_off'); + }); + + it('shows nothing about it under a shell that doesn\'t know it', async () => { + bridge({}); + render(); + await screen.findByText('default_app_button'); + expect(screen.queryByText('label')).toBeNull(); + }); +}); diff --git a/components/settings/desktop-os-settings.tsx b/components/settings/desktop-os-settings.tsx index 1703487e6..7541e0b3f 100644 --- a/components/settings/desktop-os-settings.tsx +++ b/components/settings/desktop-os-settings.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { getDesktopBridge } from "@/lib/desktop/bridge"; import type { DesktopOsDefaultAppStatus, DesktopOsSettings as Settings, DesktopOsStatus } from "@/lib/desktop/types"; +import { DesktopQuickComposeSetting } from "./desktop-quick-compose-setting"; import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section"; /** On Windows and Linux this is what a press of the keys shows; macOS maps it to Cmd. */ @@ -17,7 +18,6 @@ function shortcutLabel(platform: string | undefined): string { /** * The desktop app's own settings, on the General page: only where there's an app to * set. A browser never sees it, and it renders nothing until the bridge has answered. - * Quick compose adds its shortcut here later. */ export function DesktopOsSettings() { const t = useTranslations("desktopOs"); @@ -102,6 +102,13 @@ export function DesktopOsSettings() { onChange={(on) => change({ showShortcut: on ? SHOW_SHORTCUT : null })} /> + {status.quickCompose && ( + change({ quickComposeShortcut })} + /> + )} void; +}) { + const t = useTranslations("desktopQuickCompose"); + const [recording, setRecording] = useState(false); + const { shortcut, active, locked } = status; + const keys = shortcut ? describeAccelerator(shortcut, platform) : ""; + + const description = recording ? t("recording_hint") + : !shortcut ? t("description_off") + : active ? t("description", { keys }) + : t("taken", { keys }); + + return ( + +
+ {recording ? ( + + ) : shortcut && ( + + )} + onChange(on ? (status.builtIn ?? QUICK_COMPOSE_DEFAULT) : null)} /> +
+
+ ); +} 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 2/3] 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 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 3/3] 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": "提醒",