fix: translate the shell's resize, close, folder count and update labels, and announce a waiting update on the admin entry

This commit is contained in:
Linus Rath
2026-10-28 04:06:12 +01:00
parent 5d013f7669
commit a62317ba63
35 changed files with 280 additions and 14 deletions
@@ -0,0 +1,55 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Shell labels come from the translations (keys in tests), and the admin
// entry says that an update is waiting instead of a dot nobody hears.
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/keyboard-shortcuts-modal', () => ({ KeyboardShortcutsModal: () => null }));
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
vi.mock('@/hooks/use-stalwart-admin', () => ({ useStalwartAdmin: () => true }));
vi.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() }),
usePathname: () => '/',
Link: ({ children, href, ...rest }: { children: React.ReactNode; href: string }) => <a href={href} {...rest}>{children}</a>,
}));
const { NavigationRail } = await import('../navigation-rail');
const { MvTopBar } = await import('../mv-top-bar');
const { ResizeHandle } = await import('../resize-handle');
const { InlineAppView } = await import('../inline-app-view');
const { useUpdateStore } = await import('@/stores/update-store');
beforeEach(() => {
useUpdateStore.setState({
startPolling: vi.fn(),
status: { updateAvailable: true, severity: 'security', current: process.env.NEXT_PUBLIC_APP_VERSION ?? '', latest: '9.9.9' },
} as never);
});
describe('shell labels', () => {
it('names the admin entry with the waiting update, in the rail, the tab bar and Mountain View', () => {
const { unmount } = render(<NavigationRail collapsed />);
expect(screen.getByRole('link', { name: 'admin, update_important' })).toBeInTheDocument();
unmount();
const bar = render(<NavigationRail orientation="horizontal" />);
expect(screen.getByRole('link', { name: /update_important/ })).toBeInTheDocument();
bar.unmount();
render(<MvTopBar showMenuButton={false} />);
expect(screen.getByRole('link', { name: 'admin, update_important' })).toBeInTheDocument();
});
it('translates the resize handle and the inline app close button', () => {
render(
<>
<ResizeHandle onResize={() => {}} />
<InlineAppView apps={[]} activeAppId="x" onClose={() => {}} />
</>
);
expect(screen.getByRole('separator', { name: 'resize' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'close' })).toBeInTheDocument();
});
});
+6 -7
View File
@@ -1,6 +1,8 @@
'use client';
import { useTranslations } from 'next-intl';
import { X } from '@/components/icons';
import { IconButton } from '@/components/ui/icon-button';
import { cn } from '@/lib/utils';
import type { InlineAppState } from '@/hooks/use-sidebar-apps';
@@ -12,6 +14,7 @@ interface InlineAppViewProps {
}
export function InlineAppView({ apps, activeAppId, onClose, className }: InlineAppViewProps) {
const tCommon = useTranslations('common');
const activeApp = apps.find((a) => a.id === activeAppId);
return (
@@ -19,13 +22,9 @@ export function InlineAppView({ apps, activeAppId, onClose, className }: InlineA
{/* Header bar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-secondary/50 flex-shrink-0">
<h3 className="text-sm font-medium truncate">{activeApp?.name}</h3>
<button
onClick={onClose}
className="p-1 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
<IconButton label={tCommon("close")} onClick={onClose} className="text-muted-foreground">
<X />
</IconButton>
</div>
{/* Iframes - active one visible, rest hidden but alive */}
<div className="flex-1 relative">
+3 -2
View File
@@ -117,6 +117,7 @@ export function MvTopBar({
}: MvTopBarProps) {
const t = useTranslations("sidebar");
const tSearch = useTranslations("advanced_search");
const tShell = useTranslations("shell");
const pathname = usePathname();
const router = useRouter();
const allInboxesReachable = useAllInboxesReachable();
@@ -289,7 +290,7 @@ export function MvTopBar({
href={`${getPathPrefix()}/admin`}
className="relative grid place-items-center w-12 h-12 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={t("admin")}
aria-label={t("admin")}
aria-label={hasUpdate ? `${t("admin")}, ${tShell(updateImportant ? "update_important" : "update_available")}` : t("admin")}
>
<Shield className="w-5 h-5" />
{hasUpdate && (
@@ -298,7 +299,7 @@ export function MvTopBar({
"absolute top-3 end-3 w-2 h-2 rounded-full ring-2 ring-background",
updateImportant ? "bg-red-500" : "bg-amber-500",
)}
aria-label={updateImportant ? "Important update available" : "Update available"}
aria-hidden="true"
/>
)}
</a>
+7 -2
View File
@@ -253,6 +253,9 @@ export function NavigationRail({
const startUpdatePolling = useUpdateStore((s) => s.startPolling);
useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated';
const tShell = useTranslations("shell");
// Said with the admin entry; its dot alone is not announced.
const updateLabel = hasUpdate ? tShell(updateImportant ? "update_important" : "update_available") : null;
// Account list for rail
const accounts = useAccountStore((s) => s.accounts);
@@ -501,11 +504,12 @@ export function NavigationRail({
"absolute -top-0.5 -end-0.5 w-2 h-2 rounded-full ring-2 ring-background",
updateImportant ? "bg-red-500" : "bg-amber-500",
)}
aria-label={updateImportant ? "Important update available" : "Update available"}
aria-hidden="true"
/>
)}
</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
{updateLabel && <span className="sr-only">{`, ${updateLabel}`}</span>}
</a>
)}
@@ -729,6 +733,7 @@ export function NavigationRail({
adminActive ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
title={t("admin") || "Admin"}
aria-label={updateLabel ? `${t("admin") || "Admin"}, ${updateLabel}` : undefined}
aria-current={adminActive ? "page" : undefined}
>
<Shield className="w-[18px] h-[18px]" />
@@ -738,7 +743,7 @@ export function NavigationRail({
"absolute top-2 end-2 w-2 h-2 rounded-full ring-2 ring-background",
updateImportant ? "bg-red-500" : "bg-amber-500",
)}
aria-label={updateImportant ? "Important update available" : "Update available"}
aria-hidden="true"
/>
)}
</a>
+3 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
interface ResizeHandleProps {
@@ -17,6 +18,7 @@ interface ResizeHandleProps {
const KEYBOARD_STEP = 10;
export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleClick, orientation = "vertical", className, testId }: ResizeHandleProps) {
const t = useTranslations("shell");
const handleRef = useRef<HTMLDivElement>(null);
// Id of the pointer that started the drag, or null when idle. Only that
// pointer may move or end the drag, so a second finger or button can't
@@ -120,7 +122,7 @@ export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleCli
ref={handleRef}
role="separator"
aria-orientation={isHorizontal ? "horizontal" : "vertical"}
aria-label="Resize"
aria-label={t("resize")}
data-testid={testId}
tabIndex={0}
onPointerDown={handlePointerDown}
+3 -2
View File
@@ -222,6 +222,7 @@ function SidebarRowCounts({
isSelected: boolean;
onUnreadClick?: () => void;
}) {
const tShell = useTranslations('shell');
const showFolderTotalCount = useSettingsStore(s => s.showFolderTotalCount);
const mountainView = useSettingsStore(s => s.interfaceLayout === 'mountain-view');
// Per-folder override from the folder settings (#982).
@@ -272,7 +273,7 @@ function SidebarRowCounts({
}
}}
className={cn(unreadClass, "cursor-pointer hover:underline")}
title={`${unreadCount} unread`}
title={tShell("folder_unread", { count: unreadCount })}
>
{unreadCount}
</span>
@@ -284,7 +285,7 @@ function SidebarRowCounts({
return (
<span
className="ms-2 flex-shrink-0 flex items-baseline gap-1"
title={totalCount > 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}
title={totalCount > 0 ? tShell("folder_counts", { unread: unreadCount, total: totalCount }) : tShell("folder_unread", { count: unreadCount })}
data-testid="folder-counts"
data-unread={unreadCount}
data-total={totalCount}
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "خيارات أخرى"
},
"shell": {
"resize": "تغيير الحجم",
"update_available": "يتوفر تحديث",
"update_important": "يتوفر تحديث مهم",
"folder_unread": "{count} غير مقروءة",
"folder_counts": "{unread} غير مقروءة، {total} إجمالًا"
},
"plugin_pages": {
"loading": "جارٍ تحميل الصفحة",
"not_available_title": "هذه الصفحة غير متاحة",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "Més opcions"
},
"shell": {
"resize": "Canvia la mida",
"update_available": "Hi ha una actualització",
"update_important": "Hi ha una actualització important",
"folder_unread": "{count} sense llegir",
"folder_counts": "{unread} sense llegir, {total} en total"
},
"plugin_pages": {
"loading": "S'està carregant la pàgina",
"not_available_title": "Aquesta pàgina no està disponible",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "Další možnosti"
},
"shell": {
"resize": "Změnit velikost",
"update_available": "Je k dispozici aktualizace",
"update_important": "Je k dispozici důležitá aktualizace",
"folder_unread": "Nepřečteno: {count}",
"folder_counts": "Nepřečteno: {unread}, celkem: {total}"
},
"plugin_pages": {
"loading": "Načítání stránky",
"not_available_title": "Tato stránka není k dispozici",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "Flere muligheder"
},
"shell": {
"resize": "Tilpas størrelse",
"update_available": "Opdatering tilgængelig",
"update_important": "Vigtig opdatering tilgængelig",
"folder_unread": "{count} ulæste",
"folder_counts": "{unread} ulæste, {total} i alt"
},
"plugin_pages": {
"loading": "Indlæser side",
"not_available_title": "Denne side er ikke tilgængelig",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "Weitere Optionen"
},
"shell": {
"resize": "Größe ändern",
"update_available": "Update verfügbar",
"update_important": "Wichtiges Update verfügbar",
"folder_unread": "{count} ungelesen",
"folder_counts": "{unread} ungelesen, {total} insgesamt"
},
"plugin_pages": {
"loading": "Seite wird geladen",
"not_available_title": "Diese Seite ist nicht verfügbar",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "More options"
},
"shell": {
"resize": "Resize",
"update_available": "Update available",
"update_important": "Important update available",
"folder_unread": "{count} unread",
"folder_counts": "{unread} unread, {total} total"
},
"plugin_pages": {
"loading": "Loading page",
"not_available_title": "This page is not available",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "Más opciones"
},
"shell": {
"resize": "Cambiar tamaño",
"update_available": "Actualización disponible",
"update_important": "Actualización importante disponible",
"folder_unread": "{count} sin leer",
"folder_counts": "{unread} sin leer, {total} en total"
},
"plugin_pages": {
"loading": "Cargando la página",
"not_available_title": "Esta página no está disponible",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "Rohkem valikuid"
},
"shell": {
"resize": "Muuda suurust",
"update_available": "Saadaval on värskendus",
"update_important": "Saadaval on oluline värskendus",
"folder_unread": "Lugemata: {count}",
"folder_counts": "Lugemata: {unread}, kokku: {total}"
},
"plugin_pages": {
"loading": "Lehe laadimine",
"not_available_title": "See leht pole saadaval",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "گزینه‌های بیشتر"
},
"shell": {
"resize": "تغییر اندازه",
"update_available": "به‌روزرسانی موجود است",
"update_important": "به‌روزرسانی مهم موجود است",
"folder_unread": "{count} خوانده‌نشده",
"folder_counts": "{unread} خوانده‌نشده، {total} در مجموع"
},
"plugin_pages": {
"loading": "در حال بارگذاری صفحه",
"not_available_title": "این صفحه در دسترس نیست",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "Plus d'options"
},
"shell": {
"resize": "Redimensionner",
"update_available": "Mise à jour disponible",
"update_important": "Mise à jour importante disponible",
"folder_unread": "{count} non lus",
"folder_counts": "{unread} non lus, {total} au total"
},
"plugin_pages": {
"loading": "Chargement de la page",
"not_available_title": "Cette page n'est pas disponible",
+7
View File
@@ -4581,6 +4581,13 @@
"top_bar": {
"more": "אפשרויות נוספות"
},
"shell": {
"resize": "שינוי גודל",
"update_available": "יש עדכון",
"update_important": "יש עדכון חשוב",
"folder_unread": "{count} שלא נקראו",
"folder_counts": "{unread} שלא נקראו, {total} בסך הכול"
},
"plugin_pages": {
"loading": "טוען את הדף",
"not_available_title": "הדף הזה אינו זמין",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "További lehetőségek"
},
"shell": {
"resize": "Átméretezés",
"update_available": "Frissítés érhető el",
"update_important": "Fontos frissítés érhető el",
"folder_unread": "{count} olvasatlan",
"folder_counts": "{unread} olvasatlan, összesen {total}"
},
"plugin_pages": {
"loading": "Oldal betöltése",
"not_available_title": "Ez az oldal nem érhető el",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "Altre opzioni"
},
"shell": {
"resize": "Ridimensiona",
"update_available": "Aggiornamento disponibile",
"update_important": "Aggiornamento importante disponibile",
"folder_unread": "{count} non letti",
"folder_counts": "{unread} non letti, {total} in totale"
},
"plugin_pages": {
"loading": "Caricamento della pagina",
"not_available_title": "Questa pagina non è disponibile",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "その他のオプション"
},
"shell": {
"resize": "サイズを変更",
"update_available": "アップデートがあります",
"update_important": "重要なアップデートがあります",
"folder_unread": "未読 {count} 件",
"folder_counts": "未読 {unread} 件 / 全 {total} 件"
},
"plugin_pages": {
"loading": "ページを読み込んでいます",
"not_available_title": "このページは利用できません",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "추가 옵션"
},
"shell": {
"resize": "크기 조정",
"update_available": "업데이트가 있습니다",
"update_important": "중요한 업데이트가 있습니다",
"folder_unread": "읽지 않음 {count}개",
"folder_counts": "읽지 않음 {unread}개, 전체 {total}개"
},
"plugin_pages": {
"loading": "페이지를 불러오는 중",
"not_available_title": "이 페이지를 사용할 수 없습니다",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "Citas iespējas"
},
"shell": {
"resize": "Mainīt izmēru",
"update_available": "Pieejams atjauninājums",
"update_important": "Pieejams svarīgs atjauninājums",
"folder_unread": "Nelasīti: {count}",
"folder_counts": "Nelasīti: {unread}, kopā: {total}"
},
"plugin_pages": {
"loading": "Ielādē lapu",
"not_available_title": "Šī lapa nav pieejama",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "Бусад сонголт"
},
"shell": {
"resize": "Хэмжээ өөрчлөх",
"update_available": "Шинэчлэл гарсан",
"update_important": "Чухал шинэчлэл гарсан",
"folder_unread": "{count} уншаагүй",
"folder_counts": "{unread} уншаагүй, нийт {total}"
},
"plugin_pages": {
"loading": "Хуудсыг ачаалж байна",
"not_available_title": "Энэ хуудас боломжгүй байна",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "Flere alternativer"
},
"shell": {
"resize": "Endre størrelse",
"update_available": "Oppdatering tilgjengelig",
"update_important": "Viktig oppdatering tilgjengelig",
"folder_unread": "{count} uleste",
"folder_counts": "{unread} uleste, {total} totalt"
},
"plugin_pages": {
"loading": "Laster inn siden",
"not_available_title": "Denne siden er ikke tilgjengelig",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "Meer opties"
},
"shell": {
"resize": "Formaat wijzigen",
"update_available": "Update beschikbaar",
"update_important": "Belangrijke update beschikbaar",
"folder_unread": "{count} ongelezen",
"folder_counts": "{unread} ongelezen, {total} totaal"
},
"plugin_pages": {
"loading": "Pagina laden",
"not_available_title": "Deze pagina is niet beschikbaar",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "Więcej opcji"
},
"shell": {
"resize": "Zmień rozmiar",
"update_available": "Dostępna aktualizacja",
"update_important": "Dostępna ważna aktualizacja",
"folder_unread": "Nieprzeczytane: {count}",
"folder_counts": "Nieprzeczytane: {unread}, łącznie: {total}"
},
"plugin_pages": {
"loading": "Wczytywanie strony",
"not_available_title": "Ta strona jest niedostępna",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "Mais opções"
},
"shell": {
"resize": "Redimensionar",
"update_available": "Atualização disponível",
"update_important": "Atualização importante disponível",
"folder_unread": "{count} não lidas",
"folder_counts": "{unread} não lidas, {total} no total"
},
"plugin_pages": {
"loading": "Carregando a página",
"not_available_title": "Esta página não está disponível",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "Mai multe opțiuni"
},
"shell": {
"resize": "Redimensionează",
"update_available": "Actualizare disponibilă",
"update_important": "Actualizare importantă disponibilă",
"folder_unread": "{count} necitite",
"folder_counts": "{unread} necitite, {total} în total"
},
"plugin_pages": {
"loading": "Se încarcă pagina",
"not_available_title": "Această pagină nu este disponibilă",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "Другие действия"
},
"shell": {
"resize": "Изменить размер",
"update_available": "Доступно обновление",
"update_important": "Доступно важное обновление",
"folder_unread": "Непрочитанных: {count}",
"folder_counts": "Непрочитанных: {unread}, всего: {total}"
},
"plugin_pages": {
"loading": "Загрузка страницы",
"not_available_title": "Эта страница недоступна",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "Ďalšie možnosti"
},
"shell": {
"resize": "Zmeniť veľkosť",
"update_available": "K dispozícii je aktualizácia",
"update_important": "K dispozícii je dôležitá aktualizácia",
"folder_unread": "Neprečítané: {count}",
"folder_counts": "Neprečítané: {unread}, spolu: {total}"
},
"plugin_pages": {
"loading": "Načítava sa stránka",
"not_available_title": "Táto stránka nie je k dispozícii",
+7
View File
@@ -41,6 +41,13 @@
"top_bar": {
"more": "ตัวเลือกเพิ่มเติม"
},
"shell": {
"resize": "ปรับขนาด",
"update_available": "มีอัปเดต",
"update_important": "มีอัปเดตสำคัญ",
"folder_unread": "ยังไม่ได้อ่าน {count}",
"folder_counts": "ยังไม่ได้อ่าน {unread} จากทั้งหมด {total}"
},
"date": {
"today": "วันนี้",
"yesterday": "เมื่อวานนี้",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "Diğer seçenekler"
},
"shell": {
"resize": "Yeniden boyutlandır",
"update_available": "Güncelleme var",
"update_important": "Önemli güncelleme var",
"folder_unread": "{count} okunmamış",
"folder_counts": "{unread} okunmamış, toplam {total}"
},
"plugin_pages": {
"loading": "Sayfa yükleniyor",
"not_available_title": "Bu sayfa kullanılamıyor",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "Інші дії"
},
"shell": {
"resize": "Змінити розмір",
"update_available": "Доступне оновлення",
"update_important": "Доступне важливе оновлення",
"folder_unread": "Непрочитаних: {count}",
"folder_counts": "Непрочитаних: {unread}, усього: {total}"
},
"plugin_pages": {
"loading": "Завантаження сторінки",
"not_available_title": "Ця сторінка недоступна",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "更多選項"
},
"shell": {
"resize": "調整大小",
"update_available": "有可用的更新",
"update_important": "有重要更新",
"folder_unread": "{count} 封未讀",
"folder_counts": "{unread} 封未讀,共 {total} 封"
},
"plugin_pages": {
"loading": "正在載入頁面",
"not_available_title": "此頁面無法使用",
+7
View File
@@ -4580,6 +4580,13 @@
"top_bar": {
"more": "更多选项"
},
"shell": {
"resize": "调整大小",
"update_available": "有可用更新",
"update_important": "有重要更新",
"folder_unread": "{count} 封未读",
"folder_counts": "{unread} 封未读,共 {total} 封"
},
"plugin_pages": {
"loading": "正在加载页面",
"not_available_title": "此页面不可用",