diff --git a/components/email/__tests__/email-context-menu-copy.test.tsx b/components/email/__tests__/email-context-menu-copy.test.tsx index f888a4cb8..61411ed2e 100644 --- a/components/email/__tests__/email-context-menu-copy.test.tsx +++ b/components/email/__tests__/email-context-menu-copy.test.tsx @@ -89,8 +89,12 @@ describe('Copy to entry of the message menu', () => { }); fireEvent.mouseEnter(screen.getByTestId('ctx-move-to').parentElement!); + // The shared account's own folders at the top, the open one greyed; the + // login's own folders one level down, under its name. const items = screen.getAllByTestId(/^move-to:/).map((el) => el.getAttribute('data-testid')); - expect(items).toEqual(['move-to:S:junk', 'move-to:inbox', 'move-to:junk']); + expect(items).toEqual(['move-to:S:inbox', 'move-to:S:junk']); + expect(screen.getByTestId('move-to:S:inbox')).toBeDisabled(); + expect(screen.getByTestId('move-account:A')).toHaveTextContent('me@example.org'); fireEvent.click(screen.getByTestId('move-to:S:junk')); expect(onMoveToMailbox).toHaveBeenCalledWith('S:junk'); diff --git a/components/email/__tests__/email-context-menu-move-to-open-folder.test.tsx b/components/email/__tests__/email-context-menu-move-to-open-folder.test.tsx index 932a82b11..51de81f15 100644 --- a/components/email/__tests__/email-context-menu-move-to-open-folder.test.tsx +++ b/components/email/__tests__/email-context-menu-move-to-open-folder.test.tsx @@ -66,10 +66,11 @@ describe('Move to the open folder from search results', () => { expect(onMoveToMailbox).toHaveBeenCalledWith('inbox'); }); - it('does not offer the open folder for a message already in it', () => { + it('shows the open folder greyed for a message already in it', () => { openMoveMenu({ email: inInbox }); - expect(screen.queryByTestId('move-to:inbox')).toBeNull(); - expect(screen.getByTestId('move-to:work')).toBeTruthy(); + // Shown, as in the sidebar, but it can't be picked. + expect(screen.getByTestId('move-to:inbox')).toBeDisabled(); + expect(screen.getByTestId('move-to:work')).not.toBeDisabled(); }); it('offers it for a selection when one of the messages is elsewhere', () => { diff --git a/components/email/__tests__/move-menu-native.test.tsx b/components/email/__tests__/move-menu-native.test.tsx new file mode 100644 index 000000000..145de43d0 --- /dev/null +++ b/components/email/__tests__/move-menu-native.test.tsx @@ -0,0 +1,88 @@ +import { render, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ContextMenu, ContextMenuSubMenu } from '@/components/ui/context-menu'; +import type { BulwarkDesktopBridge, DesktopNativeMenuItem } from '@/lib/desktop/types'; +import type { Mailbox } from '@/lib/jmap/types'; +import { buildMoveMenu } from '@/lib/move-menu'; +import { MoveFolderList } from '../move-target-list'; + +// The desktop app shows the Move menu natively, read back from the HTML one. +// A native menu can't indent and the account headers used to be plain divs, +// so it showed every account's Inbox, Sent, Spam and Trash in one flat list +// with nothing to tell them apart. + +const writable = { mayAddItems: true } as Mailbox['myRights']; +const box = (id: string, name: string, extra: Partial = {}) => + ({ + id, name, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, + myRights: writable, isSubscribed: true, accountId: 'A', accountName: 'me@example.org', isShared: false, ...extra, + }) as Mailbox; +const shared = (id: string, name: string, extra: Partial = {}) => + box(`S:${id}`, name, { accountId: 'S', accountName: 'team@example.org', isShared: true, ...extra }); + +const mailboxes = [ + box('inbox', 'Inbox', { role: 'inbox' }), + box('trash', 'Trash', { role: 'trash' }), + box('work', 'Work'), + box('clients', 'Clients', { parentId: 'work' }), + box('acme', 'Acme', { parentId: 'clients' }), + box('rd', 'R&D'), + shared('inbox', 'Inbox', { role: 'inbox' }), + shared('trash', 'Trash', { role: 'trash' }), +]; + +let popup: ReturnType; + +beforeEach(() => { + popup = vi.fn(() => new Promise(() => {})); + window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup: popup as never } } as unknown as BulwarkDesktopBridge; +}); + +afterEach(() => { + window.bulwarkDesktop = undefined; +}); + +async function nativeMoveMenu(): Promise { + const model = buildMoveMenu(mailboxes, { currentMailboxId: 'inbox', recentIds: () => ['acme'] }); + render( + {}}> + + {}} /> + + , + ); + await waitFor(() => expect(popup).toHaveBeenCalled()); + const items = popup.mock.calls[0][0] as DesktopNativeMenuItem[]; + return items[0].submenu!; +} + +const labels = (items: DesktopNativeMenuItem[] | undefined) => (items ?? []).map((item) => item.label ?? item.type); + +describe('the Move menu shown natively', () => { + it('has the recent folders under a header, then the account\'s own folders', async () => { + const move = await nativeMoveMenu(); + expect(labels(move)).toEqual([ + 'recent', 'Work / Clients / Acme', 'separator', + 'mailboxes.inbox', 'mailboxes.trash', 'R&D', 'Work', + 'separator', 'team@example.org', + ]); + expect(move[0].enabled).toBe(false); + // The open folder is there, greyed. + expect(move[3].enabled).toBe(false); + }); + + it('nests subfolders as submenus, the folder itself first', async () => { + const move = await nativeMoveMenu(); + const work = move.find((item) => item.label === 'Work')!; + expect(labels(work.submenu)).toEqual(['Work', 'separator', 'Clients']); + expect(work.submenu![0].id).toBeTruthy(); + const clients = work.submenu![2]; + expect(labels(clients.submenu)).toEqual(['Clients', 'separator', 'Acme']); + }); + + it('puts another account\'s folders in a submenu under its name', async () => { + const move = await nativeMoveMenu(); + const team = move.find((item) => item.label === 'team@example.org')!; + expect(labels(team.submenu)).toEqual(['mailboxes.inbox', 'mailboxes.trash']); + }); +}); diff --git a/components/email/__tests__/move-target-list.test.tsx b/components/email/__tests__/move-target-list.test.tsx index a85de34bc..149615116 100644 --- a/components/email/__tests__/move-target-list.test.tsx +++ b/components/email/__tests__/move-target-list.test.tsx @@ -1,25 +1,41 @@ import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; -import type { MailboxNode } from '@/lib/utils'; -import { MoveTargetList } from '../move-target-list'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Mailbox } from '@/lib/jmap/types'; +import { buildMoveMenu } from '@/lib/move-menu'; +import { readRecentMoveTargets } from '@/lib/move-recent'; +import { MoveFolderList } from '../move-target-list'; -// The reader's Move menus show the names the sidebar shows (Stalwart's -// "Sent Items" / "Junk Mail" / "Deleted Items" are Sent, Spam and Trash, in -// the user's language), and indent subfolders on the reading side. +// The Move lists show the names the sidebar shows (Stalwart's "Sent Items" / +// "Junk Mail" are Sent and Spam, in the user's language), nest subfolders, +// and get a filter field once they're long. -const node = (id: string, name: string, role: string | undefined, children: MailboxNode[] = []) => - ({ id, name, role, children, depth: 0 }) as unknown as MailboxNode; +const writable = { mayAddItems: true } as Mailbox['myRights']; +const box = (id: string, name: string, extra: Partial = {}) => + ({ + id, name, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, + myRights: writable, isSubscribed: true, accountId: 'A', accountName: 'me@example.org', isShared: false, ...extra, + }) as Mailbox; -const tree = [ - node('sent', 'Sent Items', 'sent'), - node('junk', 'Junk Mail', 'junk'), - node('archive', 'Archive', 'archive', [node('y2025', '2025', undefined)]), +const short = [ + box('inbox', 'Inbox', { role: 'inbox' }), + box('sent', 'Sent Items', { role: 'sent' }), + box('junk', 'Junk Mail', { role: 'junk' }), + box('archive', 'Archive', { role: 'archive' }), + box('y2025', '2025', { parentId: 'archive' }), ]; -describe('MoveTargetList', () => { +const long = [ + ...short, + ...Array.from({ length: 12 }, (_, i) => box(`f${i}`, `Folder ${i}`)), + box('acme', 'Acme', { parentId: 'f3' }), +]; + +afterEach(() => localStorage.clear()); + +describe('MoveFolderList', () => { it('shows the localized folder names and moves on a pick', () => { const onPick = vi.fn(); - render(); + render(); // The i18n mock returns the key: the role's name, not the server's. expect(screen.queryByText('Sent Items')).toBeNull(); expect(screen.getByText('mailboxes.sent')).toBeTruthy(); @@ -27,10 +43,63 @@ describe('MoveTargetList', () => { expect(onPick).toHaveBeenCalledWith('junk'); }); - it('indents subfolders with a logical padding', () => { - render(); - const row = screen.getByText('2025').closest('button')!; - expect(row.style.paddingInlineStart).toBe('1.75rem'); - expect(row.style.paddingLeft).toBe(''); + it('greys the open folder and keeps the pick as a recent folder', () => { + const onPick = vi.fn(); + render(); + expect(screen.getByTestId('move-to:inbox')).toBeDisabled(); + fireEvent.click(screen.getByTestId('move-to:inbox')); + expect(onPick).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByTestId('move-to:y2025')); + expect(readRecentMoveTargets('A')).toEqual(['y2025']); + }); + + it('puts recent folders at the top, with where they live', () => { + render( + ['y2025'] })} + onPick={vi.fn()} + />, + ); + expect(screen.getByText('recent')).toBeTruthy(); + expect(screen.getByText('Archive / 2025')).toBeTruthy(); + }); + + it('nests subfolders in a group the desktop app makes a submenu of', () => { + render(); + const group = screen.getByTestId('move-to:archive').closest('[data-native-submenu]')!; + expect(group).not.toBeNull(); + expect(group.contains(screen.getByTestId('move-to:y2025'))).toBe(true); + }); + + it('has no filter while short', () => { + render(); + expect(screen.queryByTestId('move-filter')).toBeNull(); + expect(screen.queryByTestId('move-choose-folder')).toBeNull(); + }); + + it('a long list gets a filter that keeps the folders above a match, and Enter moves to the first', () => { + const onPick = vi.fn(); + const onChooseFolder = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('move-choose-folder')); + expect(onChooseFolder).toHaveBeenCalled(); + + const filter = screen.getByTestId('move-filter'); + fireEvent.change(filter, { target: { value: 'acm' } }); + expect(screen.getByTestId('move-to:f3')).toBeTruthy(); + expect(screen.getByTestId('move-to:acme')).toBeTruthy(); + expect(screen.queryByTestId('move-to:f4')).toBeNull(); + fireEvent.keyDown(filter, { key: 'Enter' }); + expect(onPick).toHaveBeenCalledWith('f3'); + + fireEvent.change(filter, { target: { value: 'nothing like it' } }); + expect(screen.getByText('no_match')).toBeTruthy(); + }); + + it('indents subfolders on the reading side in the phone panel', () => { + render(); + const row = screen.getByTestId('move-to:y2025'); + expect(row.parentElement!.className).toContain('ps-6'); }); }); diff --git a/components/email/__tests__/move-to-dialog.test.tsx b/components/email/__tests__/move-to-dialog.test.tsx new file mode 100644 index 000000000..571e1d07d --- /dev/null +++ b/components/email/__tests__/move-to-dialog.test.tsx @@ -0,0 +1,46 @@ +import { act, fireEvent, render, renderHook, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { MoveToDialogHost, useChooseMoveFolder, useMoveDialogStore } from '../move-to-dialog'; + +// "Choose folder…" at the end of a long Move menu opens this dialog: a native +// menu has no filter field, the dialog has one. + +const box = (id: string, name: string, extra: Partial = {}) => + ({ + id, name, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, + myRights: { mayAddItems: true }, isSubscribed: true, accountId: 'A', accountName: 'me@example.org', isShared: false, ...extra, + }) as Mailbox; + +const mailboxes = [box('inbox', 'Inbox', { role: 'inbox' }), box('work', 'Work'), box('travel', 'Travel')]; +const email = { id: 'e1', mailboxIds: { inbox: true } } as unknown as Email; + +afterEach(() => { + act(() => useMoveDialogStore.setState({ request: null })); +}); + +describe('the Move dialog', () => { + it('is only offered where a host can show it', () => { + const without = renderHook(() => useChooseMoveFolder()); + expect(without.result.current).toBeUndefined(); + without.unmount(); + + render(); + const withHost = renderHook(() => useChooseMoveFolder()); + expect(withHost.result.current).toBeTypeOf('function'); + }); + + it('shows the folders with a filter, and moves to the one picked', () => { + render(); + const onMove = vi.fn(); + act(() => useMoveDialogStore.getState().open({ emails: [email], onMove })); + + expect(screen.getByTestId('move-to:inbox')).toBeDisabled(); + fireEvent.change(screen.getByTestId('move-filter'), { target: { value: 'tra' } }); + expect(screen.queryByTestId('move-to:work')).toBeNull(); + fireEvent.click(screen.getByTestId('move-to:travel')); + + expect(onMove).toHaveBeenCalledWith('travel'); + expect(useMoveDialogStore.getState().request).toBeNull(); + }); +}); diff --git a/components/email/__tests__/mv-list-toolbar.test.tsx b/components/email/__tests__/mv-list-toolbar.test.tsx index 082bb667b..96862dbfc 100644 --- a/components/email/__tests__/mv-list-toolbar.test.tsx +++ b/components/email/__tests__/mv-list-toolbar.test.tsx @@ -67,7 +67,8 @@ describe('MvListToolbar filing actions', () => { toolbar(); fireEvent.click(screen.getByLabelText('move_to')); - expect(screen.queryByText('mailboxes.inbox')).toBeNull(); + // Shown greyed, where the sidebar has it. + expect(screen.getByText('mailboxes.inbox').closest('button')).toBeDisabled(); }); it('puts a tag on the whole selection when only some of it carries the tag', async () => { @@ -105,7 +106,7 @@ describe('MvListToolbar filing actions', () => { fireEvent.click(screen.getByRole('button', { name: 'move_to' })); expect(screen.getByText('Travel')).toBeInTheDocument(); - fireEvent.change(screen.getByRole('textbox', { name: 'move_to' }), { target: { value: 'rec' } }); + fireEvent.change(screen.getByRole('textbox', { name: 'filter_placeholder' }), { target: { value: 'rec' } }); expect(screen.getByText('Receipts')).toBeInTheDocument(); expect(screen.queryByText('Travel')).toBeNull(); diff --git a/components/email/conversation-header-actions.tsx b/components/email/conversation-header-actions.tsx index daccc86de..fd1e6a735 100644 --- a/components/email/conversation-header-actions.tsx +++ b/components/email/conversation-header-actions.tsx @@ -8,12 +8,13 @@ import { ContextMenu } from "@/components/ui/context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; import { useCopyToAccount, useEmailExport } from "@/hooks/use-message-file-actions"; import { useArchiveActionEnabled } from "@/lib/archive-gate"; -import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { resolveMoveOwnerAccountId } from "@/lib/move-targets"; import { resolveEmailBlobContext } from "@/stores/email-store"; import { useAuthStore } from "@/stores/auth-store"; import { Archive, FolderInput, MoreVertical, Trash2 } from "@/components/icons"; import { EmailContextMenu } from "./email-context-menu"; -import { MoveTargetList } from "./move-target-list"; +import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "./move-target-list"; +import { useChooseMoveFolder } from "./move-to-dialog"; import { ViewSourceDialog } from "./view-source-dialog"; import { canExport } from "@/lib/device-policy"; import type { MessageActionKey } from "./message-actions"; @@ -56,11 +57,10 @@ export function ConversationHeaderActions({ email, actions }: { email: Email; ac const { mailboxes, selectedMailbox, onArchive, onDelete, onMoveToMailbox } = actions; const ownerAccountId = resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox); - const { tree: moveTree, targetIds: moveTargetIds } = useMemo( - () => buildMoveTargets(mailboxes, { currentMailboxId: selectedMailbox, ownerAccountId, emails: [email] }), - [mailboxes, selectedMailbox, ownerAccountId, email], - ); - const canMove = !!onMoveToMailbox && moveTree.length > 0; + const moveEmails = useMemo(() => [email], [email]); + const moveModel = useMoveMenu(mailboxes, { currentMailboxId: selectedMailbox, ownerAccountId, emails: moveEmails }); + const canMove = !!onMoveToMailbox && !moveMenuIsEmpty(moveModel); + const chooseMoveFolder = useChooseMoveFolder(); const exportFollowUp = { onArchive, onDelete, archiveEnabled }; const source = resolveEmailBlobContext(email, client); @@ -107,11 +107,13 @@ export function ConversationHeaderActions({ email, actions }: { email: Email; ac onClose={moveMenu.closeContextMenu} label={t("email_viewer.move_to")} > -
- + { moveMenu.closeContextMenu(); onMoveToMailbox!(mailboxId); }} + onChooseFolder={chooseMoveFolder + ? () => { moveMenu.closeContextMenu(); chooseMoveFolder({ emails: moveEmails, onMove: onMoveToMailbox! }); } + : undefined} />
diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index a27531ead..45d6d96de 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo } from "react"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { useSettingsStore } from "@/stores/settings-store"; import { useEmailStore, removalFolder, canRemoveFromFolder } from "@/stores/email-store"; import { useEmailTagScope, type TagScope } from "@/hooks/use-account-tags"; @@ -30,10 +30,10 @@ import { Fish, } from "@/components/icons"; import { useMessageActionList, type MessageActionEntry, type MessageActionKey } from "./message-actions"; -import { buildMailboxTree, MailboxNode } from "@/lib/utils"; -import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"; -import { localizeMailboxName } from "@/lib/mailbox-label"; -import { moveMailboxIcon } from "./move-target-list"; +import { resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { buildMoveMenu, type MoveMenuModel } from "@/lib/move-menu"; +import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "./move-target-list"; +import { useChooseMoveFolder } from "./move-to-dialog"; import { getEmailTagIds } from "@/lib/thread-utils"; import { TagPicker } from "./tag-picker"; import { RulesContextSubMenu } from "./rules-menu"; @@ -200,7 +200,6 @@ export function EmailContextMenu({ const t = useTranslations("context_menu"); const archiveActionEnabled = useArchiveActionEnabled(); const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view"); - const tSidebar = useTranslations("sidebar"); const isUnread = !email.keywords?.$seen; const currentTagIds = getEmailTagIds(email.keywords); // The row's account names its tags; a selection uses batchTagScope. @@ -221,68 +220,49 @@ export function EmailContextMenu({ ? !!onBatchRemoveFromFolder && !!batchEmails?.length && batchEmails.every((e) => canRemoveFromFolder(e, folderToLeave)) : !!onRemoveFromFolder && canRemoveFromFolder(email, folderToLeave); - // Move-to submenu: the message's own account first (#1149) - const { tree: moveTree, targetIds: moveTargetIds } = buildMoveTargets(mailboxes, { + // Move-to submenu: the message's own account first (#1149), shared with + // every other Move list (lib/move-menu.ts). + const movedEmails = useMemo( + () => (showBatchActions ? batchEmails ?? [] : [email]), + [showBatchActions, batchEmails, email], + ); + const moveModel = useMoveMenu(mailboxes, { currentMailboxId: selectedMailbox, ownerAccountId: resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox), - emails: showBatchActions ? batchEmails : [email], + emails: movedEmails, }); - - // Filter tree to only include branches that contain valid targets - const filterTree = (nodes: MailboxNode[], targetIds: Set): MailboxNode[] => { - return nodes.reduce((acc, node) => { - const filteredChildren = filterTree(node.children, targetIds); - if (targetIds.has(node.id) || filteredChildren.length > 0) { - acc.push({ ...node, children: filteredChildren }); - } - return acc; - }, []); - }; + const canMoveAnywhere = !moveMenuIsEmpty(moveModel); + const chooseMoveFolder = useChooseMoveFolder(); // Folders of the other connected accounts a copy can land in. - const copyTrees = (copyTargets ?? []) - .map((target) => { - const targetIds = new Set( - target.mailboxes - .filter((m) => !m.isShared && m.role !== "drafts" && m.myRights?.mayAddItems) - .map((m) => m.id) - ); - return { ...target, targetIds, tree: filterTree(buildMailboxTree(target.mailboxes), targetIds) }; - }) - .filter((target) => target.tree.length > 0); + const locale = useLocale(); + const copyTrees = useMemo( + () => (copyTargets ?? []) + .map((target) => ({ + ...target, + model: buildMoveMenu(target.mailboxes.filter((m) => !m.isShared), { locale }), + })) + .filter((target) => target.model.targetCount > 0), + [copyTargets, locale], + ); - const renderMailboxNodes = ( - nodes: MailboxNode[], - targetIds: Set, - testIdPrefix: string, - onPick: (mailboxId: string) => void, - ): React.ReactNode => - nodes.map((node) => { - const Icon = moveMailboxIcon(node.role); - const nodeLabel = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`)); - return ( -
- {targetIds.has(node.id) ? ( - handleAction(() => onPick(node.id))} - /> - ) : ( -
- - {nodeLabel} -
- )} - {node.children.length > 0 && ( -
- {renderMailboxNodes(node.children, targetIds, testIdPrefix, onPick)} -
- )} -
- ); - }); + const renderMoveList = (onPick?: (mailboxId: string) => void) => ( + handleAction(() => onPick?.(mailboxId))} + onChooseFolder={chooseMoveFolder && onPick + ? () => handleAction(() => chooseMoveFolder({ emails: movedEmails, onMove: onPick })) + : undefined} + /> + ); + const renderCopyList = (target: { accountId: string; model: MoveMenuModel }) => ( + handleAction(() => onCopyToAccount?.(target.accountId, mailboxId))} + /> + ); const handleAction = (action: () => void) => { action(); @@ -294,7 +274,7 @@ export function EmailContextMenu({ const singleEntries = useMessageActionList({ email, currentMailboxRole, - canMove: moveTree.length > 0, + canMove: canMoveAnywhere, canRemoveFromFolder: canRemove, canCopyToAccount: !!onCopyToAccount && copyTrees.length > 0, onReply, @@ -388,9 +368,9 @@ export function EmailContextMenu({ const moveItem = ( <> {/* Move to submenu */} - {moveTree.length > 0 && ( + {canMoveAnywhere && ( - {renderMailboxNodes(moveTree, moveTargetIds, "move-to:", (mailboxId) => onBatchMoveToMailbox?.(mailboxId))} + {renderMoveList(onBatchMoveToMailbox)} )} {canRemove && ( @@ -407,9 +387,7 @@ export function EmailContextMenu({ ? copyTrees.map((target) => (
{target.label} - {renderMailboxNodes(target.tree, target.targetIds, `copy-to:${target.accountId}:`, (mailboxId) => - onCopyToAccount(target.accountId, mailboxId) - )} + {renderCopyList(target)}
)) : null; @@ -478,7 +456,7 @@ export function EmailContextMenu({ case "move": return ( - {renderMailboxNodes(moveTree, moveTargetIds, "move-to:", (mailboxId) => onMoveToMailbox?.(mailboxId))} + {renderMoveList(onMoveToMailbox)} ); case "copy_to_account": diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 0b1b5fac7..ba0a2a066 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -47,11 +47,12 @@ import { PluginPanelToggle, panelLabelOf, panelShortcutOf } from "@/components/p import type { ComposerMetadata } from "@/lib/plugin-sandbox/composer-registry"; import { QuickReplyPluginActions } from "./quick-reply-plugin-actions"; import { quickReplyEnvelope } from "@/lib/outgoing/quick-reply-envelope"; -import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview, type MailboxNode } from "@/lib/utils"; +import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview } from "@/lib/utils"; import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone"; import { itemsToHide } from "@/lib/toolbar-overflow"; import { formatScheduledSendAt } from "@/lib/scheduled-send-label"; -import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { buildMoveMenu, type MoveMenuModel } from "@/lib/move-menu"; import { emailDisplayDate } from "@/lib/email-date"; import { TagBadge } from "./tag-badge"; import { TagPicker } from "./tag-picker"; @@ -63,7 +64,8 @@ import { getSecurityStatus, extractListHeaders, getSenderVerification, brandIndi import { emailToReadView } from "@/lib/plugin-projection"; import type { EmailReadView, ExternalLinkContext } from "@/lib/plugin-types"; import { ViewSourceDialog } from "./view-source-dialog"; -import { MoveTargetList } from "./move-target-list"; +import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "./move-target-list"; +import { useChooseMoveFolder } from "./move-to-dialog"; import { SaveToFilesDialog, type SaveableAttachment } from "./save-to-files-dialog"; import { usePolicyStore } from "@/stores/policy-store"; import { ScheduleSendDialog } from "./schedule-send-dialog"; @@ -122,7 +124,7 @@ import { Paperclip, } from "@/components/icons"; import { MailMessageSkeleton, MailMessageToolbarSkeleton } from "@/components/boot-skeleton/mail-skeleton"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { useRouter } from "@/i18n/navigation"; import type { Attachment as PostalMimeAttachment } from 'postal-mime'; import { useSettingsStore } from "@/stores/settings-store"; @@ -1172,8 +1174,7 @@ interface ReaderMorePanelBodyProps { openSub: (sub: MoreMenuSub) => void; close: () => void; entryRefs: React.MutableRefObject>; - moveTree: MailboxNode[]; - moveTargetIds: Set; + moveModel: MoveMenuModel; onMoveToMailbox?: (mailboxId: string) => void; currentTagIds: string[]; sortedTagIds: string[]; @@ -1197,8 +1198,7 @@ function ReaderMorePanelBody({ openSub, close, entryRefs, - moveTree, - moveTargetIds, + moveModel, onMoveToMailbox, currentTagIds, sortedTagIds, @@ -1209,6 +1209,7 @@ function ReaderMorePanelBody({ pluginTarget, }: ReaderMorePanelBodyProps) { const { email } = options; + const locale = useLocale(); const { tagColor } = useKeywordFormat(tagScope); const { copyTargets, copyToAccount } = useCopyToAccount(email, true); const entries = useMessageActionList({ @@ -1218,11 +1219,10 @@ function ReaderMorePanelBody({ }); if (sub === 'move') { - return moveTree.length > 0 && onMoveToMailbox ? ( - { onMoveToMailbox(mailboxId); close(); }} /> ) : null; @@ -1231,14 +1231,15 @@ function ReaderMorePanelBody({ return ( <> {copyTargets.map((target) => { - const { tree, targetIds } = buildMoveTargets(target.mailboxes.filter((m) => !m.isShared && m.role !== 'drafts'), {}); + const model = buildMoveMenu(target.mailboxes.filter((m) => !m.isShared), { locale }); return (
{target.label}
- { void copyToAccount([email], target.accountId, mailboxId); close(); }} />
@@ -1978,14 +1979,14 @@ export function EmailViewer({ // Move-to dropdown: the message's own account first (#1149) const moveOwnerAccountId = resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox); - const { tree: moveTree, targetIds: moveTargetIds } = useMemo( - () => buildMoveTargets(mailboxes, { - currentMailboxId: selectedMailbox, - ownerAccountId: moveOwnerAccountId, - emails: email ? [email] : [], - }), - [mailboxes, selectedMailbox, moveOwnerAccountId, email], - ); + const moveEmails = useMemo(() => (email ? [email] : []), [email]); + const moveModel = useMoveMenu(mailboxes, { + currentMailboxId: selectedMailbox, + ownerAccountId: moveOwnerAccountId, + emails: moveEmails, + }); + const canMoveAnywhere = !moveMenuIsEmpty(moveModel); + const chooseMoveFolder = useChooseMoveFolder(); // Close dropdown menus on click outside useEffect(() => { @@ -2164,7 +2165,7 @@ export function EmailViewer({ email?.id, showToolbarLabels, isLoading, - moveTree.length, + canMoveAnywhere, emailKeywords.length, currentColor, isInJunkFolder, @@ -4271,7 +4272,7 @@ ${printWatermark ? printWatermark.html : ''} const readerActionOptions: MessageActionOptions = { email, currentMailboxRole, - canMove: moveTree.length > 0 && !!onMoveToMailbox, + canMove: canMoveAnywhere && !!onMoveToMailbox, canRemoveFromFolder: !!onRemoveFromFolder, onReply: onReply ? () => onReply() : undefined, onReplyAll, @@ -4467,7 +4468,7 @@ ${printWatermark ? printWatermark.html : ''} {showToolbarLabels && {t('delete')}} {/* Move to folder */} - {moveTree.length > 0 && onMoveToMailbox && ( + {canMoveAnywhere && onMoveToMailbox && (
-
- + { onMoveToMailbox(mailboxId); closeMoveMenu(); }} + onChooseFolder={chooseMoveFolder ? () => { closeMoveMenu(); chooseMoveFolder({ emails: moveEmails, onMove: onMoveToMailbox }); } : undefined} />
@@ -4831,8 +4832,7 @@ ${printWatermark ? printWatermark.html : ''} openSub={setMoreMenuSub} close={closeMoreMenu} entryRefs={moreEntryRefs} - moveTree={moveTree} - moveTargetIds={moveTargetIds} + moveModel={moveModel} onMoveToMailbox={onMoveToMailbox} currentTagIds={currentTagIds} sortedTagIds={sortedTagIds} diff --git a/components/email/move-target-list.tsx b/components/email/move-target-list.tsx index cc99987a4..51395ccb0 100644 --- a/components/email/move-target-list.tsx +++ b/components/email/move-target-list.tsx @@ -1,9 +1,21 @@ "use client"; -import { useTranslations } from "next-intl"; -import { Archive, Ban, File, Folder, Inbox, Send, Trash2, type AppIcon } from "@/components/icons"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useLocale, useTranslations } from "next-intl"; +import { Archive, Ban, Clock, File, Folder, FolderSearch, Inbox, Search, Send, Trash2, Users, type AppIcon } from "@/components/icons"; +import { ContextMenuHeader, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu"; import { localizeMailboxName } from "@/lib/mailbox-label"; -import type { MailboxNode } from "@/lib/utils"; +import type { Email, Mailbox } from "@/lib/jmap/types"; +import { + buildMoveMenu, + countTargets, + filterMoveNodes, + firstEnabled, + MOVE_FILTER_THRESHOLD, + type MoveMenuModel, + type MoveMenuNode, +} from "@/lib/move-menu"; +import { noteMoveTarget, readRecentMoveTargets } from "@/lib/move-recent"; import { cn } from "@/lib/utils"; /** A folder's icon in a Move list, the sidebar's for the folders with a role. */ @@ -19,58 +31,257 @@ export function moveMailboxIcon(role?: string | null): AppIcon { } } -interface MoveTargetListProps { - /** From buildMoveTargets. */ - tree: MailboxNode[]; - targetIds: Set; +type MoveMenuEmails = Array>; + +/** The Move list for these messages, with this device's recent folders. */ +export function useMoveMenu( + mailboxes: Mailbox[], + opts: { currentMailboxId?: string | null; ownerAccountId?: string; emails?: MoveMenuEmails }, +): MoveMenuModel { + const locale = useLocale(); + const { currentMailboxId, ownerAccountId, emails } = opts; + return useMemo( + () => buildMoveMenu(mailboxes, { currentMailboxId, ownerAccountId, emails, locale, recentIds: readRecentMoveTargets }), + [mailboxes, currentMailboxId, ownerAccountId, emails, locale], + ); +} + +/** Whether the list is long enough for a filter field and "Choose folder…". */ +export function moveMenuIsLong(model: MoveMenuModel): boolean { + return model.targetCount > MOVE_FILTER_THRESHOLD; +} + +export function moveMenuIsEmpty(model: MoveMenuModel): boolean { + return model.targetCount === 0 && model.others.length === 0; +} + +interface MoveFolderListProps { + model: MoveMenuModel; onPick: (mailboxId: string) => void; - /** `menu` for a dropdown, `panel` for the phone's full-width panel (44px rows). */ - variant?: "menu" | "panel"; + /** + * `menu`: inside a ContextMenu; other accounts are submenus. + * `dialog`: a list in a dialog; other accounts follow under their names. + * `panel`: the phone's full-width panel (44px rows). + */ + variant?: "menu" | "dialog" | "panel"; + /** Opens the Move dialog. Offered at the end of a long list, since a native menu has no filter field. */ + onChooseFolder?: () => void; + /** Always show the filter field (the dialog). */ + alwaysFilter?: boolean; + /** Stable hook for integration tests: each row is ``. */ + testIdPrefix?: string; + /** Don't keep the pick as a recent folder (a copy, not a move). */ + noRecent?: boolean; } /** - * The folders a message can be moved to, as the reader's Move dropdown, its ⋮ - * and the phone panel show them: the names the sidebar shows (localized - * Sent, Spam, Trash), subfolders indented on the reading side. + * The folders messages can be moved to (lib/move-menu.ts): recent ones, the + * account's own folders nested and in mail-client order, the open folder + * greyed, other reachable accounts under their names, and a filter field + * once the list is long. + * + * Each folder with subfolders is wrapped in `data-native-submenu`: the + * desktop app's native copy of the menu makes it a submenu there, the folder + * itself first (lib/desktop/native-menu.ts). */ -export function MoveTargetList({ tree, targetIds, onPick, variant = "menu" }: MoveTargetListProps) { +export function MoveFolderList({ + model, + onPick, + variant = "menu", + onChooseFolder, + alwaysFilter = false, + testIdPrefix = "move-to:", + noRecent = false, +}: MoveFolderListProps) { + const t = useTranslations("menuMove"); const tSidebar = useTranslations("sidebar"); + const locale = useLocale(); + const [query, setQuery] = useState(""); + const inputRef = useRef(null); const panel = variant === "panel"; - const indent = (depth: number) => `${(panel ? 1 : 0.75) + depth}rem`; + const showFilter = alwaysFilter || moveMenuIsLong(model); - const render = (nodes: MailboxNode[], depth: number): React.ReactNode => - nodes.map((node) => { - const Icon = moveMailboxIcon(node.role); - const label = localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`)); - const rowClass = panel - ? "w-full pe-4 py-2.5 min-h-[44px] text-sm text-start flex items-center gap-3" - : "w-full pe-3 py-1.5 text-sm text-start flex items-center gap-2 pointer-coarse:min-h-11"; - const icon = ; + const labelOf = (node: Pick) => + localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`)); + + const nodes = filterMoveNodes(model.nodes, query, labelOf, locale); + const others = model.others + .map((account) => ({ ...account, nodes: filterMoveNodes(account.nodes, query, labelOf, locale) })) + .filter((account) => countTargets(account.nodes) > 0); + const filtering = query.trim().length > 0; + + const pick = (id: string) => { + if (!noRecent) noteMoveTarget(model.accountId, id); + onPick(id); + }; + + // The filter takes the focus as the list shows, so typing narrows it at + // once. It can be hidden for a frame while its panel is placed. + useEffect(() => { + if (!showFilter || panel) return; + let tries = 0; + let frame = 0; + const attempt = () => { + const input = inputRef.current; + if (!input) return; + const visible = input.getClientRects().length > 0 && getComputedStyle(input).visibility !== "hidden"; + if (visible) { + input.focus({ preventScroll: true }); + return; + } + if (++tries < 20) frame = requestAnimationFrame(attempt); + }; + frame = requestAnimationFrame(attempt); + return () => cancelAnimationFrame(frame); + }, [showFilter, panel]); + + const row = (node: MoveMenuNode, label: string, key: string, title?: string) => { + const Icon = moveMailboxIcon(node.role); + const rowTitle = title ?? (node.current ? t("current_folder", { folder: label }) : label); + if (panel) { return ( -
- {targetIds.has(node.id) ? ( - - ) : ( -
- {icon} - {label} -
- )} - {node.children.length > 0 && render(node.children, depth + 1)} + + ); + } + return ( + pick(node.id)} + /> + ); + }; + + const tree = (list: MoveMenuNode[]): React.ReactNode => + list.map((node) => { + const label = labelOf(node); + if (node.children.length === 0) return row(node, label, node.id); + return ( +
+ {row(node, label, node.id)} +
{tree(node.children)}
); }); - return <>{render(tree, 0)}; + const recent = !filtering && !noRecent ? model.recent : []; + const nothing = nodes.length === 0 && others.length === 0; + const separator = panel ?
: ; + + return ( +
+ {showFilter && ( +
+ +
+ )} + + {recent.length > 0 && ( +
+ {panel ? ( +
+ + {t("recent")} +
+ ) : ( + {t("recent")} + )} + {recent.map((node) => { + const label = [...node.path, labelOf(node)].join(" / "); + return row({ ...node, children: [] }, label, `recent-${node.id}`, label); + })} + {separator} +
+ )} + + {tree(nodes)} + + {nothing && ( +
{t("no_match")}
+ )} + + {others.length > 0 && (variant === "menu" ? ( + <> + {nodes.length > 0 && } + {others.map((account) => ( + + {tree(account.nodes)} + + ))} + + ) : ( + others.map((account) => ( +
+ {panel ? ( +
{account.name}
+ ) : ( + {account.name} + )} + {tree(account.nodes)} +
+ )) + ))} + + {onChooseFolder && moveMenuIsLong(model) && !filtering && ( + <> + {separator} + {panel ? ( + + ) : ( + + )} + + )} +
+ ); } diff --git a/components/email/move-to-dialog.tsx b/components/email/move-to-dialog.tsx index 591ef49c8..33a8c68fd 100644 --- a/components/email/move-to-dialog.tsx +++ b/components/email/move-to-dialog.tsx @@ -1,15 +1,47 @@ "use client"; -import { useMemo } from "react"; +import { useEffect } from "react"; import { useTranslations } from "next-intl"; +import { create } from "zustand"; 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"; +import { resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { MoveFolderList, useMoveMenu } from "./move-target-list"; + +interface MoveDialogRequest { + /** The messages to move. */ + emails: Email[]; + onMove: (mailboxId: string) => void; +} + +interface MoveDialogState { + request: MoveDialogRequest | null; + /** Mounted hosts: without one, nothing offers the dialog. */ + hosts: number; + open: (request: MoveDialogRequest) => void; + close: () => void; +} + +export const useMoveDialogStore = create((set) => ({ + request: null, + hosts: 0, + open: (request) => set({ request }), + close: () => set({ request: null }), +})); + +/** + * "Choose folder…" at the end of a long Move list: the dialog with the filter + * field. Undefined where no dialog host is mounted. + */ +export function useChooseMoveFolder(): ((request: MoveDialogRequest) => void) | undefined { + const hosts = useMoveDialogStore((s) => s.hosts); + const open = useMoveDialogStore((s) => s.open); + return hosts > 0 ? open : undefined; +} interface MoveToDialogProps { - /** The message to move; the dialog is open while it is set. */ - email: Email | null; + /** The messages to move; the dialog is open while there are some. */ + emails: Email[]; mailboxes: Mailbox[]; selectedMailbox: string; onClose: () => void; @@ -17,25 +49,24 @@ interface MoveToDialogProps { } /** - * "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. + * Move to…: the same folders as the Move menus (lib/move-menu.ts), in a + * dialog with a filter field. From the command palette, the Message menu and + * "Choose folder…" in a long Move menu. */ -export function MoveToDialog({ email, mailboxes, selectedMailbox, onClose, onMove }: MoveToDialogProps) { +export function MoveToDialog({ emails, mailboxes, selectedMailbox, onClose, onMove }: MoveToDialogProps) { const t = useTranslations("context_menu"); - const { tree, targetIds } = useMemo( - () => buildMoveTargets(mailboxes, { - currentMailboxId: selectedMailbox, - ownerAccountId: resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox), - emails: email ? [email] : [], - }), - [email, mailboxes, selectedMailbox], - ); + const model = useMoveMenu(mailboxes, { + currentMailboxId: selectedMailbox, + ownerAccountId: resolveMoveOwnerAccountId(emails[0], mailboxes, selectedMailbox), + emails, + }); return ( - -
- 0} onClose={onClose} title={t("move_to_dialog")} bodyClassName="px-2" size="sm"> +
+ { onClose(); onMove(mailboxId); @@ -45,3 +76,23 @@ export function MoveToDialog({ email, mailboxes, selectedMailbox, onClose, onMov
); } + +/** Shows the dialog a Move menu asked for. Mounted once, where the mail is. */ +export function MoveToDialogHost({ mailboxes, selectedMailbox }: { mailboxes: Mailbox[]; selectedMailbox: string }) { + const request = useMoveDialogStore((s) => s.request); + const close = useMoveDialogStore((s) => s.close); + useEffect(() => { + useMoveDialogStore.setState((s) => ({ hosts: s.hosts + 1 })); + return () => useMoveDialogStore.setState((s) => ({ hosts: s.hosts - 1, request: s.hosts <= 1 ? null : s.request })); + }, []); + if (!request) return null; + return ( + + ); +} diff --git a/components/email/mv-list-toolbar.tsx b/components/email/mv-list-toolbar.tsx index e3ddc9dcf..ae56cb2fc 100644 --- a/components/email/mv-list-toolbar.tsx +++ b/components/email/mv-list-toolbar.tsx @@ -1,17 +1,15 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Archive, CheckSquare, - Folder, FolderInput, FolderX, Loader2, Mail, MailOpen, MoreVertical, - Search, ShieldAlert, ShieldCheck, Square, @@ -31,14 +29,15 @@ import { markSelectionReadWithUndo, runBatchEmailAction, setSelectionTagWithUndo import { toast } from "@/stores/toast-store"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useContextMenu } from "@/hooks/use-context-menu"; -import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuHeader, CONTEXT_MENU_ITEM_CLASS } from "@/components/ui/context-menu"; +import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuHeader } from "@/components/ui/context-menu"; import { useArchiveActionEnabled } from "@/lib/archive-gate"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { TagPicker } from "@/components/email/tag-picker"; -import { localizeMailboxName } from "@/lib/mailbox-label"; -import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "@/components/email/move-target-list"; +import { useChooseMoveFolder } from "@/components/email/move-to-dialog"; import { getEmailTagIds } from "@/lib/thread-utils"; -import { cn, type MailboxNode } from "@/lib/utils"; +import { cn } from "@/lib/utils"; import { PluginActions } from "@/components/plugins/plugin-actions"; import { useUnreadFirstSection } from "@/hooks/use-unread-first-section"; import { useTimeoutWhileMounted } from "@/hooks/use-timeout-while-mounted"; @@ -170,8 +169,6 @@ export function MvListToolbar({ const moveOpen = moveMenu.contextMenu.isOpen; const closeMove = moveMenu.closeContextMenu; // Gmail's "Move to" menu opens on a search field that narrows the folders. - const [moveQuery, setMoveQuery] = useState(""); - useEffect(() => { if (!moveOpen) setMoveQuery(""); }, [moveOpen]); const tagMenu = useContextMenu(); const tagOpen = tagMenu.contextMenu.isOpen; const closeTag = tagMenu.closeContextMenu; @@ -288,58 +285,12 @@ export function MvListToolbar({ // The same targets the row's own context menu offers, with the account of // the selection leading (#1149). - const { tree: moveTree, targetIds: moveTargetIds } = buildMoveTargets(mailboxes, { + const moveModel = useMoveMenu(mailboxes, { currentMailboxId: selectedMailbox, ownerAccountId: resolveMoveOwnerAccountId(selectedEmails[0], mailboxes, selectedMailbox), emails: selectedEmails, }); - - const renderMoveNodes = (nodes: MailboxNode[], depth = 0): React.ReactNode => - nodes.map((node) => { - const label = localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`)); - const isTarget = moveTargetIds.has(node.id); - return ( -
- {isTarget ? ( - - ) : ( -
- - {label} -
- )} - {node.children.length > 0 && renderMoveNodes(node.children, depth + 1)} -
- ); - }); - - // While a search narrows the move menu it is a flat list of the matches. - const moveMatches = (() => { - const q = moveQuery.trim().toLowerCase(); - if (!q) return null; - const out: MailboxNode[] = []; - const walk = (nodes: MailboxNode[]) => { - for (const node of nodes) { - const label = localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`)); - if (moveTargetIds.has(node.id) && label.toLowerCase().includes(q)) out.push({ ...node, children: [] }); - walk(node.children); - } - }; - walk(moveTree); - return out; - })(); + const chooseMoveFolder = useChooseMoveFolder(); // What the tag picker shows: a tag every selected message carries is // checked, one only some of them carry is drawn as partial. @@ -560,7 +511,7 @@ export function MvListToolbar({ disabled={isProcessing} />
- {moveTree.length > 0 && ( + {!moveMenuIsEmpty(moveModel) && (