mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
fix: show the message's own folders in Move to, in mail-client order, with recents and a filter
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.
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<Mailbox> = {}) =>
|
||||
({
|
||||
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<Mailbox> = {}) =>
|
||||
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<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
popup = vi.fn(() => new Promise<string | null>(() => {}));
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup: popup as never } } as unknown as BulwarkDesktopBridge;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.bulwarkDesktop = undefined;
|
||||
});
|
||||
|
||||
async function nativeMoveMenu(): Promise<DesktopNativeMenuItem[]> {
|
||||
const model = buildMoveMenu(mailboxes, { currentMailboxId: 'inbox', recentIds: () => ['acme'] });
|
||||
render(
|
||||
<ContextMenu isOpen position={{ x: 10, y: 10 }} onClose={() => {}}>
|
||||
<ContextMenuSubMenu label="Move to">
|
||||
<MoveFolderList model={model} onPick={() => {}} />
|
||||
</ContextMenuSubMenu>
|
||||
</ContextMenu>,
|
||||
);
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -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<Mailbox> = {}) =>
|
||||
({
|
||||
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(<MoveTargetList tree={tree} targetIds={new Set(['sent', 'junk', 'archive', 'y2025'])} onPick={onPick} />);
|
||||
render(<MoveFolderList model={buildMoveMenu(short, { currentMailboxId: 'inbox' })} onPick={onPick} />);
|
||||
// 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(<MoveTargetList tree={tree} targetIds={new Set(['y2025'])} onPick={vi.fn()} />);
|
||||
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(<MoveFolderList model={buildMoveMenu(short, { currentMailboxId: 'inbox' })} onPick={onPick} />);
|
||||
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(
|
||||
<MoveFolderList
|
||||
model={buildMoveMenu(short, { currentMailboxId: 'inbox', recentIds: () => ['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(<MoveFolderList model={buildMoveMenu(short, {})} onPick={vi.fn()} />);
|
||||
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(<MoveFolderList model={buildMoveMenu(short, {})} onPick={vi.fn()} onChooseFolder={vi.fn()} />);
|
||||
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(<MoveFolderList model={buildMoveMenu(long, {})} onPick={onPick} onChooseFolder={onChooseFolder} />);
|
||||
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(<MoveFolderList variant="panel" model={buildMoveMenu(short, {})} onPick={vi.fn()} />);
|
||||
const row = screen.getByTestId('move-to:y2025');
|
||||
expect(row.parentElement!.className).toContain('ps-6');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Mailbox> = {}) =>
|
||||
({
|
||||
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(<MoveToDialogHost mailboxes={mailboxes} selectedMailbox="inbox" />);
|
||||
const withHost = renderHook(() => useChooseMoveFolder());
|
||||
expect(withHost.result.current).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('shows the folders with a filter, and moves to the one picked', () => {
|
||||
render(<MoveToDialogHost mailboxes={mailboxes} selectedMailbox="inbox" />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
<div className="w-56 max-h-72 overflow-y-auto">
|
||||
<MoveTargetList
|
||||
tree={moveTree}
|
||||
targetIds={moveTargetIds}
|
||||
<div className="w-64 max-w-full">
|
||||
<MoveFolderList
|
||||
model={moveModel}
|
||||
onPick={(mailboxId) => { moveMenu.closeContextMenu(); onMoveToMailbox!(mailboxId); }}
|
||||
onChooseFolder={chooseMoveFolder
|
||||
? () => { moveMenu.closeContextMenu(); chooseMoveFolder({ emails: moveEmails, onMove: onMoveToMailbox! }); }
|
||||
: undefined}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
|
||||
@@ -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<string>): MailboxNode[] => {
|
||||
return nodes.reduce<MailboxNode[]>((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<string>,
|
||||
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 (
|
||||
<div key={node.id}>
|
||||
{targetIds.has(node.id) ? (
|
||||
<ContextMenuItem
|
||||
icon={Icon}
|
||||
label={nodeLabel}
|
||||
testId={`${testIdPrefix}${node.id}`}
|
||||
onClick={() => handleAction(() => onPick(node.id))}
|
||||
/>
|
||||
) : (
|
||||
<div className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground">
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{nodeLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
{node.children.length > 0 && (
|
||||
<div className="ps-4">
|
||||
{renderMailboxNodes(node.children, targetIds, testIdPrefix, onPick)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
const renderMoveList = (onPick?: (mailboxId: string) => void) => (
|
||||
<MoveFolderList
|
||||
model={moveModel}
|
||||
onPick={(mailboxId) => handleAction(() => onPick?.(mailboxId))}
|
||||
onChooseFolder={chooseMoveFolder && onPick
|
||||
? () => handleAction(() => chooseMoveFolder({ emails: movedEmails, onMove: onPick }))
|
||||
: undefined}
|
||||
/>
|
||||
);
|
||||
const renderCopyList = (target: { accountId: string; model: MoveMenuModel }) => (
|
||||
<MoveFolderList
|
||||
model={target.model}
|
||||
noRecent
|
||||
testIdPrefix={`copy-to:${target.accountId}:`}
|
||||
onPick={(mailboxId) => 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 && (
|
||||
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")} testId="ctx-move-to">
|
||||
{renderMailboxNodes(moveTree, moveTargetIds, "move-to:", (mailboxId) => onBatchMoveToMailbox?.(mailboxId))}
|
||||
{renderMoveList(onBatchMoveToMailbox)}
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
{canRemove && (
|
||||
@@ -407,9 +387,7 @@ export function EmailContextMenu({
|
||||
? copyTrees.map((target) => (
|
||||
<div key={target.accountId}>
|
||||
<ContextMenuHeader>{target.label}</ContextMenuHeader>
|
||||
{renderMailboxNodes(target.tree, target.targetIds, `copy-to:${target.accountId}:`, (mailboxId) =>
|
||||
onCopyToAccount(target.accountId, mailboxId)
|
||||
)}
|
||||
{renderCopyList(target)}
|
||||
</div>
|
||||
))
|
||||
: null;
|
||||
@@ -478,7 +456,7 @@ export function EmailContextMenu({
|
||||
case "move":
|
||||
return (
|
||||
<ContextMenuSubMenu key={entry.key} icon={entry.icon} label={entry.label} testId={entry.testId}>
|
||||
{renderMailboxNodes(moveTree, moveTargetIds, "move-to:", (mailboxId) => onMoveToMailbox?.(mailboxId))}
|
||||
{renderMoveList(onMoveToMailbox)}
|
||||
</ContextMenuSubMenu>
|
||||
);
|
||||
case "copy_to_account":
|
||||
|
||||
@@ -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<Record<MoreMenuSub, HTMLButtonElement | null>>;
|
||||
moveTree: MailboxNode[];
|
||||
moveTargetIds: Set<string>;
|
||||
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 ? (
|
||||
<MoveTargetList
|
||||
return !moveMenuIsEmpty(moveModel) && onMoveToMailbox ? (
|
||||
<MoveFolderList
|
||||
variant="panel"
|
||||
tree={moveTree}
|
||||
targetIds={moveTargetIds}
|
||||
model={moveModel}
|
||||
onPick={(mailboxId) => { 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 (
|
||||
<div key={target.accountId}>
|
||||
<div className="px-4 pt-3 pb-1 text-xs font-medium text-muted-foreground truncate" title={target.label}>{target.label}</div>
|
||||
<MoveTargetList
|
||||
<MoveFolderList
|
||||
variant="panel"
|
||||
tree={tree}
|
||||
targetIds={targetIds}
|
||||
noRecent
|
||||
model={model}
|
||||
testIdPrefix={`copy-to:${target.accountId}:`}
|
||||
onPick={(mailboxId) => { void copyToAccount([email], target.accountId, mailboxId); close(); }}
|
||||
/>
|
||||
</div>
|
||||
@@ -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 && <span className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('delete')}</span>}
|
||||
</Button>
|
||||
{/* Move to folder */}
|
||||
{moveTree.length > 0 && onMoveToMailbox && (
|
||||
{canMoveAnywhere && onMoveToMailbox && (
|
||||
<div data-overflow-item data-overflow-priority="5" className={cn("relative", mvViewer && "order-6")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -4487,11 +4488,11 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
{showToolbarLabels && <span className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('move')}</span>}
|
||||
</Button>
|
||||
<ContextMenu ref={moveMenu.menuRef} isOpen={moveMenuOpen} position={moveMenu.contextMenu.position} onClose={closeMoveMenu} label={t('move_to')}>
|
||||
<div className="w-56 max-h-72 overflow-y-auto">
|
||||
<MoveTargetList
|
||||
tree={moveTree}
|
||||
targetIds={moveTargetIds}
|
||||
<div className="w-64 max-w-full" data-reader-move-menu="">
|
||||
<MoveFolderList
|
||||
model={moveModel}
|
||||
onPick={(mailboxId) => { onMoveToMailbox(mailboxId); closeMoveMenu(); }}
|
||||
onChooseFolder={chooseMoveFolder ? () => { closeMoveMenu(); chooseMoveFolder({ emails: moveEmails, onMove: onMoveToMailbox }); } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
@@ -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}
|
||||
|
||||
@@ -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<string>;
|
||||
type MoveMenuEmails = Array<Pick<Email, "mailboxIds" | "sourceAccountId">>;
|
||||
|
||||
/** 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 `<prefix><mailbox id>`. */
|
||||
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<HTMLInputElement>(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 = <Icon className={cn(panel ? "w-5 h-5" : "w-4 h-4", "flex-shrink-0")} />;
|
||||
const labelOf = (node: Pick<MoveMenuNode, "role" | "name">) =>
|
||||
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 (
|
||||
<div key={node.id}>
|
||||
{targetIds.has(node.id) ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-mailbox-id={node.id}
|
||||
onClick={() => onPick(node.id)}
|
||||
className={cn(rowClass, "hover:bg-muted focus-visible:bg-muted focus-visible:outline-none")}
|
||||
style={{ paddingInlineStart: indent(depth) }}
|
||||
title={label}
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate" dir="auto">{label}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className={cn(rowClass, "text-muted-foreground")} style={{ paddingInlineStart: indent(depth) }}>
|
||||
{icon}
|
||||
<span className="truncate" dir="auto" title={label}>{label}</span>
|
||||
</div>
|
||||
)}
|
||||
{node.children.length > 0 && render(node.children, depth + 1)}
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-mailbox-id={node.id}
|
||||
data-testid={`${testIdPrefix}${node.id}`}
|
||||
disabled={!node.enabled}
|
||||
onClick={() => pick(node.id)}
|
||||
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-start flex items-center gap-3 hover:bg-muted focus-visible:bg-muted focus-visible:outline-none disabled:text-muted-foreground disabled:hover:bg-transparent"
|
||||
title={rowTitle}
|
||||
>
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="truncate" dir="auto" data-menu-label="">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ContextMenuItem
|
||||
key={key}
|
||||
icon={Icon}
|
||||
label={label}
|
||||
title={rowTitle}
|
||||
disabled={!node.enabled}
|
||||
testId={`${testIdPrefix}${node.id}`}
|
||||
onClick={() => 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 (
|
||||
<div key={node.id} data-native-submenu="">
|
||||
{row(node, label, node.id)}
|
||||
<div className={panel ? "ps-6" : "ps-4"}>{tree(node.children)}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return <>{render(tree, 0)}</>;
|
||||
const recent = !filtering && !noRecent ? model.recent : [];
|
||||
const nothing = nodes.length === 0 && others.length === 0;
|
||||
const separator = panel ? <div className="h-px bg-border my-1" role="separator" /> : <ContextMenuSeparator />;
|
||||
|
||||
return (
|
||||
<div data-move-folder-list="">
|
||||
{showFilter && (
|
||||
<div className={cn("sticky top-0 z-10 bg-background", panel ? "px-4 py-2" : "px-2 pt-1 pb-1")}>
|
||||
<label className="flex items-center gap-2 rounded-md border border-border px-2 focus-within:ring-2 focus-within:ring-ring">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const first = firstEnabled(nodes) ?? others.map((a) => firstEnabled(a.nodes)).find(Boolean);
|
||||
if (first) pick(first.id);
|
||||
}}
|
||||
placeholder={t("filter_placeholder")}
|
||||
aria-label={t("filter_placeholder")}
|
||||
className={cn("min-w-0 flex-1 bg-transparent text-sm focus:outline-none", panel ? "h-10" : "h-8")}
|
||||
data-testid="move-filter"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recent.length > 0 && (
|
||||
<div data-move-recent="">
|
||||
{panel ? (
|
||||
<div className="px-4 pt-3 pb-1 text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{t("recent")}
|
||||
</div>
|
||||
) : (
|
||||
<ContextMenuHeader>{t("recent")}</ContextMenuHeader>
|
||||
)}
|
||||
{recent.map((node) => {
|
||||
const label = [...node.path, labelOf(node)].join(" / ");
|
||||
return row({ ...node, children: [] }, label, `recent-${node.id}`, label);
|
||||
})}
|
||||
{separator}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tree(nodes)}
|
||||
|
||||
{nothing && (
|
||||
<div className={cn("text-sm text-muted-foreground", panel ? "px-4 py-3" : "px-3 py-2")}>{t("no_match")}</div>
|
||||
)}
|
||||
|
||||
{others.length > 0 && (variant === "menu" ? (
|
||||
<>
|
||||
{nodes.length > 0 && <ContextMenuSeparator />}
|
||||
{others.map((account) => (
|
||||
<ContextMenuSubMenu
|
||||
key={account.accountId}
|
||||
icon={Users}
|
||||
label={account.name}
|
||||
testId={`move-account:${account.accountId}`}
|
||||
>
|
||||
{tree(account.nodes)}
|
||||
</ContextMenuSubMenu>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
others.map((account) => (
|
||||
<div key={account.accountId} data-move-account={account.accountId}>
|
||||
{panel ? (
|
||||
<div className="px-4 pt-3 pb-1 text-xs font-medium text-muted-foreground truncate" title={account.name}>{account.name}</div>
|
||||
) : (
|
||||
<ContextMenuHeader>{account.name}</ContextMenuHeader>
|
||||
)}
|
||||
{tree(account.nodes)}
|
||||
</div>
|
||||
))
|
||||
))}
|
||||
|
||||
{onChooseFolder && moveMenuIsLong(model) && !filtering && (
|
||||
<>
|
||||
{separator}
|
||||
{panel ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={onChooseFolder}
|
||||
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-start flex items-center gap-3 hover:bg-muted"
|
||||
data-testid="move-choose-folder"
|
||||
>
|
||||
<FolderSearch className="w-5 h-5 flex-shrink-0" />
|
||||
<span data-menu-label="">{t("choose_folder")}</span>
|
||||
</button>
|
||||
) : (
|
||||
<ContextMenuItem icon={FolderSearch} label={t("choose_folder")} onClick={onChooseFolder} testId="move-choose-folder" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<MoveDialogState>((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 (
|
||||
<Dialog isOpen={!!email} onClose={onClose} title={t("move_to_dialog")} bodyClassName="px-2" size="sm">
|
||||
<div role="menu" aria-label={t("move_to_dialog")} className="max-h-[60vh] overflow-y-auto">
|
||||
<MoveTargetList
|
||||
tree={tree}
|
||||
targetIds={targetIds}
|
||||
<Dialog isOpen={emails.length > 0} onClose={onClose} title={t("move_to_dialog")} bodyClassName="px-2" size="sm">
|
||||
<div role="menu" aria-label={t("move_to_dialog")} className="max-h-[60vh] overflow-y-auto" data-testid="move-dialog">
|
||||
<MoveFolderList
|
||||
variant="dialog"
|
||||
alwaysFilter
|
||||
model={model}
|
||||
onPick={(mailboxId) => {
|
||||
onClose();
|
||||
onMove(mailboxId);
|
||||
@@ -45,3 +76,23 @@ export function MoveToDialog({ email, mailboxes, selectedMailbox, onClose, onMov
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<MoveToDialog
|
||||
emails={request.emails}
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox={selectedMailbox}
|
||||
onClose={close}
|
||||
onMove={request.onMove}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<null>();
|
||||
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 (
|
||||
<div key={node.id}>
|
||||
{isTarget ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => handleMove(node.id)}
|
||||
style={{ paddingInlineStart: `${12 + depth * 16}px` }}
|
||||
className={CONTEXT_MENU_ITEM_CLASS}
|
||||
>
|
||||
<Folder className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
style={{ paddingInlineStart: `${12 + depth * 16}px` }}
|
||||
className="flex items-center gap-2 pe-3 py-1.5 text-sm text-muted-foreground"
|
||||
>
|
||||
<Folder className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
)}
|
||||
{node.children.length > 0 && renderMoveNodes(node.children, depth + 1)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// 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}
|
||||
/>
|
||||
<div className="w-px h-6 bg-border mx-1 shrink-0" />
|
||||
{moveTree.length > 0 && (
|
||||
{!moveMenuIsEmpty(moveModel) && (
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
@@ -581,18 +532,14 @@ export function MvListToolbar({
|
||||
<ContextMenu ref={moveMenu.menuRef} isOpen={moveOpen} position={moveMenu.contextMenu.position} onClose={closeMove}>
|
||||
<div className="min-w-64" data-mv-move-menu="">
|
||||
<ContextMenuHeader>{tMenu("move_to")}</ContextMenuHeader>
|
||||
<label className="mx-3 my-1 flex items-center gap-2 border-b border-border">
|
||||
<input
|
||||
value={moveQuery}
|
||||
onChange={(e) => setMoveQuery(e.target.value)}
|
||||
className="h-8 min-w-0 flex-1 bg-transparent text-sm focus:outline-none"
|
||||
aria-label={tMenu("move_to")}
|
||||
/>
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</label>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{moveMatches ? renderMoveNodes(moveMatches) : renderMoveNodes(moveTree)}
|
||||
</div>
|
||||
<MoveFolderList
|
||||
model={moveModel}
|
||||
alwaysFilter
|
||||
onPick={handleMove}
|
||||
onChooseFolder={chooseMoveFolder
|
||||
? () => { closeMove(); chooseMoveFolder({ emails: selectedEmails, onMove: handleMove }); }
|
||||
: undefined}
|
||||
/>
|
||||
{onCreateFolder && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
|
||||
@@ -138,7 +138,7 @@ import { deliveryAddresses, resolveComposeAccountEmail } from "@/lib/reply-ident
|
||||
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder } from "@/components/icons";
|
||||
import { useRegisterCommands } from "@/lib/command-registry";
|
||||
import { MoveToDialog } from "@/components/email/move-to-dialog";
|
||||
import { MoveToDialog, MoveToDialogHost } from "@/components/email/move-to-dialog";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolbarButton, ToolbarSearchAction } from "@/components/ui/toolbar";
|
||||
@@ -6224,8 +6224,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
<MoveToDialogHost mailboxes={mailboxes} selectedMailbox={selectedMailbox} />
|
||||
{moveDialogEmail && <MoveToDialog
|
||||
email={moveDialogEmail}
|
||||
emails={[moveDialogEmail]}
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox={selectedMailbox}
|
||||
onClose={() => setMoveDialogEmail(null)}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { buildMoveMenu, filterMoveNodes, type MoveMenuNode } from '../move-menu';
|
||||
|
||||
const writable = { mayAddItems: true } as Mailbox['myRights'];
|
||||
const readOnly = { mayAddItems: false } as Mailbox['myRights'];
|
||||
|
||||
function own(id: string, name: string, extra: Partial<Mailbox> = {}): Mailbox {
|
||||
return {
|
||||
id, originalId: 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;
|
||||
}
|
||||
|
||||
function shared(account: string, id: string, name: string, extra: Partial<Mailbox> = {}): Mailbox {
|
||||
return own(`${account}:${id}`, name, { originalId: id, accountId: account, accountName: `${account.toLowerCase()}@example.org`, isShared: true, ...extra });
|
||||
}
|
||||
|
||||
// What a real server hands over: system folders with a high sortOrder, the
|
||||
// user's folders in creation order, nesting three deep.
|
||||
const mailboxes = [
|
||||
own('zzz', 'Zettelkasten'),
|
||||
own('trash', 'Trash', { role: 'trash', sortOrder: 50 }),
|
||||
own('work', 'Work'),
|
||||
own('inbox', 'Inbox', { role: 'inbox', sortOrder: 10 }),
|
||||
own('y10', '10', { parentId: 'receipts' }),
|
||||
own('y9', '9', { parentId: 'receipts' }),
|
||||
own('junk', 'Junk Mail', { role: 'junk', sortOrder: 40 }),
|
||||
own('receipts', 'Receipts'),
|
||||
own('sent', 'Sent Items', { role: 'sent', sortOrder: 30 }),
|
||||
own('drafts', 'Drafts', { role: 'drafts', sortOrder: 20 }),
|
||||
own('archive', 'Archive', { role: 'archive', sortOrder: 35 }),
|
||||
own('clients', 'Clients', { parentId: 'work' }),
|
||||
own('acme', 'Acme', { parentId: 'clients' }),
|
||||
own('apple', 'apple'),
|
||||
own('ro', 'Read only', { myRights: readOnly }),
|
||||
own('ro-child', 'Below read only', { parentId: 'ro' }),
|
||||
shared('S', 'inbox', 'Inbox', { role: 'inbox' }),
|
||||
shared('S', 'projects', 'Projects'),
|
||||
shared('T', 'x', 'Nothing writable', { myRights: readOnly }),
|
||||
];
|
||||
|
||||
const names = (nodes: MoveMenuNode[]) => nodes.map((n) => n.name);
|
||||
const ids = (nodes: MoveMenuNode[]): string[] => nodes.flatMap((n) => [n.id, ...ids(n.children)]);
|
||||
|
||||
describe('buildMoveMenu', () => {
|
||||
it('lists system folders first in mail-client order, then the rest by name', () => {
|
||||
const { nodes } = buildMoveMenu(mailboxes, { currentMailboxId: 'work', ownerAccountId: 'A', locale: 'en' });
|
||||
// Drafts can't take a moved message; the server's sortOrder doesn't count.
|
||||
expect(names(nodes)).toEqual(['Inbox', 'Sent Items', 'Archive', 'Junk Mail', 'Trash', 'apple', 'Read only', 'Receipts', 'Work', 'Zettelkasten']);
|
||||
});
|
||||
|
||||
it('keeps the nesting, sorts numbers as numbers, and keeps a read-only parent as a step', () => {
|
||||
const { nodes } = buildMoveMenu(mailboxes, { ownerAccountId: 'A', locale: 'en' });
|
||||
const receipts = nodes.find((n) => n.id === 'receipts')!;
|
||||
expect(names(receipts.children)).toEqual(['9', '10']);
|
||||
const work = nodes.find((n) => n.id === 'work')!;
|
||||
expect(work.children[0].children[0]).toMatchObject({ id: 'acme', path: ['Work', 'Clients'] });
|
||||
const ro = nodes.find((n) => n.id === 'ro')!;
|
||||
expect(ro.enabled).toBe(false);
|
||||
expect(ro.children.map((n) => n.id)).toEqual(['ro-child']);
|
||||
});
|
||||
|
||||
it('shows the open folder greyed when the messages are all in it', () => {
|
||||
const inInbox = { mailboxIds: { inbox: true } } as unknown as Email;
|
||||
const { nodes, targetCount } = buildMoveMenu(mailboxes, { currentMailboxId: 'inbox', ownerAccountId: 'A', emails: [inInbox] });
|
||||
expect(nodes[0]).toMatchObject({ id: 'inbox', enabled: false, current: true });
|
||||
expect(targetCount).toBe(13);
|
||||
});
|
||||
|
||||
it('lets a search hit from another folder move into the open one (#1452)', () => {
|
||||
const elsewhere = { mailboxIds: { work: true } } as unknown as Email;
|
||||
const { nodes } = buildMoveMenu(mailboxes, { currentMailboxId: 'inbox', ownerAccountId: 'A', emails: [elsewhere] });
|
||||
expect(nodes[0]).toMatchObject({ id: 'inbox', enabled: true, current: true });
|
||||
});
|
||||
|
||||
it('keeps only the message\'s own account at the top, the others under their names', () => {
|
||||
const own = buildMoveMenu(mailboxes, { ownerAccountId: 'A' });
|
||||
expect(ids(own.nodes).some((id) => id.startsWith('S:'))).toBe(false);
|
||||
// An account with nothing writable isn't offered.
|
||||
expect(own.others.map((a) => [a.accountId, a.name])).toEqual([['S', 's@example.org']]);
|
||||
|
||||
const inShared = buildMoveMenu(mailboxes, { ownerAccountId: 'S', currentMailboxId: 'S:inbox' });
|
||||
expect(inShared.accountId).toBe('S');
|
||||
expect(names(inShared.nodes)).toEqual(['Inbox', 'Projects']);
|
||||
expect(inShared.others.map((a) => [a.accountId, a.name])).toEqual([['A', 'me@example.org']]);
|
||||
});
|
||||
|
||||
it('offers the last folders moved to that are still there and can still be picked', () => {
|
||||
const inInbox = { mailboxIds: { inbox: true } } as unknown as Email;
|
||||
const recentIds = (account: string | undefined) => (account === 'A' ? ['gone', 'inbox', 'acme', 'S:projects', 'sent', 'trash'] : []);
|
||||
const { recent } = buildMoveMenu(mailboxes, { currentMailboxId: 'inbox', ownerAccountId: 'A', emails: [inInbox], recentIds });
|
||||
// A deleted folder and the open one drop out; three show.
|
||||
expect(recent.map((n) => n.id)).toEqual(['acme', 'S:projects', 'sent']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterMoveNodes', () => {
|
||||
it('keeps the matches with the folders above them, ignoring case and accents', () => {
|
||||
const { nodes } = buildMoveMenu([...mailboxes, own('kueche', 'Küche')], { ownerAccountId: 'A', locale: 'de' });
|
||||
const label = (n: MoveMenuNode) => n.name;
|
||||
expect(ids(filterMoveNodes(nodes, 'ACM', label))).toEqual(['work', 'clients', 'acme']);
|
||||
expect(ids(filterMoveNodes(nodes, 'kuche', label))).toEqual(['kueche']);
|
||||
expect(filterMoveNodes(nodes, ' ', label)).toBe(nodes);
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,19 @@ export function readMenu(root: HTMLElement): ReadMenu {
|
||||
continue;
|
||||
}
|
||||
if (child.dataset.submenuPanel !== undefined) continue;
|
||||
if (child.dataset.nativeSubmenu !== undefined) {
|
||||
// A folder with subfolders (a Move list): a native menu can't indent,
|
||||
// so it's a submenu with the folder itself first, as Thunderbird has it.
|
||||
const group: DesktopNativeMenuItem[] = [];
|
||||
walk(child, group);
|
||||
const [head, ...rest] = group;
|
||||
if (head && rest.length && head.type !== 'separator' && head.label) {
|
||||
into.push({ label: head.label, submenu: [head, { type: 'separator' }, ...rest] });
|
||||
} else {
|
||||
into.push(...group);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (child.dataset.contextMenuHeader !== undefined) {
|
||||
const label = labelOf(child);
|
||||
if (label) into.push({ id: `h${next++}`, label, enabled: false });
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* What every "Move to" list shows: the context menu (and the desktop app's
|
||||
* native copy of it), the reader's Move button, the phone panel, the
|
||||
* selection toolbar and the Move dialog. One model, so they can't drift.
|
||||
*
|
||||
* - The message's own account at the top. Other accounts a move can reach
|
||||
* (shared ones) come after it, each under its own name.
|
||||
* - System folders first, in the order mail clients use (Inbox, Drafts,
|
||||
* Sent, Archive, Spam, Trash), then the user's folders by name, sorted
|
||||
* for the interface language. The server's sortOrder doesn't count here:
|
||||
* the sidebar honours it, a Move list is for finding a folder by name.
|
||||
* - Nesting kept. The open folder stays in the list, disabled, when the
|
||||
* messages are all in it (#1452: a search hit from elsewhere can move in).
|
||||
* - The last few folders moved to, per account, as "Recent".
|
||||
*/
|
||||
|
||||
import type { Email, Mailbox } from "./jmap/types";
|
||||
import { emailInMailbox } from "./move-targets";
|
||||
|
||||
export const SYSTEM_FOLDER_ORDER = ["inbox", "drafts", "sent", "archive", "junk", "trash"] as const;
|
||||
|
||||
/** Past this many folders to pick from, the list gets a filter field. */
|
||||
export const MOVE_FILTER_THRESHOLD = 12;
|
||||
|
||||
/** How many recent folders show. */
|
||||
export const MOVE_RECENT_SHOWN = 3;
|
||||
|
||||
export interface MoveMenuNode {
|
||||
/** The mailbox id, as the store knows it. */
|
||||
id: string;
|
||||
name: string;
|
||||
role?: string | null;
|
||||
/** Can be picked. */
|
||||
enabled: boolean;
|
||||
/** The open folder, which the messages are already in. */
|
||||
current: boolean;
|
||||
/** Names of the folders above it, outermost first. */
|
||||
path: string[];
|
||||
children: MoveMenuNode[];
|
||||
}
|
||||
|
||||
export interface MoveMenuAccount {
|
||||
accountId: string;
|
||||
name: string;
|
||||
nodes: MoveMenuNode[];
|
||||
}
|
||||
|
||||
export interface MoveMenuModel {
|
||||
/** The account the messages are in; recents are kept per account. */
|
||||
accountId?: string;
|
||||
nodes: MoveMenuNode[];
|
||||
/** Other accounts the messages can be moved to, each a submenu. */
|
||||
others: MoveMenuAccount[];
|
||||
recent: MoveMenuNode[];
|
||||
/** Pickable folders in the own account. */
|
||||
targetCount: number;
|
||||
}
|
||||
|
||||
export interface MoveMenuOptions {
|
||||
currentMailboxId?: string | null;
|
||||
/** The account the messages are in (resolveMoveOwnerAccountId). */
|
||||
ownerAccountId?: string;
|
||||
/** The messages being moved; without them the open folder counts as theirs. */
|
||||
emails?: Array<Pick<Email, "mailboxIds" | "sourceAccountId">>;
|
||||
/** The folders last moved to in an account, most recent first (readRecentMoveTargets). */
|
||||
recentIds?: (accountId: string | undefined) => string[];
|
||||
/** The interface language, for sorting names. */
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
function rolePosition(role: string | null | undefined): number {
|
||||
if (!role) return SYSTEM_FOLDER_ORDER.length;
|
||||
const normal = role === "spam" ? "junk" : role;
|
||||
const at = (SYSTEM_FOLDER_ORDER as readonly string[]).indexOf(normal);
|
||||
return at < 0 ? SYSTEM_FOLDER_ORDER.length : at;
|
||||
}
|
||||
|
||||
export function compareMoveNodes(collator: Intl.Collator) {
|
||||
return (a: Pick<MoveMenuNode, "role" | "name">, b: Pick<MoveMenuNode, "role" | "name">): number => {
|
||||
const byRole = rolePosition(a.role) - rolePosition(b.role);
|
||||
if (byRole !== 0) return byRole;
|
||||
return collator.compare(a.name, b.name);
|
||||
};
|
||||
}
|
||||
|
||||
function makeCollator(locale?: string): Intl.Collator {
|
||||
try {
|
||||
return new Intl.Collator(locale || undefined, { numeric: true, sensitivity: "base" });
|
||||
} catch {
|
||||
return new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
}
|
||||
|
||||
/** A target: may take mail, isn't Drafts, isn't a virtual header. */
|
||||
function isTarget(mailbox: Mailbox): boolean {
|
||||
return mailbox.role !== "drafts" && !mailbox.id.startsWith("shared-") && !!mailbox.myRights?.mayAddItems;
|
||||
}
|
||||
|
||||
/** One account's folders as a sorted tree, with only the branches that lead somewhere. */
|
||||
function accountTree(
|
||||
boxes: Mailbox[],
|
||||
currentId: string | undefined,
|
||||
hideCurrent: boolean,
|
||||
collator: Intl.Collator,
|
||||
): MoveMenuNode[] {
|
||||
const ids = new Set(boxes.map((m) => m.id));
|
||||
const childrenOf = new Map<string, Mailbox[]>();
|
||||
const roots: Mailbox[] = [];
|
||||
for (const box of boxes) {
|
||||
if (box.parentId && ids.has(box.parentId) && box.parentId !== box.id) {
|
||||
const list = childrenOf.get(box.parentId) ?? [];
|
||||
list.push(box);
|
||||
childrenOf.set(box.parentId, list);
|
||||
} else {
|
||||
roots.push(box);
|
||||
}
|
||||
}
|
||||
const compare = compareMoveNodes(collator);
|
||||
const seen = new Set<string>();
|
||||
const build = (list: Mailbox[], path: string[]): MoveMenuNode[] => {
|
||||
const out: MoveMenuNode[] = [];
|
||||
for (const box of list) {
|
||||
if (seen.has(box.id)) continue;
|
||||
seen.add(box.id);
|
||||
const current = box.id === currentId;
|
||||
const children = build(childrenOf.get(box.id) ?? [], [...path, box.name]);
|
||||
const enabled = isTarget(box) && !(current && hideCurrent);
|
||||
// A folder that can't take mail stays as a step to the ones below it;
|
||||
// the open folder stays so the list reads like the sidebar.
|
||||
if (!enabled && !children.length && !(current && isTarget(box))) continue;
|
||||
out.push({ id: box.id, name: box.name, role: box.role ?? null, enabled, current, path, children });
|
||||
}
|
||||
return out.sort(compare);
|
||||
};
|
||||
return build(roots, []);
|
||||
}
|
||||
|
||||
function walk(nodes: MoveMenuNode[], visit: (node: MoveMenuNode) => void): void {
|
||||
for (const node of nodes) {
|
||||
visit(node);
|
||||
walk(node.children, visit);
|
||||
}
|
||||
}
|
||||
|
||||
export function countTargets(nodes: MoveMenuNode[]): number {
|
||||
let count = 0;
|
||||
walk(nodes, (node) => {
|
||||
if (node.enabled) count++;
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
export function buildMoveMenu(mailboxes: Mailbox[], opts: MoveMenuOptions = {}): MoveMenuModel {
|
||||
const collator = makeCollator(opts.locale);
|
||||
const current = mailboxes.find((m) => m.id === opts.currentMailboxId);
|
||||
const emails = opts.emails ?? [];
|
||||
const hideCurrent = !!current && (emails.length === 0 || emails.every((e) => emailInMailbox(e, current)));
|
||||
|
||||
// Folders by account: the login's own, then each shared account.
|
||||
const groups = new Map<string, { name: string; shared: boolean; boxes: Mailbox[] }>();
|
||||
const OWN = "__own_account__";
|
||||
for (const box of mailboxes) {
|
||||
if (box.id.startsWith("shared-")) continue;
|
||||
const key = box.isShared ? box.accountId ?? "unknown" : OWN;
|
||||
let group = groups.get(key);
|
||||
if (!group) {
|
||||
group = { name: box.accountName || box.accountId || "", shared: !!box.isShared, boxes: [] };
|
||||
groups.set(key, group);
|
||||
}
|
||||
if (!group.name && box.accountName) group.name = box.accountName;
|
||||
group.boxes.push(box);
|
||||
}
|
||||
const ownAccountId = groups.get(OWN)?.boxes.find((m) => m.accountId)?.accountId;
|
||||
const ownerKey = opts.ownerAccountId && groups.get(opts.ownerAccountId)?.shared ? opts.ownerAccountId : OWN;
|
||||
|
||||
const treeOf = (key: string) => {
|
||||
const group = groups.get(key);
|
||||
return group ? accountTree(group.boxes, current?.id, hideCurrent, collator) : [];
|
||||
};
|
||||
const nodes = treeOf(ownerKey);
|
||||
const others: MoveMenuAccount[] = [];
|
||||
for (const [key, group] of groups) {
|
||||
if (key === ownerKey) continue;
|
||||
const tree = treeOf(key);
|
||||
if (countTargets(tree) === 0) continue;
|
||||
others.push({ accountId: key === OWN ? ownAccountId ?? "" : key, name: group.name, nodes: tree });
|
||||
}
|
||||
// The login's own account first among the others, then shared ones by name.
|
||||
others.sort((a, b) => {
|
||||
const aOwn = a.accountId === ownAccountId ? 0 : 1;
|
||||
const bOwn = b.accountId === ownAccountId ? 0 : 1;
|
||||
return aOwn - bOwn || collator.compare(a.name, b.name);
|
||||
});
|
||||
|
||||
const accountId = ownerKey === OWN ? ownAccountId : ownerKey;
|
||||
const byId = new Map<string, MoveMenuNode>();
|
||||
walk(nodes, (node) => byId.set(node.id, node));
|
||||
for (const other of others) walk(other.nodes, (node) => byId.set(node.id, node));
|
||||
const recent: MoveMenuNode[] = [];
|
||||
for (const id of opts.recentIds?.(accountId) ?? []) {
|
||||
const node = byId.get(id);
|
||||
if (node?.enabled && !recent.includes(node)) recent.push(node);
|
||||
if (recent.length >= MOVE_RECENT_SHOWN) break;
|
||||
}
|
||||
|
||||
return { accountId, nodes, others, recent, targetCount: countTargets(nodes) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The nodes whose name has the query in it, with the folders above them kept
|
||||
* so a match still shows where it lives. Case and accents don't count.
|
||||
*/
|
||||
export function filterMoveNodes(nodes: MoveMenuNode[], query: string, labelOf: (node: MoveMenuNode) => string, locale?: string): MoveMenuNode[] {
|
||||
const q = fold(query.trim(), locale);
|
||||
if (!q) return nodes;
|
||||
const keep = (list: MoveMenuNode[]): MoveMenuNode[] =>
|
||||
list.reduce<MoveMenuNode[]>((acc, node) => {
|
||||
const children = keep(node.children);
|
||||
if (fold(labelOf(node), locale).includes(q) || children.length) acc.push({ ...node, children });
|
||||
return acc;
|
||||
}, []);
|
||||
return keep(nodes);
|
||||
}
|
||||
|
||||
function fold(text: string, locale?: string): string {
|
||||
let lower: string;
|
||||
try {
|
||||
lower = text.toLocaleLowerCase(locale || undefined);
|
||||
} catch {
|
||||
lower = text.toLowerCase();
|
||||
}
|
||||
return lower.normalize("NFD").replace(/\p{M}/gu, "");
|
||||
}
|
||||
|
||||
/** The first folder that can be picked, in list order. */
|
||||
export function firstEnabled(nodes: MoveMenuNode[]): MoveMenuNode | undefined {
|
||||
for (const node of nodes) {
|
||||
if (node.enabled) return node;
|
||||
const below = firstEnabled(node.children);
|
||||
if (below) return below;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Any folder tree in Move-list order (system folders first, then by name),
|
||||
* for pickers that build their own tree, such as the Rules menu's.
|
||||
*/
|
||||
export function sortFolderTree<T extends { role?: string | null; name: string; children: T[] }>(nodes: T[], locale?: string): T[] {
|
||||
const compare = compareMoveNodes(makeCollator(locale));
|
||||
const sort = (list: T[]): T[] => [...list].map((node) => ({ ...node, children: sort(node.children) })).sort(compare);
|
||||
return sort(nodes);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* The folders messages were last moved to, per account, most recent first.
|
||||
* Kept on this device only; the Move lists show the few that still exist.
|
||||
*/
|
||||
|
||||
const KEY = "bulwark.moveRecent.v1";
|
||||
const KEPT = 8;
|
||||
|
||||
type Store = Record<string, string[]>;
|
||||
|
||||
function read(): Store {
|
||||
try {
|
||||
const raw = typeof localStorage === "undefined" ? null : localStorage.getItem(KEY);
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : null;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||
const out: Store = {};
|
||||
for (const [account, ids] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (Array.isArray(ids)) out[account] = ids.filter((id): id is string => typeof id === "string").slice(0, KEPT);
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function readRecentMoveTargets(accountId: string | undefined): string[] {
|
||||
if (!accountId) return [];
|
||||
return read()[accountId] ?? [];
|
||||
}
|
||||
|
||||
export function noteMoveTarget(accountId: string | undefined, mailboxId: string): void {
|
||||
if (!accountId || !mailboxId) return;
|
||||
const store = read();
|
||||
store[accountId] = [mailboxId, ...(store[accountId] ?? []).filter((id) => id !== mailboxId)].slice(0, KEPT);
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(store));
|
||||
} catch {
|
||||
// Storage full or off: the list just has no recents.
|
||||
}
|
||||
}
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "البحث عن رسائل من {name}"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "ابحث عن مجلد",
|
||||
"recent": "الأخيرة",
|
||||
"no_match": "لا يوجد مجلد مطابق",
|
||||
"choose_folder": "اختيار مجلد…",
|
||||
"current_folder": "موجودة بالفعل في {folder}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "تمييز المجلد كمقروء",
|
||||
"mark_folder_tree_read": "تمييز المجلد والمجلدات الفرعية كمقروءة",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "Cerca correus de {name}"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "Cerca una carpeta",
|
||||
"recent": "Recents",
|
||||
"no_match": "Cap carpeta coincideix",
|
||||
"choose_folder": "Tria una carpeta…",
|
||||
"current_folder": "Ja és a {folder}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Marca la carpeta com a llegida",
|
||||
"mark_folder_tree_read": "Marca la carpeta i les subcarpetes com a llegides",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "Najít e-maily od {name}"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "Najít složku",
|
||||
"recent": "Nedávné",
|
||||
"no_match": "Žádná složka neodpovídá",
|
||||
"choose_folder": "Vybrat složku…",
|
||||
"current_folder": "Už je ve složce {folder}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Označit složku jako přečtenou",
|
||||
"mark_folder_tree_read": "Označit složku a podsložky jako přečtené",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "Find e-mails fra {name}"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "Find en mappe",
|
||||
"recent": "Seneste",
|
||||
"no_match": "Ingen mapper passer",
|
||||
"choose_folder": "Vælg mappe…",
|
||||
"current_folder": "Allerede i {folder}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Markér mappe som læst",
|
||||
"mark_folder_tree_read": "Markér mappe & undermapper som læst",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "E-Mails von {name} finden"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "Ordner suchen",
|
||||
"recent": "Zuletzt verwendet",
|
||||
"no_match": "Kein Ordner gefunden",
|
||||
"choose_folder": "Ordner auswählen…",
|
||||
"current_folder": "Bereits in {folder}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Ordner als gelesen markieren",
|
||||
"mark_folder_tree_read": "Ordner & Unterordner als gelesen markieren",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "Find emails from {name}"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "Find a folder",
|
||||
"recent": "Recent",
|
||||
"no_match": "No folder matches",
|
||||
"choose_folder": "Choose folder…",
|
||||
"current_folder": "Already in {folder}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
"mark_folder_tree_read": "Mark folder & subfolders as read",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "Buscar correos de {name}"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "Buscar una carpeta",
|
||||
"recent": "Recientes",
|
||||
"no_match": "Ninguna carpeta coincide",
|
||||
"choose_folder": "Elegir carpeta…",
|
||||
"current_folder": "Ya está en {folder}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Marcar carpeta como leída",
|
||||
"mark_folder_tree_read": "Marcar carpeta y subcarpetas como leídas",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "Leia kirjad saatjalt {name}"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "Otsi kausta",
|
||||
"recent": "Viimati kasutatud",
|
||||
"no_match": "Ükski kaust ei sobi",
|
||||
"choose_folder": "Vali kaust…",
|
||||
"current_folder": "Juba kaustas {folder}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Märgi kaust loetuks",
|
||||
"mark_folder_tree_read": "Märgi kaust ja alamkaustad loetuks",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "یافتن ایمیلهای {name}"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "یافتن پوشه",
|
||||
"recent": "اخیر",
|
||||
"no_match": "هیچ پوشهای مطابق نیست",
|
||||
"choose_folder": "انتخاب پوشه…",
|
||||
"current_folder": "از قبل در {folder}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "علامتگذاری پوشه خوانده شده",
|
||||
"mark_folder_tree_read": "علامتگذاری پوشه و زیرپوشهها",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "Rechercher les e-mails de {name}"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "Rechercher un dossier",
|
||||
"recent": "Récents",
|
||||
"no_match": "Aucun dossier ne correspond",
|
||||
"choose_folder": "Choisir un dossier…",
|
||||
"current_folder": "Déjà dans {folder}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Marquer le dossier comme lu",
|
||||
"mark_folder_tree_read": "Marquer le dossier et les sous-dossiers comme lus",
|
||||
|
||||
@@ -3592,6 +3592,13 @@
|
||||
},
|
||||
"find_emails_from": "חיפוש הודעות מ{name}"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "חיפוש תיקייה",
|
||||
"recent": "אחרונות",
|
||||
"no_match": "אין תיקייה תואמת",
|
||||
"choose_folder": "בחירת תיקייה…",
|
||||
"current_folder": "כבר בתיקייה {folder}"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "קיצורי מקלדת",
|
||||
"tip": "לחץ על? בכל עת כדי להראות את העזרה הזו",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "{name} e-mailjeinek keresése"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "Mappa keresése",
|
||||
"recent": "Legutóbbiak",
|
||||
"no_match": "Nincs ilyen mappa",
|
||||
"choose_folder": "Mappa kiválasztása…",
|
||||
"current_folder": "Már itt van: {folder}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mappa olvasottnak jelölése",
|
||||
"mark_folder_tree_read": "Mappa és almappák olvasottnak jelölése",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "Trova email da {name}"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "Cerca una cartella",
|
||||
"recent": "Recenti",
|
||||
"no_match": "Nessuna cartella corrisponde",
|
||||
"choose_folder": "Scegli cartella…",
|
||||
"current_folder": "Già in {folder}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Segna cartella come letta",
|
||||
"mark_folder_tree_read": "Segna cartella e sottocartelle come lette",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "{name} からのメールを検索"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "フォルダーを検索",
|
||||
"recent": "最近使用",
|
||||
"no_match": "一致するフォルダーはありません",
|
||||
"choose_folder": "フォルダーを選択…",
|
||||
"current_folder": "すでに {folder} にあります"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "フォルダーを既読にする",
|
||||
"mark_folder_tree_read": "フォルダーとサブフォルダーを既読にする",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "{name}님이 보낸 이메일 찾기"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "폴더 찾기",
|
||||
"recent": "최근",
|
||||
"no_match": "일치하는 폴더 없음",
|
||||
"choose_folder": "폴더 선택…",
|
||||
"current_folder": "이미 {folder}에 있음"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "폴더를 읽음으로 표시",
|
||||
"mark_folder_tree_read": "폴더 및 하위 폴더를 읽음으로 표시",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "Atrast e-pastus no {name}"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "Meklēt mapi",
|
||||
"recent": "Nesen izmantotās",
|
||||
"no_match": "Neviena mape neatbilst",
|
||||
"choose_folder": "Izvēlēties mapi…",
|
||||
"current_folder": "Jau mapē {folder}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Atzīmēt mapi kā lasītu",
|
||||
"mark_folder_tree_read": "Atzīmēt mapi un apakšmapes kā lasītas",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "{name}-с ирсэн имэйлүүдийг хайх"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "Хавтас хайх",
|
||||
"recent": "Сүүлд ашигласан",
|
||||
"no_match": "Тохирох хавтас алга",
|
||||
"choose_folder": "Хавтас сонгох…",
|
||||
"current_folder": "Аль хэдийн {folder}-д байна"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Хавтсыг уншсан гэж тэмдэглэх",
|
||||
"mark_folder_tree_read": "Хавтас болон дэд хавтаснуудыг уншсан гэж тэмдэглэх",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "Finn e-poster fra {name}"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "Finn en mappe",
|
||||
"recent": "Nylig brukt",
|
||||
"no_match": "Ingen mapper passer",
|
||||
"choose_folder": "Velg mappe…",
|
||||
"current_folder": "Allerede i {folder}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Merk mappen som lest",
|
||||
"mark_folder_tree_read": "Merk mappe og undermapper som lest",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "E-mails van {name} zoeken"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "Map zoeken",
|
||||
"recent": "Recent",
|
||||
"no_match": "Geen map gevonden",
|
||||
"choose_folder": "Map kiezen…",
|
||||
"current_folder": "Staat al in {folder}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Map markeren als gelezen",
|
||||
"mark_folder_tree_read": "Map en submappen markeren als gelezen",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "Znajdź e-maile od {name}"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "Znajdź folder",
|
||||
"recent": "Ostatnie",
|
||||
"no_match": "Brak pasującego folderu",
|
||||
"choose_folder": "Wybierz folder…",
|
||||
"current_folder": "Już w folderze {folder}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Oznacz folder jako przeczytany",
|
||||
"mark_folder_tree_read": "Oznacz folder i podfoldery jako przeczytane",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "Encontrar e-mails de {name}"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "Procurar uma pasta",
|
||||
"recent": "Recentes",
|
||||
"no_match": "Nenhuma pasta corresponde",
|
||||
"choose_folder": "Escolher pasta…",
|
||||
"current_folder": "Já está em {folder}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Marcar pasta como lida",
|
||||
"mark_folder_tree_read": "Marcar pasta e subpastas como lidas",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "Găsește e-mailurile de la {name}"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "Caută un folder",
|
||||
"recent": "Recente",
|
||||
"no_match": "Niciun folder nu se potrivește",
|
||||
"choose_folder": "Alege folderul…",
|
||||
"current_folder": "Deja în {folder}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Marcați folderul ca citit",
|
||||
"mark_folder_tree_read": "Marcați folderul și subfolderele ca citite",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "Найти письма от {name}"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "Найти папку",
|
||||
"recent": "Недавние",
|
||||
"no_match": "Подходящих папок нет",
|
||||
"choose_folder": "Выбрать папку…",
|
||||
"current_folder": "Уже в папке {folder}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Отметить папку как прочитанную",
|
||||
"mark_folder_tree_read": "Отметить папку и вложенные папки как прочитанные",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "Nájsť e-maily od {name}"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "Nájsť priečinok",
|
||||
"recent": "Nedávne",
|
||||
"no_match": "Žiadny priečinok nevyhovuje",
|
||||
"choose_folder": "Vybrať priečinok…",
|
||||
"current_folder": "Už je v priečinku {folder}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Označiť priečinok ako prečítaný",
|
||||
"mark_folder_tree_read": "Označiť priečinok a podpriečinky ako prečítané",
|
||||
|
||||
@@ -915,6 +915,13 @@
|
||||
},
|
||||
"find_emails_from": "ค้นหาอีเมลจาก {name}"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "ค้นหาโฟลเดอร์",
|
||||
"recent": "ล่าสุด",
|
||||
"no_match": "ไม่มีโฟลเดอร์ที่ตรงกัน",
|
||||
"choose_folder": "เลือกโฟลเดอร์…",
|
||||
"current_folder": "อยู่ใน {folder} แล้ว"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "ทำเครื่องหมายโฟลเดอร์ว่าอ่านแล้ว",
|
||||
"mark_folder_tree_read": "ทำเครื่องหมายโฟลเดอร์และโฟลเดอร์ย่อยว่าอ่านแล้ว",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "{name} adlı kişiden gelen e-postaları bul"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "Klasör bul",
|
||||
"recent": "Son kullanılanlar",
|
||||
"no_match": "Eşleşen klasör yok",
|
||||
"choose_folder": "Klasör seç…",
|
||||
"current_folder": "Zaten {folder} içinde"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Klasörü okundu olarak işaretle",
|
||||
"mark_folder_tree_read": "Klasörü ve alt klasörleri okundu olarak işaretle",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "Знайти листи від {name}"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "Знайти папку",
|
||||
"recent": "Нещодавні",
|
||||
"no_match": "Немає відповідних папок",
|
||||
"choose_folder": "Вибрати папку…",
|
||||
"current_folder": "Уже в папці {folder}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Позначити папку як прочитану",
|
||||
"mark_folder_tree_read": "Позначити папку та вкладені папки як прочитані",
|
||||
|
||||
@@ -3660,6 +3660,13 @@
|
||||
},
|
||||
"find_emails_from": "Tìm email từ {name}"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "Tìm thư mục",
|
||||
"recent": "Gần đây",
|
||||
"no_match": "Không có thư mục phù hợp",
|
||||
"choose_folder": "Chọn thư mục…",
|
||||
"current_folder": "Đã ở trong {folder}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Đánh dấu thư mục là đã đọc",
|
||||
"mark_folder_tree_read": "Đánh dấu thư mục và thư mục con là đã đọc",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "尋找來自 {name} 的郵件"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "尋找資料夾",
|
||||
"recent": "最近使用",
|
||||
"no_match": "沒有符合的資料夾",
|
||||
"choose_folder": "選擇資料夾…",
|
||||
"current_folder": "已在「{folder}」中"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "將信件匣標示為已讀",
|
||||
"mark_folder_tree_read": "將信件匣與子信件匣標示為已讀",
|
||||
|
||||
@@ -3648,6 +3648,13 @@
|
||||
},
|
||||
"find_emails_from": "查找来自 {name} 的邮件"
|
||||
},
|
||||
"menuMove": {
|
||||
"filter_placeholder": "查找文件夹",
|
||||
"recent": "最近使用",
|
||||
"no_match": "没有匹配的文件夹",
|
||||
"choose_folder": "选择文件夹…",
|
||||
"current_folder": "已在“{folder}”中"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "将文件夹标记为已读",
|
||||
"mark_folder_tree_read": "将文件夹及子文件夹标记为已读",
|
||||
|
||||
Reference in New Issue
Block a user