mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
Merge branch 'feat/desktop-offline' into feat/do-edit-attachments
# Conflicts: # lib/desktop/languages.ts # locales/ar/common.json # locales/ca/common.json # locales/cs/common.json # locales/da/common.json # locales/de/common.json # locales/en/common.json # locales/es/common.json # locales/et/common.json # locales/fa/common.json # locales/fr/common.json # locales/he/common.json # locales/hu/common.json # locales/it/common.json # locales/ja/common.json # locales/ko/common.json # locales/lv/common.json # locales/mn/common.json # locales/nb/common.json # locales/nl/common.json # locales/pl/common.json # locales/pt/common.json # locales/ro/common.json # locales/ru/common.json # locales/sk/common.json # locales/th/common.json # locales/tr/common.json # locales/uk/common.json # locales/vi/common.json # locales/zh-TW/common.json # locales/zh/common.json
This commit is contained in:
@@ -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<unknown>) => { 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(<DesktopHost />);
|
||||
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(<DesktopHost />);
|
||||
push.mockClear();
|
||||
offerReturnedComposer({ returnId: 'r1', payload: { mode: 'compose' }, answer: () => {}, sendReady: () => {} });
|
||||
expect(push).toHaveBeenCalledWith('/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -320,6 +320,8 @@ export interface ComposerPopOut {
|
||||
/** True once the composer is on screen there. Until then this one keeps the text. */
|
||||
handOver(data: ComposerDraftData): Promise<boolean>;
|
||||
cancel(): void;
|
||||
/** What to say when handOver answered false, if not that the new window was late. */
|
||||
notShownMessage?: () => string;
|
||||
}
|
||||
|
||||
interface EmailComposerProps {
|
||||
@@ -392,6 +394,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
|
||||
@@ -400,8 +404,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.
|
||||
@@ -569,6 +577,7 @@ export function EmailComposer({
|
||||
onDiscardDraft,
|
||||
onSaveState,
|
||||
popOut,
|
||||
popOutToMainLabel,
|
||||
ownWindow,
|
||||
onSubjectChange,
|
||||
className,
|
||||
@@ -3181,7 +3190,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;
|
||||
@@ -4493,7 +4502,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;
|
||||
@@ -4510,12 +4519,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"}
|
||||
>
|
||||
<ExternalLink className={iconClassName} />
|
||||
{popOutToMainLabel ? <Minimize2 className={iconClassName} /> : <ExternalLink className={iconClassName} />}
|
||||
</button>
|
||||
);
|
||||
|
||||
|
||||
@@ -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<typeof offerReturnedComposer>[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(<MailApp />);
|
||||
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(<MailApp />);
|
||||
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(<EmbeddedContext.Provider value><MailApp /></EmbeddedContext.Provider>);
|
||||
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(<EmbeddedContext.Provider value><MailApp /></EmbeddedContext.Provider>);
|
||||
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(<MailApp />);
|
||||
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(<MailApp />);
|
||||
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(<MailApp />);
|
||||
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(<MailApp />);
|
||||
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'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,6 +160,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";
|
||||
@@ -1498,6 +1499,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,
|
||||
@@ -1516,8 +1518,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,
|
||||
});
|
||||
|
||||
@@ -1719,6 +1722,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<string>());
|
||||
const composerInUseRef = useRef(false);
|
||||
const composerInUse = showComposer || pendingMailtoAccountChoice !== null
|
||||
@@ -1750,7 +1754,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,6 +1777,44 @@ 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, and so may one on its
|
||||
// way behind a plugin's onBeforeCompose, so either says no.
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !client || proTakingOver) return;
|
||||
const adopt = async () => {
|
||||
const returned = takeReturnedComposer();
|
||||
if (!returned) return;
|
||||
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;
|
||||
}
|
||||
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');
|
||||
};
|
||||
void adopt();
|
||||
return onReturnedComposer(() => void adopt());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAuthenticated, client, proTakingOver, isMobile, composerSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !client) return;
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -311,3 +311,239 @@ 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 === '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;
|
||||
}
|
||||
|
||||
const openComposer = async () => {
|
||||
render(<PopoutComposeApp />);
|
||||
await settle();
|
||||
return composer as unknown as {
|
||||
popOut?: () => { handOver: (state: object) => Promise<boolean>; 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(), { timeout: 5000 });
|
||||
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(), { timeout: 5000 });
|
||||
const target = (composer as unknown as { popOut: () => { handOver: (state: object) => Promise<boolean> } }).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<PopoutMessage, { type: 'composer-return' }>;
|
||||
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(), { timeout: 5000 });
|
||||
const target = (composer as unknown as { popOut: () => { handOver: (state: object) => Promise<boolean>; 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();
|
||||
});
|
||||
});
|
||||
|
||||
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(<PopoutComposeApp />);
|
||||
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(<PopoutComposeApp />);
|
||||
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(<PopoutComposeApp />);
|
||||
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<void>((resolve) => { started = resolve; })) } as never);
|
||||
render(<PopoutComposeApp />);
|
||||
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(<PopoutComposeApp />);
|
||||
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(<PopoutComposeApp />);
|
||||
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(<PopoutComposeApp />);
|
||||
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(<PopoutComposeApp />);
|
||||
// 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(<PopoutComposeApp />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,70 @@ 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(<PopoutHost />);
|
||||
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');
|
||||
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' });
|
||||
});
|
||||
|
||||
it('is left to a window holding its login', async () => {
|
||||
render(<PopoutHost />);
|
||||
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(<PopoutHost />);
|
||||
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('a call-off after the mail page took it stops the page opening it', async () => {
|
||||
render(<PopoutHost />);
|
||||
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(<PopoutHost />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,10 +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";
|
||||
@@ -36,6 +40,12 @@ type SendData = Parameters<NonNullable<ComposerProps["onSend"]>>[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;
|
||||
|
||||
/** 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;
|
||||
|
||||
@@ -161,9 +171,52 @@ function RestoredDraft({ draftId, accountId, ownerAccountId, onReady }: {
|
||||
return <PopoutSkeleton label={tWindows("opening")} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <PopoutSkeleton label={tWindows("opening")} />;
|
||||
}
|
||||
|
||||
/** 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 +309,33 @@ 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,
|
||||
() => mainWindow.alive(),
|
||||
);
|
||||
if (result === "shown") getDesktopBridge()?.windows?.focus?.();
|
||||
return result === "shown";
|
||||
},
|
||||
};
|
||||
}, [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.
|
||||
useEffect(() => {
|
||||
@@ -277,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]);
|
||||
|
||||
@@ -304,6 +389,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"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
@@ -313,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<ComposePopoutPayload | null>(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<ComposePopoutPayload | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!handoffId) return;
|
||||
@@ -349,8 +439,9 @@ export function PopoutComposeApp() {
|
||||
return (
|
||||
<PopoutShell kind="compose" accountId={accountId}>
|
||||
{payload ? <PopoutComposer payload={payload} handoffId={handoffId} />
|
||||
: handoffId ? <PopoutSkeleton label={t("opening")} />
|
||||
: draftId ? <RestoredDraft draftId={draftId} accountId={accountId} ownerAccountId={ownerAccountId} onReady={setPayload} />
|
||||
: <PopoutSkeleton label={t("opening")} />}
|
||||
: <NewMessageGate onReady={setPayload} />}
|
||||
</PopoutShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { commitReturnedComposer, 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,27 @@ 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 }),
|
||||
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;
|
||||
case "main-ping":
|
||||
if (!closing) channel?.post({ type: "main-pong", windowId, accountIds: shareableSessions().map((session) => session.accountId) });
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
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<typeof import('next-intl')>()),
|
||||
useTranslations: () => (key: string, values?: Record<string, string>) => (values?.keys ? `${key}:${values.keys}` : key),
|
||||
}));
|
||||
vi.mock('../settings-section', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../settings-section')>()),
|
||||
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
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<DesktopQuickComposeStatus>, platform = 'win32') => {
|
||||
const onChange = vi.fn();
|
||||
render(<DesktopQuickComposeSetting status={{ ...HELD, ...status }} platform={platform} onChange={onChange} />);
|
||||
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(<DesktopQuickComposeSetting status={{ shortcut: null, active: false, locked: false, builtIn: 'CommandOrControl+Alt+M' }} platform="win32" onChange={again} />);
|
||||
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('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'));
|
||||
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<DesktopOsStatus>) {
|
||||
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(<DesktopOsSettings />);
|
||||
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(<DesktopOsSettings />);
|
||||
await screen.findByText('default_app_button');
|
||||
expect(screen.queryByText('label')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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 })}
|
||||
/>
|
||||
</SettingItem>
|
||||
{status.quickCompose && (
|
||||
<DesktopQuickComposeSetting
|
||||
status={status.quickCompose}
|
||||
platform={bridge?.platform}
|
||||
onChange={(quickComposeShortcut) => change({ quickComposeShortcut })}
|
||||
/>
|
||||
)}
|
||||
<SettingItem
|
||||
label={t("default_app_label")}
|
||||
description={defaultApp?.mailto ? t("default_app_is_default") : t("default_app_description")}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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<KeyboardMap | null> {
|
||||
try {
|
||||
const keyboard = (navigator as unknown as { keyboard?: { getLayoutMap?: () => Promise<KeyboardMap> } }).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.
|
||||
*/
|
||||
export function DesktopQuickComposeSetting({ status, platform, onChange }: {
|
||||
status: DesktopQuickComposeStatus;
|
||||
platform: string | undefined;
|
||||
onChange: (shortcut: string | null) => void;
|
||||
}) {
|
||||
const t = useTranslations("desktopQuickCompose");
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [refused, setRefused] = useState<"types-character" | "reserved" | null>(null);
|
||||
const keyboardMap = useRef<KeyboardMap | null>(null);
|
||||
const { shortcut, active, locked } = status;
|
||||
const keys = shortcut ? describeAccelerator(shortcut, platform) : "";
|
||||
|
||||
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 });
|
||||
|
||||
return (
|
||||
<SettingItem label={t("label")} description={description} locked={locked}>
|
||||
<div className="flex items-center gap-2">
|
||||
{recording ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
autoFocus
|
||||
className="text-xs h-7"
|
||||
data-testid="quick-compose-recording"
|
||||
onBlur={stopRecording}
|
||||
onKeyDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.key === "Escape") {
|
||||
stopRecording();
|
||||
return;
|
||||
}
|
||||
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")}
|
||||
</Button>
|
||||
) : shortcut && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7"
|
||||
aria-label={t("change", { keys })}
|
||||
title={t("change", { keys })}
|
||||
data-testid="quick-compose-keys"
|
||||
onClick={() => setRecording(true)}
|
||||
>
|
||||
{keys}
|
||||
</Button>
|
||||
)}
|
||||
<ToggleSwitch checked={!!shortcut} onChange={(on) => onChange(on ? (status.builtIn ?? QUICK_COMPOSE_DEFAULT) : null)} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,7 @@
|
||||
<string id="QuickComposeShortcut">Globale Tastenkombination für neue Nachricht</string>
|
||||
<string id="QuickComposeShortcut_Explain">Die Tastenkombination, die von überall aus eine neue Nachricht öffnet, etwa CommandOrControl+Alt+N. Geben Sie off ein, um sie auszuschalten.</string>
|
||||
<string id="AuthServerAllowlist">Server für die integrierte Anmeldung</string>
|
||||
<string id="AuthServerAllowlist_Explain">Die Server, die die Windows-Anmeldung (Kerberos, NTLM) verwenden dürfen, durch Kommas getrennt, etwa *.example.com. Wird beim Neustart der App wirksam.</string>
|
||||
<string id="AuthServerAllowlist_Explain">Die Server, die die Windows-Anmeldung (Kerberos, NTLM) verwenden dürfen, durch Kommas getrennt, etwa *.example.com. Ein von der Liste entfernter Server erhält sie sofort nicht mehr. Ein neuer braucht einen Neustart der App.</string>
|
||||
<string id="AuthNegotiateDelegateAllowlist">Server für die delegierte Anmeldung</string>
|
||||
<string id="AuthNegotiateDelegateAllowlist_Explain">Die Server, an die die App Kerberos-Anmeldedaten delegieren darf, durch Kommas getrennt. Wird beim Neustart der App wirksam.</string>
|
||||
<string id="AutoSelectCertificateForUrls">Clientzertifikate automatisch wählen</string>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<string id="QuickComposeShortcut">Global shortcut for a new message</string>
|
||||
<string id="QuickComposeShortcut_Explain">The key combination that opens a new message from anywhere, such as CommandOrControl+Alt+N. Enter off to turn the shortcut off.</string>
|
||||
<string id="AuthServerAllowlist">Servers for integrated sign-in</string>
|
||||
<string id="AuthServerAllowlist_Explain">The servers that may use the Windows sign-in (Kerberos, NTLM), separated by commas, such as *.example.com. Takes effect when the app restarts.</string>
|
||||
<string id="AuthServerAllowlist_Explain">The servers that may use the Windows sign-in (Kerberos, NTLM), separated by commas, such as *.example.com. A server taken off the list stops getting it at once. A new one needs the app to restart.</string>
|
||||
<string id="AuthNegotiateDelegateAllowlist">Servers for delegated sign-in</string>
|
||||
<string id="AuthNegotiateDelegateAllowlist_Explain">The servers the app may delegate Kerberos credentials to, separated by commas. Takes effect when the app restarts.</string>
|
||||
<string id="AutoSelectCertificateForUrls">Choose client certificates automatically</string>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { JMAPClient, RequestTimeoutError } from '../jmap/client';
|
||||
import { UploadXHR, uploadClient } from './helpers/upload-xhr';
|
||||
|
||||
/**
|
||||
* The desktop app closed its connections under the page. A request under way then never settles
|
||||
* by itself, so all that ends it is being told.
|
||||
*/
|
||||
|
||||
const session = {
|
||||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||||
accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} } },
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' },
|
||||
apiUrl: 'https://mail.example.com/jmap/api',
|
||||
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
|
||||
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
|
||||
eventSourceUrl: 'https://mail.example.com/jmap/eventsource',
|
||||
};
|
||||
|
||||
let reset: () => void;
|
||||
|
||||
function withBridge(): void {
|
||||
const listeners = new Set<() => void>();
|
||||
reset = () => {
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = {
|
||||
version: 1,
|
||||
certificates: {
|
||||
onConnectionsReset: (listener: () => void) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Takes the request, and nothing ever comes back. Only an abort ends it. */
|
||||
const silent = (_url: unknown, init?: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason ?? new DOMException('aborted', 'AbortError')));
|
||||
});
|
||||
|
||||
/** The one all of the client's calls go through. */
|
||||
const requestOf = (client: JMAPClient) => (client as unknown as { request(calls: unknown[]): Promise<unknown> }).request.bind(client);
|
||||
|
||||
async function connectedClient(): Promise<JMAPClient> {
|
||||
const client = new JMAPClient('https://mail.example.com', 'alice', 'secret');
|
||||
vi.mocked(fetch).mockImplementationOnce(async () => new Response(JSON.stringify(session), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop;
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('when the desktop app closes its connections', () => {
|
||||
it('a send under way fails as "may have sent", at once, and isn\'t sent again', async () => {
|
||||
withBridge();
|
||||
const client = await connectedClient();
|
||||
vi.mocked(fetch).mockClear();
|
||||
vi.mocked(fetch).mockImplementation(silent);
|
||||
const send = requestOf(client)([['EmailSubmission/set', { accountId: 'acct-1', create: { s: {} } }, '0']]);
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
|
||||
reset();
|
||||
await expect(send).rejects.toBeInstanceOf(RequestTimeoutError);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
expect(fetch, 'the client retries other failures of a read after 1 s: never this').toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a read under way fails too, and nothing waits for its deadline', async () => {
|
||||
withBridge();
|
||||
const client = await connectedClient();
|
||||
vi.mocked(fetch).mockClear();
|
||||
vi.mocked(fetch).mockImplementation(silent);
|
||||
const read = requestOf(client)([['Mailbox/get', { accountId: 'acct-1' }, '0']]);
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
|
||||
reset();
|
||||
await expect(read).rejects.toBeInstanceOf(RequestTimeoutError);
|
||||
});
|
||||
|
||||
it('a body still being read is caught as well', async () => {
|
||||
withBridge();
|
||||
const client = await connectedClient();
|
||||
vi.mocked(fetch).mockClear();
|
||||
let signal: AbortSignal | undefined;
|
||||
vi.mocked(fetch).mockImplementation(async (_url, init) => {
|
||||
signal = init?.signal ?? undefined;
|
||||
return new Response('{}', { status: 200 });
|
||||
});
|
||||
await requestOf(client)([['Mailbox/get', { accountId: 'acct-1' }, '0']]).catch(() => {});
|
||||
expect(signal?.aborted).toBe(false);
|
||||
reset();
|
||||
expect(signal?.reason).toBeInstanceOf(RequestTimeoutError);
|
||||
});
|
||||
|
||||
it('an upload under way fails the same way, not five minutes later', async () => {
|
||||
withBridge();
|
||||
UploadXHR.instances = [];
|
||||
vi.stubGlobal('XMLHttpRequest', UploadXHR);
|
||||
const upload = uploadClient().uploadBlob(new File(['x'], 'a.txt', { type: 'text/plain' }), { onProgress: () => {} });
|
||||
await vi.waitFor(() => expect(UploadXHR.instances).toHaveLength(1));
|
||||
reset();
|
||||
await expect(upload).rejects.toBeInstanceOf(RequestTimeoutError);
|
||||
expect(UploadXHR.instances[0].abort).toHaveBeenCalledOnce();
|
||||
expect(UploadXHR.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('nothing changes in a browser, where a deadline is all there is', async () => {
|
||||
const client = await connectedClient();
|
||||
vi.mocked(fetch).mockClear();
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(fetch).mockImplementation(silent);
|
||||
const read = requestOf(client)([['Mailbox/get', { accountId: 'acct-1' }, '0']]);
|
||||
const failed = expect(read).rejects.toBeInstanceOf(RequestTimeoutError);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(vi.getTimerCount(), 'only the deadline').toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000);
|
||||
await failed;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describeAccelerator, readShortcutPress, shortcutProblem, type ShortcutKeyEvent } from '../shortcut-keys';
|
||||
|
||||
const press = (key: string, code: string, modifiers: Partial<ShortcutKeyEvent> = {}): ShortcutKeyEvent => ({
|
||||
key,
|
||||
code,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
metaKey: false,
|
||||
...modifiers,
|
||||
});
|
||||
|
||||
const CTRL_ALT = { ctrlKey: true, altKey: true };
|
||||
|
||||
describe('describeAccelerator', () => {
|
||||
it('names the keys as each OS does', () => {
|
||||
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');
|
||||
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('readShortcutPress', () => {
|
||||
it('turns a press into what the shell takes', () => {
|
||||
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(readShortcutPress(press('z', 'KeyY', CTRL_ALT), 'win32')).toEqual({ accelerator: 'CommandOrControl+Alt+Z' });
|
||||
});
|
||||
|
||||
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', () => {
|
||||
expect(readShortcutPress(press('n', 'KeyN', { metaKey: true, ctrlKey: true }), 'darwin')).toEqual({ accelerator: 'CommandOrControl+Control+N' });
|
||||
});
|
||||
|
||||
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('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' });
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,31 @@ import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { shellLabels } from '../languages';
|
||||
import { certificateLabels } from '../certificate-labels';
|
||||
import { updateLabels } from '../update-labels';
|
||||
|
||||
const en = JSON.parse(readFileSync(path.join(process.cwd(), 'locales/en/common.json'), 'utf8')).desktop_updates as Record<string, string>;
|
||||
|
||||
describe('the certificate picker text the shell gets', () => {
|
||||
const picker = JSON.parse(readFileSync(path.join(process.cwd(), 'locales/en/common.json'), 'utf8')).desktop_certificates as Record<string, string>;
|
||||
|
||||
it('keeps the placeholders the shell fills in itself', () => {
|
||||
const labels = certificateLabels((key, values) => {
|
||||
const text = picker[key.replace('desktop_certificates.', '')];
|
||||
return Object.entries(values ?? {}).reduce((out, [name, value]) => out.replaceAll(`{${name}}`, value), text);
|
||||
});
|
||||
expect(labels.certificateTitle).toBe('{host} asks for a certificate');
|
||||
expect(labels.certificateRemember).toBe('Remember this for {host}');
|
||||
expect(labels.certificateIssued).toBe('Issued by {issuer}, valid until {date}');
|
||||
expect(Object.keys(labels).sort()).toEqual(['certificateDetail', 'certificateIssued', 'certificateMore', 'certificateNone', 'certificateRemember', 'certificateTitle']);
|
||||
expect(Object.values(labels).every((text) => typeof text === 'string' && text.length > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('goes out with the labels of the menus', () => {
|
||||
expect(shellLabels((key) => key).certificateNone).toBe('desktop_certificates.picker_none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the updater text the shell gets', () => {
|
||||
it('keeps the placeholders the shell fills in itself', () => {
|
||||
const labels = updateLabels((key, values) => {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* The client certificate picker's text in the interface language, by its keys in the shell's
|
||||
* src/main/strings.ts. The shell fills in the {placeholders} itself, so they go through as
|
||||
* they are. It goes out with the rest of the labels in shellLabels (languages.ts).
|
||||
*/
|
||||
export function certificateLabels(t: (key: string, values?: Record<string, string>) => string): Record<string, string> {
|
||||
const keep = { host: '{host}', issuer: '{issuer}', date: '{date}' };
|
||||
return {
|
||||
certificateTitle: t('desktop_certificates.picker_title', keep),
|
||||
certificateDetail: t('desktop_certificates.picker_detail'),
|
||||
certificateIssued: t('desktop_certificates.picker_issued', keep),
|
||||
certificateRemember: t('desktop_certificates.picker_remember', keep),
|
||||
certificateNone: t('desktop_certificates.picker_none'),
|
||||
certificateMore: t('desktop_certificates.picker_more'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { getDesktopBridge } from './bridge';
|
||||
|
||||
/**
|
||||
* The desktop app's connections were closed under the page: at the last sign-out with a client
|
||||
* certificate in use, when a policy changed, or when Chromium's network process crashed. A
|
||||
* request under way then never settles, as Electron's request proxy doesn't pass the loss on. So
|
||||
* main says so, and whoever holds a request gives up on it. Nothing happens in a browser.
|
||||
*/
|
||||
|
||||
let wiredTo: unknown = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
/** Null where there's nothing to listen to: in a browser, or on an older shell. */
|
||||
export function onConnectionsReset(listener: () => void): (() => void) | null {
|
||||
const bridge = getDesktopBridge();
|
||||
const subscribe = bridge?.certificates?.onConnectionsReset;
|
||||
if (!subscribe) return null;
|
||||
if (wiredTo !== bridge) {
|
||||
wiredTo = bridge;
|
||||
subscribe(() => {
|
||||
for (const each of [...listeners]) each();
|
||||
});
|
||||
}
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
@@ -10,6 +10,21 @@ export function clearKeystoreGrants(): void {
|
||||
void Promise.resolve(getDesktopBridge()?.keystore?.clearGrants?.()).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets which client certificate goes to which server. An account's own sign-out takes what
|
||||
* was chosen under it. The last sign-out takes everything, with what the app keeps of it in
|
||||
* memory, so the next person at this computer gets asked. That restarts the app's connections,
|
||||
* so an account switch never comes here. Nothing happens in a browser or on a shell without the call.
|
||||
*/
|
||||
export function clearDesktopCertificateChoices(scope: { account?: string; last?: boolean }): void {
|
||||
void Promise.resolve(getDesktopBridge()?.certificates?.clearChoices?.(scope)).catch(() => {});
|
||||
}
|
||||
|
||||
/** Which account a certificate chosen from now on belongs to. */
|
||||
export function noteDesktopCertificateAccount(account: string | null): void {
|
||||
void Promise.resolve(getDesktopBridge()?.certificates?.setAccount?.(account)).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the crash files on this computer. They can hold the mail of whoever was signed
|
||||
* in when the app crashed, so the next account at this computer doesn't get to send them.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { getDesktopBridge } from './bridge';
|
||||
import { certificateLabels } from './certificate-labels';
|
||||
import { updateLabels } from './update-labels';
|
||||
|
||||
let ui = '';
|
||||
@@ -42,6 +43,7 @@ export function noteComposerLanguage(key: string, language: string | null): void
|
||||
export function shellLabels(t: (key: string, values?: Record<string, string>) => string): Record<string, string> {
|
||||
return {
|
||||
...updateLabels(t),
|
||||
...certificateLabels(t),
|
||||
editCancel: t('desktopEdit.cancel'),
|
||||
editDiscardTitle: t('desktopEdit.discard_title', { name: '{name}' }),
|
||||
editDiscardDetail: t('desktopEdit.discard_detail'),
|
||||
@@ -61,6 +63,7 @@ export function shellLabels(t: (key: string, values?: Record<string, string>) =>
|
||||
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'),
|
||||
|
||||
@@ -337,8 +337,8 @@ export const POLICY_SCHEMA: Record<PolicyKey, PolicyDefinition> = {
|
||||
label: { en: 'Servers', de: 'Server' },
|
||||
title: { en: 'Servers for integrated sign-in', de: 'Server für die integrierte Anmeldung' },
|
||||
description: {
|
||||
en: 'The servers that may use the Windows sign-in (Kerberos, NTLM), separated by commas, such as *.example.com. Takes effect when the app restarts.',
|
||||
de: 'Die Server, die die Windows-Anmeldung (Kerberos, NTLM) verwenden dürfen, durch Kommas getrennt, etwa *.example.com. Wird beim Neustart der App wirksam.',
|
||||
en: 'The servers that may use the Windows sign-in (Kerberos, NTLM), separated by commas, such as *.example.com. A server taken off the list stops getting it at once. A new one needs the app to restart.',
|
||||
de: 'Die Server, die die Windows-Anmeldung (Kerberos, NTLM) verwenden dürfen, durch Kommas getrennt, etwa *.example.com. Ein von der Liste entfernter Server erhält sie sofort nicht mehr. Ein neuer braucht einen Neustart der App.',
|
||||
},
|
||||
},
|
||||
AuthNegotiateDelegateAllowlist: {
|
||||
@@ -825,8 +825,8 @@ export const POLICY_CONSUMERS: Record<PolicyKey, PolicyConsumer> = {
|
||||
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' },
|
||||
AutoSelectCertificateForUrls: { pendingConsumer: 'step 7, client certificates' },
|
||||
QuickComposeShortcut: { repo: 'shell', file: 'src/main/features/os-quick-compose.ts' },
|
||||
AutoSelectCertificateForUrls: { repo: 'shell', file: 'src/main/features/client-certificates.ts' },
|
||||
KeystoreAllowedOrigins: { repo: 'shell', file: 'src/main/features/policies.ts' },
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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<string, string> = {
|
||||
control: '⌃',
|
||||
ctrl: '⌃',
|
||||
alt: '⌥',
|
||||
option: '⌥',
|
||||
altgr: '⌥',
|
||||
shift: '⇧',
|
||||
commandorcontrol: '⌘',
|
||||
cmdorctrl: '⌘',
|
||||
command: '⌘',
|
||||
cmd: '⌘',
|
||||
super: '⌘',
|
||||
meta: '⌘',
|
||||
};
|
||||
|
||||
const OTHER_NAMES: Record<string, string> = {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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})|(Enter))$/.exec(event.code);
|
||||
return code ? (code[1] ?? code[2] ?? code[3] ?? code[4]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function typesCharacter(event: ShortcutKeyEvent, platform: string | undefined, plainKey: string | undefined): boolean {
|
||||
const mac = platform === 'darwin';
|
||||
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'] : []),
|
||||
key,
|
||||
].join('+');
|
||||
const problem = shortcutProblem(accelerator, platform);
|
||||
return problem ? { refused: problem } : { accelerator };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -323,6 +325,19 @@ export interface DesktopKeystoreBridge {
|
||||
clearGrants?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface DesktopCertificatesBridge {
|
||||
/**
|
||||
* Forgets client certificates chosen for servers. `account`: those chosen while that account was
|
||||
* active, at its own sign-out. `last`: all of them, and what the app keeps of them in memory, at
|
||||
* the sign-out that leaves nobody. Only narrows.
|
||||
*/
|
||||
clearChoices: (scope: { account?: string; last?: boolean }) => Promise<void>;
|
||||
/** The account a choice made from now on belongs to. */
|
||||
setAccount: (account: string | null) => Promise<void>;
|
||||
/** The app's connections were closed: a request under way will never settle by itself. */
|
||||
onConnectionsReset: (listener: () => void) => () => void;
|
||||
}
|
||||
|
||||
export interface DesktopPolicyBridge {
|
||||
/** What's in force now. Synchronous: a page can read it in its first render. */
|
||||
get: () => PolicySnapshot;
|
||||
@@ -458,6 +473,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 +493,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 {
|
||||
@@ -716,6 +745,8 @@ export interface BulwarkDesktopBridge {
|
||||
auth?: DesktopAuthBridge;
|
||||
plugins?: DesktopPluginsBridge;
|
||||
keystore?: DesktopKeystoreBridge;
|
||||
/** Absent on older shells. */
|
||||
certificates?: DesktopCertificatesBridge;
|
||||
appLock?: DesktopAppLockBridge;
|
||||
localImport?: DesktopLocalImportBridge;
|
||||
privacy?: DesktopPrivacyBridge;
|
||||
|
||||
+28
-4
@@ -1,5 +1,6 @@
|
||||
import { recordJmapCall, reportJmapFailure } from "@/lib/error-reporting/client";
|
||||
import { getDesktopBridge } from '@/lib/desktop/bridge';
|
||||
import { onConnectionsReset } from '@/lib/desktop/connections-reset';
|
||||
import { desktopJmapBase, desktopJmapOrigin, isDesktopProxiedServer, withDesktopBlobType } from '@/lib/desktop/jmap-route';
|
||||
import { advertisesMailboxMetadata, bulwarkEntries, escapeMetadataPathSegment, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataPatchError, MetadataRefusedError, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata";
|
||||
import { IncompleteDeleteError } from "./delete-error";
|
||||
@@ -1855,14 +1856,23 @@ export class JMAPClient implements IJMAPClient {
|
||||
// "we gave up" from "the caller cancelled", and callers must be able to tell
|
||||
// those apart (a cancelled send is not a failed send).
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
const giveUp = () => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
// The reason goes on to a body still being read after this returned.
|
||||
controller.abort(new RequestTimeoutError(timeoutMs));
|
||||
};
|
||||
const timer = setTimeout(giveUp, timeoutMs);
|
||||
// The desktop app closed its connections, so this will never settle by itself. It's what a
|
||||
// deadline running out means: the request may have reached the server, only the answer is
|
||||
// lost, and it isn't replayed (authenticatedFetch).
|
||||
const stopWatching = onConnectionsReset(giveUp);
|
||||
|
||||
try {
|
||||
return await fetch(url, { ...withJmapCredentials(init), headers, signal: controller.signal });
|
||||
const response = await fetch(url, { ...withJmapCredentials(init), headers, signal: controller.signal });
|
||||
if (stopWatching) setTimeout(stopWatching, timeoutMs);
|
||||
return response;
|
||||
} catch (error) {
|
||||
stopWatching?.();
|
||||
if (timedOut) throw new RequestTimeoutError(timeoutMs);
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -6363,6 +6373,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
stopWatchingConnections?.();
|
||||
xhr.onload = xhr.onerror = xhr.onabort = xhr.ontimeout = null;
|
||||
xhr.upload.onprogress = null;
|
||||
};
|
||||
@@ -6379,6 +6390,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
};
|
||||
const onAbort = () => abortWith(new DOMException('Upload aborted', 'AbortError'));
|
||||
const onTimeout = () => abortWith(new RequestTimeoutError(JMAPClient.TRANSFER_TIMEOUT_MS));
|
||||
const stopWatchingConnections = onConnectionsReset(onTimeout);
|
||||
const resetWatchdog = () => {
|
||||
clearTimeout(timer);
|
||||
// XHR uses this budget for inactivity, unlike fetch's deadline until
|
||||
@@ -11670,6 +11682,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
* Checking on the way back in makes the recovery immediate rather than
|
||||
* leaving the app on a socket that will never deliver another byte.
|
||||
*/
|
||||
private stopWatchingConnections: (() => void) | null = null;
|
||||
|
||||
private recycleStaleSSE(): void {
|
||||
if (this.intentionallyDisconnected) return;
|
||||
if (!this.sseAbortController) return;
|
||||
@@ -11704,6 +11718,14 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
};
|
||||
window.addEventListener('online', this.onlineHandler);
|
||||
// A stream under way when the desktop app closes its connections would stay silent.
|
||||
this.stopWatchingConnections = onConnectionsReset(() => {
|
||||
if (this.intentionallyDisconnected || !this.sseAbortController) return;
|
||||
this.stopSSEPingMonitor();
|
||||
this.sseAbortController.abort();
|
||||
this.sseAbortController = null;
|
||||
this.scheduleSSEReconnect();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11716,6 +11738,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
window.removeEventListener('online', this.onlineHandler);
|
||||
this.onlineHandler = null;
|
||||
}
|
||||
this.stopWatchingConnections?.();
|
||||
this.stopWatchingConnections = null;
|
||||
}
|
||||
|
||||
onConnectionChange(callback: (connected: boolean) => void): void {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
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, 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, 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);
|
||||
FakeBroadcastChannel.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
takeReturnedComposer();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
/** Plays the main window's end of the channel. */
|
||||
function mainWindow(onMessage: (message: PopoutMessage, post: (m: PopoutMessage) => void) => void) {
|
||||
const heard: PopoutMessage[] = [];
|
||||
const channel = openPopoutChannel((message) => {
|
||||
heard.push(message);
|
||||
onMessage(message, (m) => channel?.post(m));
|
||||
});
|
||||
return heard;
|
||||
}
|
||||
|
||||
describe('from the pop-out', () => {
|
||||
it('sends the draft and its login, commits when the main window is ready, and goes by its answer', async () => {
|
||||
const seen: unknown[] = [];
|
||||
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', 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) => {
|
||||
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 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', 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(), 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();
|
||||
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('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({ returnId: 'a', payload: PAYLOAD, answer, claim: async () => true });
|
||||
expect(answer).not.toHaveBeenCalled();
|
||||
const stop = registerComposeFlush(async () => {});
|
||||
expect(answer).toHaveBeenCalledWith('shown');
|
||||
stop();
|
||||
});
|
||||
});
|
||||
@@ -96,6 +96,11 @@ 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-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' }
|
||||
| { type: 'main-pong'; windowId: string; accountIds: string[] }
|
||||
| { type: 'main-gone'; windowId: string }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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';
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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';
|
||||
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;
|
||||
/** 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 | undefined,
|
||||
mainStillThere: () => boolean,
|
||||
waitMs = RETURN_WAIT_MS,
|
||||
): Promise<ReturnResult> {
|
||||
return new Promise((resolve) => {
|
||||
const returnId = newPopoutId();
|
||||
let aliveTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const finish = (result: ReturnResult) => {
|
||||
clearTimeout(timer);
|
||||
if (aliveTimer) clearInterval(aliveTimer);
|
||||
channel?.close();
|
||||
resolve(result);
|
||||
};
|
||||
const channel = openPopoutChannel((message) => {
|
||||
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(() => {
|
||||
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;
|
||||
/** Asks the pop-out to go ahead. False: it has given up, and keeps the text. */
|
||||
claim: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
let waiting: ReturnedComposer | null = null;
|
||||
const mailListeners = new Set<() => void>();
|
||||
const elsewhereListeners = new Set<() => void>();
|
||||
const commits = new Map<string, (go: boolean) => 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(incoming: Omit<ReturnedComposer, 'claim'> & { sendReady: () => void }): void {
|
||||
const { sendReady, ...returned } = incoming;
|
||||
if (waiting) {
|
||||
returned.answer('busy');
|
||||
return;
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
export function takeReturnedComposer(): ReturnedComposer | null {
|
||||
const returned = waiting;
|
||||
waiting = 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. */
|
||||
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. 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');
|
||||
});
|
||||
}
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "سحب الإذن",
|
||||
"revoked": "تم سحب الإذن"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "يطلب {host} شهادة",
|
||||
"picker_detail": "يريد الخادم معرفة هويتك. الشهادة التي تختارها تذهب إلى هذا الخادم فقط. إذا كنت لا تعرف هذا الخادم، فلا ترسل شهادة.",
|
||||
"picker_issued": "صادرة عن {issuer}، صالحة حتى {date}",
|
||||
"picker_remember": "تذكّر هذا لـ {host}",
|
||||
"picker_none": "عدم إرسال شهادة",
|
||||
"picker_more": "المزيد من الشهادات…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "تطبيق سطح المكتب",
|
||||
"section_description": "طريقة عمل التطبيق مع حاسوبك.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "لم تُرفق أو تُحفظ بعد، وتُحذف الآن: {names}.",
|
||||
"signout_held": "ما زالت مفتوحة في برنامج آخر، لذا لن تُحذف إلا بعد إغلاقها هناك: {names}."
|
||||
},
|
||||
"desktopQuickCompose": {
|
||||
"label": "كتابة سريعة",
|
||||
"menu": "كتابة سريعة",
|
||||
"description": "يفتح {keys} نافذة صغيرة لرسالة جديدة من أي تطبيق آخر.",
|
||||
"description_off": "يفتح نافذة صغيرة لرسالة جديدة من أي تطبيق آخر.",
|
||||
"taken": "يستخدم تطبيق آخر {keys}، لذلك لا يعمل هنا. اختر مفاتيح أخرى أو أغلق ذلك التطبيق.",
|
||||
"change": "تغيير المفاتيح: {keys}",
|
||||
"recording": "اضغط المفاتيح…",
|
||||
"recording_hint": "اضغط مع الاستمرار على Ctrl (أو Cmd في Mac) مع Alt أو Shift، ثم اضغط حرفًا أو رقمًا أو Enter أو مفتاح وظيفة. يلغي Esc العملية.",
|
||||
"back_to_main": "نقل إلى النافذة الرئيسية",
|
||||
"back_busy": "توجد رسالة مفتوحة بالفعل في النافذة الرئيسية. أغلقها ثم حاول مرة أخرى.",
|
||||
"back_no_answer": "لم تستجب النافذة الرئيسية. تبقى رسالتك هنا.",
|
||||
"types_character": "تكتب هذه المفاتيح حرفًا على لوحة مفاتيحك، وسيتوقف عن العمل في كل التطبيقات. جرّب مفاتيح أخرى.",
|
||||
"reserved": "يستخدم النظام أو معظم التطبيقات هذه المفاتيح. جرّب مفاتيح أخرى."
|
||||
},
|
||||
"desktopReminders": {
|
||||
"generic_title": "تذكير",
|
||||
"snooze": "تأجيل {count} د",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "Retira",
|
||||
"revoked": "Permís retirat"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} demana un certificat",
|
||||
"picker_detail": "El servidor vol saber qui ets. El certificat que triïs només va a aquest servidor. Si no coneixes aquest servidor, no n'enviïs cap.",
|
||||
"picker_issued": "Emès per {issuer}, vàlid fins al {date}",
|
||||
"picker_remember": "Recorda-ho per a {host}",
|
||||
"picker_none": "No enviïs cap certificat",
|
||||
"picker_more": "Més certificats…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "Aplicació d'escriptori",
|
||||
"section_description": "Com funciona l'aplicació amb l'ordinador.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "Encara sense adjuntar ni desar, i eliminats ara: {names}.",
|
||||
"signout_held": "Encara oberts en un altre programa, així que no s’eliminen fins que els tanqueu allà: {names}."
|
||||
},
|
||||
"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 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í.",
|
||||
"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",
|
||||
"snooze": "Posposa {count} min",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "Odvolat",
|
||||
"revoked": "Povolení bylo odvoláno"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} žádá o certifikát",
|
||||
"picker_detail": "Server chce vědět, kdo jste. Certifikát, který vyberete, půjde jen na tento server. Pokud tento server neznáte, žádný neposílejte.",
|
||||
"picker_issued": "Vydal {issuer}, platí do {date}",
|
||||
"picker_remember": "Zapamatovat pro {host}",
|
||||
"picker_none": "Neposílat certifikát",
|
||||
"picker_more": "Další certifikáty…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "Desktopová aplikace",
|
||||
"section_description": "Jak aplikace spolupracuje s vaším počítačem.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "Zatím nepřipojené ani neuložené, a teď smazané: {names}.",
|
||||
"signout_held": "Stále otevřené v jiném programu, takže se smažou až po zavření: {names}."
|
||||
},
|
||||
"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 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.",
|
||||
"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í",
|
||||
"snooze": "Odložit o {count} min",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "Træk tilbage",
|
||||
"revoked": "Tilladelsen er trukket tilbage"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} beder om et certifikat",
|
||||
"picker_detail": "Serveren vil vide, hvem du er. Et certifikat, du vælger, sendes kun til denne server. Hvis du ikke kender serveren, så send ikke noget.",
|
||||
"picker_issued": "Udstedt af {issuer}, gyldigt til {date}",
|
||||
"picker_remember": "Husk dette for {host}",
|
||||
"picker_none": "Send ikke et certifikat",
|
||||
"picker_more": "Flere certifikater…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "Skrivebordsapp",
|
||||
"section_description": "Sådan arbejder appen sammen med din computer.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "Endnu ikke vedhæftet eller gemt, og slettet nu: {names}.",
|
||||
"signout_held": "Stadig åbne i et andet program, så de slettes først, når du lukker dem der: {names}."
|
||||
},
|
||||
"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 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.",
|
||||
"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",
|
||||
"snooze": "Udsæt {count} min.",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "Zurücknehmen",
|
||||
"revoked": "Erlaubnis zurückgenommen"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} fragt nach einem Zertifikat",
|
||||
"picker_detail": "Der Server möchte wissen, wer Sie sind. Ein Zertifikat, das Sie wählen, geht nur an diesen Server. Wenn Sie den Server nicht kennen, senden Sie keines.",
|
||||
"picker_issued": "Ausgestellt von {issuer}, gültig bis {date}",
|
||||
"picker_remember": "Für {host} merken",
|
||||
"picker_none": "Kein Zertifikat senden",
|
||||
"picker_more": "Weitere Zertifikate…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "Desktop-App",
|
||||
"section_description": "Wie die App mit Ihrem Computer zusammenarbeitet.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "Noch nicht angehängt oder gespeichert, und jetzt gelöscht: {names}.",
|
||||
"signout_held": "Noch in einem anderen Programm geöffnet, daher erst gelöscht, wenn Sie sie dort schließen: {names}."
|
||||
},
|
||||
"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 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.",
|
||||
"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",
|
||||
"snooze": "{count} Min. schlummern",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "Take back",
|
||||
"revoked": "Permission taken back"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} asks for a certificate",
|
||||
"picker_detail": "The server wants to know who you are. A certificate you choose goes to this server only. If you don't know this server, don't send one.",
|
||||
"picker_issued": "Issued by {issuer}, valid until {date}",
|
||||
"picker_remember": "Remember this for {host}",
|
||||
"picker_none": "Don't send a certificate",
|
||||
"picker_more": "More certificates…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "Desktop app",
|
||||
"section_description": "How the app works with your computer.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "Not attached or saved yet, and deleted now: {names}.",
|
||||
"signout_held": "Still open in another program, so not deleted until you close it there: {names}."
|
||||
},
|
||||
"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 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.",
|
||||
"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",
|
||||
"snooze": "Snooze {count} min",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "Retirar",
|
||||
"revoked": "Permiso retirado"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} pide un certificado",
|
||||
"picker_detail": "El servidor quiere saber quién eres. El certificado que elijas solo se envía a este servidor. Si no conoces este servidor, no envíes ninguno.",
|
||||
"picker_issued": "Emitido por {issuer}, válido hasta {date}",
|
||||
"picker_remember": "Recordar para {host}",
|
||||
"picker_none": "No enviar ningún certificado",
|
||||
"picker_more": "Más certificados…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "Aplicación de escritorio",
|
||||
"section_description": "Cómo funciona la aplicación con tu ordenador.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "Aún sin adjuntar ni guardar, y eliminados ahora: {names}.",
|
||||
"signout_held": "Siguen abiertos en otro programa, así que no se eliminan hasta que los cierre allí: {names}."
|
||||
},
|
||||
"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 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í.",
|
||||
"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",
|
||||
"snooze": "Posponer {count} min",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "Võta tagasi",
|
||||
"revoked": "Luba võeti tagasi"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} küsib sertifikaati",
|
||||
"picker_detail": "Server tahab teada, kes sa oled. Sinu valitud sertifikaat läheb ainult sellele serverile. Kui sa seda serverit ei tunne, ära seda saada.",
|
||||
"picker_issued": "Väljaandja {issuer}, kehtib kuni {date}",
|
||||
"picker_remember": "Jäta {host} jaoks meelde",
|
||||
"picker_none": "Ära saada sertifikaati",
|
||||
"picker_more": "Rohkem sertifikaate…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "Töölauarakendus",
|
||||
"section_description": "Kuidas rakendus sinu arvutiga koos töötab.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "Veel lisamata ja salvestamata ning nüüd kustutatud: {names}.",
|
||||
"signout_held": "Endiselt teises programmis avatud, seega kustutatakse alles pärast sulgemist: {names}."
|
||||
},
|
||||
"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 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.",
|
||||
"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",
|
||||
"snooze": "Lükka {count} min edasi",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "پس گرفتن",
|
||||
"revoked": "اجازه پس گرفته شد"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} یک گواهی میخواهد",
|
||||
"picker_detail": "سرور میخواهد بداند شما چه کسی هستید. گواهیای که انتخاب میکنید فقط به این سرور فرستاده میشود. اگر این سرور را نمیشناسید، گواهی نفرستید.",
|
||||
"picker_issued": "صادرشده توسط {issuer}، معتبر تا {date}",
|
||||
"picker_remember": "برای {host} به خاطر بسپار",
|
||||
"picker_none": "گواهی نفرست",
|
||||
"picker_more": "گواهیهای بیشتر…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "برنامه دسکتاپ",
|
||||
"section_description": "نحوه کار برنامه با رایانه شما.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "هنوز پیوست یا ذخیره نشده و اکنون حذف میشود: {names}.",
|
||||
"signout_held": "هنوز در برنامه دیگری باز است، پس فقط پس از بستن در همانجا حذف میشود: {names}."
|
||||
},
|
||||
"desktopQuickCompose": {
|
||||
"label": "نوشتن سریع",
|
||||
"menu": "نوشتن سریع",
|
||||
"description": "{keys} از هر برنامه دیگری یک پنجره کوچک برای پیام جدید باز میکند.",
|
||||
"description_off": "از هر برنامه دیگری یک پنجره کوچک برای پیام جدید باز میکند.",
|
||||
"taken": "برنامه دیگری از {keys} استفاده میکند، پس اینجا کاری انجام نمیدهد. کلیدهای دیگری انتخاب کنید یا آن برنامه را ببندید.",
|
||||
"change": "تغییر کلیدها: {keys}",
|
||||
"recording": "کلیدها را فشار دهید…",
|
||||
"recording_hint": "Ctrl (در Mac کلید Cmd) را همراه با Alt یا Shift نگه دارید و یک حرف، عدد، Enter یا کلید تابعی را فشار دهید. Esc لغو میکند.",
|
||||
"back_to_main": "انتقال به پنجره اصلی",
|
||||
"back_busy": "پنجره اصلی از قبل یک پیام باز دارد. آن را ببندید و دوباره امتحان کنید.",
|
||||
"back_no_answer": "پنجره اصلی پاسخ نداد. پیام شما اینجا میماند.",
|
||||
"types_character": "این کلیدها روی صفحهکلید شما نویسهای مینویسند و آن نویسه دیگر در هیچ برنامهای کار نمیکند. کلیدهای دیگری را امتحان کنید.",
|
||||
"reserved": "سیستم یا بیشتر برنامهها از این کلیدها استفاده میکنند. کلیدهای دیگری را امتحان کنید."
|
||||
},
|
||||
"desktopReminders": {
|
||||
"generic_title": "یادآوری",
|
||||
"snooze": "تعویق {count} دقیقه",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "Retirer",
|
||||
"revoked": "Autorisation retirée"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} demande un certificat",
|
||||
"picker_detail": "Le serveur veut savoir qui vous êtes. Le certificat que vous choisissez n'est envoyé qu'à ce serveur. Si vous ne connaissez pas ce serveur, n'en envoyez pas.",
|
||||
"picker_issued": "Émis par {issuer}, valable jusqu'au {date}",
|
||||
"picker_remember": "Mémoriser pour {host}",
|
||||
"picker_none": "Ne pas envoyer de certificat",
|
||||
"picker_more": "Plus de certificats…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "Application de bureau",
|
||||
"section_description": "Comment l'application fonctionne avec votre ordinateur.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "Pas encore jointes ni enregistrées, et supprimées maintenant : {names}.",
|
||||
"signout_held": "Encore ouvertes dans un autre programme, donc supprimées seulement une fois fermées : {names}."
|
||||
},
|
||||
"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 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.",
|
||||
"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",
|
||||
"snooze": "Reporter de {count} min",
|
||||
|
||||
@@ -6928,6 +6928,14 @@
|
||||
"revoke": "ביטול",
|
||||
"revoked": "ההרשאה בוטלה"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} מבקש אישור",
|
||||
"picker_detail": "השרת רוצה לדעת מי אתם. האישור שתבחרו יישלח לשרת הזה בלבד. אם אינכם מכירים את השרת הזה, אל תשלחו אישור.",
|
||||
"picker_issued": "הונפק על ידי {issuer}, בתוקף עד {date}",
|
||||
"picker_remember": "לזכור עבור {host}",
|
||||
"picker_none": "לא לשלוח אישור",
|
||||
"picker_more": "אישורים נוספים…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "יישום שולחן עבודה",
|
||||
"section_description": "איך היישום עובד עם המחשב שלך.",
|
||||
@@ -7004,6 +7012,21 @@
|
||||
"signout_changed": "עדיין לא צורפו או נשמרו, ונמחקים עכשיו: {names}.",
|
||||
"signout_held": "עדיין פתוחים בתוכנה אחרת, ולכן יימחקו רק אחרי שתסגרו אותם שם: {names}."
|
||||
},
|
||||
"desktopQuickCompose": {
|
||||
"label": "כתיבה מהירה",
|
||||
"menu": "כתיבה מהירה",
|
||||
"description": "{keys} פותח חלון קטן להודעה חדשה מכל אפליקציה אחרת.",
|
||||
"description_off": "פותח חלון קטן להודעה חדשה מכל אפליקציה אחרת.",
|
||||
"taken": "אפליקציה אחרת משתמשת ב-{keys}, ולכן כאן זה לא עושה כלום. בחרו מקשים אחרים או סגרו את האפליקציה הזו.",
|
||||
"change": "שינוי המקשים: {keys}",
|
||||
"recording": "לחצו על המקשים…",
|
||||
"recording_hint": "החזיקו Ctrl (Cmd ב-Mac) יחד עם Alt או Shift, ולחצו על אות, מספר, Enter או מקש פונקציה. Esc מבטל.",
|
||||
"back_to_main": "העברה לחלון הראשי",
|
||||
"back_busy": "בחלון הראשי כבר פתוחה הודעה. סגרו אותה ונסו שוב.",
|
||||
"back_no_answer": "החלון הראשי לא ענה. ההודעה שלכם נשארת כאן.",
|
||||
"types_character": "המקשים האלה מקלידים תו במקלדת שלכם, והוא יפסיק לעבוד בכל האפליקציות. נסו מקשים אחרים.",
|
||||
"reserved": "המערכת או רוב האפליקציות משתמשות במקשים האלה. נסו מקשים אחרים."
|
||||
},
|
||||
"desktopReminders": {
|
||||
"generic_title": "תזכורת",
|
||||
"snooze": "דחייה ב-{count} דק'",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "Visszavonás",
|
||||
"revoked": "Engedély visszavonva"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "A(z) {host} tanúsítványt kér",
|
||||
"picker_detail": "A kiszolgáló tudni szeretné, ki vagy. A kiválasztott tanúsítvány csak ehhez a kiszolgálóhoz kerül. Ha nem ismered ezt a kiszolgálót, ne küldj tanúsítványt.",
|
||||
"picker_issued": "Kiállító: {issuer}, érvényes eddig: {date}",
|
||||
"picker_remember": "Megjegyzés ehhez: {host}",
|
||||
"picker_none": "Ne küldjön tanúsítványt",
|
||||
"picker_more": "További tanúsítványok…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "Asztali alkalmazás",
|
||||
"section_description": "Hogyan működik az alkalmazás a számítógépével.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "Még nincs csatolva vagy mentve, és most törlődik: {names}.",
|
||||
"signout_held": "Még nyitva van egy másik programban, ezért csak a bezárása után törlődik: {names}."
|
||||
},
|
||||
"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": "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.",
|
||||
"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ő",
|
||||
"snooze": "Halasztás {count} perccel",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "Ritira",
|
||||
"revoked": "Permesso ritirato"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} chiede un certificato",
|
||||
"picker_detail": "Il server vuole sapere chi sei. Il certificato che scegli va solo a questo server. Se non conosci questo server, non inviarne nessuno.",
|
||||
"picker_issued": "Emesso da {issuer}, valido fino al {date}",
|
||||
"picker_remember": "Ricorda per {host}",
|
||||
"picker_none": "Non inviare un certificato",
|
||||
"picker_more": "Altri certificati…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "App desktop",
|
||||
"section_description": "Come l'app funziona con il tuo computer.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "Non ancora allegati o salvati, ed eliminati ora: {names}.",
|
||||
"signout_held": "Ancora aperti in un altro programma, quindi eliminati solo quando li chiudi lì: {names}."
|
||||
},
|
||||
"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 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.",
|
||||
"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",
|
||||
"snooze": "Rimanda di {count} min",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "取り消す",
|
||||
"revoked": "許可を取り消しました"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} が証明書を求めています",
|
||||
"picker_detail": "サーバーがあなたを確認しようとしています。選んだ証明書はこのサーバーにだけ送られます。このサーバーに心当たりがない場合は送らないでください。",
|
||||
"picker_issued": "発行者: {issuer}、有効期限: {date}",
|
||||
"picker_remember": "{host} について記憶する",
|
||||
"picker_none": "証明書を送らない",
|
||||
"picker_more": "他の証明書…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "デスクトップアプリ",
|
||||
"section_description": "アプリとパソコンの連携方法です。",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "まだ添付も保存もされておらず、今削除されます: {names}。",
|
||||
"signout_held": "まだ別のプログラムで開かれているため、そちらで閉じるまで削除されません: {names}。"
|
||||
},
|
||||
"desktopQuickCompose": {
|
||||
"label": "クイック作成",
|
||||
"menu": "クイック作成",
|
||||
"description": "{keys} で、ほかのどのアプリからでも新規メッセージ用の小さなウィンドウを開きます。",
|
||||
"description_off": "ほかのどのアプリからでも新規メッセージ用の小さなウィンドウを開きます。",
|
||||
"taken": "{keys} は別のアプリが使用中のため、ここでは動作しません。別のキーを選ぶか、そのアプリを閉じてください。",
|
||||
"change": "キーを変更: {keys}",
|
||||
"recording": "キーを押してください…",
|
||||
"recording_hint": "Ctrl (Mac では Cmd) と Alt または Shift を押したまま、文字、数字、Enter、ファンクションキーのいずれかを押します。Esc でキャンセルします。",
|
||||
"back_to_main": "メインウィンドウに移動",
|
||||
"back_busy": "メインウィンドウで別のメッセージが開いています。それを閉じてからもう一度お試しください。",
|
||||
"back_no_answer": "メインウィンドウから応答がありません。メッセージはここに残ります。",
|
||||
"types_character": "このキーはお使いのキーボードで文字を入力します。割り当てると、すべてのアプリでその文字を入力できなくなります。別のキーをお試しください。",
|
||||
"reserved": "このキーはシステムまたは多くのアプリが使用しています。別のキーをお試しください。"
|
||||
},
|
||||
"desktopReminders": {
|
||||
"generic_title": "リマインダー",
|
||||
"snooze": "{count} 分後に再通知",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "취소",
|
||||
"revoked": "허용을 취소했습니다"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host}에서 인증서를 요청합니다",
|
||||
"picker_detail": "서버에서 사용자를 확인하려고 합니다. 선택한 인증서는 이 서버로만 전송됩니다. 모르는 서버라면 보내지 마세요.",
|
||||
"picker_issued": "발급자: {issuer}, 유효 기간: {date}까지",
|
||||
"picker_remember": "{host}에 대해 기억",
|
||||
"picker_none": "인증서 보내지 않기",
|
||||
"picker_more": "다른 인증서…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "데스크톱 앱",
|
||||
"section_description": "앱이 컴퓨터와 함께 작동하는 방식입니다.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "아직 첨부하거나 저장하지 않았으며 지금 삭제됩니다: {names}.",
|
||||
"signout_held": "아직 다른 프로그램에서 열려 있어 그곳에서 닫아야 삭제됩니다: {names}."
|
||||
},
|
||||
"desktopQuickCompose": {
|
||||
"label": "빠른 작성",
|
||||
"menu": "빠른 작성",
|
||||
"description": "{keys}를 누르면 다른 앱에서도 새 메시지용 작은 창이 열립니다.",
|
||||
"description_off": "다른 앱에서도 새 메시지용 작은 창을 엽니다.",
|
||||
"taken": "다른 앱이 {keys}를 사용 중이라 여기서는 작동하지 않습니다. 다른 키를 고르거나 그 앱을 닫으세요.",
|
||||
"change": "키 변경: {keys}",
|
||||
"recording": "키를 누르세요…",
|
||||
"recording_hint": "Ctrl(Mac에서는 Cmd)과 Alt 또는 Shift를 누른 채 문자, 숫자, Enter 또는 기능 키를 누르세요. Esc를 누르면 취소됩니다.",
|
||||
"back_to_main": "기본 창으로 이동",
|
||||
"back_busy": "기본 창에 이미 열린 메시지가 있습니다. 그 메시지를 닫고 다시 시도하세요.",
|
||||
"back_no_answer": "기본 창이 응답하지 않았습니다. 메시지는 여기에 그대로 있습니다.",
|
||||
"types_character": "이 키는 사용 중인 키보드에서 문자를 입력하며, 지정하면 모든 앱에서 그 문자를 입력할 수 없게 됩니다. 다른 키를 사용해 보세요.",
|
||||
"reserved": "시스템 또는 대부분의 앱이 이 키를 사용합니다. 다른 키를 사용해 보세요."
|
||||
},
|
||||
"desktopReminders": {
|
||||
"generic_title": "알림",
|
||||
"snooze": "{count}분 후 다시 알림",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "Atsaukt",
|
||||
"revoked": "Atļauja atsaukta"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} prasa sertifikātu",
|
||||
"picker_detail": "Serveris vēlas zināt, kas jūs esat. Jūsu izvēlētais sertifikāts tiks nosūtīts tikai šim serverim. Ja nepazīstat šo serveri, nesūtiet to.",
|
||||
"picker_issued": "Izdevējs: {issuer}, derīgs līdz {date}",
|
||||
"picker_remember": "Atcerēties vietnei {host}",
|
||||
"picker_none": "Nesūtīt sertifikātu",
|
||||
"picker_more": "Vairāk sertifikātu…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "Darbvirsmas lietotne",
|
||||
"section_description": "Kā lietotne darbojas ar jūsu datoru.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "Vēl nav pievienoti vai saglabāti, un tagad izdzēsti: {names}.",
|
||||
"signout_held": "Joprojām atvērti citā programmā, tāpēc tiks izdzēsti tikai pēc aizvēršanas: {names}."
|
||||
},
|
||||
"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 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.",
|
||||
"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",
|
||||
"snooze": "Atlikt par {count} min",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "Буцааж авах",
|
||||
"revoked": "Зөвшөөрлийг буцааж авлаа"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} гэрчилгээ хүсэж байна",
|
||||
"picker_detail": "Сервер таныг хэн болохыг мэдэхийг хүсэж байна. Таны сонгосон гэрчилгээ зөвхөн энэ сервер рүү очно. Хэрэв та энэ серверийг мэдэхгүй бол бүү илгээ.",
|
||||
"picker_issued": "Олгосон: {issuer}, хүчинтэй хугацаа: {date} хүртэл",
|
||||
"picker_remember": "{host}-д санах",
|
||||
"picker_none": "Гэрчилгээ илгээхгүй",
|
||||
"picker_more": "Бусад гэрчилгээ…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "Ширээний апп",
|
||||
"section_description": "Апп таны компьютертэй хэрхэн ажиллах.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "Хавсаргаагүй, хадгалаагүй бөгөөд одоо устгагдана: {names}.",
|
||||
"signout_held": "Өөр программд нээлттэй хэвээр тул тэнд хаасны дараа л устана: {names}."
|
||||
},
|
||||
"desktopQuickCompose": {
|
||||
"label": "Хурдан бичих",
|
||||
"menu": "Хурдан бичих",
|
||||
"description": "{keys} нь өөр ямар ч аппаас шинэ мессежийн жижиг цонх нээнэ.",
|
||||
"description_off": "Өөр ямар ч аппаас шинэ мессежийн жижиг цонх нээнэ.",
|
||||
"taken": "{keys}-г өөр апп ашиглаж байгаа тул энд ажиллахгүй. Өөр товч сонгох эсвэл тэр аппыг хаана уу.",
|
||||
"change": "Товчийг өөрчлөх: {keys}",
|
||||
"recording": "Товчнуудыг дарна уу…",
|
||||
"recording_hint": "Ctrl (Mac дээр Cmd)-г Alt эсвэл Shift-тэй хамт дарж байгаад үсэг, тоо, Enter эсвэл функц товч дарна уу. Esc цуцална.",
|
||||
"back_to_main": "Үндсэн цонх руу зөөх",
|
||||
"back_busy": "Үндсэн цонхонд аль хэдийн мессеж нээлттэй байна. Түүнийг хаагаад дахин оролдоно уу.",
|
||||
"back_no_answer": "Үндсэн цонх хариу өгсөнгүй. Таны мессеж энд үлдэнэ.",
|
||||
"types_character": "Эдгээр товч таны гар дээр тэмдэгт бичдэг тул тэр тэмдэгт бүх аппад ажиллахаа болино. Өөр товч туршина уу.",
|
||||
"reserved": "Систем эсвэл ихэнх апп эдгээр товчийг ашигладаг. Өөр товч туршина уу."
|
||||
},
|
||||
"desktopReminders": {
|
||||
"generic_title": "Сануулга",
|
||||
"snooze": "{count} минут хойшлуулах",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "Trekk tilbake",
|
||||
"revoked": "Tillatelsen er trukket tilbake"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} ber om et sertifikat",
|
||||
"picker_detail": "Serveren vil vite hvem du er. Et sertifikat du velger, går bare til denne serveren. Hvis du ikke kjenner serveren, ikke send noe.",
|
||||
"picker_issued": "Utstedt av {issuer}, gyldig til {date}",
|
||||
"picker_remember": "Husk dette for {host}",
|
||||
"picker_none": "Ikke send et sertifikat",
|
||||
"picker_more": "Flere sertifikater…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "Skrivebordsapp",
|
||||
"section_description": "Slik samarbeider appen med datamaskinen din.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "Ikke lagt ved eller lagret ennå, og slettet nå: {names}.",
|
||||
"signout_held": "Fortsatt åpne i et annet program, så de slettes først når du lukker dem der: {names}."
|
||||
},
|
||||
"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 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.",
|
||||
"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",
|
||||
"snooze": "Utsett {count} min",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "Intrekken",
|
||||
"revoked": "Toestemming ingetrokken"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} vraagt om een certificaat",
|
||||
"picker_detail": "De server wil weten wie je bent. Een certificaat dat je kiest, gaat alleen naar deze server. Ken je deze server niet, stuur er dan geen.",
|
||||
"picker_issued": "Uitgegeven door {issuer}, geldig tot {date}",
|
||||
"picker_remember": "Onthouden voor {host}",
|
||||
"picker_none": "Geen certificaat sturen",
|
||||
"picker_more": "Meer certificaten…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "Desktop-app",
|
||||
"section_description": "Hoe de app samenwerkt met je computer.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "Nog niet bijgevoegd of opgeslagen, en nu verwijderd: {names}.",
|
||||
"signout_held": "Nog geopend in een ander programma, dus pas verwijderd als u ze daar sluit: {names}."
|
||||
},
|
||||
"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 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.",
|
||||
"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",
|
||||
"snooze": "{count} min uitstellen",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "Cofnij",
|
||||
"revoked": "Zgoda została cofnięta"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} prosi o certyfikat",
|
||||
"picker_detail": "Serwer chce wiedzieć, kim jesteś. Wybrany certyfikat trafi tylko do tego serwera. Jeśli nie znasz tego serwera, nie wysyłaj go.",
|
||||
"picker_issued": "Wystawca: {issuer}, ważny do {date}",
|
||||
"picker_remember": "Zapamiętaj dla {host}",
|
||||
"picker_none": "Nie wysyłaj certyfikatu",
|
||||
"picker_more": "Więcej certyfikatów…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "Aplikacja na komputer",
|
||||
"section_description": "Jak aplikacja współpracuje z Twoim komputerem.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "Jeszcze niedołączone ani niezapisane, a teraz usunięte: {names}.",
|
||||
"signout_held": "Nadal otwarte w innym programie, więc zostaną usunięte dopiero po zamknięciu: {names}."
|
||||
},
|
||||
"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 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.",
|
||||
"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",
|
||||
"snooze": "Odłóż o {count} min",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "Retirar",
|
||||
"revoked": "Permissão retirada"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} pede um certificado",
|
||||
"picker_detail": "O servidor quer saber quem é. O certificado que escolher vai apenas para este servidor. Se não conhece este servidor, não envie nenhum.",
|
||||
"picker_issued": "Emitido por {issuer}, válido até {date}",
|
||||
"picker_remember": "Lembrar para {host}",
|
||||
"picker_none": "Não enviar um certificado",
|
||||
"picker_more": "Mais certificados…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "Aplicação de ambiente de trabalho",
|
||||
"section_description": "Como a aplicação funciona com o seu computador.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "Ainda não anexados nem guardados, e eliminados agora: {names}.",
|
||||
"signout_held": "Ainda abertos noutro programa, por isso só são eliminados depois de os fechar lá: {names}."
|
||||
},
|
||||
"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 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.",
|
||||
"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",
|
||||
"snooze": "Adiar {count} min",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "Retrage",
|
||||
"revoked": "Permisiune retrasă"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} cere un certificat",
|
||||
"picker_detail": "Serverul vrea să știe cine ești. Certificatul pe care îl alegi merge doar la acest server. Dacă nu cunoști acest server, nu trimite niciunul.",
|
||||
"picker_issued": "Emis de {issuer}, valabil până la {date}",
|
||||
"picker_remember": "Reține pentru {host}",
|
||||
"picker_none": "Nu trimite un certificat",
|
||||
"picker_more": "Mai multe certificate…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "Aplicație desktop",
|
||||
"section_description": "Cum funcționează aplicația cu computerul tău.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "Încă neatașate sau nesalvate, și șterse acum: {names}.",
|
||||
"signout_held": "Încă deschise în alt program, așa că se șterg doar după ce le închideți acolo: {names}."
|
||||
},
|
||||
"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ă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.",
|
||||
"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",
|
||||
"snooze": "Amână {count} min",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "Отозвать",
|
||||
"revoked": "Разрешение отозвано"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} запрашивает сертификат",
|
||||
"picker_detail": "Сервер хочет знать, кто вы. Выбранный сертификат будет отправлен только на этот сервер. Если вы не знаете этот сервер, не отправляйте его.",
|
||||
"picker_issued": "Выдан: {issuer}, действует до {date}",
|
||||
"picker_remember": "Запомнить для {host}",
|
||||
"picker_none": "Не отправлять сертификат",
|
||||
"picker_more": "Другие сертификаты…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "Настольное приложение",
|
||||
"section_description": "Как приложение работает с вашим компьютером.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "Ещё не вложены и не сохранены, и сейчас удаляются: {names}.",
|
||||
"signout_held": "Ещё открыты в другой программе, поэтому удалятся только после закрытия там: {names}."
|
||||
},
|
||||
"desktopQuickCompose": {
|
||||
"label": "Быстрое письмо",
|
||||
"menu": "Быстрое письмо",
|
||||
"description": "{keys} открывает маленькое окно для нового письма из любого другого приложения.",
|
||||
"description_off": "Открывает маленькое окно для нового письма из любого другого приложения.",
|
||||
"taken": "{keys} занято другим приложением, поэтому здесь сочетание не работает. Выберите другие клавиши или закройте это приложение.",
|
||||
"change": "Изменить клавиши: {keys}",
|
||||
"recording": "Нажмите клавиши…",
|
||||
"recording_hint": "Удерживайте Ctrl (Cmd на Mac) вместе с Alt или Shift и нажмите букву, цифру, Enter или функциональную клавишу. Esc отменяет.",
|
||||
"back_to_main": "Переместить в главное окно",
|
||||
"back_busy": "В главном окне уже открыто письмо. Закройте его и попробуйте снова.",
|
||||
"back_no_answer": "Главное окно не ответило. Ваше письмо остаётся здесь.",
|
||||
"types_character": "На вашей клавиатуре эти клавиши вводят символ, и он перестал бы работать во всех приложениях. Попробуйте другие клавиши.",
|
||||
"reserved": "Эти клавиши использует система или большинство приложений. Попробуйте другие клавиши."
|
||||
},
|
||||
"desktopReminders": {
|
||||
"generic_title": "Напоминание",
|
||||
"snooze": "Отложить на {count} мин",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "Odvolať",
|
||||
"revoked": "Povolenie bolo odvolané"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} žiada o certifikát",
|
||||
"picker_detail": "Server chce vedieť, kto ste. Certifikát, ktorý vyberiete, pôjde len na tento server. Ak tento server nepoznáte, žiadny neposielajte.",
|
||||
"picker_issued": "Vydal {issuer}, platí do {date}",
|
||||
"picker_remember": "Zapamätať pre {host}",
|
||||
"picker_none": "Neposielať certifikát",
|
||||
"picker_more": "Ďalšie certifikáty…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "Počítačová aplikácia",
|
||||
"section_description": "Ako aplikácia spolupracuje s vaším počítačom.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "Zatiaľ nepripojené ani neuložené, a teraz odstránené: {names}.",
|
||||
"signout_held": "Stále otvorené v inom programe, takže sa odstránia až po zatvorení: {names}."
|
||||
},
|
||||
"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 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.",
|
||||
"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",
|
||||
"snooze": "Odložiť o {count} min",
|
||||
|
||||
@@ -6935,6 +6935,14 @@
|
||||
"revoke": "ถอน",
|
||||
"revoked": "ถอนการอนุญาตแล้ว"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} ขอใบรับรอง",
|
||||
"picker_detail": "เซิร์ฟเวอร์ต้องการทราบว่าคุณคือใคร ใบรับรองที่คุณเลือกจะส่งไปยังเซิร์ฟเวอร์นี้เท่านั้น หากคุณไม่รู้จักเซิร์ฟเวอร์นี้ อย่าส่ง",
|
||||
"picker_issued": "ออกโดย {issuer} ใช้ได้ถึง {date}",
|
||||
"picker_remember": "จดจำสำหรับ {host}",
|
||||
"picker_none": "ไม่ส่งใบรับรอง",
|
||||
"picker_more": "ใบรับรองเพิ่มเติม…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "แอปเดสก์ท็อป",
|
||||
"section_description": "วิธีที่แอปทำงานกับคอมพิวเตอร์ของคุณ",
|
||||
@@ -7011,6 +7019,21 @@
|
||||
"signout_changed": "ยังไม่ได้แนบหรือบันทึก และจะถูกลบตอนนี้: {names}",
|
||||
"signout_held": "ยังเปิดอยู่ในโปรแกรมอื่น จึงจะถูกลบหลังจากคุณปิดที่นั่นเท่านั้น: {names}"
|
||||
},
|
||||
"desktopQuickCompose": {
|
||||
"label": "เขียนด่วน",
|
||||
"menu": "เขียนด่วน",
|
||||
"description": "{keys} เปิดหน้าต่างเล็กสำหรับข้อความใหม่จากแอปอื่นใดก็ได้",
|
||||
"description_off": "เปิดหน้าต่างเล็กสำหรับข้อความใหม่จากแอปอื่นใดก็ได้",
|
||||
"taken": "แอปอื่นกำลังใช้ {keys} อยู่ จึงไม่มีผลที่นี่ เลือกปุ่มอื่นหรือปิดแอปนั้น",
|
||||
"change": "เปลี่ยนปุ่ม: {keys}",
|
||||
"recording": "กดปุ่ม…",
|
||||
"recording_hint": "กด Ctrl (Cmd บน Mac) ค้างไว้พร้อม Alt หรือ Shift แล้วกดตัวอักษร ตัวเลข Enter หรือปุ่มฟังก์ชัน กด Esc เพื่อยกเลิก",
|
||||
"back_to_main": "ย้ายไปหน้าต่างหลัก",
|
||||
"back_busy": "หน้าต่างหลักมีข้อความเปิดอยู่แล้ว ปิดข้อความนั้นแล้วลองอีกครั้ง",
|
||||
"back_no_answer": "หน้าต่างหลักไม่ตอบสนอง ข้อความของคุณยังอยู่ที่นี่",
|
||||
"types_character": "ปุ่มเหล่านี้พิมพ์อักขระบนแป้นพิมพ์ของคุณ และอักขระนั้นจะใช้ไม่ได้ในทุกแอป ลองใช้ปุ่มอื่น",
|
||||
"reserved": "ระบบหรือแอปส่วนใหญ่ใช้ปุ่มเหล่านี้ ลองใช้ปุ่มอื่น"
|
||||
},
|
||||
"desktopReminders": {
|
||||
"generic_title": "การแจ้งเตือน",
|
||||
"snooze": "เลื่อน {count} นาที",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "Geri al",
|
||||
"revoked": "İzin geri alındı"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} bir sertifika istiyor",
|
||||
"picker_detail": "Sunucu kim olduğunuzu bilmek istiyor. Seçtiğiniz sertifika yalnızca bu sunucuya gider. Bu sunucuyu tanımıyorsanız göndermeyin.",
|
||||
"picker_issued": "Veren: {issuer}, geçerlilik: {date} tarihine kadar",
|
||||
"picker_remember": "{host} için hatırla",
|
||||
"picker_none": "Sertifika gönderme",
|
||||
"picker_more": "Daha fazla sertifika…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "Masaüstü uygulaması",
|
||||
"section_description": "Uygulamanın bilgisayarınızla nasıl çalıştığı.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "Henüz eklenmedi veya kaydedilmedi, şimdi siliniyor: {names}.",
|
||||
"signout_held": "Hâlâ başka bir programda açık, bu yüzden ancak orada kapatınca silinir: {names}."
|
||||
},
|
||||
"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 (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.",
|
||||
"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ı",
|
||||
"snooze": "{count} dk ertele",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "Відкликати",
|
||||
"revoked": "Дозвіл відкликано"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} запитує сертифікат",
|
||||
"picker_detail": "Сервер хоче знати, хто ви. Вибраний сертифікат буде надіслано лише на цей сервер. Якщо ви не знаєте цей сервер, не надсилайте його.",
|
||||
"picker_issued": "Видав {issuer}, дійсний до {date}",
|
||||
"picker_remember": "Запам'ятати для {host}",
|
||||
"picker_none": "Не надсилати сертифікат",
|
||||
"picker_more": "Інші сертифікати…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "Настільний застосунок",
|
||||
"section_description": "Як застосунок працює з вашим комп’ютером.",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "Ще не вкладено й не збережено, і зараз видаляється: {names}.",
|
||||
"signout_held": "Ще відкрито в іншій програмі, тому видалиться лише після закриття там: {names}."
|
||||
},
|
||||
"desktopQuickCompose": {
|
||||
"label": "Швидкий лист",
|
||||
"menu": "Швидкий лист",
|
||||
"description": "{keys} відкриває маленьке вікно для нового листа з будь-якого іншого застосунку.",
|
||||
"description_off": "Відкриває маленьке вікно для нового листа з будь-якого іншого застосунку.",
|
||||
"taken": "{keys} використовує інший застосунок, тому тут нічого не відбувається. Виберіть інші клавіші або закрийте той застосунок.",
|
||||
"change": "Змінити клавіші: {keys}",
|
||||
"recording": "Натисніть клавіші…",
|
||||
"recording_hint": "Утримуйте Ctrl (Cmd на Mac) разом з Alt або Shift і натисніть літеру, цифру, Enter або функціональну клавішу. Esc скасовує.",
|
||||
"back_to_main": "Перемістити в головне вікно",
|
||||
"back_busy": "У головному вікні вже відкрито лист. Закрийте його й спробуйте ще раз.",
|
||||
"back_no_answer": "Головне вікно не відповіло. Ваш лист залишається тут.",
|
||||
"types_character": "На вашій клавіатурі ці клавіші вводять символ, і він перестав би працювати в усіх застосунках. Спробуйте інші клавіші.",
|
||||
"reserved": "Ці клавіші використовує система або більшість застосунків. Спробуйте інші клавіші."
|
||||
},
|
||||
"desktopReminders": {
|
||||
"generic_title": "Нагадування",
|
||||
"snooze": "Відкласти на {count} хв",
|
||||
|
||||
@@ -6939,6 +6939,14 @@
|
||||
"revoke": "Thu hồi",
|
||||
"revoked": "Đã thu hồi quyền"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} yêu cầu một chứng chỉ",
|
||||
"picker_detail": "Máy chủ muốn biết bạn là ai. Chứng chỉ bạn chọn chỉ được gửi đến máy chủ này. Nếu bạn không biết máy chủ này, đừng gửi.",
|
||||
"picker_issued": "Do {issuer} cấp, có hiệu lực đến {date}",
|
||||
"picker_remember": "Ghi nhớ cho {host}",
|
||||
"picker_none": "Không gửi chứng chỉ",
|
||||
"picker_more": "Thêm chứng chỉ…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "Ứng dụng máy tính",
|
||||
"section_description": "Cách ứng dụng hoạt động với máy tính của bạn.",
|
||||
@@ -7015,6 +7023,21 @@
|
||||
"signout_changed": "Chưa đính kèm hoặc lưu, và bị xóa ngay: {names}.",
|
||||
"signout_held": "Vẫn đang mở trong chương trình khác nên chỉ bị xóa sau khi bạn đóng ở đó: {names}."
|
||||
},
|
||||
"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ữ 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.",
|
||||
"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",
|
||||
"snooze": "Báo lại sau {count} phút",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "收回",
|
||||
"revoked": "已收回許可"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} 要求提供憑證",
|
||||
"picker_detail": "伺服器想確認你的身分。你選擇的憑證只會傳送到此伺服器。如果你不認識此伺服器,請不要傳送。",
|
||||
"picker_issued": "發行者:{issuer},有效期限至 {date}",
|
||||
"picker_remember": "為 {host} 記住此選擇",
|
||||
"picker_none": "不傳送憑證",
|
||||
"picker_more": "更多憑證…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "桌面應用程式",
|
||||
"section_description": "應用程式如何與您的電腦配合。",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "尚未附加或儲存,現在將刪除:{names}。",
|
||||
"signout_held": "仍在其他程式中開啟,因此要在那裡關閉後才會刪除:{names}。"
|
||||
},
|
||||
"desktopQuickCompose": {
|
||||
"label": "快速撰寫",
|
||||
"menu": "快速撰寫",
|
||||
"description": "{keys} 可從任何其他應用程式開啟用於新郵件的小視窗。",
|
||||
"description_off": "從任何其他應用程式開啟用於新郵件的小視窗。",
|
||||
"taken": "另一個應用程式正在使用 {keys},所以在這裡沒有作用。請選擇其他按鍵,或關閉該應用程式。",
|
||||
"change": "變更按鍵:{keys}",
|
||||
"recording": "請按下按鍵…",
|
||||
"recording_hint": "按住 Ctrl(Mac 上為 Cmd)和 Alt 或 Shift,再按一個字母、數字、Enter 或功能鍵。按 Esc 取消。",
|
||||
"back_to_main": "移到主視窗",
|
||||
"back_busy": "主視窗已有開啟的郵件。請先關閉它,然後再試一次。",
|
||||
"back_no_answer": "主視窗沒有回應。您的郵件仍在這裡。",
|
||||
"types_character": "這些鍵在您的鍵盤上會輸入一個字元,占用後該字元在所有應用程式中都無法輸入。請試試其他按鍵。",
|
||||
"reserved": "系統或大多數應用程式正在使用這些鍵。請試試其他按鍵。"
|
||||
},
|
||||
"desktopReminders": {
|
||||
"generic_title": "提醒",
|
||||
"snooze": "{count} 分鐘後再提醒",
|
||||
|
||||
@@ -6927,6 +6927,14 @@
|
||||
"revoke": "收回",
|
||||
"revoked": "已收回许可"
|
||||
},
|
||||
"desktop_certificates": {
|
||||
"picker_title": "{host} 要求提供证书",
|
||||
"picker_detail": "服务器想确认你的身份。你选择的证书只会发送到此服务器。如果你不认识此服务器,请不要发送。",
|
||||
"picker_issued": "颁发者:{issuer},有效期至 {date}",
|
||||
"picker_remember": "为 {host} 记住此选择",
|
||||
"picker_none": "不发送证书",
|
||||
"picker_more": "更多证书…"
|
||||
},
|
||||
"desktopOs": {
|
||||
"section_title": "桌面应用",
|
||||
"section_description": "应用如何与你的电脑配合。",
|
||||
@@ -7003,6 +7011,21 @@
|
||||
"signout_changed": "尚未附加或保存,现在将删除:{names}。",
|
||||
"signout_held": "仍在其他程序中打开,因此要在那里关闭后才会删除:{names}。"
|
||||
},
|
||||
"desktopQuickCompose": {
|
||||
"label": "快速撰写",
|
||||
"menu": "快速撰写",
|
||||
"description": "{keys} 可从任何其他应用打开一个用于新邮件的小窗口。",
|
||||
"description_off": "从任何其他应用打开一个用于新邮件的小窗口。",
|
||||
"taken": "另一个应用正在使用 {keys},所以在这里不起作用。请选择其他按键,或关闭那个应用。",
|
||||
"change": "更改按键:{keys}",
|
||||
"recording": "请按下按键…",
|
||||
"recording_hint": "按住 Ctrl(Mac 上为 Cmd)和 Alt 或 Shift,再按一个字母、数字、Enter 或功能键。按 Esc 取消。",
|
||||
"back_to_main": "移到主窗口",
|
||||
"back_busy": "主窗口中已有打开的邮件。请先关闭它,然后重试。",
|
||||
"back_no_answer": "主窗口没有响应。您的邮件仍在这里。",
|
||||
"types_character": "这些键在您的键盘上会输入一个字符,占用后该字符在所有应用中都无法输入。请试试其他按键。",
|
||||
"reserved": "系统或大多数应用在使用这些键。请试试其他按键。"
|
||||
},
|
||||
"desktopReminders": {
|
||||
"generic_title": "提醒",
|
||||
"snooze": "{count} 分钟后再提醒",
|
||||
|
||||
@@ -73,6 +73,62 @@ describe('sign-out with plugins', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('client certificates chosen for servers', () => {
|
||||
afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; });
|
||||
const withBridge = (clearChoices?: () => Promise<void>) => {
|
||||
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, certificates: { clearChoices } };
|
||||
};
|
||||
|
||||
it('all of them at the sign-out that leaves nobody', async () => {
|
||||
const clearChoices = vi.fn().mockResolvedValue(undefined);
|
||||
withBridge(clearChoices);
|
||||
await useAuthStore.getState().logout();
|
||||
expect(clearChoices).toHaveBeenCalledTimes(1);
|
||||
expect(clearChoices.mock.calls[0][0]).toMatchObject({ last: true });
|
||||
});
|
||||
|
||||
it('all of them when all accounts sign out', async () => {
|
||||
const clearChoices = vi.fn().mockResolvedValue(undefined);
|
||||
withBridge(clearChoices);
|
||||
await useAuthStore.getState().logoutAll();
|
||||
expect(clearChoices).toHaveBeenCalledWith({ last: true });
|
||||
});
|
||||
|
||||
it('only its own while another account stays', async () => {
|
||||
const clearChoices = vi.fn().mockResolvedValue(undefined);
|
||||
withBridge(clearChoices);
|
||||
const accounts = useAccountStore.getState();
|
||||
const entry = (id: string) => ({ id, label: id, serverUrl: 'https://mail.example.test', username: id, authMode: 'basic' as const, rememberMe: true, displayName: id, email: id, lastLoginAt: 1, isConnected: true, hasError: false, isDefault: false, cookieSlot: id === 'a1' ? 0 : 1, avatarColor: '#000' });
|
||||
useAccountStore.setState({ ...accounts, accounts: [entry('a1'), entry('a2')], activeAccountId: 'a1' });
|
||||
useAuthStore.setState({ isAuthenticated: true, activeAccountId: 'a1' });
|
||||
await useAuthStore.getState().logout();
|
||||
expect(clearChoices).toHaveBeenCalledWith({ account: 'a1', last: false });
|
||||
expect(clearChoices.mock.calls.some(([scope]) => scope.last === true)).toBe(false);
|
||||
});
|
||||
|
||||
it('nothing when switching to another account or searching across them', () => {
|
||||
const clearChoices = vi.fn().mockResolvedValue(undefined);
|
||||
const setAccount = vi.fn().mockResolvedValue(undefined);
|
||||
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, certificates: { clearChoices, setAccount } };
|
||||
useAuthStore.setState({ isAuthenticated: true, activeAccountId: 'a1' });
|
||||
useAuthStore.setState({ activeAccountId: 'a2' });
|
||||
useAuthStore.setState({ activeAccountId: 'a1' });
|
||||
expect(clearChoices).not.toHaveBeenCalled();
|
||||
expect(setAccount.mock.calls.at(-1)).toEqual(['a1']);
|
||||
expect(setAccount).toHaveBeenCalledWith('a2');
|
||||
});
|
||||
|
||||
it('signs out all the same on a shell without the call, or one that fails', async () => {
|
||||
withBridge(undefined);
|
||||
await useAuthStore.getState().logout();
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||
useAuthStore.setState({ isAuthenticated: true });
|
||||
withBridge(vi.fn().mockRejectedValue(new Error('gone')));
|
||||
await useAuthStore.getState().logout();
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('keys in the OS store', () => {
|
||||
afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; });
|
||||
const withBridge = (clearGrants?: () => Promise<void>) => {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* A token renewal holds the token lock for every window. A request that never answers, as one
|
||||
* caught when the desktop app closes its connections, can't be left to hold it for good.
|
||||
*/
|
||||
|
||||
const SERVER = 'https://mail.example.test';
|
||||
|
||||
function account(id: string, slot: number) {
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
serverUrl: SERVER,
|
||||
username: `${id}@example.test`,
|
||||
authMode: 'oauth' as const,
|
||||
rememberMe: true,
|
||||
displayName: id,
|
||||
email: `${id}@example.test`,
|
||||
lastLoginAt: 1,
|
||||
isConnected: false,
|
||||
hasError: false,
|
||||
isDefault: true,
|
||||
cookieSlot: slot,
|
||||
avatarColor: '#000',
|
||||
};
|
||||
}
|
||||
|
||||
describe('a token renewal that gets no answer', { timeout: 30_000 }, () => {
|
||||
const held = new Set<string>();
|
||||
let renewals = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
held.clear();
|
||||
renewals = 0;
|
||||
Object.defineProperty(navigator, 'locks', {
|
||||
configurable: true,
|
||||
value: {
|
||||
request: async (name: string, options: { ifAvailable?: boolean }, callback: (lock: unknown) => unknown) => {
|
||||
if (options.ifAvailable && held.has(name)) return callback(null);
|
||||
held.add(name);
|
||||
try {
|
||||
return await callback({});
|
||||
} finally {
|
||||
held.delete(name);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn((url: unknown, init?: RequestInit) => {
|
||||
if (String(url).includes('/api/auth/token') && init?.method === 'PUT') {
|
||||
renewals += 1;
|
||||
return new Promise((_resolve, reject) => {
|
||||
init.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
|
||||
});
|
||||
}
|
||||
return Promise.resolve(new Response('{}', { status: 200 }));
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop;
|
||||
delete (navigator as { locks?: unknown }).locks;
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function storeWithAccount() {
|
||||
const { useAuthStore } = await import('../auth-store');
|
||||
const { useAccountStore } = await import('../account-store');
|
||||
useAccountStore.setState({ accounts: [account('A', 0)], activeAccountId: 'A', defaultAccountId: 'A' });
|
||||
useAuthStore.setState({ isAuthenticated: true, isLoading: false, serverUrl: SERVER, username: 'A@example.test', activeAccountId: 'A', authMode: 'oauth' });
|
||||
// After the imports: they don't finish on a stopped clock.
|
||||
vi.useFakeTimers();
|
||||
return useAuthStore;
|
||||
}
|
||||
|
||||
it('lets go of the lock after its time, and counts as an outage', async () => {
|
||||
const store = await storeWithAccount();
|
||||
const done = vi.fn();
|
||||
void store.getState().refreshAccessToken({ accountId: 'A' }).then(done);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(renewals).toBe(1);
|
||||
expect(held.size, 'it holds the lock while it runs').toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(29_000);
|
||||
expect(done).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(held.size).toBe(0);
|
||||
expect(done).toHaveBeenCalledWith(null);
|
||||
expect(store.getState().isAuthenticated, 'nobody is signed out for it').toBe(true);
|
||||
});
|
||||
|
||||
it('lets go at once when the desktop app says its connections went', async () => {
|
||||
let reset = () => {};
|
||||
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = {
|
||||
version: 1,
|
||||
certificates: {
|
||||
setAccount: async () => {},
|
||||
onConnectionsReset: (listener: () => void) => {
|
||||
reset = listener;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
};
|
||||
const store = await storeWithAccount();
|
||||
const done = vi.fn();
|
||||
void store.getState().refreshAccessToken({ accountId: 'A' }).then(done);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(held.size).toBe(1);
|
||||
reset();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(held.size).toBe(0);
|
||||
expect(done).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
+31
-3
@@ -18,7 +18,8 @@ import { generateAccountId, MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } from '@/lib/browser-navigation';
|
||||
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
|
||||
import { getDesktopBridge } from '@/lib/desktop/bridge';
|
||||
import { clearDesktopCrashFiles, clearKeystoreGrants } from '@/lib/desktop/keystore-grants';
|
||||
import { clearDesktopCertificateChoices, clearDesktopCrashFiles, clearKeystoreGrants, noteDesktopCertificateAccount } from '@/lib/desktop/keystore-grants';
|
||||
import { onConnectionsReset } from '@/lib/desktop/connections-reset';
|
||||
import { DESKTOP_CLIENT_ID_KEY } from '@/lib/desktop/browser-sign-in';
|
||||
import { isEmbedded, notifyParent } from '@/lib/iframe-bridge';
|
||||
import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager';
|
||||
@@ -359,6 +360,26 @@ function trackSessionWrite<T>(slot: number, run: (signal: AbortSignal) => Promis
|
||||
return request;
|
||||
}
|
||||
|
||||
/** It holds the token lock, for every window: a request that never answers can't be left to hold it. */
|
||||
const TOKEN_RENEWAL_TIMEOUT_MS = 30_000;
|
||||
|
||||
function renewalSignal(outer: AbortSignal): { signal: AbortSignal; done: () => void } {
|
||||
const controller = new AbortController();
|
||||
const stop = () => controller.abort();
|
||||
if (outer.aborted) stop();
|
||||
else outer.addEventListener('abort', stop, { once: true });
|
||||
const timer = setTimeout(stop, TOKEN_RENEWAL_TIMEOUT_MS);
|
||||
const stopWatching = onConnectionsReset(stop);
|
||||
return {
|
||||
signal: controller.signal,
|
||||
done: () => {
|
||||
clearTimeout(timer);
|
||||
outer.removeEventListener('abort', stop);
|
||||
stopWatching?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const TIMED_OUT = Symbol('timed out');
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT> {
|
||||
@@ -649,8 +670,10 @@ function fetchSlotAccessToken(slot: number, opts: { force?: boolean; remember?:
|
||||
const force = opts.force && !contended;
|
||||
if (!IS_LITE) {
|
||||
const query = `${force ? '&force=true' : ''}${opts.remember === false ? '&remember=false' : ''}`;
|
||||
return apiFetch(`/api/auth/token?slot=${slot}${query}`, { method: 'PUT', signal })
|
||||
.then((res) => noteSessionAnswer(res, slot));
|
||||
const renewal = renewalSignal(signal);
|
||||
return apiFetch(`/api/auth/token?slot=${slot}${query}`, { method: 'PUT', signal: renewal.signal })
|
||||
.then((res) => noteSessionAnswer(res, slot))
|
||||
.finally(renewal.done);
|
||||
}
|
||||
// Like the route's cookie cache: a restore resumes with the token it had,
|
||||
// since a renewal before the refresh token's `nbf` is refused (#552).
|
||||
@@ -2544,6 +2567,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
useSettingsStore.getState().disableSync();
|
||||
|
||||
clearKeystoreGrants();
|
||||
clearDesktopCertificateChoices({ account: accountId ?? undefined, last: useAccountStore.getState().accounts.length === 0 });
|
||||
clearDesktopCrashFiles();
|
||||
void authHooks.onAfterLogout.emit({
|
||||
accountId: accountId ?? 'all',
|
||||
@@ -2635,6 +2659,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
// mail never passed a question, so it waits for their next sign-in.
|
||||
accountStore.removeAccount(remaining.id, 'evicted');
|
||||
}
|
||||
clearDesktopCertificateChoices({ last: true });
|
||||
}
|
||||
|
||||
// Full logout. The credentials are cleared before the page state:
|
||||
@@ -2679,6 +2704,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
evictAccount(accountId);
|
||||
forgetAccountRows(accountId, get().client);
|
||||
accountStore.removeAccount(accountId, reason);
|
||||
clearDesktopCertificateChoices({ account: accountId });
|
||||
forgetPrincipals(accountId);
|
||||
clearPluginStorageForAccount(accountId);
|
||||
},
|
||||
@@ -2725,6 +2751,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
}
|
||||
|
||||
clearKeystoreGrants();
|
||||
clearDesktopCertificateChoices({ last: true });
|
||||
clearDesktopCrashFiles();
|
||||
void authHooks.onAfterLogout.emit({
|
||||
accountId: 'all'
|
||||
@@ -3872,6 +3899,7 @@ useAuthStore.subscribe((state, prev) => {
|
||||
const ready = state.isAuthenticated && !!state.client && !!state.activeAccountId;
|
||||
const wasReady = prev.isAuthenticated && !!prev.client && !!prev.activeAccountId;
|
||||
if (ready && !wasReady) void authHooks.onAuthReady.emit({ accountId: state.activeAccountId });
|
||||
if (state.activeAccountId !== prev.activeAccountId) noteDesktopCertificateAccount(state.activeAccountId);
|
||||
if (state.activeAccountId && prev.activeAccountId && state.activeAccountId !== prev.activeAccountId) {
|
||||
clearKeystoreGrants();
|
||||
clearDesktopCrashFiles();
|
||||
|
||||
Reference in New Issue
Block a user