From 6b3eda6d7970cd20d20acf901c38eece9302e8c4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:11:19 +0100 Subject: [PATCH] fix: name folders in the reader's Move menus the way the sidebar does, with the Spam icon and right-to-left indents --- .../__tests__/more-actions-menu.test.tsx | 3 +- .../email/__tests__/move-target-list.test.tsx | 36 +++++ components/email/email-context-menu.tsx | 25 +--- components/email/email-viewer.tsx | 134 +++--------------- components/email/move-target-list.tsx | 76 ++++++++++ 5 files changed, 136 insertions(+), 138 deletions(-) create mode 100644 components/email/__tests__/move-target-list.test.tsx create mode 100644 components/email/move-target-list.tsx diff --git a/components/email/__tests__/more-actions-menu.test.tsx b/components/email/__tests__/more-actions-menu.test.tsx index 086347a38..caa6676bf 100644 --- a/components/email/__tests__/more-actions-menu.test.tsx +++ b/components/email/__tests__/more-actions-menu.test.tsx @@ -125,7 +125,8 @@ describe('email viewer More actions menu', () => { flushFrames(); const subMenu = screen.getByRole('menu', { name: 'move_to' }); - tap(within(subMenu).getByRole('menuitem', { name: /Archive/ })); + // Named as the sidebar names it (the i18n mock renders the key). + tap(within(subMenu).getByRole('menuitem', { name: /mailboxes.archive/ })); expect(onMoveToMailbox).toHaveBeenCalledWith('archive'); }); diff --git a/components/email/__tests__/move-target-list.test.tsx b/components/email/__tests__/move-target-list.test.tsx new file mode 100644 index 000000000..a85de34bc --- /dev/null +++ b/components/email/__tests__/move-target-list.test.tsx @@ -0,0 +1,36 @@ +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'; + +// 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. + +const node = (id: string, name: string, role: string | undefined, children: MailboxNode[] = []) => + ({ id, name, role, children, depth: 0 }) as unknown as MailboxNode; + +const tree = [ + node('sent', 'Sent Items', 'sent'), + node('junk', 'Junk Mail', 'junk'), + node('archive', 'Archive', 'archive', [node('y2025', '2025', undefined)]), +]; + +describe('MoveTargetList', () => { + it('shows the localized folder names and moves on a pick', () => { + const onPick = vi.fn(); + render(); + // The i18n mock returns the key: the role's name, not the server's. + expect(screen.queryByText('Sent Items')).toBeNull(); + expect(screen.getByText('mailboxes.sent')).toBeTruthy(); + fireEvent.click(screen.getByText('mailboxes.spam')); + expect(onPick).toHaveBeenCalledWith('junk'); + }); + + it('indents subfolders with a logical padding', () => { + render(); + const row = screen.getByText('2025').closest('button')!; + expect(row.style.paddingInlineStart).toBe('1.75rem'); + expect(row.style.paddingLeft).toBe(''); + }); +}); diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 866131191..e91de40e9 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -28,10 +28,6 @@ import { FolderInput, FolderX, Tag, - Inbox, - Send, - File, - Folder, ShieldAlert, ShieldCheck, EditIcon, @@ -48,6 +44,7 @@ import { useCopyLink } from "@/hooks/use-copy-link"; 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 { getEmailTagIds } from "@/lib/thread-utils"; import { TagPicker } from "./tag-picker"; import { RulesContextSubMenu } from "./rules-menu"; @@ -133,24 +130,6 @@ interface EmailContextMenuProps { onBatchForwardAsAttachment?: () => void; } -// Get mailbox icon based on role -const getMailboxIcon = (role?: string) => { - switch (role) { - case "inbox": - return Inbox; - case "sent": - return Send; - case "drafts": - return File; - case "trash": - return Trash2; - case "archive": - return Archive; - default: - return Folder; - } -}; - export function EmailContextMenu({ email, position, @@ -259,7 +238,7 @@ export function EmailContextMenu({ onPick: (mailboxId: string) => void, ): React.ReactNode => nodes.map((node) => { - const Icon = getMailboxIcon(node.role); + const Icon = moveMailboxIcon(node.role); const nodeLabel = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`)); return (
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index fdbdfe1ed..cefae0293 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -21,7 +21,7 @@ import { SubMenuPanel } from "@/components/ui/context-menu"; import { useBottomControls } from "@/hooks/use-bottom-controls"; import { ReaderSelectionMenu, useReaderSelectionMenu, watchBodySelection, watchElementSelection } from "./reader-selection-menu"; import { onPanelRequest } from "@/lib/plugin-sandbox/panel-requests"; -import { formatFileSize, cn, MailboxNode, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview } 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"; @@ -36,6 +36,7 @@ import { getSecurityStatus, extractListHeaders, getSenderVerification } from "@/ 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 { ScheduleSendDialog } from "./schedule-send-dialog"; import { Reply, @@ -84,8 +85,6 @@ import { Send, FolderInput, FolderX, - Inbox, - Folder, Sun, Upload, Moon, @@ -1645,18 +1644,6 @@ export function EmailViewer({ [mailboxes, selectedMailbox, moveOwnerAccountId], ); - // Get mailbox icon based on role - const getMoveMailboxIcon = (role?: string) => { - switch (role) { - case "inbox": return Inbox; - case "sent": return Send; - case "drafts": return File; - case "trash": return Trash2; - case "archive": return Archive; - default: return Folder; - } - }; - // Close dropdown menus on click outside useEffect(() => { if (!moreMenuOpen && !tagMenuOpen && !moveMenuOpen) return; @@ -3993,39 +3980,11 @@ export function EmailViewer({ aria-label={t('move_to')} className="absolute end-0 top-full mt-1 py-1 w-48 max-h-72 overflow-y-auto bg-background rounded-lg shadow-lg border border-border z-10" > - {(() => { - const renderNodes = (nodes: MailboxNode[], depth = 0) => { - return nodes.map((node) => { - const Icon = getMoveMailboxIcon(node.role); - const isTarget = moveTargetIds.has(node.id); - return ( -
- {isTarget ? ( - - ) : ( -
- - {node.name} -
- )} - {node.children.length > 0 && renderNodes(node.children, depth + 1)} -
- ); - }); - }; - return renderNodes(moveTree); - })()} + { onMoveToMailbox(mailboxId); setMoveMenuOpen(false); }} + />
)} @@ -4268,39 +4227,11 @@ export function EmailViewer({ aria-label={t('move_to')} className="py-1 w-48 max-h-72 overflow-y-auto bg-background rounded-md shadow-lg border border-border" > - {(() => { - const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { - return nodes.map((node) => { - const Icon = getMoveMailboxIcon(node.role); - const isTarget = moveTargetIds.has(node.id); - return ( -
- {isTarget ? ( - - ) : ( -
- - {node.name} -
- )} - {node.children.length > 0 && renderMobileNodes(node.children, depth + 1)} -
- ); - }); - }; - return renderMobileNodes(moveTree); - })()} + { onMoveToMailbox(mailboxId); setMoreMenuOpen(false); setMoreMenuSub(null); }} + /> )} @@ -4766,39 +4697,14 @@ export function EmailViewer({ )} )} - {moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && (() => { - const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { - return nodes.map((node) => { - const Icon = getMoveMailboxIcon(node.role); - const isTarget = moveTargetIds.has(node.id); - return ( -
- {isTarget ? ( - - ) : ( -
- - {node.name} -
- )} - {node.children.length > 0 && renderMobileNodes(node.children, depth + 1)} -
- ); - }); - }; - return renderMobileNodes(moveTree); - })()} + {moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && ( + { onMoveToMailbox(mailboxId); setMoreMenuOpen(false); setMoreMenuSub(null); }} + /> + )} {moreMenuSub === 'tag' && ( ; + onPick: (mailboxId: string) => void; + /** `menu` for a dropdown, `panel` for the phone's full-width panel (44px rows). */ + variant?: "menu" | "panel"; +} + +/** + * 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. + */ +export function MoveTargetList({ tree, targetIds, onPick, variant = "menu" }: MoveTargetListProps) { + const tSidebar = useTranslations("sidebar"); + const panel = variant === "panel"; + const indent = (depth: number) => `${(panel ? 1 : 0.75) + depth}rem`; + + 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"; + const icon = ; + return ( +
+ {targetIds.has(node.id) ? ( + + ) : ( +
+ {icon} + {label} +
+ )} + {node.children.length > 0 && render(node.children, depth + 1)} +
+ ); + }); + + return <>{render(tree, 0)}; +}