From af6deee047d4be32b6a8132e637ff863205d9a0a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:27:17 +0100 Subject: [PATCH] feat: add Mail's commands to the command palette: reply, archive, delete, move, read state, spam and go to a folder --- components/email/move-to-dialog.tsx | 46 +++++++ .../__tests__/mail-palette-commands.test.tsx | 122 ++++++++++++++++++ components/mail/mail-app.tsx | 63 ++++++++- locales/ar/common.json | 2 + locales/ca/common.json | 2 + locales/cs/common.json | 2 + locales/da/common.json | 2 + locales/de/common.json | 2 + locales/en/common.json | 2 + locales/es/common.json | 2 + locales/et/common.json | 2 + locales/fa/common.json | 2 + locales/fr/common.json | 2 + locales/he/common.json | 2 + locales/hu/common.json | 2 + locales/it/common.json | 2 + locales/ja/common.json | 2 + locales/ko/common.json | 2 + locales/lv/common.json | 2 + locales/mn/common.json | 2 + locales/nb/common.json | 2 + locales/nl/common.json | 2 + locales/pl/common.json | 2 + locales/pt/common.json | 2 + locales/ro/common.json | 2 + locales/ru/common.json | 2 + locales/sk/common.json | 2 + locales/th/common.json | 2 + locales/tr/common.json | 2 + locales/uk/common.json | 2 + locales/zh-TW/common.json | 2 + locales/zh/common.json | 2 + 32 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 components/email/move-to-dialog.tsx create mode 100644 components/mail/__tests__/mail-palette-commands.test.tsx diff --git a/components/email/move-to-dialog.tsx b/components/email/move-to-dialog.tsx new file mode 100644 index 000000000..aab1ad4b7 --- /dev/null +++ b/components/email/move-to-dialog.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useMemo } from "react"; +import { useTranslations } from "next-intl"; +import { Dialog } from "@/components/ui/dialog"; +import type { Email, Mailbox } from "@/lib/jmap/types"; +import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { MoveTargetList } from "./move-target-list"; + +interface MoveToDialogProps { + /** The message to move; the dialog is open while it is set. */ + email: Email | null; + mailboxes: Mailbox[]; + selectedMailbox: string; + onClose: () => void; + onMove: (mailboxId: string) => void; +} + +/** + * "Move to…" from the command palette: the same folder list as the reader's + * Move menu (the message's own account first), in a dialog. + */ +export function MoveToDialog({ email, mailboxes, selectedMailbox, onClose, onMove }: MoveToDialogProps) { + const t = useTranslations("context_menu"); + const { tree, targetIds } = useMemo( + () => buildMoveTargets(mailboxes, { + currentMailboxId: selectedMailbox, + ownerAccountId: resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox), + }), + [email, mailboxes, selectedMailbox], + ); + return ( + +
+ { + onClose(); + onMove(mailboxId); + }} + /> +
+
+ ); +} diff --git a/components/mail/__tests__/mail-palette-commands.test.tsx b/components/mail/__tests__/mail-palette-commands.test.tsx new file mode 100644 index 000000000..11744d27d --- /dev/null +++ b/components/mail/__tests__/mail-palette-commands.test.tsx @@ -0,0 +1,122 @@ +import React, { Suspense, lazy } from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +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 { clearAllHooks } from '@/lib/plugin-hooks'; +import type { CommandGroup } from '@/lib/command-registry'; + +// Mail adds its own commands to the palette (Ctrl/⌘+K): the open message's +// actions and a "Go to" per folder. + +vi.mock('next/dynamic', () => ({ default: (load: () => Promise>>) => { + const Component = lazy(async () => ({ default: await load() })); + return function Dynamic(props: Record) { + return ; + }; +} })); +vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () =>
viewer
})); +vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null })); +vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => 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/email/message-list-tabs', () => ({ 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, +})); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ EmailList: () =>
message list
})); + +const { MailApp } = await import('../mail-app'); + +const folder = (id: string, role: string | undefined, name = role ?? id): Mailbox => ({ + id, name, role, sortOrder: 0, totalEmails: 1, unreadEmails: 0, totalThreads: 1, unreadThreads: 0, isSubscribed: true, + myRights: { mayAddItems: true, mayRemoveItems: true, mayReadItems: true }, +}) as Mailbox; +const folders = [folder('inbox', 'inbox'), folder('trash', 'trash'), folder('work', undefined, 'Work')]; +const open = { id: 'm1', threadId: 't1', subject: 'Hello', receivedAt: '2026-10-26T10:00:00Z', keywords: { $seen: true }, mailboxIds: { inbox: true } } as unknown as Email; + +let client: Record; +beforeEach(() => { + 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) }); + useToastStore.setState({ toasts: [] }); + clearAllHooks(); + client = { + getAccountId: () => 'acc', getUsername: () => 'a@example.org', + getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null, + getMailboxes: vi.fn().mockResolvedValue(folders), + moveEmail: vi.fn().mockResolvedValue(undefined), + }; + 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, mailLayout: 'split' } as never); + useEmailStore.setState({ + emails: [open], selectedEmail: open, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: folders, + viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, + isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', selectedEmailKeys: new Set(), + 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(); +}); + +const { useRegisteredCommandGroups } = await import('@/lib/command-registry'); +function Palette({ onGroups }: { onGroups: (groups: CommandGroup[]) => void }) { + onGroups(useRegisteredCommandGroups()); + return null; +} + +describe('Mail in the command palette', () => { + it('lists the open message\'s actions and a Go to per other folder', async () => { + let groups: CommandGroup[] = []; + render(<> { groups = g; }} />); + await screen.findByText('viewer', {}, { timeout: 5000 }); + await waitFor(() => expect(groups.find((g) => g.label === 'sidebar.mail')).toBeDefined()); + const ids = groups.find((g) => g.label === 'sidebar.mail')!.commands.map((c) => c.id); + expect(ids).toEqual(expect.arrayContaining(['reply', 'archive', 'delete', 'move', 'mark-unread', 'spam', 'go-trash', 'go-work'])); + expect(ids).not.toContain('go-inbox'); + expect(ids).not.toContain('mark-read'); + }); + + it('moves the open message from "Move to…"', async () => { + let groups: CommandGroup[] = []; + render(<> { groups = g; }} />); + await screen.findByText('viewer', {}, { timeout: 5000 }); + await waitFor(() => expect(groups.find((g) => g.label === 'sidebar.mail')).toBeDefined()); + const move = groups.find((g) => g.label === 'sidebar.mail')!.commands.find((c) => c.id === 'move')!; + act(() => move.run()); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeNull()); + fireEvent.click(await screen.findByText('Work')); + await waitFor(() => expect(client.moveEmail).toHaveBeenCalledWith('m1', 'work', undefined)); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 6adec5b90..35763c35a 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -120,7 +120,9 @@ import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; import { findDraftIdentityId, findReplyIdentityId, resolveComposeAccountEmail } from "@/lib/reply-identity"; import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; -import { Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw } from "@/components/icons"; +import { Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder } from "@/components/icons"; +import { useRegisterCommands } from "@/lib/command-registry"; +import { MoveToDialog } from "@/components/email/move-to-dialog"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { Button } from "@/components/ui/button"; import { useDocumentTitle } from "@/hooks/use-document-title"; @@ -251,6 +253,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [showShortcutsModal, setShowShortcutsModal] = useState(false); + // The message the palette's "Move to…" was picked for. + const [moveDialogEmail, setMoveDialogEmail] = useState(null); // The scheduled message Reschedule was picked for in the list's menu. const [rescheduleTarget, setRescheduleTarget] = useState(null); const [showAdvancedFields, setShowAdvancedFields] = useState(false); @@ -4197,6 +4201,54 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { onAccountMailboxSelect: handleAccountMailboxSelect, }); + // Mail's commands in the palette (Ctrl/⌘+K), on the open message, plus a + // "Go to" for every folder. Compose is the palette's own. + const paletteEmail = isScheduledView ? null : selectedEmail; + const hasPaletteEmail = () => !!paletteEmail; + useRegisterCommands(t('sidebar.mail'), [ + { id: 'reply', label: t('context_menu.reply'), icon: Reply, shortcut: 'r', when: hasPaletteEmail, run: () => void handleReply() }, + { id: 'reply-all', label: t('context_menu.reply_all'), icon: ReplyAll, shortcut: 'a', when: hasPaletteEmail, run: () => void handleReplyAll() }, + { id: 'forward', label: t('context_menu.forward'), icon: Forward, shortcut: 'f', when: hasPaletteEmail, run: () => void handleForward() }, + { + id: 'archive', label: t('context_menu.archive'), icon: Archive, shortcut: 'e', + when: () => !!paletteEmail && archiveActionEnabled && conversationRole !== 'archive', + run: () => void handleArchive(paletteEmail), + }, + { id: 'delete', label: t('context_menu.delete'), icon: Trash2, shortcut: '#', when: hasPaletteEmail, run: () => void handleDelete(paletteEmail) }, + { + id: 'move', label: t('context_menu.move_to_dialog'), icon: FolderInput, keywords: [t('context_menu.move_to')], + when: hasPaletteEmail, run: () => setMoveDialogEmail(paletteEmail), + }, + { + id: 'mark-unread', label: t('context_menu.mark_unread'), icon: Mail, shortcut: 'u', + when: () => !!paletteEmail?.keywords?.$seen, + run: () => { if (client && paletteEmail) void markAsRead(client, paletteEmail.id, false, emailKeyFor(paletteEmail)).catch(toastUpdateFailure); }, + }, + { + id: 'mark-read', label: t('context_menu.mark_read'), icon: MailOpen, shortcut: 'Shift + I', + when: () => !!paletteEmail && !paletteEmail.keywords?.$seen, + run: () => { if (client && paletteEmail) void markAsRead(client, paletteEmail.id, true, emailKeyFor(paletteEmail)).catch(toastUpdateFailure); }, + }, + { + id: 'spam', label: conversationInJunk ? t('context_menu.not_spam') : t('context_menu.mark_as_spam'), + icon: conversationInJunk ? ShieldCheck : ShieldAlert, shortcut: '!', + when: () => !!paletteEmail && conversationSpamApplicable, + run: () => void (conversationInJunk ? handleUndoSpam(paletteEmail) : handleMarkAsSpam(paletteEmail)), + }, + ...mailboxes + .filter((mb) => !mb.id.startsWith('shared-') && mb.id !== selectedMailbox) + .map((mb) => { + const name = localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`)); + return { + id: `go-${mb.id}`, + label: t('context_menu.go_to_folder', { folder: name }), + icon: Folder, + keywords: [name], + run: () => void handleMailboxSelect(mb.id), + }; + }), + ], !isEmbedded); + if (!initialCheckDone || authLoading || (!isAuthenticated || !client)) { return (
@@ -5657,6 +5709,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { /> )} + setMoveDialogEmail(null)} + onMove={(mailboxId) => { + if (moveDialogEmail) void handleMoveToMailbox(moveDialogEmail.id, mailboxId, emailKeyFor(moveDialogEmail)); + }} + /> {rescheduleTarget && (