Merge branch 'feat/do-ux-p2-desktop-fix' into feat/desktop-offline

This commit is contained in:
Linus Rath
2026-11-04 13:20:33 +01:00
71 changed files with 860 additions and 106 deletions
+2
View File
@@ -115,6 +115,7 @@ import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
import { IS_LITE } from "@/lib/lite";
import { findMeetingLink } from "@/lib/event-links";
import { useDocumentTitle } from "@/hooks/use-document-title";
import { useDesktopWindowName } from "@/lib/desktop/window-name";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { MvTopBar } from "@/components/layout/mv-top-bar";
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
@@ -161,6 +162,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const tSidebar = useTranslations("sidebar");
const tOffline = useTranslations("calendar_offline");
useDocumentTitle(tSidebar("calendar"));
useDesktopWindowName(tSidebar("calendar"), { withAccount: true });
const skeletonLabels = useSkeletonLabels();
const tWebcalAction = useTranslations("calendar.webcal_action");
const { demoMode } = useConfig();
+2
View File
@@ -60,6 +60,7 @@ import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
import { useDocumentTitle } from "@/hooks/use-document-title";
import { useDesktopWindowName } from "@/lib/desktop/window-name";
import { ContactsSkeleton, ContactsSkeletonBody } from "@/components/boot-skeleton/contacts-skeleton";
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
import { useRegisterCommands } from "@/lib/command-registry";
@@ -110,6 +111,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
const tShell = useTranslations("shell");
const tMerge = useTranslations("ux_pim_cimport");
useDocumentTitle(tSidebar("contacts"));
useDesktopWindowName(tSidebar("contacts"), { withAccount: true });
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
@@ -112,6 +112,15 @@ function target(shown = true): ComposerPopOut & { handed: ComposerDraftData[]; c
const DRAFT: ComposerDraftData = { to: '', cc: '', bcc: '', subject: 'Old', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: null };
describe('"Move to the main window" in quick compose', () => {
it('shows an arrow going in, not the arrows of leaving full screen', () => {
mount({ popOut: vi.fn(() => target()), popOutToMainLabel: 'Move to the main window' });
const svg = screen.getByTestId('composer-back-to-main').querySelector('svg');
expect(svg?.getAttribute('class')).toContain('tabler-icon-login');
expect(svg?.getAttribute('class')).not.toContain('minimize');
});
});
describe('"Open in new window" on a composer', () => {
it('is not there without the host offering it: browsers stay as they are', () => {
mount({});
+3 -1
View File
@@ -38,6 +38,7 @@ import {
Minimize2,
Trash2,
ExternalLink,
LogIn,
ReplyAll,
Reply,
Forward,
@@ -4682,7 +4683,8 @@ export function EmailComposer({
title={popOutToMainLabel ?? tWindows('open_in_window')}
data-testid={popOutToMainLabel ? "composer-back-to-main" : "composer-open-in-window"}
>
{popOutToMainLabel ? <Minimize2 className={iconClassName} /> : <ExternalLink className={iconClassName} />}
{/* Into the main window: an arrow going in, not the inward arrows of leaving full screen. */}
{popOutToMainLabel ? <LogIn className={iconClassName} /> : <ExternalLink className={iconClassName} />}
</button>
);
+2
View File
@@ -51,6 +51,7 @@ import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
import { useDocumentTitle } from "@/hooks/use-document-title";
import { useDesktopWindowName } from "@/lib/desktop/window-name";
import { MvTopBar } from "@/components/layout/mv-top-bar";
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
import { FilesSkeleton, FilesSkeletonBody } from "@/components/boot-skeleton/files-skeleton";
@@ -69,6 +70,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
const tSidebar = useTranslations("sidebar");
const tShell = useTranslations("shell");
useDocumentTitle(tSidebar("files"));
useDesktopWindowName(tSidebar("files"), { withAccount: true });
const tDeepLink = useTranslations("deep_link");
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
+1 -1
View File
@@ -372,7 +372,7 @@ export function WindowMenuBar() {
aria-haspopup={isSub ? "menu" : undefined}
aria-expanded={isSub ? opened : undefined}
aria-disabled={item.enabled ? undefined : true}
aria-keyshortcuts={item.accelerator ? menuKeyText(item.accelerator, getDesktopBridge()?.platform) : undefined}
aria-keyshortcuts={item.accelerator ? menuKeyText(item.accelerator, getDesktopBridge()?.platform, 'en') : undefined}
data-menu-line={index}
tabIndex={-1}
onMouseEnter={(event) => {
@@ -3,6 +3,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Mailbox } from '@/lib/jmap/types';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
import { useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
import { usePluginStore } from '@/stores/plugin-store';
@@ -211,6 +212,40 @@ describe('a mail link from another program in the desktop app', () => {
expect(screen.queryByText('stub composer')).not.toBeInTheDocument();
});
it('files from Send to or Open with go to a compose window too, attached, and the main window shows no composer', async () => {
const { handOffDraft } = await import('@/lib/launch-handoff');
render(<MailApp />);
const file = new File(['pdf'], 'report.pdf', { type: 'application/pdf' });
act(() => { handOffDraft({ files: [file], fromOs: true }); });
await waitFor(() => expect(desktopWindows.handOver).toHaveBeenCalledTimes(1));
expect(desktopWindows.handOver).toHaveBeenCalledWith(expect.objectContaining({ mode: 'compose', initialFiles: [file] }));
await act(() => new Promise((r) => setTimeout(r, 50)));
expect(screen.queryByText('stub composer')).not.toBeInTheDocument();
});
it('with two accounts it opens from the default one, in its own window, with no question', async () => {
const client = useAuthStore.getState().client;
useAuthStore.setState({ getAllConnectedClients: () => new Map([['login', client], ['second', client]]) as never });
useAccountStore.setState({
accounts: [
{ id: 'login', email: 'me@example.com', isConnected: true, isDefault: false },
{ id: 'second', email: 'work@example.com', isConnected: true, isDefault: true },
],
defaultAccountId: 'second',
} as never);
try {
render(<MailApp />);
await fromOs();
await waitFor(() => expect(desktopWindows.handOver).toHaveBeenCalledTimes(1));
expect(desktopWindows.open).toHaveBeenCalledWith('second');
expect(screen.queryByText('select_account_title')).not.toBeInTheDocument();
// The main window keeps the account it shows.
expect(useAuthStore.getState().activeAccountId).toBe('login');
} finally {
useAccountStore.setState({ accounts: [], defaultAccountId: null } as never);
}
});
it('a link inside the app still opens here', async () => {
render(<MailApp />);
act(() => { requestInternalMailto('mailto:bob@example.com?subject=Hi'); });
@@ -0,0 +1,183 @@
import React from 'react';
import { act, cleanup, render, 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';
import { useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
import { usePluginStore } from '@/stores/plugin-store';
import { useThemeStore } from '@/stores/theme-store';
import { useToastStore } from '@/stores/toast-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
import { resetDesktopCommands, runDesktopCommand } from '@/lib/desktop/commands';
import { useMoveDialogStore } from '@/components/email/move-to-dialog';
// The desktop app's Message menu with several messages selected: each line acts on all
// of them, or isn't offered. Reply to one of seven, or Move for the open one only, acts on
// mail nobody picked (usability pass 2, desktop-p2-3).
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;
onSaveState: (data: { subject: string; mode?: string }) => void;
onSend: (data: { to: string[]; cc: string[]; bcc: string[]; subject: string; body: string }) => Promise<unknown>;
sourceEmailId?: string;
}) => {
seen.sourceEmailId = sourceEmailId;
useEffect(() => registerComposeFlush(async () => {}), []);
const [subject, setSubject] = useState(initialData?.subject ?? '');
const initial = useRef(subject);
const latest = useRef(subject);
latest.current = subject;
useEffect(() => () => {
if (latest.current !== initial.current) onSaveState({ subject: latest.current, mode: initialData?.mode });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<input aria-label="stub subject" value={subject} onChange={(e) => setSubject(e.target.value)} />
<button onClick={() => void onSend({ to: ['a@example.org'], cc: [], bcc: [], subject, body: 'x' })}>stub send</button>
</>
);
},
};
});
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
vi.mock('@/components/search/mv-search-form', () => ({ MvSearchForm: () => null }));
vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => <div>message viewer</div> }));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
// A compact desktop: rail and folders beside the list, the composer in the pane.
const device = vi.hoisted(() => ({ current: { isMobile: false, isCompact: true, isDesktop: true } }));
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => device.current,
useMediaQuery: () => false,
useIsDesktop: () => true,
atLeast: (px: number) => `(width >= ${px}px)`,
PHONE_BELOW: 600,
}));
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
vi.mock('@/components/email/email-list', () => ({ EmailList: () => <div>message list</div> }));
const { MailApp } = await import('../mail-app');
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 3, unreadEmails: 0, myRights: { mayRemoveItems: true, mayAddItems: true } } as unknown as Mailbox;
const later = { id: 'later', name: 'Read later', role: null, totalEmails: 0, unreadEmails: 0, myRights: { mayRemoveItems: true, mayAddItems: true } } as unknown as Mailbox;
const message = (id: string) => ({ id, threadId: `t-${id}`, subject: `Subject ${id}`, receivedAt: '2026-10-31T10:00:00Z', keywords: { $seen: true }, mailboxIds: { inbox: true } }) as unknown as Email;
const [a, b, c] = ['a', 'b', 'c'].map(message);
let reported: Record<string, unknown> = {};
let named: string[] = [];
let batchMoveToMailbox: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {} })));
reported = {};
named = [];
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = {
version: 1,
platform: 'win32',
menu: { setState: (state: Record<string, unknown>) => { reported = state; }, onCommand: () => () => {} },
windows: { setMenuName: (name: string) => { named.push(name); } },
};
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
const client = {
getAccountId: () => 'acc', getUsername: () => 'a@example.org',
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
};
useAuthStore.setState({
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
getClientForAccount: () => client as never,
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
});
useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split', proInterface: false } as never);
batchMoveToMailbox = vi.fn().mockResolvedValue(true);
useEmailStore.setState({
emails: [a, b, c], selectedEmail: a, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox, later],
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false,
selectedEmailKeys: new Set(['a', 'b', 'c']), unreadSection: null, threadEmailsCache: new Map(), error: null,
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
fetchEmailContent: vi.fn().mockResolvedValue(undefined),
refreshCurrentMailbox: vi.fn().mockResolvedValue(undefined),
batchMoveToMailbox,
} as never);
});
afterEach(() => {
cleanup();
resetDesktopCommands();
useMoveDialogStore.setState({ request: null });
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('the Message menu with three messages selected', () => {
it('offers no Reply, Reply all, Forward or Star, which would act on one of them', async () => {
render(<MailApp />);
await waitFor(() => expect(reported['message.archive']).toBe(true));
expect(reported['message.reply']).toBe(false);
expect(reported['message.replyAll']).toBe(false);
expect(reported['message.forward']).toBe(false);
expect(reported['message.star']).toBe(false);
expect(reported['message.openWindow']).toBe(false);
expect(reported['message.move']).toBe(true);
});
it('Move to… moves all three, and the toast says how many', async () => {
render(<MailApp />);
await waitFor(() => expect(reported['message.move']).toBe(true));
act(() => { runDesktopCommand('message.move'); });
const request = useMoveDialogStore.getState().request;
expect(request?.emails.map((email) => email.id)).toEqual(['a', 'b', 'c']);
await act(async () => { request!.onMove('later'); });
await waitFor(() => expect(batchMoveToMailbox).toHaveBeenCalledWith(expect.anything(), 'later', undefined, expect.any(Array)));
await waitFor(() => expect(useToastStore.getState().toasts.map((toast) => toast.title)).toContain('moved_count_to'));
});
it('with one message open and none selected, Reply and Move are for that one', async () => {
useEmailStore.setState({ selectedEmailKeys: new Set() } as never);
render(<MailApp />);
await waitFor(() => expect(reported['message.reply']).toBe(true));
expect(reported['message.move']).toBe(true);
act(() => { runDesktopCommand('message.move'); });
expect(useMoveDialogStore.getState().request).toBeNull();
expect(batchMoveToMailbox).not.toHaveBeenCalled();
});
});
describe('the Window menu name of the main window', () => {
it('is the folder, not the message open in it, so it never reads like the window of that message', async () => {
useEmailStore.setState({ selectedEmailKeys: new Set() } as never);
render(<MailApp />);
await waitFor(() => expect(named.at(-1)).toBe('sidebar.mailboxes.inbox'));
expect(named).not.toContain('Subject a');
});
});
+54 -19
View File
@@ -145,7 +145,8 @@ import { deliveryAddresses, resolveComposeAccountEmail } from "@/lib/reply-ident
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder, Upload, Star } from "@/components/icons";
import { useRegisterCommands } from "@/lib/command-registry";
import { MoveToDialog, MoveToDialogHost } from "@/components/email/move-to-dialog";
import { MoveToDialog, MoveToDialogHost, useMoveDialogStore } from "@/components/email/move-to-dialog";
import { useDesktopWindowName } from "@/lib/desktop/window-name";
import { SearchResultCount } from "@/components/email/search-result-count";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button";
@@ -155,7 +156,7 @@ import { mailTitleContext } from "@/lib/tab-title";
import { usePluginStore } from "@/stores/plugin-store";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { canDownload } from "@/lib/device-policy";
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
import { consumePendingMailto, mailLinkAccountId, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
import { setNotificationReplyOpener } from "@/lib/pwa-notify/page-actions";
import { setMailLinkOpener } from "@/lib/desktop/mail-links";
import { requestTabReply } from "@/lib/pwa-notify/tab-reply";
@@ -1366,7 +1367,26 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// The desktop app's menu bar: the same handlers as the keys, and what's enabled
// from what's on screen (lib/desktop/commands.ts).
const desktopTarget = !showComposer && (!!selectedEmail || selectedEmailKeys.size > 0);
const desktopSingle = !showComposer && !!selectedEmail;
// With several selected, a menu line acts on all of them or isn't offered: Reply to one
// of seven, or Move for the open one only, would act on mail nobody picked.
const desktopMulti = selectedEmailKeys.size > 1;
const desktopSingle = !showComposer && !!selectedEmail && !desktopMulti;
const tListExtra = useTranslations('mail_list_extra');
/** Message > Move to… on the selection, as the selection bar's Move does: one toast with the count. */
const moveSelectionTo = async (mailboxId: string) => {
if (!client) return;
const count = selectedEmailKeys.size;
const restore = restoreBatchesForSelection(client);
const receipts: CrossAccountMoveReceipt[] = [];
const destination = mailboxes.find((m) => m.id === mailboxId);
await runBatchEmailAction(() => useEmailStore.getState().batchMoveToMailbox(client, mailboxId, undefined, receipts), {
success: destination
? tListExtra('moved_count_to', { count, mailbox: getMailboxPath(destination, mailboxes) })
: tNotifications('emails_moved', { count }),
error: tNotifications('move_failed'),
undo: undoMoveAction(client, restore, undo, receipts),
});
};
const mailLayoutSetting = useSettingsStore((s) => s.mailLayout);
const layoutFixed = interfaceLayout === 'mountain-view';
// A shared folder you may only read from: nothing can leave it.
@@ -1384,7 +1404,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
'message.markRead': () => void keyboardHandlers.onMarkAsRead?.(),
'message.markUnread': () => void keyboardHandlers.onMarkAsUnread?.(),
'message.star': () => void keyboardHandlers.onToggleStar?.(),
'message.move': () => { if (selectedEmail) setMoveDialogEmail(selectedEmail); },
'message.move': () => {
if (shortcutsTargetSelection) {
const state = useEmailStore.getState();
const picked = resolveSelectedEmails({ emails: state.emails, threadEmailsCache: state.threadEmailsCache, selectedEmailKeys });
if (picked.length > 0) useMoveDialogStore.getState().open({ emails: picked, onMove: (mailboxId) => void moveSelectionTo(mailboxId) });
} else if (selectedEmail) {
setMoveDialogEmail(selectedEmail);
}
},
'message.archive': keyboardHandlers.onArchive ? () => void keyboardHandlers.onArchive?.() : undefined,
'message.spam': () => void keyboardHandlers.onToggleSpam?.(),
'message.delete': () => void keyboardHandlers.onDelete?.(),
@@ -1408,7 +1436,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
'message.markRead': desktopTarget && !(selectedEmail && selectedEmailKeys.size === 0 && selectedEmail.keywords?.$seen),
'message.markUnread': desktopTarget && !(selectedEmail && selectedEmailKeys.size === 0 && !selectedEmail.keywords?.$seen),
'message.star': desktopSingle,
'message.move': desktopSingle && desktopMayRemove,
'message.move': desktopTarget && desktopMayRemove,
'message.archive': desktopTarget && archiveActionEnabled && desktopMayRemove,
'message.spam': desktopTarget && desktopMayRemove,
'message.delete': desktopTarget && desktopMayRemove,
@@ -1497,6 +1525,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
return mailTitleContext({ composer, subject, mailbox: mailboxLine }, { showSubject: subjectInTabTitle });
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, subjectInTabTitle]);
useDocumentTitle(titleContext);
// The Window menu names this window by its folder, never by the open message (desktop-p2-5).
const windowView = useMemo(() => {
const mailbox = selectedMailbox ? mailboxes.find(mb => mb.id === selectedMailbox) : undefined;
return mailbox ? localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`)) : t('sidebar.mail');
}, [selectedMailbox, mailboxes, t]);
useDesktopWindowName(windowView, { withAccount: true });
// When this page is rendered inside the Pro shell as the Mail tab body,
// we hoist every "show composer" intent into its own Pro tab and reset
@@ -1682,8 +1716,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// A mail link from another program, in the desktop app: a compose window of its own,
// as Outlook and Thunderbird do, and the main window stays where it was. Plugins get
// their say first. False when it has to open here instead.
const openMailtoInWindow = useCallback(async (pending: DraftRequest): Promise<boolean> => {
if (!pending.fromOs || pending.initialFiles?.length || !hasDesktopWindows() || !popoutsAvailable()) return false;
const openMailtoInWindow = useCallback(async (pending: DraftRequest, accountId?: string): Promise<boolean> => {
if (!pending.fromOs || !hasDesktopWindows() || !popoutsAvailable()) return false;
let prefill: ComposePrefill | null = null;
if (emailHooks.onBeforeCompose.size > 0) {
prefill = await runBeforeCompose(composeOptionsFor('compose', {
@@ -1695,12 +1729,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// A plugin said no: nothing opens, here or there.
if (!prefill) return true;
}
const opened = openComposerWindow(useAuthStore.getState().activeAccountId);
// The account the link goes out from, in its own window: the main window keeps the one it shows.
const opened = openComposerWindow(accountId ?? useAuthStore.getState().activeAccountId);
if (!opened) return false;
void opened.handOver({
mode: 'compose',
initialData: mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode),
prefill: prefill && Object.keys(prefill).length > 0 ? prefill : null,
// Send to and Open with: the files go along (desktop-p2-6).
...(pending.initialFiles?.length ? { initialFiles: pending.initialFiles } : {}),
});
return true;
}, [identities, t]);
@@ -1732,6 +1769,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}, [openMailtoInWindow, openMailtoHere]);
const openMailtoForAccount = useCallback(async (pending: DraftRequest, accountId: string) => {
if (await openMailtoInWindow(pending, accountId)) {
setPendingMailtoAccountChoice(null);
return;
}
setIsProtocolAccountSwitching(true);
try {
if (useAuthStore.getState().activeAccountId !== accountId) {
@@ -1742,7 +1783,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
} finally {
setIsProtocolAccountSwitching(false);
}
}, [openMailtoDraft, switchAccount]);
}, [openMailtoDraft, openMailtoInWindow, switchAccount]);
// onMailtoIntercept: a plugin may stop a mailto: from opening the composer.
const mailtoAllowed = useCallback(async (pending: ParsedMailto & { href?: string }): Promise<boolean> => {
@@ -1755,17 +1796,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
});
}, []);
// A draft that comes from outside: which account it goes out from.
// A draft that comes from outside goes out from the default account, as in Outlook and
// Thunderbird, with no question: the composer's From changes it (desktop-p2-8).
const routeOutsideDraft = useCallback(async (pending: DraftRequest): Promise<void> => {
const protocolAccounts = getMailtoProtocolAccounts();
if (protocolAccounts.length > 1) {
// The question is asked in this window: bring it up.
if (pending.fromOs) getDesktopBridge()?.windows?.focus?.();
setPendingMailtoAccountChoice(pending);
return;
}
const accountId = protocolAccounts[0]?.id ?? activeAccountId;
const accountId = mailLinkAccountId(getMailtoProtocolAccounts(), useAccountStore.getState().defaultAccountId, activeAccountId);
if (accountId) {
await openMailtoForAccount(pending, accountId);
return;
@@ -1823,6 +1857,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
body: handoff.body ?? '',
initialFiles: handoff.files,
unedited: true,
...(handoff.fromOs ? { fromOs: true } : {}),
});
};
+6
View File
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer";
import { ErrorBoundary, ComposerErrorFallback } from "@/components/error";
import { useDocumentTitle } from "@/hooks/use-document-title";
import { useDesktopWindowName } from "@/lib/desktop/window-name";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
@@ -313,6 +314,9 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
const [sessionKey] = useState(() => `popout-${handoffId ?? Date.now().toString(36)}`);
const mode = payload.initialData?.mode ?? payload.mode;
// The Window menu names a composer by its subject, or "New message" before there is one.
const [subject, setSubject] = useState(payload.initialData?.subject ?? "");
useDesktopWindowName(subject.trim() || t("email_composer.new_message"));
useDocumentTitle(
mode === "reply" ? t("email_composer.reply")
: mode === "replyAll" ? t("email_composer.reply_all")
@@ -512,6 +516,7 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
replyTo={payload.replyTo}
initialDraftText={payload.initialDraftText}
initialData={payload.initialData}
initialFiles={payload.initialFiles ?? null}
prefill={payload.prefill}
sessionKey={sessionKey}
sourceEmailId={payload.sourceEmailId ?? undefined}
@@ -523,6 +528,7 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
ownWindow={ownWindow}
popOut={mainHoldsLogin ? backToMain : undefined}
popOutToMainLabel={mainHoldsLogin ? tQuick("back_to_main") : undefined}
onSubjectChange={setSubject}
className="min-h-0 flex-1"
/>
</ErrorBoundary>
+3
View File
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { ProEmailView } from "@/components/pro/pro-email-tab-body";
import { useDocumentTitle } from "@/hooks/use-document-title";
import { useDesktopWindowName } from "@/lib/desktop/window-name";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { usePolicyStore } from "@/stores/policy-store";
@@ -105,6 +106,8 @@ function PopoutMessage({ emailId, accountId, ownerAccountId, fromLink }: { email
const [subject, setSubject] = useState<string | null>(null);
const subjectInTitle = usePolicyStore((s) => s.loaded && s.isFeatureEnabled("tabTitleSubjectEnabled"));
useDocumentTitle(subjectInTitle ? subject : null);
// Kept out where the organisation keeps subjects out of titles: the shell then says "Message".
useDesktopWindowName(subjectInTitle ? subject : null);
// Folders first: without them Delete has no Trash to move to, Archive has no
// button yet, and the toolbar moves under the pointer once they arrive.
@@ -167,7 +167,7 @@ export function ProtocolAccountPicker({
{t("switching_account")}
</span>
) : (
<span className="text-xs text-muted-foreground">{t("select_account_note")}</span>
<span />
)}
<button
type="button"
@@ -256,3 +256,26 @@ describe('NotificationSettings - push errors', () => {
expect(text).toBe('Relay register failed: 502');
});
});
describe('NotificationSettings - the unread count in the desktop app', () => {
beforeEach(() => {
useSettingsStore.setState({ emailNotificationsEnabled: true });
useAuthStore.setState({ client: fakeClient, username: 'me@example.com' });
usePolicyStore.setState({ ...usePolicyStore.getState(), policy: {}, isSettingLocked: () => false, isSettingHidden: () => false } as never);
});
afterEach(() => {
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
});
it('speaks of the taskbar icon there, not of a tab', async () => {
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32' };
const { findByText, queryByText } = render(<NotificationSettings />);
await findByText('unread_badge_label');
expect(queryByText('favicon_unread_badge.label')).toBeNull();
});
it('keeps the tab in a browser', async () => {
const { findByText } = render(<NotificationSettings />);
await findByText('favicon_unread_badge.label');
});
});
+6 -10
View File
@@ -5,16 +5,10 @@ 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 { describeAccelerator, showShortcutDefault } from "@/lib/desktop/shortcut-keys";
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. */
const SHOW_SHORTCUT = "CommandOrControl+Shift+B";
function shortcutLabel(platform: string | undefined): string {
return platform === "darwin" ? "⇧⌘B" : "Ctrl+Shift+B";
}
/**
* The desktop app's own settings: only where there's an app to set. A browser never sees
* them, and they render nothing until the bridge has answered. The General page has how
@@ -46,6 +40,8 @@ export function DesktopOsSettings({ part = "app" }: { part?: "app" | "notificati
if (!os || !status) return null;
const { settings } = status;
const platform = bridge?.platform;
const showKeys = describeAccelerator(settings.showShortcut ?? showShortcutDefault(platform), platform);
const change = (patch: Partial<Settings>) => {
void os.setSettings(patch).then(setStatus);
};
@@ -103,12 +99,12 @@ export function DesktopOsSettings({ part = "app" }: { part?: "app" | "notificati
<SettingItem
label={t("show_shortcut_label")}
description={settings.showShortcut && !status.shortcutActive
? t("show_shortcut_taken", { keys: shortcutLabel(bridge?.platform) })
: t("show_shortcut_description", { keys: shortcutLabel(bridge?.platform) })}
? t("show_shortcut_taken", { keys: showKeys })
: t("show_shortcut_description", { keys: showKeys })}
>
<ToggleSwitch
checked={!!settings.showShortcut}
onChange={(on) => change({ showShortcut: on ? SHOW_SHORTCUT : null })}
onChange={(on) => change({ showShortcut: on ? showShortcutDefault(platform) : null })}
/>
</SettingItem>
{status.quickCompose && (
@@ -33,6 +33,7 @@ import {
import { webPushErrorKey } from '@/lib/web-push-errors';
import type { PushDevice } from '@/lib/web-push';
import { IS_LITE } from '@/lib/lite';
import { isDesktopApp } from '@/lib/desktop/bridge';
import { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode';
import {
resolveActiveRelayUrl,
@@ -55,6 +56,7 @@ type DevicesState =
export function NotificationSettings() {
const t = useTranslations('settings.notifications');
const tAppearance = useTranslations('settings.appearance');
const tDesktopP2 = useTranslations('uxp2_desktop');
const tUnread = useTranslations('account_unread');
const severalAccounts = useAccountStore((s) => s.accounts.length > 1);
const tThirdParty = useTranslations('third_party');
@@ -354,8 +356,11 @@ export function NotificationSettings() {
</SettingItem>
)}
{/* The tab/app icon badge is a new-mail signal too; people look for it here. */}
<SettingItem label={tAppearance('favicon_unread_badge.label')} description={tAppearance('favicon_unread_badge.description')}>
{/* The tab/app icon badge is a new-mail signal too; people look for it here. The desktop app has no tab: the taskbar icon. */}
<SettingItem
label={isDesktopApp() ? tDesktopP2('unread_badge_label') : tAppearance('favicon_unread_badge.label')}
description={isDesktopApp() ? tDesktopP2('unread_badge_description') : tAppearance('favicon_unread_badge.description')}
>
<ToggleSwitch
checked={faviconUnreadBadge}
onChange={(checked) => updateSetting('faviconUnreadBadge', checked)}
+2
View File
@@ -95,6 +95,7 @@ import { useAccountStore } from '@/stores/account-store';
import { useMediaQuery, atLeast, PHONE_BELOW } from '@/hooks/use-media-query';
import { useLiteLinkSegments } from '@/hooks/use-lite-link-segments';
import { useDocumentTitle } from '@/hooks/use-document-title';
import { useDesktopWindowName } from '@/lib/desktop/window-name';
import { MvTopBar } from "@/components/layout/mv-top-bar";
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
import { scrollBehavior } from '@/lib/reduced-motion';
@@ -816,6 +817,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
? `${activeTabLabel} - ${tSidebar('settings')}`
: tSidebar('settings'),
);
useDesktopWindowName(tSidebar('settings'), { withAccount: true });
const handleTabSelect = (tabId: SettingsTabId) => {
setActiveTab(tabId);
@@ -27,6 +27,19 @@ describe('WelcomeBanner tips', () => {
expect(screen.getByText('tip_shortcuts')).toBeInTheDocument();
});
it('in the desktop app, names the keys it answers: Ctrl+N and Ctrl+K, not c', () => {
device.isMobile = false;
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32' };
try {
render(<WelcomeBanner />);
expect(screen.getByText('tip_compose_keys')).toBeInTheDocument();
expect(screen.getByText('tip_palette_keys')).toBeInTheDocument();
expect(screen.queryByText('tip_compose')).toBeNull();
} finally {
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
}
});
it('gives touch tips on a phone', () => {
device.isMobile = true;
render(<WelcomeBanner />);
+6 -1
View File
@@ -9,6 +9,8 @@ import { useRouter } from "@/i18n/navigation";
import { useTour } from "@/components/tour/tour-provider";
import { useSettingsStore } from "@/stores/settings-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { isDesktopApp } from "@/lib/desktop/bridge";
import { formatShortcutCombo } from "@/lib/shortcut-keys";
const ONBOARDING_KEY = "onboarding_completed";
@@ -38,6 +40,7 @@ export function useWelcomeBannerDue(): boolean {
*/
export function WelcomeBanner({ variant = "list" }: { variant?: "list" | "pane" }) {
const t = useTranslations("welcome");
const tDesktop = useTranslations("uxp2_desktop");
const router = useRouter();
const { startTour } = useTour();
const onboardingCompleted = useSettingsStore((s) => s.onboardingCompleted);
@@ -113,7 +116,9 @@ export function WelcomeBanner({ variant = "list" }: { variant?: "list" | "pane"
{t("title")}
</h3>
<ul className="space-y-1 text-sm text-muted-foreground">
<li>{isMobile ? t("tip_compose_touch") : t("tip_compose")}</li>
{/* The desktop app answers the keys Windows mail apps use, not single letters (desktop-p2-12). */}
<li>{isMobile ? t("tip_compose_touch") : isDesktopApp() ? tDesktop("tip_compose_keys", { keys: formatShortcutCombo("Mod + N") }) : t("tip_compose")}</li>
{!isMobile && isDesktopApp() && <li>{tDesktop("tip_palette_keys", { keys: formatShortcutCombo("Mod + K") })}</li>}
{!isMobile && <li>{t("tip_shortcuts")}</li>}
<li>{isMobile ? t("tip_sidebar_touch") : t("tip_sidebar")}</li>
<li>{t("tip_settings")}</li>
@@ -15,6 +15,7 @@ describe('the shortcut help in the desktop app', () => {
expect(shortcutKeyFor(row('shortcuts.actions.reply'), 'windows')).toBe('Mod + R / r');
expect(shortcutKeyFor(row('shortcuts.actions.reply_all'), 'linux')).toBe('Mod + Shift + R / R / a');
expect(shortcutKeyFor(row('shortcuts.actions.forward'), 'mac')).toBe('Mod + Shift + F / f');
expect(shortcutKeyFor(row('shortcuts.actions.forward'), 'windows')).toBe('Ctrl + F / Ctrl + Shift + F / f');
expect(shortcutKeyFor(row('shortcuts.global.search'), 'windows')).toBe('Ctrl + E / /');
expect(shortcutKeyFor(row('shortcuts.global.refresh'), 'windows')).toBe('F9 / F5 / Shift + G');
});
+14
View File
@@ -0,0 +1,14 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
// "Not sent - the server rejected every recipient." read as a hyphen where a colon belongs
// (usability pass 2, desktop-p2-18).
const LOCALES = join(process.cwd(), 'locales');
describe('the send error for refused recipients', () => {
it.each(readdirSync(LOCALES))('%s: no hyphen standing in for a dash or colon', (locale) => {
const messages = JSON.parse(readFileSync(join(LOCALES, locale, 'common.json'), 'utf8'));
expect(messages.email_composer.send_recipients_rejected).not.toContain(' - ');
});
});
+19
View File
@@ -13,6 +13,25 @@ describe('shortcut notation', () => {
expect(formatShortcutCombo('Ctrl+Shift+Y', false)).toBe('Ctrl+Shift+Y');
});
it('names keys the way Windows does in the app\'s language, and a Mac keeps its symbols', () => {
expect(formatShortcutCombo('Mod + Shift + R', false, 'de')).toBe('Strg + Umschalt + R');
expect(formatShortcutCombo('Shift + Mod + Enter', false, 'de-AT')).toBe('Umschalt + Strg + Eingabe');
expect(shortcutAlternatives('# / Del', false, 'de')).toEqual(['#', 'Entf']);
expect(formatShortcutCombo('Alt + Shift + N', false, 'fr')).toBe('Alt + Maj + N');
expect(formatShortcutCombo('Mod + Shift + R', true, 'de')).toBe('⌘ + Shift + R');
expect(formatShortcutCombo('Mod + Shift + R', false, 'nl')).toBe('Ctrl + Shift + R');
expect(formatShortcutCombo('Mod + Shift + R', false, 'en')).toBe('Ctrl + Shift + R');
});
it('follows the app\'s language when none is given', () => {
document.documentElement.lang = 'de';
try {
expect(formatShortcutCombo('Mod + K', false)).toBe('Strg + K');
} finally {
document.documentElement.lang = '';
}
});
it('splits alternatives', () => {
expect(shortcutAlternatives('j / ↓', false)).toEqual(['j', '↓']);
expect(shortcutAlternatives('Mod + A', true)).toEqual(['⌘ + A']);
@@ -278,6 +278,14 @@ describe('the menu bar in the desktop app', () => {
expect(menuKeyText('?', 'win32')).toBe('?');
expect(menuKeyText('CommandOrControl+Shift+Z', 'win32')).toBe('Ctrl+Shift+Z');
});
it('and in the app\'s language, as German Windows writes them', () => {
expect(menuKeyText('CmdOrCtrl+Shift+R', 'win32', 'de')).toBe('Strg+Umschalt+R');
expect(menuKeyText('Delete', 'win32', 'de')).toBe('Entf');
expect(menuKeyText('CmdOrCtrl+Enter', 'linux', 'de')).toBe('Strg+Eingabe');
expect(menuKeyText('Alt+Left', 'win32', 'de')).toBe('Alt+←');
expect(menuKeyText('Delete', 'win32', 'fr')).toBe('Suppr');
});
});
describe('the window controls over the app', () => {
@@ -348,6 +356,32 @@ describe('the window controls over the app', () => {
expect(root.hasAttribute(CONTROLS_ATTRIBUTE)).toBe(false);
expect(rightInset()).toBe('');
});
describe('when the window changes size in one step', () => {
const startWidth = window.innerWidth;
const setWidth = (width: number) => Object.defineProperty(window, 'innerWidth', { value: width, configurable: true, writable: true });
afterEach(() => setWidth(startWidth));
// As Chromium does it: geometrychange with the new rect while the page still has
// its old width, then the page takes the new width and fires resize.
const resizeTo = (fake: EventTarget, width: number) => act(() => {
(fake as unknown as { getTitlebarAreaRect: () => object }).getTitlebarAreaRect = () => ({ x: 0, y: 0, width: width - 137, height: 56 });
fake.dispatchEvent(new Event('geometrychange'));
setWidth(width);
window.dispatchEvent(new Event('resize'));
});
it('keeps the room for the controls after a maximise, a restore, a snap and a shrink', () => {
setWidth(1366);
const fake = overlayAt(0, 1366 - 137);
render(<WindowControlsHost />);
expect(rightInset()).toBe('137px');
for (const width of [2048, 1366, 1024, 640, 1920]) {
resizeTo(fake, width);
expect(rightInset(), `at ${width} px`).toBe('137px');
}
});
});
});
describe('which rows hold the controls', () => {
@@ -132,6 +132,12 @@ describe('launches', () => {
expect(takeHandoff('compose')?.files.map((file) => file.name)).toEqual(['a.pdf', 'b.png']);
});
it('files sent from another program are marked so, for a compose window of their own, as a mail link', async () => {
const { takeHandoff } = await import('@/lib/launch-handoff');
await run({ type: 'files', reason: 'attach', failed: [], files: [{ name: 'a.pdf', data: text('a') }] });
expect(takeHandoff('compose')).toMatchObject({ fromOs: true });
});
it('a file opened with the app asks: new message or Files, a new message first', async () => {
const { takeHandoff } = await import('@/lib/launch-handoff');
const { listenForFileChoices } = await import('@/lib/file-drop/choice');
+20
View File
@@ -290,6 +290,15 @@ describe('the summary', () => {
expect(nextEventOf([event('past', '2026-11-03T09:00:00Z')], now)).toBeNull();
});
it('the event\'s time follows the app\'s time format, not the language\'s', async () => {
const { formatEventTime } = await import('../os-summary');
const at = new Date(2026, 10, 3, 11, 0).getTime();
const morning = new Date(2026, 10, 3, 8, 0).getTime();
expect(formatEventTime(at, morning, 'en-US', '24h')).toBe('11:00');
expect(formatEventTime(at, morning, 'en-US', '12h')).toMatch(/^11:00\sAM$/);
expect(formatEventTime(new Date(2026, 10, 3, 9, 5).getTime(), morning, 'en-US', '24h')).toBe('09:05');
});
describe('with a login', () => {
const row = { id: 'e1', threadId: 't1', subject: 'Lunch?', from: [{ name: 'Alice', email: 'a@example.org' }] };
const getEmails = vi.fn(async () => ({ emails: [row], hasMore: false, total: 1 }));
@@ -422,6 +431,17 @@ describe('the settings section', () => {
await waitFor(() => expect(os.openDefaultAppSettings).toHaveBeenCalled());
});
it('switching "Show the app" on takes a key the composer doesn\'t use', async () => {
const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings');
const os = bridgeFor(status());
setBridge(os as never);
render(<DesktopOsSettings />);
expect(await screen.findByText('show_shortcut_label')).toBeInTheDocument();
// Close to tray, start with the computer, then this one.
fireEvent.click(screen.getAllByRole('switch')[2]);
await waitFor(() => expect(os.setSettings).toHaveBeenCalledWith({ showShortcut: 'CommandOrControl+Alt+F11' }));
});
it('says when another app holds the shortcut', async () => {
const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings');
setBridge(bridgeFor(status({ showShortcut: 'CommandOrControl+Shift+B' })) as never);
@@ -37,6 +37,11 @@ describe('the labels the desktop shell gets', () => {
}
});
it('names File > Unlock, which the menus offer while the app is locked', () => {
expect(labelsFor('de').labels.menuUnlock).toBe('Entsperren');
expect(labelsFor('en').labels.menuUnlock).toBe('Unlock');
});
it.each(LOCALES)('%s: leaves the shell its placeholders to fill in', (locale) => {
const { labels } = labelsFor(locale);
expect(labels.contextAddToDictionary).toContain('{word}');
+22 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { describeAccelerator, readShortcutPress, shortcutProblem, type ShortcutKeyEvent } from '../shortcut-keys';
import { describeAccelerator, readShortcutPress, shortcutProblem, showShortcutDefault, type ShortcutKeyEvent } from '../shortcut-keys';
const press = (key: string, code: string, modifiers: Partial<ShortcutKeyEvent> = {}): ShortcutKeyEvent => ({
key,
@@ -27,6 +27,14 @@ describe('describeAccelerator', () => {
});
});
describe('describeAccelerator in the app\'s language', () => {
it('says Strg and Umschalt in German, and keeps a Mac\'s symbols', () => {
expect(describeAccelerator('CommandOrControl+Alt+F11', 'win32', 'de')).toBe('Strg+Alt+F11');
expect(describeAccelerator('CommandOrControl+Shift+Enter', 'linux', 'de')).toBe('Strg+Umschalt+Eingabe');
expect(describeAccelerator('CommandOrControl+Shift+B', 'darwin', 'de')).toBe('⇧⌘B');
});
});
describe('readShortcutPress', () => {
it('turns a press into what the shell takes', () => {
expect(readShortcutPress(press('n', 'KeyN', CTRL_ALT), 'win32')).toEqual({ accelerator: 'CommandOrControl+Alt+N' });
@@ -108,4 +116,17 @@ describe('shortcutProblem', () => {
expect(shortcutProblem('CommandOrControl+Control+N', 'win32')).toBe('incomplete');
expect(shortcutProblem('CommandOrControl+Control+N', 'darwin')).toBeNull();
});
it('refuses the app\'s own keys: a global one would take them from the composer and the menus', () => {
for (const keys of ['CommandOrControl+Shift+B', 'CommandOrControl+Shift+C', 'CommandOrControl+Shift+Enter', 'CommandOrControl+Shift+M', 'CommandOrControl+Shift+N', 'CommandOrControl+Shift+S', 'CommandOrControl+Shift+R', 'CommandOrControl+Shift+F']) {
for (const platform of ['win32', 'darwin', 'linux']) expect(shortcutProblem(keys, platform), `${keys} on ${platform}`).toBe('reserved');
}
expect(readShortcutPress(press('B', 'KeyB', { ctrlKey: true, shiftKey: true }), 'win32')).toEqual({ refused: 'reserved' });
});
it('"Show the app" by default is none of them, and on Windows types nothing with AltGr', () => {
for (const platform of ['win32', 'darwin', 'linux']) expect(shortcutProblem(showShortcutDefault(platform), platform)).toBeNull();
expect(showShortcutDefault('win32')).toBe('CommandOrControl+Alt+F11');
expect(showShortcutDefault('linux')).toBe('CommandOrControl+Alt+B');
});
});
@@ -0,0 +1,41 @@
import { cleanup, render } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { useDesktopWindowName, windowMenuName } from '../window-name';
import { useAccountStore } from '@/stores/account-store';
// The desktop app's Window menu: each window by what it shows (usability pass 2, desktop-p2-5).
function Named({ view, withAccount }: { view: string | null; withAccount?: boolean }) {
useDesktopWindowName(view, { withAccount });
return null;
}
afterEach(() => {
cleanup();
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
});
describe('the Window menu name', () => {
it('is the view and the account for the main window, the view alone otherwise', () => {
expect(windowMenuName('Inbox', 'a@example.org')).toBe('Inbox - a@example.org');
expect(windowMenuName('Quarterly budget', null)).toBe('Quarterly budget');
expect(windowMenuName(' ', null)).toBeNull();
});
it('goes to the shell, with the account in view, and again when it changes', () => {
const named: string[] = [];
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', windows: { setMenuName: (name: string) => named.push(name) } };
useAccountStore.setState({ accounts: [{ id: 'acc-1', email: 'a@example.org' }], activeAccountId: 'acc-1' } as never);
const { rerender } = render(<Named view="Inbox" withAccount />);
expect(named).toEqual(['Inbox - a@example.org']);
rerender(<Named view="Sent" withAccount />);
expect(named).toEqual(['Inbox - a@example.org', 'Sent - a@example.org']);
rerender(<Named view="A subject" />);
expect(named.at(-1)).toBe('A subject');
});
it('is nothing to an older shell, or in a browser', () => {
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', windows: {} };
expect(() => render(<Named view="Inbox" />)).not.toThrow();
});
});
+1
View File
@@ -152,6 +152,7 @@ export function shellLabels(t: (key: string, values?: Record<string, string>) =>
trayNewMessage: t('email_composer.new_message'),
trayLockNow: t('appLock.lock_now'),
menuLockNow: t('appLock.lock_now'),
menuUnlock: t('appLock.unlock'),
trayUnread: t('email_list.sections.unread'),
trayPause: t('desktopMenuBar.tray_pause'),
trayPauseHour: t('desktopMenuBar.tray_pause_hour'),
+8 -4
View File
@@ -8,6 +8,7 @@
import { useEffect, useState } from 'react';
import { getDesktopBridge } from './bridge';
import { describeAccelerator } from './shortcut-keys';
import { localKeyName } from '@/lib/shortcut-keys';
import { MENU_BAR_STORAGE_KEY } from './boot';
@@ -66,14 +67,17 @@ const KEY_NAMES: Record<string, string> = {
space: 'Space',
};
/** An Electron accelerator the way a Windows or Linux menu shows it: `Ctrl+Shift+N`, `Alt+←`, `?`. */
export function menuKeyText(accelerator: string, platform: string | undefined): string {
/**
* An Electron accelerator the way a Windows or Linux menu shows it: `Ctrl+Shift+N`, `Alt+←`, `?`,
* in the app's language (`Strg+Umschalt+N`, `Entf` in German). `language` defaults to the app's.
*/
export function menuKeyText(accelerator: string, platform: string | undefined, language?: string): string {
// A key that is itself "+", as in Ctrl++, ends in an empty part.
const parts = accelerator.split('+');
const last = parts[parts.length - 1] === '' ? '+' : parts[parts.length - 1];
const key = KEY_NAMES[last.toLowerCase()] ?? (last.length === 1 ? last.toUpperCase() : last);
const key = localKeyName(KEY_NAMES[last.toLowerCase()] ?? (last.length === 1 ? last.toUpperCase() : last), language);
const modifiers = parts.slice(0, parts[parts.length - 1] === '' ? -2 : -1);
if (modifiers.length === 0) return key;
const named = describeAccelerator(`${modifiers.join('+')}+X`, platform);
const named = describeAccelerator(`${modifiers.join('+')}+X`, platform, language);
return `${named.slice(0, -1)}${key}`;
}
+5 -2
View File
@@ -133,10 +133,13 @@ function handleFiles(command: Extract<DesktopOsCommand, { type: 'files' }>, env:
const made = command.files.map(({ name, data }) => new File([new Uint8Array(data)], name));
const failed = [...command.failed];
if (command.reason === 'attach' && command.ask) {
// Opened with the app ("Open with"): the same choice as dropping it on the window.
// An older shell's "Open with": the same choice as dropping it on the window.
requestFileChoice({ files: made, preferred: 'new-message' });
} else if (command.reason === 'attach') {
if (made.length > 0) handOffDraft({ files: made });
// Send to and Open with: a compose window with the files, as a mail link gets, and the
// main window stays where it is. Signed out, the sign-in has to show.
if (!useAuthStore.getState().isAuthenticated) showWindow();
if (made.length > 0) handOffDraft({ files: made, fromOs: true });
} else {
for (const file of made) {
const kind = kindForFile(file);
+7 -4
View File
@@ -16,6 +16,7 @@ import { useAuthStore } from '@/stores/auth-store';
import { useCalendarStore } from '@/stores/calendar-store';
import { useEmailStore } from '@/stores/email-store';
import { usePolicyStore } from '@/stores/policy-store';
import { useSettingsStore, type TimeFormat } from '@/stores/settings-store';
const ROWS = 5;
const REFRESH_DELAY_MS = 2000;
@@ -36,12 +37,14 @@ export function nextEventOf(events: readonly CalendarEvent[], now: number): Cale
return ahead[0] ?? null;
}
export function formatEventTime(start: number, now: number, locale: string | undefined): string {
/** In the app's own time format (Settings > General), not the language's: 11:00, never 11:00 AM where the app shows 24 hours. */
export function formatEventTime(start: number, now: number, locale: string | undefined, timeFormat: TimeFormat = '24h'): string {
const date = new Date(start);
const sameDay = date.toDateString() === new Date(now).toDateString();
const hour12 = timeFormat === '12h';
const options: Intl.DateTimeFormatOptions = sameDay
? { hour: 'numeric', minute: '2-digit' }
: { weekday: 'short', hour: 'numeric', minute: '2-digit' };
? { hour: hour12 ? 'numeric' : '2-digit', minute: '2-digit', hour12 }
: { weekday: 'short', hour: hour12 ? 'numeric' : '2-digit', minute: '2-digit', hour12 };
return new Intl.DateTimeFormat(locale, options).format(date);
}
@@ -79,7 +82,7 @@ export async function readSummary(locale: string | undefined, now = Date.now()):
before: new Date(now + EVENT_WINDOW_MS).toISOString(),
}).catch(() => [] as CalendarEvent[]);
const next = nextEventOf(events, now);
if (next) nextEvent = { title: next.title ?? '', when: formatEventTime(eventStart(next), now, locale) };
if (next) nextEvent = { title: next.title ?? '', when: formatEventTime(eventStart(next), now, locale, useSettingsStore.getState().timeFormat) };
}
// The tray lists one account's inbox: with more than one, it says whose.
const several = useAccountStore.getState().accounts.length > 1;
+21 -3
View File
@@ -4,12 +4,23 @@
* so the settings page can say why before the shell silently refuses.
*/
import { localKeyName } from '@/lib/shortcut-keys';
/**
* 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';
/**
* What "Show the app with a keyboard shortcut" takes when switched on: Ctrl+Alt+B (⌥⌘B), and
* on Windows Ctrl+Alt+F11, since Ctrl+Alt is AltGr there and B types on many layouts. The
* shell's showShortcutBuiltIn, which also moves anyone still on the old Ctrl+Shift+B to it.
*/
export function showShortcutDefault(platform: string | undefined): string {
return platform === 'win32' ? 'CommandOrControl+Alt+F11' : 'CommandOrControl+Alt+B';
}
const MAC_SYMBOLS: Record<string, string> = {
control: '⌃',
ctrl: '⌃',
@@ -41,7 +52,8 @@ const OTHER_NAMES: Record<string, string> = {
/** The order a Mac shows modifiers in. */
const MAC_ORDER = ['⌃', '⌥', '⇧', '⌘'];
export function describeAccelerator(accelerator: string, platform: string | undefined): string {
/** An Electron accelerator as people read it: ⇧⌘B on a Mac, Ctrl+Alt+F11 elsewhere, in the app's language (Strg in German). */
export function describeAccelerator(accelerator: string, platform: string | undefined, language?: string): string {
const parts = accelerator.split('+');
const key = parts.pop() ?? '';
const named = parts.map((part) => part.toLowerCase());
@@ -50,7 +62,7 @@ export function describeAccelerator(accelerator: string, platform: string | unde
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('+');
return [...named.map((part) => OTHER_NAMES[part] ?? superKey), key].map((part) => localKeyName(part, language)).join('+');
}
export interface ShortcutKeyEvent {
@@ -67,6 +79,12 @@ export interface ShortcutKeyEvent {
* 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']);
/**
* The app's own keys with two modifiers, which a global key would take from Bulwark itself:
* the composer's Cc, Bcc and schedule send, and the menus' new message, new window, Save as
* PDF, reply all and forward. Same list as APP_KEYS in the shell's os-rules.ts.
*/
const APP_KEYS = new Set(['cmd+shift+B', 'cmd+shift+C', 'cmd+shift+ENTER', 'cmd+shift+M', 'cmd+shift+N', 'cmd+shift+S', 'cmd+shift+R', 'cmd+shift+F']);
const RESERVED_ON_MAC = new Set([
'cmd+shift+3', 'cmd+shift+4', 'cmd+shift+5', 'cmd+shift+Q',
'cmd+ctrl+Q', 'cmd+ctrl+F',
@@ -119,7 +137,7 @@ export function shortcutProblem(accelerator: string, platform: string | undefine
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;
return RESERVED.has(named) || APP_KEYS.has(`${[...modifiers].sort().join('+')}+${key.toUpperCase()}`) || (mac && RESERVED_ON_MAC.has(named)) ? 'reserved' : null;
}
/**
+4
View File
@@ -17,6 +17,8 @@ export interface DesktopWindowInfo {
id: number;
type: DesktopWindowType;
title: string;
/** What the page calls the window in the Window menu (`setMenuName`), if it said. */
menuName?: string;
focused: boolean;
/** False for a main window hidden while pop-outs are open. */
visible?: boolean;
@@ -46,6 +48,8 @@ export interface DesktopWindowsBridge {
/** What this window's open drafts come back as at the next start: pop-out composer addresses. */
setRestore?: (urls: string[]) => void;
setRestoreEnabled?: (enabled: boolean) => void;
/** This window's name in the Window menu: lib/desktop/window-name.ts. */
setMenuName?: (name: string) => 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. */
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { useEffect } from 'react';
import { getDesktopBridge } from './bridge';
import { useAccountStore } from '@/stores/account-store';
/**
* What the desktop app's Window menu calls this window (the shell numbers two alike and
* cuts long ones). The main window goes by its view and account ("Inbox - a@example.org"),
* not by the message open in it, so it never reads like a message window; a message window
* by its subject; a composer by its subject or "New message". Nothing in a browser.
*/
export function windowMenuName(view: string | null | undefined, account: string | null | undefined): string | null {
const parts = [view?.trim(), account?.trim()].filter(Boolean);
return parts.length ? parts.join(' - ') : null;
}
/** Tells the shell this window's Window menu name. `withAccount` adds the account in view, for the main window. */
export function useDesktopWindowName(view: string | null | undefined, { withAccount = false }: { withAccount?: boolean } = {}): void {
const account = useAccountStore((s) => (withAccount ? s.accounts.find((a) => a.id === s.activeAccountId)?.email ?? null : null));
const name = windowMenuName(view, account);
useEffect(() => {
if (!name) return;
getDesktopBridge()?.windows?.setMenuName?.(name);
}, [name]);
}
+2 -1
View File
@@ -13,7 +13,8 @@ export const MAIL_SHORTCUTS = {
actions: [
{ key: "r", description: "shortcuts.actions.reply", desktopKey: "Mod + R" },
{ key: "R / a", description: "shortcuts.actions.reply_all", desktopKey: "Mod + Shift + R" },
{ key: "f", description: "shortcuts.actions.forward", desktopKey: "Mod + Shift + F" },
// Ctrl+F forwards with a message selected (Outlook) and searches elsewhere; Ctrl+Shift+F still works.
{ key: "f", description: "shortcuts.actions.forward", desktopKey: "Mod + Shift + F", desktopPcKey: "Ctrl + F / Ctrl + Shift + F" },
{ key: "s", description: "shortcuts.actions.star" },
{ key: "e", description: "shortcuts.actions.archive", feature: "archiveActionEnabled" },
{ key: "# / Del", description: "shortcuts.actions.delete" },
+4 -1
View File
@@ -42,6 +42,8 @@ export interface Handoff {
/** New draft only. */
subject?: string;
body?: string;
/** From another program through the desktop app (Send to, Open with): a compose window of its own. */
fromOs?: boolean;
createdAt: number;
}
@@ -307,13 +309,14 @@ export function handOffFiles(files: File[], kind: HandoffKind): void {
}
/** Opens a new draft with this text and these files. Trusted: what came from outside was asked about first. */
export function handOffDraft(draft: { subject?: string; body?: string; files?: File[] }): void {
export function handOffDraft(draft: { subject?: string; body?: string; files?: File[]; fromOs?: boolean }): void {
const subject = cleanMailtoSubject(draft.subject ?? '');
const body = cleanMailtoBody(draft.body ?? '');
enqueue({
id: newId(),
kind: 'compose',
files: draft.files ?? [],
...(draft.fromOs ? { fromOs: true } : {}),
...(subject ? { subject } : {}),
...(body ? { body } : {}),
createdAt: Date.now(),
+3 -1
View File
@@ -109,7 +109,9 @@ export function createComposeHandoff(): ComposeHandoff | null {
/** Keeps the draft with this window, so a reload starts from it. */
export function rememberComposePayload(handoffId: string, payload: ComposePopoutPayload): void {
try {
sessionStorage.setItem(STORAGE_PREFIX + handoffId, JSON.stringify(payload));
// A File doesn't go into JSON: once attached, the draft holds it.
const { initialFiles: _files, ...kept } = payload;
sessionStorage.setItem(STORAGE_PREFIX + handoffId, JSON.stringify(kept));
} catch {
// Full or unavailable: a reload then starts from the saved draft's first state.
}
+5
View File
@@ -20,4 +20,9 @@ export interface ComposePopoutPayload {
sourceEmailSource?: EmailWriteSource | null;
/** The mailbox a new message was started from; picks the identity (see the composer's prop). */
composeFromAccountEmail?: string;
/**
* Files to attach as it opens: Send to and Open with in the desktop app. They cross to the
* window as they are (BroadcastChannel clones a File) and aren't kept for a reload.
*/
initialFiles?: File[];
}
+11
View File
@@ -357,3 +357,14 @@ export function subscribeToPendingWebcal(callback: () => void): () => void {
export function hasPendingWebcal(): boolean {
return hasPending(WEBCAL_KEY, isParsedWebcal);
}
/**
* The account a mail link from outside goes out from, with no question: the one marked
* default, else the one in use, as Outlook and Thunderbird do. The composer's From can
* change it. Only signed-in accounts count.
*/
export function mailLinkAccountId(accounts: readonly { id: string }[], defaultId: string | null, activeId: string | null): string | null {
if (defaultId && accounts.some((account) => account.id === defaultId)) return defaultId;
if (activeId && accounts.some((account) => account.id === activeId)) return activeId;
return accounts[0]?.id ?? activeId ?? null;
}
+39 -9
View File
@@ -12,23 +12,53 @@ export function isApplePlatform(): boolean {
const MOD = /^(mod|ctrl\/⌘|ctrl\/cmd|cmd\/ctrl|⌘\/ctrl)$/i;
/** One key or combination ("Mod + Enter") in the notation of this platform. */
export function formatShortcutCombo(combo: string, apple = isApplePlatform()): string {
/**
* Key names as Windows writes them in each language, on its keyboards and in every app's
* menus: German "Strg+Umschalt+R", "Entf". Languages not here keep the English names, as
* Windows does for them. macOS has symbols, which need no words.
*/
const KEY_NAMES: Record<string, Record<string, string>> = {
de: { Ctrl: 'Strg', Shift: 'Umschalt', Del: 'Entf', Delete: 'Entf', Enter: 'Eingabe', Backspace: 'Rücktaste', Home: 'Pos1', End: 'Ende', PageUp: 'Bild↑', PageDown: 'Bild↓', Insert: 'Einfg', Space: 'Leertaste' },
fr: { Shift: 'Maj', Del: 'Suppr', Delete: 'Suppr', Enter: 'Entrée', Backspace: 'Retour arrière', Home: 'Origine', End: 'Fin', PageUp: 'Pg.Préc', PageDown: 'Pg.Suiv', Insert: 'Inser', Space: 'Espace' },
es: { Shift: 'Mayús', Del: 'Supr', Delete: 'Supr', Enter: 'Entrar', Backspace: 'Retroceso', Home: 'Inicio', End: 'Fin', PageUp: 'Re Pág', PageDown: 'Av Pág', Space: 'Espacio' },
it: { Shift: 'Maiusc', Del: 'Canc', Delete: 'Canc', Enter: 'Invio', Space: 'Spazio' },
};
/** The app's language, from <html lang> (components/providers/intl-provider.tsx keeps it). */
function pageLanguage(): string {
if (typeof document === 'undefined') return 'en';
return (document.documentElement.lang || 'en').toLowerCase().split('-')[0];
}
/**
* One key's name in a language: "Ctrl" is "Strg" in German. Takes the English names this
* notation and Electron use ("Ctrl", "Shift", "Del", "Delete", "Enter", "PageUp"); anything
* else comes back as it is. `language` defaults to the app's.
*/
export function localKeyName(name: string, language: string = pageLanguage()): string {
const table = KEY_NAMES[language.toLowerCase().split('-')[0]];
if (!table) return name;
const canonical = Object.keys(table).find((key) => key.toLowerCase() === name.toLowerCase());
return canonical ? table[canonical] : name;
}
/** One key or combination ("Mod + Enter") in the notation of this platform and the app's language. */
export function formatShortcutCombo(combo: string, apple = isApplePlatform(), language: string = pageLanguage()): string {
// Keeps the spacing it was written with ("Ctrl+Shift+Y" from a plugin).
const separator = /\s\+\s/.test(combo) ? " + " : "+";
return combo
.split(/\s*\+\s*/)
.map((part) => {
if (MOD.test(part)) return apple ? '⌘' : 'Ctrl';
if (/^ctrl$/i.test(part)) return apple ? '⌘' : 'Ctrl';
if (/^(cmd|⌘)$/i.test(part)) return apple ? '⌘' : 'Ctrl';
if (MOD.test(part)) return apple ? '⌘' : localKeyName('Ctrl', language);
if (/^ctrl$/i.test(part)) return apple ? '⌘' : localKeyName('Ctrl', language);
if (/^(cmd|⌘)$/i.test(part)) return apple ? '⌘' : localKeyName('Ctrl', language);
if (/^alt$/i.test(part)) return apple ? '⌥' : 'Alt';
return part;
return apple ? part : localKeyName(part, language);
})
.join(separator);
}
/** The alternatives of a shortcut ("j / ↓"), each in the notation of this platform. */
export function shortcutAlternatives(key: string, apple = isApplePlatform()): string[] {
return key.split(' / ').map((combo) => formatShortcutCombo(combo, apple));
/** The alternatives of a shortcut ("j / ↓"), each in the notation of this platform and the app's language. */
export function shortcutAlternatives(key: string, apple = isApplePlatform(), language: string = pageLanguage()): string[] {
return key.split(' / ').map((combo) => formatShortcutCombo(combo, apple, language));
}
+13 -4
View File
@@ -94,9 +94,14 @@ export function applyControlsArea(area: TitlebarArea | null, root: HTMLElement =
/**
* Calls `listener` now and whenever the overlay's size or visibility changes
* (`geometrychange`). Without the API it calls it once with null and listens
* to nothing, so a browser without it runs no extra code. Returns the
* unsubscribe.
* (`geometrychange`), and again when the page's width does (`resize`).
* Without the API it calls it once with null and listens to nothing, so a
* browser without it runs no extra code. Returns the unsubscribe.
*
* `geometrychange` comes before the page has its new width: after a maximise,
* restore, snap or drag the rect is the new one and innerWidth still the old,
* so the room worked out from the two is wrong (none at all after growing, more
* than the window after shrinking). The `resize` that follows has both.
*/
export function watchTitlebarArea(listener: (area: TitlebarArea | null) => void): () => void {
listener(readTitlebarArea());
@@ -104,7 +109,11 @@ export function watchTitlebarArea(listener: (area: TitlebarArea | null) => void)
if (!current) return () => {};
const onChange = () => listener(readTitlebarArea());
current.addEventListener('geometrychange', onChange);
return () => current.removeEventListener('geometrychange', onChange);
window.addEventListener('resize', onChange);
return () => {
current.removeEventListener('geometrychange', onChange);
window.removeEventListener('resize', onChange);
};
}
const ROW_CANDIDATES = '[data-window-top-row], [data-composer-header], header, div.h-14, div.min-h-14, div.h-12, div.h-16, div.min-h-16';
+7 -2
View File
@@ -245,7 +245,6 @@
"select_account_title": "اختر الحساب",
"select_mailto_account": "اختر الحساب الذي سيفتح رابط البريد هذا.",
"select_webcal_account": "اختر الحساب الذي سيفتح رابط التقويم هذا.",
"select_account_note": "ينطبق هذا فقط على رابط البروتوكول هذا.",
"detail_to": "إلى",
"detail_subject": "الموضوع",
"detail_no_subject": "بلا موضوع",
@@ -1069,7 +1068,7 @@
"recipients_partly_refused": "رفض الخادم {recipients}. سيتلقى باقي المستلمين الرسالة.",
"send_offline": "يبدو أنك غير متصل. الرسالة ما زالت هنا، أرسلها مجددًا عند عودة الاتصال.",
"formatting": "التنسيق",
"send_recipients_rejected": "لم تُرسل الرسالة - رفض الخادم جميع المستلمين.",
"send_recipients_rejected": "لم تُرسل الرسالة: رفض الخادم جميع المستلمين.",
"send_plugin_failed": "لم يُرسل - فشلت إضافة تتولى الإرسال أو استغرقت وقتًا طويلًا. تحقق من مجلد المرسل قبل الإرسال مرة أخرى.",
"send_some_recipients_rejected": "أُرسلت الرسالة، لكن ليس إلى هؤلاء المستلمين - رفضهم الخادم.",
"continue_draft": "متابعة المسودة",
@@ -7591,5 +7590,11 @@
"send_no_consent": "شغّل تقارير الأعطال أولًا.",
"send_no_url": "لم يُحدَّد عنوان، لذا لا توجد وجهة للإرسال. أدخل عنوانًا في الأعلى.",
"send_failed": "تعذّر إرسال تقرير العطل."
},
"uxp2_desktop": {
"unread_badge_label": "عدد الرسائل غير المقروءة على أيقونة شريط المهام",
"unread_badge_description": "لترى البريد الجديد أثناء عملك في تطبيق آخر.",
"tip_compose_keys": "اضغط {keys} لكتابة رسالة جديدة",
"tip_palette_keys": "اضغط {keys} للعثور على أي أمر"
}
}
+6 -1
View File
@@ -245,7 +245,6 @@
"select_account_title": "Trieu un compte",
"select_mailto_account": "Trieu quin compte ha d'obrir aquest enllaç de correu.",
"select_webcal_account": "Trieu quin compte ha d'obrir aquest enllaç de calendari.",
"select_account_note": "Això només s'aplica a aquest enllaç de protocol.",
"detail_to": "Per a",
"detail_subject": "Assumpte",
"detail_no_subject": "Sense assumpte",
@@ -7591,5 +7590,11 @@
"send_no_consent": "Activa primer els informes d'errors greus.",
"send_no_url": "No hi ha cap adreça, així que no hi ha on enviar-ho. Escriu-ne una a dalt.",
"send_failed": "No s'ha pogut enviar l'informe d'error."
},
"uxp2_desktop": {
"unread_badge_label": "Nombre de no llegits a la icona de la barra de tasques",
"unread_badge_description": "Per veure el correu nou mentre treballeu en una altra aplicació.",
"tip_compose_keys": "Premeu {keys} per escriure un missatge nou",
"tip_palette_keys": "Premeu {keys} per trobar qualsevol ordre"
}
}
+6 -1
View File
@@ -245,7 +245,6 @@
"select_account_title": "Vybrat účet",
"select_mailto_account": "Vyberte účet, ve kterém se má tento e-mailový odkaz otevřít.",
"select_webcal_account": "Vyberte účet, ve kterém se má tento kalendářový odkaz otevřít.",
"select_account_note": "Tato volba platí jen pro tento odkaz protokolu.",
"detail_to": "Komu",
"detail_subject": "Předmět",
"detail_no_subject": "Bez předmětu",
@@ -7591,5 +7590,11 @@
"send_no_consent": "Nejprve zapněte hlášení o pádech.",
"send_no_url": "Není nastavena žádná adresa, takže to nejde kam odeslat. Zadejte ji výše.",
"send_failed": "Hlášení o pádu se nepodařilo odeslat."
},
"uxp2_desktop": {
"unread_badge_label": "Počet nepřečtených na ikoně na hlavním panelu",
"unread_badge_description": "Abyste viděli novou poštu, i když pracujete v jiné aplikaci.",
"tip_compose_keys": "Stiskněte {keys} a napište novou zprávu",
"tip_palette_keys": "Stiskněte {keys} a najděte jakýkoli příkaz"
}
}
+7 -2
View File
@@ -245,7 +245,6 @@
"select_account_title": "Vælg konto",
"select_mailto_account": "Vælg hvilken konto der skal åbne dette e-mail-link.",
"select_webcal_account": "Vælg hvilken konto der skal åbne dette kalenderlink.",
"select_account_note": "Dette gælder kun for dette protokollink.",
"detail_to": "Til",
"detail_subject": "Emne",
"detail_no_subject": "Intet emne",
@@ -1068,7 +1067,7 @@
"recipients_partly_refused": "Serveren afviste {recipients}. De øvrige modtagere får beskeden.",
"send_offline": "Du ser ud til at være offline. Beskeden er her stadig, send den igen, når du er online.",
"formatting": "Formatering",
"send_recipients_rejected": "Ikke sendt - serveren afviste alle modtagere.",
"send_recipients_rejected": "Ikke sendt: serveren afviste alle modtagere.",
"send_plugin_failed": "Ikke sendt - et plugin, der står for afsendelsen, fejlede eller tog for lang tid. Tjek mappen Sendt, før du sender igen.",
"send_some_recipients_rejected": "Sendt, men ikke til disse modtagere - serveren afviste dem.",
"continue_draft": "Fortsæt kladde",
@@ -7591,5 +7590,11 @@
"send_no_consent": "Slå først nedbrudsrapporter til.",
"send_no_url": "Der er ikke angivet en adresse, så den kan ikke sendes nogen steder. Skriv en ovenfor.",
"send_failed": "Nedbrudsrapporten kunne ikke sendes."
},
"uxp2_desktop": {
"unread_badge_label": "Antal ulæste på ikonet på proceslinjen",
"unread_badge_description": "Så du ser ny mail, mens du arbejder i en anden app.",
"tip_compose_keys": "Tryk på {keys} for at skrive en ny besked",
"tip_palette_keys": "Tryk på {keys} for at finde en kommando"
}
}
+6 -1
View File
@@ -245,7 +245,6 @@
"select_account_title": "Account auswählen",
"select_mailto_account": "Wählen Sie aus, mit welchem Account dieser E-Mail-Link geöffnet werden soll.",
"select_webcal_account": "Wählen Sie aus, mit welchem Account dieser Kalender-Link geöffnet werden soll.",
"select_account_note": "Diese Auswahl gilt nur für diesen Protokoll-Link.",
"detail_to": "An",
"detail_subject": "Betreff",
"detail_no_subject": "Ohne Betreff",
@@ -7591,5 +7590,11 @@
"send_no_consent": "Schalten Sie zuerst die Absturzberichte ein.",
"send_no_url": "Es ist keine Adresse eingetragen, daher gibt es kein Ziel. Geben Sie oben eine ein.",
"send_failed": "Der Absturzbericht konnte nicht gesendet werden."
},
"uxp2_desktop": {
"unread_badge_label": "Anzahl ungelesener Nachrichten am Taskleistensymbol",
"unread_badge_description": "So sehen Sie neue Mail, während Sie in einer anderen App arbeiten.",
"tip_compose_keys": "Drücken Sie {keys}, um eine neue Nachricht zu schreiben",
"tip_palette_keys": "Drücken Sie {keys}, um jeden Befehl zu finden"
}
}
+7 -2
View File
@@ -245,7 +245,6 @@
"select_account_title": "Choose account",
"select_mailto_account": "Choose which account should open this email link.",
"select_webcal_account": "Choose which account should open this calendar link.",
"select_account_note": "This only applies to this protocol link.",
"detail_to": "To",
"detail_subject": "Subject",
"detail_no_subject": "No subject",
@@ -1069,7 +1068,7 @@
"submission_limit": "The server refused to send the message because a limit was reached: {reason}",
"recipients_refused": "Not sent: the server refused {recipients}.",
"recipients_partly_refused": "The server refused {recipients}. The other recipients get the message.",
"send_recipients_rejected": "Not sent - the server rejected every recipient.",
"send_recipients_rejected": "Not sent: the server rejected every recipient.",
"cut": "Cut",
"copy": "Copy",
"send_plugin_failed": "Not sent - a plugin that handles sending failed or took too long. Check your Sent folder before sending again.",
@@ -7591,5 +7590,11 @@
"send_no_consent": "Turn on crash reports first.",
"send_no_url": "No address is set, so there's nowhere to send this. Enter one above.",
"send_failed": "The crash report couldn't be sent."
},
"uxp2_desktop": {
"unread_badge_label": "Unread count on the taskbar icon",
"unread_badge_description": "So you see new mail while you work in another app.",
"tip_compose_keys": "Press {keys} to write a new message",
"tip_palette_keys": "Press {keys} to find any command"
}
}
+6 -1
View File
@@ -245,7 +245,6 @@
"select_account_title": "Elegir cuenta",
"select_mailto_account": "Elige qué cuenta debe abrir este enlace de correo.",
"select_webcal_account": "Elige qué cuenta debe abrir este enlace de calendario.",
"select_account_note": "Esta selección solo se aplica a este enlace de protocolo.",
"detail_to": "Para",
"detail_subject": "Asunto",
"detail_no_subject": "Sin asunto",
@@ -7591,5 +7590,11 @@
"send_no_consent": "Activa primero los informes de fallos.",
"send_no_url": "No hay ninguna dirección, así que no hay adónde enviarlo. Escribe una arriba.",
"send_failed": "No se ha podido enviar el informe de fallo."
},
"uxp2_desktop": {
"unread_badge_label": "Número de no leídos en el icono de la barra de tareas",
"unread_badge_description": "Para ver el correo nuevo mientras trabaja en otra aplicación.",
"tip_compose_keys": "Pulse {keys} para escribir un mensaje nuevo",
"tip_palette_keys": "Pulse {keys} para buscar cualquier comando"
}
}
+7 -2
View File
@@ -245,7 +245,6 @@
"select_account_title": "Vali konto",
"select_mailto_account": "Vali, milline konto peaks selle e-posti lingi avama.",
"select_webcal_account": "Vali, milline konto peaks selle kalendrilingi avama.",
"select_account_note": "See kehtib ainult selle protokollilingi kohta.",
"detail_to": "Kellele",
"detail_subject": "Teema",
"detail_no_subject": "Teema puudub",
@@ -1069,7 +1068,7 @@
"submission_limit": "Server keeldus kirja saatmast, sest piirang on täis: {reason}",
"recipients_refused": "Ei saadetud: server lükkas tagasi {recipients}.",
"recipients_partly_refused": "Server lükkas tagasi {recipients}. Teised adressaadid saavad kirja kätte.",
"send_recipients_rejected": "Ei saadetud - server lükkas kõik adressaadid tagasi.",
"send_recipients_rejected": "Ei saadetud: server lükkas kõik adressaadid tagasi.",
"send_plugin_failed": "Ei saadetud - saatmist käsitlev pistikprogramm ebaõnnestus või võttis liiga kaua aega. Kontrolli enne uuesti saatmist kausta Saadetud.",
"send_some_recipients_rejected": "Saadetud, kuid mitte neile adressaatidele - server lükkas nad tagasi.",
"continue_draft": "Jätka mustandiga",
@@ -7591,5 +7590,11 @@
"send_no_consent": "Lülitage esmalt kokkujooksmise aruanded sisse.",
"send_no_url": "Aadressi pole määratud, seega pole seda kuhugi saata. Sisestage see ülal.",
"send_failed": "Kokkujooksmise aruannet ei õnnestunud saata."
},
"uxp2_desktop": {
"unread_badge_label": "Lugemata kirjade arv tegumiriba ikoonil",
"unread_badge_description": "Nii näete uut kirja ka siis, kui töötate teises rakenduses.",
"tip_compose_keys": "Uue kirja kirjutamiseks vajutage {keys}",
"tip_palette_keys": "Mis tahes käsu leidmiseks vajutage {keys}"
}
}
+7 -2
View File
@@ -245,7 +245,6 @@
"select_account_title": "انتخاب حساب",
"select_mailto_account": "انتخاب کنید کدام حساب این لینک ایمیل را باز کند.",
"select_webcal_account": "انتخاب کنید کدام حساب این لینک تقویم را باز کند.",
"select_account_note": "فقط برای این لینک پروتکل اعمال می‌شود.",
"detail_to": "به",
"detail_subject": "موضوع",
"detail_no_subject": "بدون موضوع",
@@ -1069,7 +1068,7 @@
"recipients_partly_refused": "سرور {recipients} را رد کرد. سایر گیرندگان پیام را دریافت می‌کنند.",
"send_offline": "به نظر می‌رسد آفلاین هستید. پیام هنوز اینجاست، وقتی آنلاین شدید دوباره ارسالش کنید.",
"formatting": "قالب‌بندی",
"send_recipients_rejected": "ارسال نشد - سرور همه گیرندگان را رد کرد.",
"send_recipients_rejected": "ارسال نشد: سرور همه گیرندگان را رد کرد.",
"send_plugin_failed": "ارسال نشد - افزونه‌ای که ارسال را انجام می‌دهد با خطا مواجه شد یا بیش از حد طول کشید. پیش از ارسال دوباره، پوشه ارسال‌شده را بررسی کنید.",
"send_some_recipients_rejected": "ارسال شد، اما نه برای این گیرندگان - سرور آنها را رد کرد.",
"continue_draft": "ادامه پیش‌نویس",
@@ -7591,5 +7590,11 @@
"send_no_consent": "ابتدا گزارش‌های خرابی را روشن کنید.",
"send_no_url": "نشانی‌ای تنظیم نشده، پس جایی برای ارسال نیست. در بالا یکی وارد کنید.",
"send_failed": "گزارش خرابی ارسال نشد."
},
"uxp2_desktop": {
"unread_badge_label": "تعداد خوانده‌نشده‌ها روی نماد نوار وظیفه",
"unread_badge_description": "تا ایمیل جدید را هنگام کار در برنامه‌ای دیگر ببینید.",
"tip_compose_keys": "برای نوشتن پیام جدید {keys} را فشار دهید",
"tip_palette_keys": "برای یافتن هر فرمانی {keys} را فشار دهید"
}
}
+6 -1
View File
@@ -245,7 +245,6 @@
"select_account_title": "Choisir un compte",
"select_mailto_account": "Choisissez le compte qui doit ouvrir ce lien e-mail.",
"select_webcal_account": "Choisissez le compte qui doit ouvrir ce lien de calendrier.",
"select_account_note": "Cette sélection s'applique uniquement à ce lien de protocole.",
"detail_to": "À",
"detail_subject": "Objet",
"detail_no_subject": "Sans objet",
@@ -7591,5 +7590,11 @@
"send_no_consent": "Activez d'abord les rapports de plantage.",
"send_no_url": "Aucune adresse n'est définie, il n'y a donc nulle part où envoyer ce rapport. Saisissez-en une ci-dessus.",
"send_failed": "Le rapport de plantage n'a pas pu être envoyé."
},
"uxp2_desktop": {
"unread_badge_label": "Nombre de non-lus sur l'icône de la barre des tâches",
"unread_badge_description": "Pour voir les nouveaux messages pendant que vous travaillez dans une autre application.",
"tip_compose_keys": "Appuyez sur {keys} pour écrire un nouveau message",
"tip_palette_keys": "Appuyez sur {keys} pour trouver n'importe quelle commande"
}
}
+7 -2
View File
@@ -991,7 +991,7 @@
"recipients_partly_refused": "השרת דחה את {recipients}. שאר הנמענים יקבלו את ההודעה.",
"send_offline": "נראה שאין חיבור לרשת. ההודעה עדיין כאן, שלחו אותה שוב כשתחזרו לרשת.",
"formatting": "עיצוב",
"send_recipients_rejected": "לא נשלח - השרת דחה את כל הנמענים.",
"send_recipients_rejected": "לא נשלח: השרת דחה את כל הנמענים.",
"send_plugin_failed": "לא נשלח - תוסף שמטפל בשליחה נכשל או לקח יותר מדי זמן. בדקו את תיקיית דואר יוצא לפני שליחה חוזרת.",
"send_some_recipients_rejected": "נשלח, אך לא לנמענים האלה - השרת דחה אותם.",
"continue_draft": "המשך טיוטה",
@@ -5521,7 +5521,6 @@
"select_account_title": "בחר חשבון",
"select_mailto_account": "בחר איזה חשבון צריך לפתוח קישור דוא״ל זה.",
"select_webcal_account": "בחר איזה חשבון צריך לפתוח קישור לוח שנה זה.",
"select_account_note": "זה חל רק על קישור פרוטוקול זה.",
"detail_to": "אל",
"detail_subject": "נושא",
"detail_no_subject": "אין נושא",
@@ -7592,5 +7591,11 @@
"send_no_consent": "הפעילו קודם את דוחות הקריסה.",
"send_no_url": "לא הוגדרה כתובת, ולכן אין לאן לשלוח. הזינו כתובת למעלה.",
"send_failed": "לא ניתן היה לשלוח את דוח הקריסה."
},
"uxp2_desktop": {
"unread_badge_label": "מספר ההודעות שלא נקראו על הסמל בשורת המשימות",
"unread_badge_description": "כדי לראות דואר חדש בזמן שאתם עובדים באפליקציה אחרת.",
"tip_compose_keys": "הקישו {keys} כדי לכתוב הודעה חדשה",
"tip_palette_keys": "הקישו {keys} כדי למצוא כל פקודה"
}
}
+6 -1
View File
@@ -245,7 +245,6 @@
"select_account_title": "Fiók kiválasztása",
"select_mailto_account": "Válaszd ki, melyik fiók nyissa meg ezt az e-mail hivatkozást.",
"select_webcal_account": "Válaszd ki, melyik fiók nyissa meg ezt a naptár hivatkozást.",
"select_account_note": "Ez csak erre a protokoll hivatkozásra vonatkozik.",
"detail_to": "Címzett",
"detail_subject": "Tárgy",
"detail_no_subject": "Nincs tárgy",
@@ -7591,5 +7590,11 @@
"send_no_consent": "Először kapcsolja be az összeomlási jelentéseket.",
"send_no_url": "Nincs megadva cím, így nincs hová küldeni. Adjon meg egyet fent.",
"send_failed": "Az összeomlási jelentést nem sikerült elküldeni."
},
"uxp2_desktop": {
"unread_badge_label": "Olvasatlan levelek száma a tálca ikonján",
"unread_badge_description": "Így akkor is látja az új leveleket, ha egy másik alkalmazásban dolgozik.",
"tip_compose_keys": "Új üzenet írásához nyomja meg: {keys}",
"tip_palette_keys": "Bármely parancs kereséséhez nyomja meg: {keys}"
}
}
+6 -1
View File
@@ -245,7 +245,6 @@
"select_account_title": "Scegli account",
"select_mailto_account": "Scegli quale account deve aprire questo link e-mail.",
"select_webcal_account": "Scegli quale account deve aprire questo link calendario.",
"select_account_note": "Questa scelta si applica solo a questo link di protocollo.",
"detail_to": "A",
"detail_subject": "Oggetto",
"detail_no_subject": "Senza oggetto",
@@ -7591,5 +7590,11 @@
"send_no_consent": "Attiva prima le segnalazioni di arresto anomalo.",
"send_no_url": "Non è impostato alcun indirizzo, quindi non c'è dove inviarla. Inseriscine uno qui sopra.",
"send_failed": "Impossibile inviare la segnalazione di arresto."
},
"uxp2_desktop": {
"unread_badge_label": "Numero di messaggi non letti sull'icona della barra delle applicazioni",
"unread_badge_description": "Per vedere la nuova posta mentre lavori in un'altra app.",
"tip_compose_keys": "Premi {keys} per scrivere un nuovo messaggio",
"tip_palette_keys": "Premi {keys} per trovare qualsiasi comando"
}
}
+6 -1
View File
@@ -245,7 +245,6 @@
"select_account_title": "アカウントを選択",
"select_mailto_account": "このメールリンクを開くアカウントを選択してください。",
"select_webcal_account": "このカレンダーリンクを開くアカウントを選択してください。",
"select_account_note": "この選択は、このプロトコル リンクにのみ適用されます。",
"detail_to": "宛先",
"detail_subject": "件名",
"detail_no_subject": "件名なし",
@@ -7591,5 +7590,11 @@
"send_no_consent": "先にクラッシュレポートをオンにしてください。",
"send_no_url": "アドレスが設定されていないため、送信先がありません。上で入力してください。",
"send_failed": "クラッシュレポートを送信できませんでした。"
},
"uxp2_desktop": {
"unread_badge_label": "タスクバーのアイコンに未読数を表示",
"unread_badge_description": "別のアプリで作業中でも新着メールがわかります。",
"tip_compose_keys": "{keys} を押して新しいメッセージを作成",
"tip_palette_keys": "{keys} を押してコマンドを検索"
}
}
+6 -1
View File
@@ -245,7 +245,6 @@
"select_account_title": "계정 선택",
"select_mailto_account": "이 이메일 링크를 열 계정을 선택하세요.",
"select_webcal_account": "이 캘린더 링크를 열 계정을 선택하세요.",
"select_account_note": "이 선택은 이 프로토콜 링크에만 적용됩니다.",
"detail_to": "받는 사람",
"detail_subject": "제목",
"detail_no_subject": "제목 없음",
@@ -7591,5 +7590,11 @@
"send_no_consent": "먼저 충돌 보고서를 켜세요.",
"send_no_url": "주소가 설정되지 않아 보낼 곳이 없습니다. 위에 입력하세요.",
"send_failed": "충돌 보고서를 보낼 수 없습니다."
},
"uxp2_desktop": {
"unread_badge_label": "작업 표시줄 아이콘에 읽지 않은 수 표시",
"unread_badge_description": "다른 앱에서 작업하는 동안에도 새 메일을 확인할 수 있습니다.",
"tip_compose_keys": "{keys}를 눌러 새 메시지 쓰기",
"tip_palette_keys": "{keys}를 눌러 명령 찾기"
}
}
+6 -1
View File
@@ -245,7 +245,6 @@
"select_account_title": "Izvēlieties kontu",
"select_mailto_account": "Izvēlieties, kurā kontā atvērt šo e-pasta saiti.",
"select_webcal_account": "Izvēlieties, kurā kontā atvērt šo kalendāra saiti.",
"select_account_note": "Šī izvēle attiecas tikai uz šo protokola saiti.",
"detail_to": "Kam",
"detail_subject": "Temats",
"detail_no_subject": "Bez temata",
@@ -7591,5 +7590,11 @@
"send_no_consent": "Vispirms ieslēdziet avāriju atskaites.",
"send_no_url": "Adrese nav iestatīta, tāpēc to nav kur sūtīt. Ievadiet to augstāk.",
"send_failed": "Avārijas atskaiti neizdevās nosūtīt."
},
"uxp2_desktop": {
"unread_badge_label": "Nelasīto skaits uzdevumjoslas ikonā",
"unread_badge_description": "Lai redzētu jaunu pastu, kamēr strādājat citā lietotnē.",
"tip_compose_keys": "Nospiediet {keys}, lai rakstītu jaunu ziņojumu",
"tip_palette_keys": "Nospiediet {keys}, lai atrastu jebkuru komandu"
}
}
+7 -2
View File
@@ -245,7 +245,6 @@
"select_account_title": "Бүртгэл сонгох",
"select_mailto_account": "Энэ имэйлийн холбоосыг аль бүртгэлээр нээхийг сонгоно уу.",
"select_webcal_account": "Энэ хуанлийн холбоосыг аль данс нээхийг сонгоно уу.",
"select_account_note": "Энэ нь зөвхөн энэ протоколын холбоост хамаарна.",
"detail_to": "Хэнд",
"detail_subject": "Сэдэв",
"detail_no_subject": "Сэдэв байхгүй",
@@ -1069,7 +1068,7 @@
"recipients_partly_refused": "Сервер дараах хүлээн авагчдыг татгалзлаа: {recipients}. Бусад хүлээн авагчид мессежийг хүлээн авна.",
"send_offline": "Та офлайн байх шиг байна. Мессеж энд хадгалагдсан тул онлайн болмогц дахин илгээнэ үү.",
"formatting": "Формат",
"send_recipients_rejected": "Илгээгдсэнгүй - сервер бүх хүлээн авагчийг татгалзсан.",
"send_recipients_rejected": "Илгээгдсэнгүй: сервер бүх хүлээн авагчийг татгалзсан.",
"send_plugin_failed": "Илгээгдсэнгүй - илгээлтийг гүйцэтгэдэг залгаас алдаа гаргасан эсвэл хэт удсан. Дахин илгээхээсээ өмнө Илгээсэн хавтсаа шалгана уу.",
"send_some_recipients_rejected": "Илгээсэн, гэхдээ эдгээр хүлээн авагчид хүрэхгүй - сервер тэднийг татгалзсан.",
"continue_draft": "Ноорог үргэлжлүүлэх",
@@ -7591,5 +7590,11 @@
"send_no_consent": "Эхлээд гэмтлийн тайланг асаана уу.",
"send_no_url": "Хаяг тохируулаагүй тул илгээх газаргүй. Дээр нэгийг оруулна уу.",
"send_failed": "Гэмтлийн тайланг илгээж чадсангүй."
},
"uxp2_desktop": {
"unread_badge_label": "Ажлын самбарын дүрс дээрх уншаагүй тоо",
"unread_badge_description": "Өөр апп дээр ажиллаж байхдаа шинэ шуудангаа харахын тулд.",
"tip_compose_keys": "Шинэ зурвас бичихийн тулд {keys} дарна уу",
"tip_palette_keys": "Ямар ч тушаал олохын тулд {keys} дарна уу"
}
}
+6 -1
View File
@@ -245,7 +245,6 @@
"select_account_title": "Velg konto",
"select_mailto_account": "Velg hvilken konto som skal åpne denne e-postlenken.",
"select_webcal_account": "Velg hvilken konto som skal åpne denne kalenderlenken.",
"select_account_note": "Dette gjelder bare denne lenken.",
"detail_to": "Til",
"detail_subject": "Emne",
"detail_no_subject": "Uten emne",
@@ -7591,5 +7590,11 @@
"send_no_consent": "Slå på krasjrapporter først.",
"send_no_url": "Ingen adresse er angitt, så den kan ikke sendes noe sted. Skriv inn en ovenfor.",
"send_failed": "Krasjrapporten kunne ikke sendes."
},
"uxp2_desktop": {
"unread_badge_label": "Antall uleste på ikonet på oppgavelinjen",
"unread_badge_description": "Slik ser du ny e-post mens du jobber i en annen app.",
"tip_compose_keys": "Trykk på {keys} for å skrive en ny melding",
"tip_palette_keys": "Trykk på {keys} for å finne en kommando"
}
}
+7 -2
View File
@@ -245,7 +245,6 @@
"select_account_title": "Account kiezen",
"select_mailto_account": "Kies welk account deze e-maillink moet openen.",
"select_webcal_account": "Kies welk account deze kalenderlink moet openen.",
"select_account_note": "Deze keuze geldt alleen voor deze protocol-link.",
"detail_to": "Aan",
"detail_subject": "Onderwerp",
"detail_no_subject": "Geen onderwerp",
@@ -1073,7 +1072,7 @@
"recipients_partly_refused": "De server heeft {recipients} geweigerd. De andere ontvangers krijgen het bericht.",
"send_offline": "Je lijkt offline te zijn. Het bericht staat er nog; verstuur het opnieuw zodra je weer online bent.",
"formatting": "Opmaak",
"send_recipients_rejected": "Niet verzonden - de server heeft alle ontvangers geweigerd.",
"send_recipients_rejected": "Niet verzonden: de server heeft alle ontvangers geweigerd.",
"send_plugin_failed": "Niet verzonden - een plug-in die het verzenden afhandelt, is mislukt of duurde te lang. Controleer je map Verzonden voordat je opnieuw verzendt.",
"send_some_recipients_rejected": "Verzonden, maar niet naar deze ontvangers - de server heeft ze geweigerd.",
"continue_draft": "Concept voortzetten",
@@ -7591,5 +7590,11 @@
"send_no_consent": "Zet eerst crashrapporten aan.",
"send_no_url": "Er is geen adres ingesteld, dus dit kan nergens heen. Vul hierboven een adres in.",
"send_failed": "Het crashrapport kon niet worden verzonden."
},
"uxp2_desktop": {
"unread_badge_label": "Aantal ongelezen op het taakbalkpictogram",
"unread_badge_description": "Zo ziet u nieuwe mail terwijl u in een andere app werkt.",
"tip_compose_keys": "Druk op {keys} om een nieuw bericht te schrijven",
"tip_palette_keys": "Druk op {keys} om elke opdracht te vinden"
}
}
+6 -1
View File
@@ -245,7 +245,6 @@
"select_account_title": "Wybierz konto",
"select_mailto_account": "Wybierz konto, które ma otworzyć ten link e-mail.",
"select_webcal_account": "Wybierz konto, które ma otworzyć ten link kalendarza.",
"select_account_note": "Ten wybór dotyczy tylko tego linku protokołu.",
"detail_to": "Do",
"detail_subject": "Temat",
"detail_no_subject": "Bez tematu",
@@ -7591,5 +7590,11 @@
"send_no_consent": "Najpierw włącz raporty o awariach.",
"send_no_url": "Nie ustawiono adresu, więc nie ma dokąd tego wysłać. Wpisz go powyżej.",
"send_failed": "Nie udało się wysłać raportu o awarii."
},
"uxp2_desktop": {
"unread_badge_label": "Liczba nieprzeczytanych na ikonie na pasku zadań",
"unread_badge_description": "Aby widzieć nową pocztę, gdy pracujesz w innej aplikacji.",
"tip_compose_keys": "Naciśnij {keys}, aby napisać nową wiadomość",
"tip_palette_keys": "Naciśnij {keys}, aby znaleźć dowolne polecenie"
}
}
+6 -1
View File
@@ -245,7 +245,6 @@
"select_account_title": "Escolher conta",
"select_mailto_account": "Escolha qual conta deve abrir este link de e-mail.",
"select_webcal_account": "Escolha qual conta deve abrir este link de calendário.",
"select_account_note": "Esta escolha se aplica apenas a este link de protocolo.",
"detail_to": "Para",
"detail_subject": "Assunto",
"detail_no_subject": "Sem assunto",
@@ -7591,5 +7590,11 @@
"send_no_consent": "Ligue primeiro os relatórios de falhas.",
"send_no_url": "Não há nenhum endereço definido, por isso não há para onde enviar. Introduza um acima.",
"send_failed": "Não foi possível enviar o relatório de falha."
},
"uxp2_desktop": {
"unread_badge_label": "Número de não lidas no ícone da barra de tarefas",
"unread_badge_description": "Para ver novos e-mails enquanto trabalha noutra aplicação.",
"tip_compose_keys": "Prima {keys} para escrever uma nova mensagem",
"tip_palette_keys": "Prima {keys} para encontrar qualquer comando"
}
}
+7 -2
View File
@@ -245,7 +245,6 @@
"select_account_title": "Alegeți contul",
"select_mailto_account": "Alegeți contul în care să se deschidă acest link de e-mail.",
"select_webcal_account": "Alegeți contul care ar trebui să deschidă acest link către calendar.",
"select_account_note": "Aceasta se aplică numai acestui link de protocol.",
"detail_to": "Către",
"detail_subject": "Subiect",
"detail_no_subject": "Fără subiect",
@@ -1069,7 +1068,7 @@
"recipients_partly_refused": "Serverul a respins {recipients}. Ceilalți destinatari primesc mesajul.",
"send_offline": "Se pare că ești offline. Mesajul este încă aici; trimite-l din nou când revii online.",
"formatting": "Formatare",
"send_recipients_rejected": "Netrimis - serverul a respins toți destinatarii.",
"send_recipients_rejected": "Netrimis: serverul a respins toți destinatarii.",
"send_plugin_failed": "Nu a fost trimis - un plugin care se ocupă de trimitere a eșuat sau a durat prea mult. Verifică dosarul Trimise înainte de a trimite din nou.",
"send_some_recipients_rejected": "Trimis, dar nu și acestor destinatari - serverul i-a respins.",
"continue_draft": "Continuă schița",
@@ -7591,5 +7590,11 @@
"send_no_consent": "Porniți mai întâi rapoartele de blocare.",
"send_no_url": "Nu este setată nicio adresă, deci nu există unde să fie trimis. Introduceți una mai sus.",
"send_failed": "Raportul de blocare nu a putut fi trimis."
},
"uxp2_desktop": {
"unread_badge_label": "Numărul de necitite pe pictograma din bara de activități",
"unread_badge_description": "Ca să vedeți e-mailurile noi în timp ce lucrați în altă aplicație.",
"tip_compose_keys": "Apăsați {keys} pentru a scrie un mesaj nou",
"tip_palette_keys": "Apăsați {keys} pentru a găsi orice comandă"
}
}
+6 -1
View File
@@ -245,7 +245,6 @@
"select_account_title": "Выберите аккаунт",
"select_mailto_account": "Выберите, в каком аккаунте открыть эту ссылку электронной почты.",
"select_webcal_account": "Выберите, в каком аккаунте открыть эту ссылку календаря.",
"select_account_note": "Этот выбор применяется только к этой ссылке протокола.",
"detail_to": "Кому",
"detail_subject": "Тема",
"detail_no_subject": "Без темы",
@@ -7591,5 +7590,11 @@
"send_no_consent": "Сначала включите отчёты о сбоях.",
"send_no_url": "Адрес не задан, поэтому отправлять некуда. Введите его выше.",
"send_failed": "Не удалось отправить отчёт о сбое."
},
"uxp2_desktop": {
"unread_badge_label": "Число непрочитанных на значке на панели задач",
"unread_badge_description": "Чтобы видеть новую почту, пока вы работаете в другом приложении.",
"tip_compose_keys": "Нажмите {keys}, чтобы написать новое письмо",
"tip_palette_keys": "Нажмите {keys}, чтобы найти любую команду"
}
}
+6 -1
View File
@@ -245,7 +245,6 @@
"select_account_title": "Vybrať účet",
"select_mailto_account": "Vyberte účet, v ktorom sa má tento e-mailový odkaz otvoriť.",
"select_webcal_account": "Vyberte účet, v ktorom sa má tento kalendárový odkaz otvoriť.",
"select_account_note": "Toto platí iba pre tento odkaz protokolu.",
"detail_to": "Komu",
"detail_subject": "Predmet",
"detail_no_subject": "Bez predmetu",
@@ -7591,5 +7590,11 @@
"send_no_consent": "Najprv zapnite hlásenia o zlyhaniach.",
"send_no_url": "Nie je nastavená žiadna adresa, takže to nie je kam odoslať. Zadajte ju vyššie.",
"send_failed": "Hlásenie o zlyhaní sa nepodarilo odoslať."
},
"uxp2_desktop": {
"unread_badge_label": "Počet neprečítaných na ikone na paneli úloh",
"unread_badge_description": "Aby ste videli novú poštu, aj keď pracujete v inej aplikácii.",
"tip_compose_keys": "Stlačte {keys} a napíšte novú správu",
"tip_palette_keys": "Stlačte {keys} a nájdite akýkoľvek príkaz"
}
}
+7 -2
View File
@@ -1448,7 +1448,7 @@
"submission_limit": "เซิร์ฟเวอร์ปฏิเสธการส่งข้อความเนื่องจากเกินขีดจำกัด: {reason}",
"recipients_refused": "ส่งไม่สำเร็จ: เซิร์ฟเวอร์ปฏิเสธผู้รับ {recipients}",
"recipients_partly_refused": "เซิร์ฟเวอร์ปฏิเสธผู้รับ {recipients} แต่ผู้รับรายอื่นจะยังคงได้รับข้อความ",
"send_recipients_rejected": "ส่งไม่สำเร็จ - เซิร์ฟเวอร์ปฏิเสธผู้รับทุกคน",
"send_recipients_rejected": "ส่งไม่สำเร็จ: เซิร์ฟเวอร์ปฏิเสธผู้รับทุกคน",
"cut": "ตัด",
"copy": "คัดลอก",
"send_plugin_failed": "ส่งไม่สำเร็จ - ปลั๊กอินที่จัดการการส่งเกิดข้อผิดพลาด กรุณาตรวจสอบโฟลเดอร์ส่งแล้วก่อนส่งซ้ำ",
@@ -2716,7 +2716,6 @@
"select_account_title": "เลือกบัญชี",
"select_mailto_account": "เลือกบัญชีที่ต้องการให้เปิดลิงก์อีเมลนี้",
"select_webcal_account": "เลือกบัญชีที่ต้องการให้เปิดลิงก์ปฏิทินนี้",
"select_account_note": "การตั้งค่านี้มีผลเฉพาะกับลิงก์โปรโตคอลนี้เท่านั้น",
"detail_to": "ถึง",
"detail_subject": "เรื่อง",
"detail_no_subject": "ไม่มีหัวข้อเรื่อง",
@@ -7599,5 +7598,11 @@
"send_no_consent": "เปิดรายงานข้อขัดข้องก่อน",
"send_no_url": "ไม่ได้ตั้งที่อยู่ จึงไม่มีปลายทางให้ส่ง ป้อนที่อยู่ด้านบน",
"send_failed": "ไม่สามารถส่งรายงานข้อขัดข้องได้"
},
"uxp2_desktop": {
"unread_badge_label": "จำนวนที่ยังไม่อ่านบนไอคอนในแถบงาน",
"unread_badge_description": "เพื่อให้เห็นอีเมลใหม่ขณะทำงานในแอปอื่น",
"tip_compose_keys": "กด {keys} เพื่อเขียนข้อความใหม่",
"tip_palette_keys": "กด {keys} เพื่อค้นหาคำสั่งใดก็ได้"
}
}
+7 -2
View File
@@ -245,7 +245,6 @@
"select_account_title": "Hesap seç",
"select_mailto_account": "Bu e-posta bağlantısını hangi hesabın açacağını seçin.",
"select_webcal_account": "Bu takvim bağlantısını hangi hesabın açacağını seçin.",
"select_account_note": "Bu seçim yalnızca bu protokol bağlantısı için geçerlidir.",
"detail_to": "Kime",
"detail_subject": "Konu",
"detail_no_subject": "Konu yok",
@@ -1069,7 +1068,7 @@
"recipients_partly_refused": "Sunucu şu alıcıları reddetti: {recipients}. Diğer alıcılar iletiyi alır.",
"send_offline": "Çevrimdışı görünüyorsunuz. İleti hâlâ burada; yeniden çevrimiçi olduğunuzda tekrar gönderin.",
"formatting": "Biçimlendirme",
"send_recipients_rejected": "Gönderilmedi - sunucu tüm alıcıları reddetti.",
"send_recipients_rejected": "Gönderilmedi: sunucu tüm alıcıları reddetti.",
"send_plugin_failed": "Gönderilmedi - gönderimi üstlenen bir eklenti başarısız oldu ya da çok uzun sürdü. Yeniden göndermeden önce Gönderilenler klasörünü kontrol edin.",
"send_some_recipients_rejected": "Gönderildi, ancak bu alıcılara değil - sunucu onları reddetti.",
"continue_draft": "Taslağa devam et",
@@ -7591,5 +7590,11 @@
"send_no_consent": "Önce kilitlenme raporlarını açın.",
"send_no_url": "Adres ayarlanmadı, bu yüzden gönderilecek yer yok. Yukarıya bir adres girin.",
"send_failed": "Kilitlenme raporu gönderilemedi."
},
"uxp2_desktop": {
"unread_badge_label": "Görev çubuğu simgesinde okunmamış sayısı",
"unread_badge_description": "Başka bir uygulamada çalışırken yeni postayı görmeniz için.",
"tip_compose_keys": "Yeni bir ileti yazmak için {keys} tuşlarına basın",
"tip_palette_keys": "Herhangi bir komutu bulmak için {keys} tuşlarına basın"
}
}
+6 -1
View File
@@ -245,7 +245,6 @@
"select_account_title": "Виберіть акаунт",
"select_mailto_account": "Виберіть акаунт, у якому слід відкрити це посилання електронної пошти.",
"select_webcal_account": "Виберіть акаунт, у якому слід відкрити це посилання календаря.",
"select_account_note": "Цей вибір застосовується лише до цього посилання протоколу.",
"detail_to": "Кому",
"detail_subject": "Тема",
"detail_no_subject": "Без теми",
@@ -7591,5 +7590,11 @@
"send_no_consent": "Спочатку ввімкніть звіти про збої.",
"send_no_url": "Адресу не задано, тому надсилати нікуди. Введіть її вище.",
"send_failed": "Не вдалося надіслати звіт про збій."
},
"uxp2_desktop": {
"unread_badge_label": "Кількість непрочитаних на значку на панелі завдань",
"unread_badge_description": "Щоб бачити нову пошту, поки ви працюєте в іншому застосунку.",
"tip_compose_keys": "Натисніть {keys}, щоб написати новий лист",
"tip_palette_keys": "Натисніть {keys}, щоб знайти будь-яку команду"
}
}
+6 -1
View File
@@ -245,7 +245,6 @@
"select_account_title": "Chọn tài khoản",
"select_mailto_account": "Chọn tài khoản nào sẽ mở liên kết email này.",
"select_webcal_account": "Chọn tài khoản nào sẽ mở liên kết lịch này.",
"select_account_note": "Điều này chỉ áp dụng cho liên kết giao thức này.",
"detail_to": "Đến",
"detail_subject": "Tiêu đề",
"detail_no_subject": "Không có tiêu đề",
@@ -7603,5 +7602,11 @@
"send_no_consent": "Hãy bật báo cáo sự cố trước.",
"send_no_url": "Chưa đặt địa chỉ nên không có nơi để gửi. Hãy nhập một địa chỉ ở trên.",
"send_failed": "Không thể gửi báo cáo sự cố."
},
"uxp2_desktop": {
"unread_badge_label": "Số thư chưa đọc trên biểu tượng thanh tác vụ",
"unread_badge_description": "Để bạn thấy thư mới khi đang làm việc trong ứng dụng khác.",
"tip_compose_keys": "Nhấn {keys} để viết thư mới",
"tip_palette_keys": "Nhấn {keys} để tìm bất kỳ lệnh nào"
}
}
+6 -1
View File
@@ -245,7 +245,6 @@
"select_account_title": "選擇帳號",
"select_mailto_account": "選擇用於開啟此電子郵件連結的帳號。",
"select_webcal_account": "選擇用於開啟此行事曆連結的帳號。",
"select_account_note": "此選擇僅適用於此協議連結。",
"detail_to": "收件者",
"detail_subject": "主題",
"detail_no_subject": "無主題",
@@ -7591,5 +7590,11 @@
"send_no_consent": "請先開啟當機報告。",
"send_no_url": "未設定地址,因此無處傳送。請在上方輸入一個。",
"send_failed": "無法傳送當機報告。"
},
"uxp2_desktop": {
"unread_badge_label": "在工作列圖示上顯示未讀數",
"unread_badge_description": "這樣您在其他應用程式中工作時也能看到新郵件。",
"tip_compose_keys": "按 {keys} 撰寫新郵件",
"tip_palette_keys": "按 {keys} 尋找任何指令"
}
}
+6 -1
View File
@@ -245,7 +245,6 @@
"select_account_title": "选择账户",
"select_mailto_account": "选择用于打开此电子邮件链接的账户。",
"select_webcal_account": "选择用于打开此日历链接的账户。",
"select_account_note": "此选择仅适用于此协议链接。",
"detail_to": "收件人",
"detail_subject": "主题",
"detail_no_subject": "无主题",
@@ -7591,5 +7590,11 @@
"send_no_consent": "请先开启崩溃报告。",
"send_no_url": "未设置地址,因此无处发送。请在上方输入一个。",
"send_failed": "无法发送崩溃报告。"
},
"uxp2_desktop": {
"unread_badge_label": "在任务栏图标上显示未读数",
"unread_badge_description": "这样您在其他应用中工作时也能看到新邮件。",
"tip_compose_keys": "按 {keys} 写新邮件",
"tip_palette_keys": "按 {keys} 查找任何命令"
}
}