mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
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:
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}</>;
|
||||
}
|
||||
Reference in New Issue
Block a user