fix: localize special-folder names by JMAP role #404
This commit is contained in:
@@ -32,6 +32,7 @@ import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navi
|
||||
import { debug } from "@/lib/debug";
|
||||
import { playNotificationSound } from "@/lib/notification-sound";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import {
|
||||
ErrorBoundary,
|
||||
SidebarErrorFallback,
|
||||
@@ -734,7 +735,7 @@ export default function Home() {
|
||||
// Mailbox view
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
if (mailbox) {
|
||||
const mailboxName = mailbox.name;
|
||||
const mailboxName = localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`));
|
||||
const unreadCount = mailbox.unreadEmails || 0;
|
||||
title = unreadCount > 0
|
||||
? `${mailboxName} (${unreadCount}) - ${appName}`
|
||||
@@ -2320,7 +2321,12 @@ export default function Home() {
|
||||
? t('sidebar.scheduled')
|
||||
: selectedMailbox === ALL_MAIL_MAILBOX_ID
|
||||
? t('sidebar.mailboxes.all_mail')
|
||||
: mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
||||
: (() => {
|
||||
const mb = mailboxes.find(m => m.id === selectedMailbox);
|
||||
return mb
|
||||
? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`))
|
||||
: "Inbox";
|
||||
})();
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
||||
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||
|
||||
interface Position {
|
||||
@@ -143,6 +144,7 @@ export function EmailContextMenu({
|
||||
onRescheduleScheduled,
|
||||
}: EmailContextMenuProps) {
|
||||
const t = useTranslations("context_menu");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const _tColor = useTranslations("email_viewer.color_tag");
|
||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
@@ -302,12 +304,13 @@ export function EmailContextMenu({
|
||||
return nodes.map((node) => {
|
||||
const Icon = getMailboxIcon(node.role);
|
||||
const isTarget = moveTargetIds.has(node.id);
|
||||
const nodeLabel = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
|
||||
return (
|
||||
<div key={node.id}>
|
||||
{isTarget ? (
|
||||
<ContextMenuItem
|
||||
icon={Icon}
|
||||
label={node.name}
|
||||
label={nodeLabel}
|
||||
onClick={() =>
|
||||
handleAction(() =>
|
||||
showBatchActions
|
||||
@@ -319,7 +322,7 @@ export function EmailContextMenu({
|
||||
) : (
|
||||
<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>{node.name}</span>
|
||||
<span>{nodeLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
{node.children.length > 0 && (
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
MailOpen,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
|
||||
@@ -439,12 +440,14 @@ function MailboxTreeItem({
|
||||
onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void;
|
||||
}) {
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const tSidebar = useTranslations('sidebar');
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = expandedFolders.has(node.id);
|
||||
const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id);
|
||||
const isVirtualNode = node.id.startsWith('shared-');
|
||||
const isSelected = selectedMailbox === node.id;
|
||||
const roleKey = resolveRoleKey(node.role, node.name);
|
||||
const label = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
|
||||
|
||||
const { isDragging: globalDragging } = useDragDropContext();
|
||||
const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({
|
||||
@@ -471,7 +474,7 @@ function MailboxTreeItem({
|
||||
<>
|
||||
<SidebarRow
|
||||
icon={<Icon className={getIconClass(isSelected, isVirtualNode, colorful, roleKey)} />}
|
||||
label={node.name}
|
||||
label={label}
|
||||
depth={node.depth}
|
||||
isSelected={isSelected}
|
||||
isVirtual={isVirtualNode}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
|
||||
// Stand-in translator mirroring the `sidebar.mailboxes` namespace.
|
||||
const RU: Record<string, string> = {
|
||||
inbox: "Входящие",
|
||||
sent: "Отправленные",
|
||||
drafts: "Черновики",
|
||||
trash: "Корзина",
|
||||
archive: "Архив",
|
||||
spam: "Спам",
|
||||
important: "Важные",
|
||||
starred: "Помечённые",
|
||||
all_mail: "Вся почта",
|
||||
};
|
||||
const translate = (key: string) => RU[key] ?? `MISSING:${key}`;
|
||||
|
||||
describe("localizeMailboxName", () => {
|
||||
it("localizes special-use folders by JMAP role, ignoring the server name", () => {
|
||||
expect(localizeMailboxName("inbox", "Inbox", translate)).toBe("Входящие");
|
||||
expect(localizeMailboxName("sent", "Sent", translate)).toBe("Отправленные");
|
||||
expect(localizeMailboxName("drafts", "Drafts", translate)).toBe("Черновики");
|
||||
expect(localizeMailboxName("trash", "Deleted Items", translate)).toBe("Корзина");
|
||||
expect(localizeMailboxName("archive", "Archive", translate)).toBe("Архив");
|
||||
expect(localizeMailboxName("important", "Important", translate)).toBe("Важные");
|
||||
});
|
||||
|
||||
it("maps junk/flagged/all onto reused translation keys", () => {
|
||||
expect(localizeMailboxName("junk", "Junk", translate)).toBe("Спам");
|
||||
expect(localizeMailboxName("flagged", "Flagged", translate)).toBe("Помечённые");
|
||||
expect(localizeMailboxName("all", "All Mail", translate)).toBe("Вся почта");
|
||||
});
|
||||
|
||||
it("leaves user-created folders (no role) untouched", () => {
|
||||
expect(localizeMailboxName(undefined, "Projects", translate)).toBe("Projects");
|
||||
expect(localizeMailboxName(null, "Работа", translate)).toBe("Работа");
|
||||
expect(localizeMailboxName("", "Receipts", translate)).toBe("Receipts");
|
||||
});
|
||||
|
||||
it("falls back to the server name for unknown roles", () => {
|
||||
expect(localizeMailboxName("subscribed", "Subscribed", translate)).toBe("Subscribed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Localizes the display name of special-use mailboxes.
|
||||
*
|
||||
* Mail servers (e.g. Stalwart) always create system folders with English
|
||||
* names — "Inbox", "Sent", "Junk" — regardless of the account locale. JMAP
|
||||
* exposes the special-use semantics via the mailbox `role`, so we map that
|
||||
* role onto a translated label (mirroring Roundcube's behaviour). Folders
|
||||
* without a recognized role — i.e. user-created folders — keep their server
|
||||
* name untouched.
|
||||
*
|
||||
* The keys live under the `sidebar.mailboxes` namespace; `junk`, `flagged`
|
||||
* and `all` reuse the existing `spam`/`starred`/`all_mail` keys so every
|
||||
* locale already has a translation.
|
||||
*/
|
||||
const ROLE_TRANSLATION_KEY: Record<string, string> = {
|
||||
inbox: "inbox",
|
||||
sent: "sent",
|
||||
drafts: "drafts",
|
||||
trash: "trash",
|
||||
archive: "archive",
|
||||
junk: "spam",
|
||||
important: "important",
|
||||
flagged: "starred",
|
||||
all: "all_mail",
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the localized name for a mailbox, or its raw server name when the
|
||||
* role is unknown/absent.
|
||||
*
|
||||
* @param translate Resolves a `sidebar.mailboxes` leaf key (e.g. `"inbox"`) to
|
||||
* its localized string. Callers scoped to the `sidebar` namespace pass
|
||||
* `(k) => t(`mailboxes.${k}`)`; root-scoped callers pass
|
||||
* `(k) => t(`sidebar.mailboxes.${k}`)`.
|
||||
*/
|
||||
export function localizeMailboxName(
|
||||
role: string | undefined | null,
|
||||
name: string,
|
||||
translate: (key: string) => string,
|
||||
): string {
|
||||
if (!role) return name;
|
||||
const key = ROLE_TRANSLATION_KEY[role];
|
||||
return key ? translate(key) : name;
|
||||
}
|
||||
Reference in New Issue
Block a user