From 4d244aad0a789cdf183f09a400623c1eec6fa742 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:49:01 +0100 Subject: [PATCH] fix: stop mail keys acting on the hidden mail tab from other Pro tabs --- .../__tests__/mail-app-pro-shortcuts.test.tsx | 127 ++++++++++++++++++ components/mail/mail-app.tsx | 18 ++- 2 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 components/mail/__tests__/mail-app-pro-shortcuts.test.tsx diff --git a/components/mail/__tests__/mail-app-pro-shortcuts.test.tsx b/components/mail/__tests__/mail-app-pro-shortcuts.test.tsx new file mode 100644 index 000000000..4dc6a83c0 --- /dev/null +++ b/components/mail/__tests__/mail-app-pro-shortcuts.test.tsx @@ -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 }; +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( + + + + + , + ); +} + +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(); + const active = activeShortcuts(); + expect(active).toHaveLength(1); + expect(active[0].handlers.onArchive).toBeTypeOf('function'); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 099d13609..44beafa0b 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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 = {}; @@ -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 });