From 4d404929e2a3fca13b71ca573e46b2ae29c43c88 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:20:14 +0100 Subject: [PATCH] fix: show the message's own folders in Move to, in mail-client order, with recents and a filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Move lists (message menu, reader Move button and its phone panel, selection toolbar, Move dialog) now share one model in lib/move-menu.ts. In the desktop app the native copy of the menu lost the shared accounts' headers and every folder's nesting, so Inbox, Sent, Spam and Trash showed once per account with nothing to tell them apart. - Only the message's own account at the top; other accounts a move can reach are submenus under their names. - System folders first (Inbox, Sent, Archive, Spam, Trash), then the user's folders by name; subfolders nest, as submenus natively. - The open folder shows greyed instead of missing. - A Recent group, a filter field on long lists, and "Choose folder…" for the dialog, which has the filter too. Tests changed: the open folder is now shown disabled rather than left out, the own account of a shared-mailbox message is a submenu, and the selection toolbar's filter field has its own label. --- .../email-context-menu-copy.test.tsx | 6 +- ...-context-menu-move-to-open-folder.test.tsx | 7 +- .../email/__tests__/move-menu-native.test.tsx | 88 ++++++ .../email/__tests__/move-target-list.test.tsx | 107 +++++-- .../email/__tests__/move-to-dialog.test.tsx | 46 +++ .../email/__tests__/mv-list-toolbar.test.tsx | 5 +- .../email/conversation-header-actions.tsx | 24 +- components/email/email-context-menu.tsx | 116 +++---- components/email/email-viewer.tsx | 66 ++-- components/email/move-target-list.tsx | 299 +++++++++++++++--- components/email/move-to-dialog.tsx | 93 ++++-- components/email/mv-list-toolbar.tsx | 87 +---- components/mail/mail-app.tsx | 5 +- lib/__tests__/move-menu.test.ts | 106 +++++++ lib/desktop/native-menu.ts | 13 + lib/move-menu.ts | 253 +++++++++++++++ lib/move-recent.ts | 40 +++ locales/ar/common.json | 7 + locales/ca/common.json | 7 + locales/cs/common.json | 7 + locales/da/common.json | 7 + locales/de/common.json | 7 + locales/en/common.json | 7 + locales/es/common.json | 7 + locales/et/common.json | 7 + locales/fa/common.json | 7 + locales/fr/common.json | 7 + locales/he/common.json | 7 + locales/hu/common.json | 7 + locales/it/common.json | 7 + locales/ja/common.json | 7 + locales/ko/common.json | 7 + locales/lv/common.json | 7 + locales/mn/common.json | 7 + locales/nb/common.json | 7 + locales/nl/common.json | 7 + locales/pl/common.json | 7 + locales/pt/common.json | 7 + locales/ro/common.json | 7 + locales/ru/common.json | 7 + locales/sk/common.json | 7 + locales/th/common.json | 7 + locales/tr/common.json | 7 + locales/uk/common.json | 7 + locales/vi/common.json | 7 + locales/zh-TW/common.json | 7 + locales/zh/common.json | 7 + 47 files changed, 1296 insertions(+), 275 deletions(-) create mode 100644 components/email/__tests__/move-menu-native.test.tsx create mode 100644 components/email/__tests__/move-to-dialog.test.tsx create mode 100644 lib/__tests__/move-menu.test.ts create mode 100644 lib/move-menu.ts create mode 100644 lib/move-recent.ts 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) && (