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 { debug } from "@/lib/debug";
|
||||||
import { playNotificationSound } from "@/lib/notification-sound";
|
import { playNotificationSound } from "@/lib/notification-sound";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||||
import {
|
import {
|
||||||
ErrorBoundary,
|
ErrorBoundary,
|
||||||
SidebarErrorFallback,
|
SidebarErrorFallback,
|
||||||
@@ -734,7 +735,7 @@ export default function Home() {
|
|||||||
// Mailbox view
|
// Mailbox view
|
||||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||||
if (mailbox) {
|
if (mailbox) {
|
||||||
const mailboxName = mailbox.name;
|
const mailboxName = localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`));
|
||||||
const unreadCount = mailbox.unreadEmails || 0;
|
const unreadCount = mailbox.unreadEmails || 0;
|
||||||
title = unreadCount > 0
|
title = unreadCount > 0
|
||||||
? `${mailboxName} (${unreadCount}) - ${appName}`
|
? `${mailboxName} (${unreadCount}) - ${appName}`
|
||||||
@@ -2320,7 +2321,12 @@ export default function Home() {
|
|||||||
? t('sidebar.scheduled')
|
? t('sidebar.scheduled')
|
||||||
: selectedMailbox === ALL_MAIL_MAILBOX_ID
|
: selectedMailbox === ALL_MAIL_MAILBOX_ID
|
||||||
? t('sidebar.mailboxes.all_mail')
|
? 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 isFocusedMailLayout = mailLayout === 'focus';
|
||||||
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
||||||
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||||
|
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
|
|
||||||
interface Position {
|
interface Position {
|
||||||
@@ -143,6 +144,7 @@ export function EmailContextMenu({
|
|||||||
onRescheduleScheduled,
|
onRescheduleScheduled,
|
||||||
}: EmailContextMenuProps) {
|
}: EmailContextMenuProps) {
|
||||||
const t = useTranslations("context_menu");
|
const t = useTranslations("context_menu");
|
||||||
|
const tSidebar = useTranslations("sidebar");
|
||||||
const _tColor = useTranslations("email_viewer.color_tag");
|
const _tColor = useTranslations("email_viewer.color_tag");
|
||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
@@ -302,12 +304,13 @@ export function EmailContextMenu({
|
|||||||
return nodes.map((node) => {
|
return nodes.map((node) => {
|
||||||
const Icon = getMailboxIcon(node.role);
|
const Icon = getMailboxIcon(node.role);
|
||||||
const isTarget = moveTargetIds.has(node.id);
|
const isTarget = moveTargetIds.has(node.id);
|
||||||
|
const nodeLabel = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
|
||||||
return (
|
return (
|
||||||
<div key={node.id}>
|
<div key={node.id}>
|
||||||
{isTarget ? (
|
{isTarget ? (
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={Icon}
|
icon={Icon}
|
||||||
label={node.name}
|
label={nodeLabel}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAction(() =>
|
handleAction(() =>
|
||||||
showBatchActions
|
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">
|
<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" />
|
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||||
<span>{node.name}</span>
|
<span>{nodeLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{node.children.length > 0 && (
|
{node.children.length > 0 && (
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
MailOpen,
|
MailOpen,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||||
|
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||||
import { Mailbox } from "@/lib/jmap/types";
|
import { Mailbox } from "@/lib/jmap/types";
|
||||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||||
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
|
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
|
||||||
@@ -439,12 +440,14 @@ function MailboxTreeItem({
|
|||||||
onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void;
|
onContextMenu?: (e: React.MouseEvent, node: MailboxNode) => void;
|
||||||
}) {
|
}) {
|
||||||
const tNotifications = useTranslations('notifications');
|
const tNotifications = useTranslations('notifications');
|
||||||
|
const tSidebar = useTranslations('sidebar');
|
||||||
const hasChildren = node.children.length > 0;
|
const hasChildren = node.children.length > 0;
|
||||||
const isExpanded = expandedFolders.has(node.id);
|
const isExpanded = expandedFolders.has(node.id);
|
||||||
const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id);
|
const Icon = getIconForMailbox(node.role, node.name, hasChildren, isExpanded, node.isShared, node.id);
|
||||||
const isVirtualNode = node.id.startsWith('shared-');
|
const isVirtualNode = node.id.startsWith('shared-');
|
||||||
const isSelected = selectedMailbox === node.id;
|
const isSelected = selectedMailbox === node.id;
|
||||||
const roleKey = resolveRoleKey(node.role, node.name);
|
const roleKey = resolveRoleKey(node.role, node.name);
|
||||||
|
const label = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
|
||||||
|
|
||||||
const { isDragging: globalDragging } = useDragDropContext();
|
const { isDragging: globalDragging } = useDragDropContext();
|
||||||
const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({
|
const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({
|
||||||
@@ -471,7 +474,7 @@ function MailboxTreeItem({
|
|||||||
<>
|
<>
|
||||||
<SidebarRow
|
<SidebarRow
|
||||||
icon={<Icon className={getIconClass(isSelected, isVirtualNode, colorful, roleKey)} />}
|
icon={<Icon className={getIconClass(isSelected, isVirtualNode, colorful, roleKey)} />}
|
||||||
label={node.name}
|
label={label}
|
||||||
depth={node.depth}
|
depth={node.depth}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
isVirtual={isVirtualNode}
|
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