fix: stop mail keys acting on the hidden mail tab from other Pro tabs

This commit is contained in:
Linus Rath
2026-11-03 13:49:01 +01:00
parent 4f4660723e
commit 4d244aad0a
2 changed files with 143 additions and 2 deletions
@@ -0,0 +1,127 @@
import React from 'react';
import { render } 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 { useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
import { usePluginStore } from '@/stores/plugin-store';
import { useThemeStore } from '@/stores/theme-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
import { EmbeddedContext } from '@/hooks/use-is-embedded';
import { ProTabFocusContext } from '@/hooks/use-pane-context';
// Under Pro the mail page stays mounted behind other tabs. Its shortcuts acted
// from there: e on the Calendar tab archived the mail selected in the hidden
// list.
type ShortcutOptions = { enabled?: boolean; handlers: Record<string, unknown> };
const shortcuts = vi.hoisted(() => ({ calls: [] as ShortcutOptions[] }));
vi.mock('@/hooks/use-keyboard-shortcuts', () => ({
useKeyboardShortcuts: (options: ShortcutOptions) => { shortcuts.calls.push(options); },
}));
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null }));
vi.mock('@/components/search/mv-search-form', () => ({ MvSearchForm: () => null }));
vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => null }));
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: () => {} }));
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
useMediaQuery: () => false,
useIsDesktop: () => true,
atLeast: (px: number) => `(width >= ${px}px)`,
}));
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
vi.mock('@/components/email/email-list', () => ({ EmailList: () => null }));
const { MailApp } = await import('../mail-app');
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 } as Mailbox;
beforeEach(() => {
shortcuts.calls = [];
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
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 } as never);
useEmailStore.setState({
emails: [], selectedEmail: null, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox],
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false,
selectedEmailKeys: new Set(), unreadSection: 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),
} as never);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
/** The enabled hook calls of the last render: MailApp makes two per render. */
function activeShortcuts() {
return shortcuts.calls.slice(-2).filter((c) => c.enabled);
}
function renderPro(focused: boolean) {
render(
<EmbeddedContext.Provider value={true}>
<ProTabFocusContext.Provider value={focused}>
<MailApp />
</ProTabFocusContext.Provider>
</EmbeddedContext.Provider>,
);
}
describe('mail shortcuts under the Pro shell', () => {
it('leaves only the help on while another tab is in front', () => {
renderPro(false);
const active = activeShortcuts();
expect(active).toHaveLength(1);
expect(Object.keys(active[0].handlers)).toEqual(['onShowHelp']);
});
it('has the mail shortcuts on while the mail tab is in front', () => {
renderPro(true);
const active = activeShortcuts();
expect(active).toHaveLength(1);
expect(active[0].handlers.onArchive).toBeTypeOf('function');
expect(active[0].handlers.onDelete).toBeTypeOf('function');
});
it('has them on in the standard shell, where there are no tabs', () => {
render(<MailApp />);
const active = activeShortcuts();
expect(active).toHaveLength(1);
expect(active[0].handlers.onArchive).toBeTypeOf('function');
});
});
+16 -2
View File
@@ -60,7 +60,7 @@ import { useIdentityStore } from "@/stores/identity-store";
import { useUIStore, listPaneCssSize, SIDEBAR_MIN, SIDEBAR_MAX, EMAIL_LIST_MIN, EMAIL_LIST_MAX, EMAIL_LIST_HEIGHT_MIN, EMAIL_LIST_HEIGHT_MAX, READING_PANE_MIN_WIDTH, READING_PANE_MIN_HEIGHT, COMPOSER_PANE_MIN_HEIGHT } from "@/stores/ui-store";
import { atLeast, useDeviceDetection } from "@/hooks/use-media-query";
import { usePaneSize } from "@/hooks/use-pane-size";
import { usePaneId } from "@/hooks/use-pane-context";
import { useIsFocusedProTab, usePaneId } from "@/hooks/use-pane-context";
import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts";
import { useUndoShortcut } from "@/hooks/use-undo-shortcut";
import { confirmMailRemoval } from "@/lib/mail-removal-confirm";
@@ -217,6 +217,7 @@ function freshMailboxes(previous: Mailbox[], timeoutMs = 10_000): Promise<Mailbo
* message itself has loaded, before showing it with a placeholder. (#1223)
*/
const MV_CONVERSATION_WAIT_MS = 800;
const NO_EMAILS: Email[] = [];
/** Plugin buttons in the top bar are not about any one item. */
const TOP_BAR_CONTEXT: Record<string, unknown> = {};
@@ -1229,8 +1230,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// reach the selection again back in the list.
const shortcutsTargetSelection = selectedEmailKeys.size > 0 && !(mvShell && !cursorOnly && selectedEmail);
const singleKeyShortcuts = useSettingsStore((s) => s.singleKeyShortcuts);
// Under Pro this page stays mounted behind other tabs. Its mail shortcuts
// are only for when it is in front: e on the Calendar tab archived the mail
// selected here, out of sight. The help opens from any tab. (c stays each
// surface's own: New event on the Calendar tab.)
const isFocusedProTab = useIsFocusedProTab();
const mailShortcutsApply = !isEmbedded || isFocusedProTab;
useKeyboardShortcuts({
enabled: isAuthenticated && !showComposer,
enabled: isAuthenticated && !showComposer && mailShortcutsApply,
singleKeyShortcuts,
emails: activeEmails,
selectedEmailId: selectedEmail?.id,
@@ -1238,6 +1245,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
mountainViewKeys: mvShell,
handlers: keyboardHandlers,
});
const anyTabHandlers = useMemo(() => ({ onShowHelp: keyboardHandlers.onShowHelp }), [keyboardHandlers]);
useKeyboardShortcuts({
enabled: isAuthenticated && !showComposer && !mailShortcutsApply,
singleKeyShortcuts,
emails: NO_EMAILS,
handlers: anyTabHandlers,
});
// Ctrl+Z / z: undo the last mail actions, newest first.
useUndoShortcut({ enabled: isAuthenticated && !showComposer, signedIn: isAuthenticated });