fix: name folders in the reader's Move menus the way the sidebar does, with the Spam icon and right-to-left indents

This commit is contained in:
Linus Rath
2026-10-28 05:16:07 +01:00
parent f7adbf1efe
commit 6b3eda6d79
5 changed files with 136 additions and 138 deletions
@@ -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');
});
@@ -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(<MoveTargetList tree={tree} targetIds={new Set(['sent', 'junk', 'archive', 'y2025'])} 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();
fireEvent.click(screen.getByText('mailboxes.spam'));
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('');
});
});
+2 -23
View File
@@ -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 (
<div key={node.id}>
+20 -114
View File
@@ -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 (
<div key={node.id}>
{isTarget ? (
<button
role="menuitem"
onClick={() => { onMoveToMailbox(node.id); setMoveMenuOpen(false); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2"
style={{ paddingLeft: `${0.75 + depth * 1}rem` }}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{node.name}</span>
</button>
) : (
<div
className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground"
style={{ paddingLeft: `${0.75 + depth * 1}rem` }}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span>{node.name}</span>
</div>
)}
{node.children.length > 0 && renderNodes(node.children, depth + 1)}
</div>
);
});
};
return renderNodes(moveTree);
})()}
<MoveTargetList
tree={moveTree}
targetIds={moveTargetIds}
onPick={(mailboxId) => { onMoveToMailbox(mailboxId); setMoveMenuOpen(false); }}
/>
</div>
)}
</div>
@@ -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 (
<div key={node.id}>
{isTarget ? (
<button
role="menuitem"
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2"
style={{ paddingLeft: `${0.75 + depth * 1}rem` }}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{node.name}</span>
</button>
) : (
<div
className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground"
style={{ paddingLeft: `${0.75 + depth * 1}rem` }}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span>{node.name}</span>
</div>
)}
{node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
</div>
);
});
};
return renderMobileNodes(moveTree);
})()}
<MoveTargetList
tree={moveTree}
targetIds={moveTargetIds}
onPick={(mailboxId) => { onMoveToMailbox(mailboxId); setMoreMenuOpen(false); setMoreMenuSub(null); }}
/>
</SubMenuPanel>
)}
</div>
@@ -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 (
<div key={node.id}>
{isTarget ? (
<button
role="menuitem"
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3"
style={{ paddingLeft: `${1 + depth * 1}rem` }}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="truncate">{node.name}</span>
</button>
) : (
<div
className="px-4 py-2.5 min-h-[44px] text-sm flex items-center gap-3 text-muted-foreground"
style={{ paddingLeft: `${1 + depth * 1}rem` }}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span>{node.name}</span>
</div>
)}
{node.children.length > 0 && renderMobileNodes(node.children, depth + 1)}
</div>
);
});
};
return renderMobileNodes(moveTree);
})()}
{moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && (
<MoveTargetList
variant="panel"
tree={moveTree}
targetIds={moveTargetIds}
onPick={(mailboxId) => { onMoveToMailbox(mailboxId); setMoreMenuOpen(false); setMoreMenuSub(null); }}
/>
)}
{moreMenuSub === 'tag' && (
<TagPicker
touch
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useTranslations } from "next-intl";
import { Archive, Ban, File, Folder, Inbox, Send, Trash2, type AppIcon } from "@/components/icons";
import { localizeMailboxName } from "@/lib/mailbox-label";
import type { MailboxNode } from "@/lib/utils";
import { cn } from "@/lib/utils";
/** A folder's icon in a Move list, the sidebar's for the folders with a role. */
export function moveMailboxIcon(role?: string | null): AppIcon {
switch (role) {
case "inbox": return Inbox;
case "sent": return Send;
case "drafts": return File;
case "trash": return Trash2;
case "junk": return Ban;
case "archive": return Archive;
default: return Folder;
}
}
interface MoveTargetListProps {
/** From buildMoveTargets. */
tree: MailboxNode[];
targetIds: Set<string>;
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 = <Icon className={cn(panel ? "w-5 h-5" : "w-4 h-4", "flex-shrink-0")} />;
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)}
</div>
);
});
return <>{render(tree, 0)}</>;
}