- {trigger}
-
+
+
setOpen((v) => !v)}
+ aria-expanded={open}
+ aria-controls={detailsId}
+ >
+ {triggerText}
+
+
-
Version: {APP_VERSION}
-
Build: {GIT_COMMIT}
+
{t("version_line", { value: APP_VERSION })}
+
{t("build_line", { value: GIT_COMMIT })}
{banner?.latest && (
-
Latest: {banner.latest}
+
{t("latest_line", { value: banner.latest })}
)}
{banner?.advisory && (
-
{banner.advisory}
+
{banner.advisory}
+ )}
+ {banner?.url && (
+
+ {t("release_notes")}
+
)}
-
- {copied ? : }
-
+
+ {copied ? : }
+
@@ -139,6 +178,7 @@ function VersionBadge() {
function LoginPageContent() {
const router = useRouter();
const t = useTranslations("login");
+ const tTheme = useTranslations("settings.appearance.theme");
const params = useParams();
const searchParams = useSearchParams();
const isAddAccountMode = searchParams.get("mode") === "add-account";
@@ -988,12 +1028,12 @@ function LoginPageContent() {
? "bg-secondary border-border text-foreground shadow-md"
: "bg-background/60 backdrop-blur-sm border-border/50 text-muted-foreground hover:text-foreground hover:bg-secondary/80 hover:border-border"
)}
- aria-label={`Theme: ${currentThemeOption.label}`}
+ aria-label={`${tTheme("label")}: ${tTheme(currentThemeOption.label)}`}
aria-expanded={showThemeMenu}
aria-haspopup="menu"
>
-
{currentThemeOption.label}
+
{tTheme(currentThemeOption.label)}
{showThemeMenu && (
@@ -1002,7 +1042,7 @@ function LoginPageContent() {
onKeyDown={onThemeMenuKeyDown}
className="absolute right-0 top-full mt-2 w-40 rounded-xl border border-border bg-background shadow-lg overflow-hidden animate-fade-in z-50"
role="menu"
- aria-label="Theme selection"
+ aria-label={tTheme("label")}
>
{THEME_OPTIONS.map((option) => {
const Icon = option.icon;
@@ -1022,7 +1062,7 @@ function LoginPageContent() {
)}
>
-
{option.label}
+
{tTheme(option.label)}
{isActive &&
}
);
@@ -1148,12 +1188,12 @@ function LoginPageContent() {
? "bg-secondary border-border text-foreground shadow-md"
: "bg-background/60 backdrop-blur-sm border-border/50 text-muted-foreground hover:text-foreground hover:bg-secondary/80 hover:border-border"
)}
- aria-label={`Theme: ${currentThemeOption.label}`}
+ aria-label={`${tTheme("label")}: ${tTheme(currentThemeOption.label)}`}
aria-expanded={showThemeMenu}
aria-haspopup="menu"
>
-
{currentThemeOption.label}
+
{tTheme(currentThemeOption.label)}
{showThemeMenu && (
@@ -1162,7 +1202,7 @@ function LoginPageContent() {
onKeyDown={onThemeMenuKeyDown}
className="absolute right-0 top-full mt-2 w-40 rounded-xl border border-border bg-background shadow-lg overflow-hidden animate-fade-in z-50"
role="menu"
- aria-label="Theme selection"
+ aria-label={tTheme("label")}
>
{THEME_OPTIONS.map((option) => {
const Icon = option.icon;
@@ -1182,7 +1222,7 @@ function LoginPageContent() {
)}
>
-
{option.label}
+
{tTheme(option.label)}
{isActive &&
}
);
@@ -1211,7 +1251,7 @@ function LoginPageContent() {
)}
{loginShowSubtitle && (
- {isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
+ {isAddAccountMode ? t("add_account_subtitle") : t("subtitle")}
)}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 691b31dc5..c1bc8093e 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "تسجيل الدخول عبر SSO",
"add_account_title": "إضافة حساب",
"add_account_subtitle": "تسجيل الدخول بحساب آخر",
+ "subtitle": "سجّل الدخول إلى حسابك",
+ "version": {
+ "details": "تفاصيل الإصدار",
+ "version_line": "الإصدار: {value}",
+ "build_line": "البنية: {value}",
+ "latest_line": "الأحدث: {value}",
+ "copy": "نسخ معلومات الإصدار",
+ "security_update": "يتوفر تحديث أمني",
+ "unsupported": "لم يعد هذا الإصدار مدعومًا",
+ "new_version": "يتوفر إصدار جديد",
+ "release_notes": "ملاحظات الإصدار"
+ },
"cancel": "إلغاء",
"website": "الموقع الإلكتروني",
"imprint": "بيانات الناشر",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 3620809c7..2265c1db3 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "Inicia la sessió amb SSO",
"add_account_title": "Afegeix un compte",
"add_account_subtitle": "Inicieu la sessió amb un altre compte",
+ "subtitle": "Inicieu la sessió al vostre compte",
+ "version": {
+ "details": "Detalls de la versió",
+ "version_line": "Versió: {value}",
+ "build_line": "Compilació: {value}",
+ "latest_line": "Més recent: {value}",
+ "copy": "Copia la informació de la versió",
+ "security_update": "Hi ha una actualització de seguretat",
+ "unsupported": "Aquesta versió ja no té suport",
+ "new_version": "Hi ha una versió nova",
+ "release_notes": "Notes de la versió"
+ },
"cancel": "Cancel·la",
"website": "Lloc web",
"imprint": "Avís legal",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 2b9719d6b..a5751e9f9 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "Přihlásit se přes SSO",
"add_account_title": "Přidat účet",
"add_account_subtitle": "Přihlásit se k jinému účtu",
+ "subtitle": "Přihlaste se ke svému účtu",
+ "version": {
+ "details": "Podrobnosti o verzi",
+ "version_line": "Verze: {value}",
+ "build_line": "Sestavení: {value}",
+ "latest_line": "Nejnovější: {value}",
+ "copy": "Kopírovat informace o verzi",
+ "security_update": "Je k dispozici bezpečnostní aktualizace",
+ "unsupported": "Tato verze už není podporována",
+ "new_version": "Je k dispozici nová verze",
+ "release_notes": "Poznámky k vydání"
+ },
"cancel": "Zrušit",
"website": "Webové stránky",
"imprint": "Tiráž",
diff --git a/locales/da/common.json b/locales/da/common.json
index 2abefd24f..69bec66e7 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "Log ind med SSO",
"add_account_title": "Tilføj konto",
"add_account_subtitle": "Log ind med en anden konto",
+ "subtitle": "Log ind på din konto",
+ "version": {
+ "details": "Versionsdetaljer",
+ "version_line": "Version: {value}",
+ "build_line": "Build: {value}",
+ "latest_line": "Nyeste: {value}",
+ "copy": "Kopiér versionsoplysninger",
+ "security_update": "Sikkerhedsopdatering tilgængelig",
+ "unsupported": "Versionen understøttes ikke længere",
+ "new_version": "Ny version tilgængelig",
+ "release_notes": "Udgivelsesnoter"
+ },
"cancel": "Annuller",
"website": "Hjemmeside",
"imprint": "Impressum",
diff --git a/locales/de/common.json b/locales/de/common.json
index b1c39a1cf..e78c5a2d6 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "Mit SSO anmelden",
"add_account_title": "Konto hinzufügen",
"add_account_subtitle": "Mit einem anderen Konto anmelden",
+ "subtitle": "Melden Sie sich bei Ihrem Konto an",
+ "version": {
+ "details": "Versionsdetails",
+ "version_line": "Version: {value}",
+ "build_line": "Build: {value}",
+ "latest_line": "Neueste: {value}",
+ "copy": "Versionsinfo kopieren",
+ "security_update": "Sicherheitsupdate verfügbar",
+ "unsupported": "Version wird nicht mehr unterstützt",
+ "new_version": "Neue Version verfügbar",
+ "release_notes": "Versionshinweise"
+ },
"cancel": "Abbrechen",
"website": "Webseite",
"imprint": "Impressum",
diff --git a/locales/en/common.json b/locales/en/common.json
index 486064e82..0eefae9e6 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "Sign in with SSO",
"add_account_title": "Add Account",
"add_account_subtitle": "Sign in with another account",
+ "subtitle": "Sign in to your account",
+ "version": {
+ "details": "Version details",
+ "version_line": "Version: {value}",
+ "build_line": "Build: {value}",
+ "latest_line": "Latest: {value}",
+ "copy": "Copy version info",
+ "security_update": "Security update available",
+ "unsupported": "Version no longer supported",
+ "new_version": "New version available",
+ "release_notes": "Release notes"
+ },
"cancel": "Cancel",
"website": "Website",
"imprint": "Imprint",
diff --git a/locales/es/common.json b/locales/es/common.json
index 36451b967..9a8105aa1 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "Iniciar sesión con SSO",
"add_account_title": "Agregar cuenta",
"add_account_subtitle": "Iniciar sesión con otra cuenta",
+ "subtitle": "Inicia sesión en tu cuenta",
+ "version": {
+ "details": "Detalles de la versión",
+ "version_line": "Versión: {value}",
+ "build_line": "Compilación: {value}",
+ "latest_line": "Última: {value}",
+ "copy": "Copiar información de la versión",
+ "security_update": "Actualización de seguridad disponible",
+ "unsupported": "Esta versión ya no tiene soporte",
+ "new_version": "Nueva versión disponible",
+ "release_notes": "Notas de la versión"
+ },
"cancel": "Cancelar",
"website": "Sitio web",
"imprint": "Aviso legal",
diff --git a/locales/et/common.json b/locales/et/common.json
index a8bd12a62..5a9692f60 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "Logi sisse SSO-ga",
"add_account_title": "Lisa konto",
"add_account_subtitle": "Logi sisse teise kontoga",
+ "subtitle": "Logige oma kontole sisse",
+ "version": {
+ "details": "Versiooni üksikasjad",
+ "version_line": "Versioon: {value}",
+ "build_line": "Järk: {value}",
+ "latest_line": "Uusim: {value}",
+ "copy": "Kopeeri versiooniteave",
+ "security_update": "Saadaval on turvavärskendus",
+ "unsupported": "Seda versiooni enam ei toetata",
+ "new_version": "Saadaval on uus versioon",
+ "release_notes": "Väljalaskemärkmed"
+ },
"cancel": "Tühista",
"website": "Veebileht",
"imprint": "Väljaandja andmed",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index e5c0151bf..da6a7f2cc 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "ورود با SSO",
"add_account_title": "افزودن حساب",
"add_account_subtitle": "ورود با حساب دیگر",
+ "subtitle": "به حساب خود وارد شوید",
+ "version": {
+ "details": "جزئیات نسخه",
+ "version_line": "نسخه: {value}",
+ "build_line": "بیلد: {value}",
+ "latest_line": "جدیدترین: {value}",
+ "copy": "کپی اطلاعات نسخه",
+ "security_update": "بهروزرسانی امنیتی موجود است",
+ "unsupported": "این نسخه دیگر پشتیبانی نمیشود",
+ "new_version": "نسخهٔ جدید موجود است",
+ "release_notes": "یادداشتهای انتشار"
+ },
"cancel": "انصراف",
"website": "وبسایت",
"imprint": "اطلاعات حقوقی",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 42fdc729d..6f8761056 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "Se connecter avec SSO",
"add_account_title": "Ajouter un compte",
"add_account_subtitle": "Se connecter avec un autre compte",
+ "subtitle": "Connectez-vous à votre compte",
+ "version": {
+ "details": "Détails de la version",
+ "version_line": "Version : {value}",
+ "build_line": "Build : {value}",
+ "latest_line": "Dernière : {value}",
+ "copy": "Copier les informations de version",
+ "security_update": "Mise à jour de sécurité disponible",
+ "unsupported": "Cette version n'est plus prise en charge",
+ "new_version": "Nouvelle version disponible",
+ "release_notes": "Notes de version"
+ },
"cancel": "Annuler",
"website": "Site web",
"imprint": "Mentions légales",
diff --git a/locales/he/common.json b/locales/he/common.json
index bc339c75a..011fde4e8 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -47,6 +47,18 @@
"sign_in_sso": "היכנס באמצעות SSO",
"add_account_title": "הוסף חשבון",
"add_account_subtitle": "היכנס עם חשבון אחר",
+ "subtitle": "התחברו לחשבון שלכם",
+ "version": {
+ "details": "פרטי הגרסה",
+ "version_line": "גרסה: {value}",
+ "build_line": "בנייה: {value}",
+ "latest_line": "העדכנית ביותר: {value}",
+ "copy": "העתקת פרטי הגרסה",
+ "security_update": "יש עדכון אבטחה",
+ "unsupported": "הגרסה הזו כבר לא נתמכת",
+ "new_version": "יש גרסה חדשה",
+ "release_notes": "הערות שחרור"
+ },
"cancel": "ביטול",
"website": "אתר",
"imprint": "פרטי מוציא לאור",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 9753edd6e..754305723 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "Bejelentkezés SSO-val",
"add_account_title": "Fiók hozzáadása",
"add_account_subtitle": "Bejelentkezés másik fiókkal",
+ "subtitle": "Jelentkezzen be a fiókjába",
+ "version": {
+ "details": "Verzió részletei",
+ "version_line": "Verzió: {value}",
+ "build_line": "Build: {value}",
+ "latest_line": "Legújabb: {value}",
+ "copy": "Verzióadatok másolása",
+ "security_update": "Biztonsági frissítés érhető el",
+ "unsupported": "Ez a verzió már nem támogatott",
+ "new_version": "Új verzió érhető el",
+ "release_notes": "Kiadási megjegyzések"
+ },
"cancel": "Mégse",
"website": "Weboldal",
"imprint": "Impresszum",
diff --git a/locales/it/common.json b/locales/it/common.json
index 37be7eab2..119871be5 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "Accedi con SSO",
"add_account_title": "Aggiungi account",
"add_account_subtitle": "Accedi con un altro account",
+ "subtitle": "Accedi al tuo account",
+ "version": {
+ "details": "Dettagli versione",
+ "version_line": "Versione: {value}",
+ "build_line": "Build: {value}",
+ "latest_line": "Ultima: {value}",
+ "copy": "Copia informazioni sulla versione",
+ "security_update": "Aggiornamento di sicurezza disponibile",
+ "unsupported": "Versione non più supportata",
+ "new_version": "Nuova versione disponibile",
+ "release_notes": "Note di rilascio"
+ },
"cancel": "Annulla",
"website": "Sito web",
"imprint": "Note legali",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 86a57673b..489d7f48f 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "SSOでサインイン",
"add_account_title": "アカウントを追加",
"add_account_subtitle": "別のアカウントでサインイン",
+ "subtitle": "アカウントにサインイン",
+ "version": {
+ "details": "バージョンの詳細",
+ "version_line": "バージョン: {value}",
+ "build_line": "ビルド: {value}",
+ "latest_line": "最新: {value}",
+ "copy": "バージョン情報をコピー",
+ "security_update": "セキュリティ更新があります",
+ "unsupported": "このバージョンはサポートが終了しました",
+ "new_version": "新しいバージョンがあります",
+ "release_notes": "リリースノート"
+ },
"cancel": "キャンセル",
"website": "ウェブサイト",
"imprint": "サイト運営者情報",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 21b3fcf13..667707691 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "SSO로 로그인",
"add_account_title": "계정 추가",
"add_account_subtitle": "다른 계정으로 로그인하기",
+ "subtitle": "계정에 로그인하세요",
+ "version": {
+ "details": "버전 정보",
+ "version_line": "버전: {value}",
+ "build_line": "빌드: {value}",
+ "latest_line": "최신: {value}",
+ "copy": "버전 정보 복사",
+ "security_update": "보안 업데이트가 있습니다",
+ "unsupported": "더 이상 지원되지 않는 버전입니다",
+ "new_version": "새 버전이 있습니다",
+ "release_notes": "릴리스 노트"
+ },
"cancel": "취소",
"website": "웹사이트",
"imprint": "회사 소개",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index daf206341..53fe7511a 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "Pierakstīties ar SSO",
"add_account_title": "Pievienot kontu",
"add_account_subtitle": "Pierakstīties citā kontā",
+ "subtitle": "Piesakieties savā kontā",
+ "version": {
+ "details": "Versijas informācija",
+ "version_line": "Versija: {value}",
+ "build_line": "Būvējums: {value}",
+ "latest_line": "Jaunākā: {value}",
+ "copy": "Kopēt versijas informāciju",
+ "security_update": "Pieejams drošības atjauninājums",
+ "unsupported": "Šī versija vairs netiek atbalstīta",
+ "new_version": "Pieejama jauna versija",
+ "release_notes": "Laidiena piezīmes"
+ },
"cancel": "Atcelt",
"website": "Tīmekļa vietne",
"imprint": "Rekvizīti",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index ecca7cdce..c673150a5 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "SSO-ээр нэвтрэх",
"add_account_title": "Бүртгэл нэмэх",
"add_account_subtitle": "Өөр бүртгэлээр нэвтрэх",
+ "subtitle": "Бүртгэлдээ нэвтэрнэ үү",
+ "version": {
+ "details": "Хувилбарын дэлгэрэнгүй",
+ "version_line": "Хувилбар: {value}",
+ "build_line": "Бүтээц: {value}",
+ "latest_line": "Хамгийн сүүлийн: {value}",
+ "copy": "Хувилбарын мэдээллийг хуулах",
+ "security_update": "Аюулгүй байдлын шинэчлэл гарсан",
+ "unsupported": "Энэ хувилбарыг дэмжихээ больсон",
+ "new_version": "Шинэ хувилбар гарсан",
+ "release_notes": "Хувилбарын тэмдэглэл"
+ },
"cancel": "Болих",
"website": "Вэбсайт",
"imprint": "Хуулийн мэдээлэл",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 224084986..8b25396ba 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "Logg på med SSO",
"add_account_title": "Legg til konto",
"add_account_subtitle": "Logg på med en annen konto",
+ "subtitle": "Logg på kontoen din",
+ "version": {
+ "details": "Versjonsdetaljer",
+ "version_line": "Versjon: {value}",
+ "build_line": "Bygg: {value}",
+ "latest_line": "Nyeste: {value}",
+ "copy": "Kopier versjonsinformasjon",
+ "security_update": "Sikkerhetsoppdatering tilgjengelig",
+ "unsupported": "Versjonen støttes ikke lenger",
+ "new_version": "Ny versjon tilgjengelig",
+ "release_notes": "Utgivelsesnotater"
+ },
"cancel": "Avbryt",
"website": "Nettsted",
"imprint": "Juridisk informasjon",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 28dea6ba8..d501ec9cd 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "Inloggen met SSO",
"add_account_title": "Account toevoegen",
"add_account_subtitle": "Inloggen met een ander account",
+ "subtitle": "Meld je aan bij je account",
+ "version": {
+ "details": "Versiedetails",
+ "version_line": "Versie: {value}",
+ "build_line": "Build: {value}",
+ "latest_line": "Nieuwste: {value}",
+ "copy": "Versie-informatie kopiëren",
+ "security_update": "Beveiligingsupdate beschikbaar",
+ "unsupported": "Deze versie wordt niet meer ondersteund",
+ "new_version": "Nieuwe versie beschikbaar",
+ "release_notes": "Releaseopmerkingen"
+ },
"cancel": "Annuleren",
"website": "Website",
"imprint": "Colofon",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 40806f3d4..4ea8489d4 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "Zaloguj się przez SSO",
"add_account_title": "Dodaj konto",
"add_account_subtitle": "Zaloguj się na inne konto",
+ "subtitle": "Zaloguj się na swoje konto",
+ "version": {
+ "details": "Szczegóły wersji",
+ "version_line": "Wersja: {value}",
+ "build_line": "Kompilacja: {value}",
+ "latest_line": "Najnowsza: {value}",
+ "copy": "Kopiuj informacje o wersji",
+ "security_update": "Dostępna aktualizacja zabezpieczeń",
+ "unsupported": "Ta wersja nie jest już wspierana",
+ "new_version": "Dostępna nowa wersja",
+ "release_notes": "Informacje o wydaniu"
+ },
"cancel": "Anuluj",
"website": "Strona internetowa",
"imprint": "Impressum",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index fe8cab340..175c6b5e2 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "Entrar com SSO",
"add_account_title": "Adicionar conta",
"add_account_subtitle": "Entrar com outra conta",
+ "subtitle": "Inicie sessão na sua conta",
+ "version": {
+ "details": "Detalhes da versão",
+ "version_line": "Versão: {value}",
+ "build_line": "Compilação: {value}",
+ "latest_line": "Mais recente: {value}",
+ "copy": "Copiar informações da versão",
+ "security_update": "Atualização de segurança disponível",
+ "unsupported": "Esta versão já não é suportada",
+ "new_version": "Nova versão disponível",
+ "release_notes": "Notas de lançamento"
+ },
"cancel": "Cancelar",
"website": "Site",
"imprint": "Informações legais",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 9d5783f49..3ee0c0726 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "Conectați-vă cu SSO",
"add_account_title": "Adăugare cont",
"add_account_subtitle": "Conectați-vă cu un alt cont",
+ "subtitle": "Conectați-vă la contul dvs.",
+ "version": {
+ "details": "Detalii despre versiune",
+ "version_line": "Versiune: {value}",
+ "build_line": "Build: {value}",
+ "latest_line": "Cea mai nouă: {value}",
+ "copy": "Copiază informațiile despre versiune",
+ "security_update": "Actualizare de securitate disponibilă",
+ "unsupported": "Versiunea nu mai este acceptată",
+ "new_version": "Versiune nouă disponibilă",
+ "release_notes": "Note de lansare"
+ },
"cancel": "Anulează",
"website": "Site web",
"imprint": "Mențiuni legale",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index a0a88227b..e9cba9a9d 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "Войти через SSO",
"add_account_title": "Добавить аккаунт",
"add_account_subtitle": "Войдите с другим аккаунтом",
+ "subtitle": "Войдите в свою учётную запись",
+ "version": {
+ "details": "Сведения о версии",
+ "version_line": "Версия: {value}",
+ "build_line": "Сборка: {value}",
+ "latest_line": "Последняя: {value}",
+ "copy": "Скопировать сведения о версии",
+ "security_update": "Доступно обновление безопасности",
+ "unsupported": "Эта версия больше не поддерживается",
+ "new_version": "Доступна новая версия",
+ "release_notes": "Примечания к выпуску"
+ },
"cancel": "Отмена",
"website": "Веб-сайт",
"imprint": "Выходные данные",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index e8c76b9b5..ed206d5a0 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "Prihlásiť sa cez SSO",
"add_account_title": "Pridať účet",
"add_account_subtitle": "Prihlásiť sa iným účtom",
+ "subtitle": "Prihláste sa do svojho účtu",
+ "version": {
+ "details": "Podrobnosti o verzii",
+ "version_line": "Verzia: {value}",
+ "build_line": "Zostavenie: {value}",
+ "latest_line": "Najnovšia: {value}",
+ "copy": "Kopírovať informácie o verzii",
+ "security_update": "K dispozícii je bezpečnostná aktualizácia",
+ "unsupported": "Táto verzia už nie je podporovaná",
+ "new_version": "K dispozícii je nová verzia",
+ "release_notes": "Poznámky k vydaniu"
+ },
"cancel": "Zrušiť",
"website": "Webová stránka",
"imprint": "Impressum",
diff --git a/locales/th/common.json b/locales/th/common.json
index cf468cab5..14b8c55ef 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -151,6 +151,18 @@
"sign_in_sso": "เข้าสู่ระบบด้วย SSO",
"add_account_title": "เพิ่มบัญชี",
"add_account_subtitle": "เข้าสู่ระบบด้วยบัญชีอื่น",
+ "subtitle": "ลงชื่อเข้าใช้บัญชีของคุณ",
+ "version": {
+ "details": "รายละเอียดเวอร์ชัน",
+ "version_line": "เวอร์ชัน: {value}",
+ "build_line": "บิลด์: {value}",
+ "latest_line": "ล่าสุด: {value}",
+ "copy": "คัดลอกข้อมูลเวอร์ชัน",
+ "security_update": "มีอัปเดตความปลอดภัย",
+ "unsupported": "เวอร์ชันนี้ไม่ได้รับการสนับสนุนแล้ว",
+ "new_version": "มีเวอร์ชันใหม่",
+ "release_notes": "บันทึกประจำรุ่น"
+ },
"cancel": "ยกเลิก",
"website": "เว็บไซต์",
"imprint": "ข้อมูลทางกฎหมาย",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 1f1fe35bc..4139e1bb0 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "SSO ile giriş yap",
"add_account_title": "Hesap Ekle",
"add_account_subtitle": "Başka bir hesapla giriş yap",
+ "subtitle": "Hesabınızda oturum açın",
+ "version": {
+ "details": "Sürüm ayrıntıları",
+ "version_line": "Sürüm: {value}",
+ "build_line": "Derleme: {value}",
+ "latest_line": "En yeni: {value}",
+ "copy": "Sürüm bilgisini kopyala",
+ "security_update": "Güvenlik güncellemesi var",
+ "unsupported": "Bu sürüm artık desteklenmiyor",
+ "new_version": "Yeni sürüm var",
+ "release_notes": "Sürüm notları"
+ },
"cancel": "İptal",
"website": "Web Sitesi",
"imprint": "Yasal Bilgiler",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index f27652d00..f179ca05f 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "Увійдіть за допомогою SSO",
"add_account_title": "Додати обліковий запис",
"add_account_subtitle": "Увійдіть за допомогою іншого облікового запису",
+ "subtitle": "Увійдіть у свій обліковий запис",
+ "version": {
+ "details": "Відомості про версію",
+ "version_line": "Версія: {value}",
+ "build_line": "Збірка: {value}",
+ "latest_line": "Найновіша: {value}",
+ "copy": "Скопіювати відомості про версію",
+ "security_update": "Доступне оновлення безпеки",
+ "unsupported": "Ця версія більше не підтримується",
+ "new_version": "Доступна нова версія",
+ "release_notes": "Примітки до випуску"
+ },
"cancel": "Скасувати",
"website": "Веб-сайт",
"imprint": "Вихідні дані",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index b81169291..160ad6140 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "使用 SSO 登入",
"add_account_title": "新增帳號",
"add_account_subtitle": "使用其他帳號登入",
+ "subtitle": "登入您的帳戶",
+ "version": {
+ "details": "版本詳細資料",
+ "version_line": "版本:{value}",
+ "build_line": "組建:{value}",
+ "latest_line": "最新:{value}",
+ "copy": "複製版本資訊",
+ "security_update": "有可用的安全性更新",
+ "unsupported": "此版本已不再支援",
+ "new_version": "有可用的新版本",
+ "release_notes": "版本資訊"
+ },
"cancel": "取消",
"website": "網站",
"imprint": "法律聲明",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index c1f8ef6ea..e01ceb86d 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -50,6 +50,18 @@
"sign_in_sso": "使用 SSO 登录",
"add_account_title": "添加账户",
"add_account_subtitle": "使用其他账户登录",
+ "subtitle": "登录您的账户",
+ "version": {
+ "details": "版本详情",
+ "version_line": "版本:{value}",
+ "build_line": "构建:{value}",
+ "latest_line": "最新:{value}",
+ "copy": "复制版本信息",
+ "security_update": "有可用的安全更新",
+ "unsupported": "此版本已不再受支持",
+ "new_version": "有可用的新版本",
+ "release_notes": "发行说明"
+ },
"cancel": "取消",
"website": "网站",
"imprint": "法律信息",
From b5dcb04c534a67eaa0ba8ca8e257de3f94e5caa9 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 03:36:01 +0100
Subject: [PATCH 11/29] fix: mirror the login page's theme button and password
toggle, and the rail badges, in right-to-left languages
---
app/(main)/[locale]/login/page.tsx | 10 +++++-----
components/layout/account-switcher.tsx | 2 +-
components/layout/navigation-rail.tsx | 10 +++++-----
3 files changed, 11 insertions(+), 11 deletions(-)
diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx
index 76cb20e1e..d5b972efa 100644
--- a/app/(main)/[locale]/login/page.tsx
+++ b/app/(main)/[locale]/login/page.tsx
@@ -1017,7 +1017,7 @@ function LoginPageContent() {
return (
{/* Theme toggle, hidden while the theme policy locks the mode (#1280) */}
-
+
@@ -1177,7 +1177,7 @@ function LoginPageContent() {
}}
/>
{/* Theme toggle - top right, dropdown style; hidden while the theme policy locks the mode (#1280) */}
-
+
@@ -1470,7 +1470,7 @@ function LoginPageContent() {
setShowPassword(!showPassword)}
- className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground hover:text-foreground transition-colors"
+ className="absolute end-3 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground hover:text-foreground transition-colors"
aria-label={showPassword ? t("hide_password") : t("show_password")}
tabIndex={-1}
>
diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx
index 7fd7da489..756fc7e37 100644
--- a/components/layout/account-switcher.tsx
+++ b/components/layout/account-switcher.tsx
@@ -411,7 +411,7 @@ export function AccountSwitcher({
{isActive && (
-
+
)}
diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx
index e18fbc43e..0ecc78380 100644
--- a/components/layout/navigation-rail.tsx
+++ b/components/layout/navigation-rail.tsx
@@ -387,7 +387,7 @@ export function NavigationRail({
)}>
{item.badge != null && item.badge > 0 && (
-
+
{item.badge > 99 ? "99+" : item.badge}
)}
@@ -488,7 +488,7 @@ export function NavigationRail({
{hasUpdate && (
0 && (
{item.badge > 99 ? "99+" : item.badge}
@@ -725,7 +725,7 @@ export function NavigationRail({
{hasUpdate && (
{isActive && (
-
+
)}
From 04f45e0b29aae3051829d92035c1cb0e1fa4efae Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 03:36:44 +0100
Subject: [PATCH 12/29] fix: point the plugin sidebar section arrows the right
way in right-to-left languages
---
.../layout/__tests__/rtl-icon-flips.test.ts | 42 +++++++++++++++++++
.../plugins/plugin-sidebar-sections.tsx | 2 +-
2 files changed, 43 insertions(+), 1 deletion(-)
create mode 100644 components/layout/__tests__/rtl-icon-flips.test.ts
diff --git a/components/layout/__tests__/rtl-icon-flips.test.ts b/components/layout/__tests__/rtl-icon-flips.test.ts
new file mode 100644
index 000000000..b9edb4a77
--- /dev/null
+++ b/components/layout/__tests__/rtl-icon-flips.test.ts
@@ -0,0 +1,42 @@
+import { readdirSync, readFileSync, statSync } from 'node:fs';
+import { join } from 'node:path';
+import { describe, expect, it } from 'vitest';
+
+// globals.css already mirrors chevron and arrow icons under [dir=rtl]. An
+// extra rtl:rotate-180 or rtl:-scale-x-100 on top cancels that out, and the
+// icon points the left-to-right way again (R11). Checked in the shell's own
+// folders.
+
+const ROOTS = [
+ 'components/layout',
+ 'components/plugins',
+ 'components/tour',
+ 'components/notifications',
+ 'components/command-palette',
+ 'components/global-search',
+ 'components/auth',
+ 'components/error',
+ 'app/(main)/[locale]/login',
+];
+
+function sources(dir: string): string[] {
+ return readdirSync(dir).flatMap((name) => {
+ const path = join(dir, name);
+ if (name === '__tests__' || name === 'node_modules') return [];
+ if (statSync(path).isDirectory()) return sources(path);
+ return /\.tsx?$/.test(name) ? [path] : [];
+ });
+}
+
+describe('directional icons in right-to-left languages', () => {
+ it('rely on the global mirror only', () => {
+ const offenders = ROOTS.flatMap(sources).flatMap((file) =>
+ readFileSync(file, 'utf8')
+ .split('\n')
+ .map((line, i) => ({ line, at: `${file}:${i + 1}` }))
+ .filter(({ line }) => /<(Chevron|Arrow)\w*[^>]*\brtl:(rotate-180|-scale-x-100)/.test(line))
+ .map(({ at }) => at),
+ );
+ expect(offenders).toEqual([]);
+ });
+});
diff --git a/components/plugins/plugin-sidebar-sections.tsx b/components/plugins/plugin-sidebar-sections.tsx
index bb77c2287..65f287da6 100644
--- a/components/plugins/plugin-sidebar-sections.tsx
+++ b/components/plugins/plugin-sidebar-sections.tsx
@@ -52,7 +52,7 @@ function PluginSidebarSection({ pluginId, extraProps }: { pluginId: string; extr
className="group flex flex-1 min-w-0 items-center select-none rounded-sm hover:bg-muted/40 transition-colors"
>
{collapsed ? (
-
+
) : (
)}
From 95a15d1fc6d23c4261464bed59dd87a58315ed52 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 03:38:26 +0100
Subject: [PATCH 13/29] fix: draw the push and install prompts in the theme's
colours and keep them above the phone's tab bar
---
components/push-notification-prompt.tsx | 54 +++++++++----------------
components/pwa-install-prompt.tsx | 41 +++++++------------
2 files changed, 33 insertions(+), 62 deletions(-)
diff --git a/components/push-notification-prompt.tsx b/components/push-notification-prompt.tsx
index da584f836..279145cca 100644
--- a/components/push-notification-prompt.tsx
+++ b/components/push-notification-prompt.tsx
@@ -3,7 +3,10 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
-import { Bell, Loader2, X } from "@/components/icons";
+import { Bell, X } from "@/components/icons";
+import { Button } from "@/components/ui/button";
+import { IconButton } from "@/components/ui/icon-button";
+import { Spinner } from "@/components/ui/spinner";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { usePolicyStore } from "@/stores/policy-store";
@@ -327,28 +330,22 @@ export function PushNotificationPrompt() {
if (!showPrompt) return null;
return (
-
+
-
+
-
+
{tPush("title")}
-
+
{tPush("description")}
-
-
-
+
+
+
{error ? (
@@ -357,32 +354,17 @@ export function PushNotificationPrompt() {
) : null}
-
+
{tInstall("not_now")}
-
- void handleEnable()}
- disabled={isEnabling}
- className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors disabled:opacity-50"
- >
- {isEnabling ? : null}
+
+ void handleEnable()} disabled={isEnabling}>
+ {isEnabling ? : null}
{tPush("enable")}
-
+
-
+
{tInstall("dont_remind")}
-
+
);
diff --git a/components/pwa-install-prompt.tsx b/components/pwa-install-prompt.tsx
index 20195bdaf..61545ae85 100644
--- a/components/pwa-install-prompt.tsx
+++ b/components/pwa-install-prompt.tsx
@@ -3,6 +3,8 @@
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { X, Download } from "@/components/icons";
+import { Button } from "@/components/ui/button";
+import { IconButton } from "@/components/ui/icon-button";
import { useConfig } from "@/hooks/use-config";
import { withBasePath } from "@/lib/browser-navigation";
@@ -88,7 +90,7 @@ export function PWAInstallPrompt() {
const darkLogoSrc = withBasePath(appLogoDarkUrl || faviconUrl);
return (
-
+
{logoSrc ? (
@@ -98,7 +100,7 @@ export function PWAInstallPrompt() {
className="w-8 h-8 shrink-0 object-contain dark:hidden"
/>
) : (
-
+
)}
{logoSrc && (
)}
-
+
{t("title", { appName })}
-
+
{t("description")}
-
-
-
+
+
+
-
+
{t("not_now")}
-
-
+
+
{t("install")}
-
+
-
+
{t("dont_remind")}
-
+
);
From cca8b617e6ea5559561cbe546720c7ff967e67ac Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 03:41:24 +0100
Subject: [PATCH 14/29] fix: confirm account removal in the app's dialog, show
its remove button on touch screens, and draw every sign-out row the same
---
.../account-switcher-remove.test.tsx | 59 +++++++++++++++++++
components/layout/account-switcher.tsx | 46 ++++++++++-----
components/layout/navigation-rail.tsx | 2 +-
locales/ar/common.json | 1 +
locales/ca/common.json | 1 +
locales/cs/common.json | 1 +
locales/da/common.json | 1 +
locales/de/common.json | 1 +
locales/en/common.json | 1 +
locales/es/common.json | 1 +
locales/et/common.json | 1 +
locales/fa/common.json | 1 +
locales/fr/common.json | 1 +
locales/he/common.json | 1 +
locales/hu/common.json | 1 +
locales/it/common.json | 1 +
locales/ja/common.json | 1 +
locales/ko/common.json | 1 +
locales/lv/common.json | 1 +
locales/mn/common.json | 1 +
locales/nb/common.json | 1 +
locales/nl/common.json | 1 +
locales/pl/common.json | 1 +
locales/pt/common.json | 1 +
locales/ro/common.json | 1 +
locales/ru/common.json | 1 +
locales/sk/common.json | 1 +
locales/th/common.json | 1 +
locales/tr/common.json | 1 +
locales/uk/common.json | 1 +
locales/zh-TW/common.json | 1 +
locales/zh/common.json | 1 +
32 files changed, 119 insertions(+), 17 deletions(-)
create mode 100644 components/layout/__tests__/account-switcher-remove.test.tsx
diff --git a/components/layout/__tests__/account-switcher-remove.test.tsx b/components/layout/__tests__/account-switcher-remove.test.tsx
new file mode 100644
index 000000000..f287edb60
--- /dev/null
+++ b/components/layout/__tests__/account-switcher-remove.test.tsx
@@ -0,0 +1,59 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+// Removing an account from the popover: the app's confirm dialog instead of
+// the browser's, a control a touch screen can reach, and sign-out rows in
+// the menu's plain text colour.
+
+const { AccountSwitcher } = await import('../account-switcher');
+const { useAccountStore } = await import('@/stores/account-store');
+const { useAuthStore } = await import('@/stores/auth-store');
+const { useSettingsStore } = await import('@/stores/settings-store');
+
+function account(id: string) {
+ return {
+ id, label: `${id}@example.com`, email: `${id}@example.com`, username: `${id}@example.com`,
+ displayName: id.toUpperCase(), serverUrl: 'https://mail.example.com', avatarColor: '#336699',
+ cookieSlot: 0, isConnected: true, isDefault: id === 'a',
+ };
+}
+
+const removeAccount = vi.fn();
+
+beforeEach(() => {
+ removeAccount.mockClear();
+ useAccountStore.setState({ accounts: [account('a'), account('b')] } as never);
+ useAuthStore.setState({ activeAccountId: 'a', removeAccount } as never);
+ useSettingsStore.setState({ interfaceLayout: 'default' } as never);
+});
+
+describe('removing an account from the popover', () => {
+ it('asks with the app dialog, not the browser, and removes on confirm', async () => {
+ const browserConfirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
+ render(
);
+ fireEvent.click(screen.getByTestId('account-switcher'));
+ fireEvent.click(screen.getByRole('menuitem', { name: 'remove_account' }));
+
+ expect(browserConfirm).not.toHaveBeenCalled();
+ const dialog = screen.getByRole('alertdialog', { name: 'remove_account_title' });
+ expect(removeAccount).not.toHaveBeenCalled();
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'remove_account' })); });
+ expect(removeAccount).toHaveBeenCalledWith('b');
+ expect(dialog).not.toBeInTheDocument();
+ browserConfirm.mockRestore();
+ });
+
+ it('keeps the remove control visible on a touch screen', () => {
+ render(
);
+ fireEvent.click(screen.getByTestId('account-switcher'));
+ expect(screen.getByRole('menuitem', { name: 'remove_account' }).className).toContain('pointer-coarse:opacity-100');
+ });
+
+ it('draws "sign out of all accounts" like the other rows, start-aligned', () => {
+ render(
);
+ fireEvent.click(screen.getByTestId('account-switcher'));
+ const all = screen.getByRole('menuitem', { name: 'sign_out_all' });
+ expect(all.className).not.toContain('text-destructive');
+ expect(all.className).toContain('text-start');
+ });
+});
diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx
index 756fc7e37..d771a2110 100644
--- a/components/layout/account-switcher.tsx
+++ b/components/layout/account-switcher.tsx
@@ -16,6 +16,9 @@ import { cn, formatFileSize } from "@/lib/utils";
import { useRouter } from "@/i18n/navigation";
import { Avatar } from "@/components/ui/avatar";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
+import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
+import { ConfirmDialog } from "@/components/ui/confirm-dialog";
+import { IconButton } from "@/components/ui/icon-button";
import { toUnicodeDomain, toUnicodeEmail } from "@/lib/idn";
interface AccountSwitcherProps {
@@ -103,6 +106,7 @@ export function AccountSwitcher({
const logout = useAuthStore((s) => s.logout);
const removeAccount = useAuthStore((s) => s.removeAccount);
const logoutAll = useAuthStore((s) => s.logoutAll);
+ const { dialogProps: confirmDialogProps, confirm } = useConfirmDialog();
const updatePosition = useCallback(() => {
if (!buttonRef.current) return;
@@ -190,11 +194,18 @@ export function AccountSwitcher({
router.push(`/login?mode=add-account` as never);
};
- const handleRemove = (e: React.MouseEvent, account: AccountEntry) => {
+ const handleRemove = async (e: React.MouseEvent, account: AccountEntry) => {
e.stopPropagation();
const label = toUnicodeEmail(account.email || account.username);
- if (!window.confirm(t("remove_account_confirm", { account: label }))) return;
- removeAccount(account.id);
+ // The popover closes first: the dialog is the only thing asking now.
+ setOpen(false);
+ const ok = await confirm({
+ title: t("remove_account_title"),
+ message: t("remove_account_confirm", { account: label }),
+ confirmText: t("remove_account"),
+ variant: "destructive",
+ });
+ if (ok) removeAccount(account.id);
};
const handleLogout = () => {
@@ -403,7 +414,7 @@ export function AccountSwitcher({
className={cn(
"w-full flex items-start gap-3 px-3 py-2.5 text-start transition-colors",
isActive ? "bg-accent/50" : "hover:bg-muted",
- (!isActive && !account.isDefault) ? (isDraggable ? "pe-14" : "pe-8") : (isDraggable && "pe-7")
+ (!isActive && !account.isDefault) ? (isDraggable ? "pe-16" : "pe-10") : (isDraggable && "pe-7")
)}
role="menuitem"
disabled={isActive}
@@ -449,22 +460,24 @@ export function AccountSwitcher({
{isDraggable && (
)}
{!isActive && !account.isDefault && (
-
handleRemove(e, account)}
+ // Shown on hover and focus, and always on a touch screen (R7).
+ void handleRemove(e, account)}
role="menuitem"
- aria-label={t("remove_account")}
- title={t("remove_account")}
- className="absolute end-1.5 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground/60 opacity-0 transition-opacity group-hover/acct:opacity-100 hover:bg-destructive/10 hover:text-destructive focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-destructive"
+ label={t("remove_account")}
+ className="absolute end-1.5 top-1/2 -translate-y-1/2 text-muted-foreground opacity-0 transition-opacity group-hover/acct:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 hover:bg-destructive/10 hover:text-destructive"
>
-
-
+
+
)}
);
@@ -544,7 +557,7 @@ export function AccountSwitcher({
@@ -568,7 +581,7 @@ export function AccountSwitcher({
)}
@@ -577,7 +590,7 @@ export function AccountSwitcher({
{!mvPopover && accounts.length > 1 && (
@@ -588,6 +601,7 @@ export function AccountSwitcher({
,
document.body
)}
+
>
);
}
diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx
index 0ecc78380..89971cfee 100644
--- a/components/layout/navigation-rail.tsx
+++ b/components/layout/navigation-rail.tsx
@@ -869,7 +869,7 @@ export function NavigationRail({
{accounts.length > 1 && (
{ setLogoutMenuOpen(false); logoutAll(); }}
- className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-destructive hover:bg-muted transition-colors"
+ className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-start text-foreground hover:bg-muted transition-colors"
role="menuitem"
>
diff --git a/locales/ar/common.json b/locales/ar/common.json
index c1bc8093e..85b15c79f 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "كل المميز بنجمة",
"unified_all_mail": "كل البريد",
"remove_account": "إزالة الحساب",
+ "remove_account_title": "إزالة الحساب؟",
"remove_account_confirm": "هل تريد إزالة {account} من هذا الجهاز؟ يمكنك إضافته مرة أخرى لاحقًا.",
"labels": "التصنيفات",
"create_label": "إنشاء تصنيف جديد",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 2265c1db3..f0614d43e 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "Tots els destacats",
"unified_all_mail": "Tot el correu",
"remove_account": "Elimina el compte",
+ "remove_account_title": "Voleu treure el compte?",
"remove_account_confirm": "Voleu eliminar {account} d'aquest dispositiu? El podreu tornar a afegir més endavant.",
"labels": "Etiquetes",
"create_label": "Crea una etiqueta nova",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index a5751e9f9..b921779e1 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "Vše s hvězdičkou",
"unified_all_mail": "Veškerá pošta",
"remove_account": "Odebrat účet",
+ "remove_account_title": "Odebrat účet?",
"remove_account_confirm": "Odebrat účet {account} z tohoto zařízení? Později ho můžete znovu přidat.",
"labels": "Štítky",
"create_label": "Vytvořit nový štítek",
diff --git a/locales/da/common.json b/locales/da/common.json
index 69bec66e7..9e39f9bb6 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "Alle med stjerne",
"unified_all_mail": "Al post",
"remove_account": "Remove account",
+ "remove_account_title": "Fjern konto?",
"remove_account_confirm": "Remove {account} from this device? You can add it back later.",
"labels": "Etiketter",
"create_label": "Opret ny etiket",
diff --git a/locales/de/common.json b/locales/de/common.json
index e78c5a2d6..288cfb3c6 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "Alle markierten",
"unified_all_mail": "Alle Mails",
"remove_account": "Konto entfernen",
+ "remove_account_title": "Konto entfernen?",
"remove_account_confirm": "{account} von diesem Gerät entfernen? Sie können das Konto später wieder hinzufügen.",
"labels": "Labels",
"create_label": "Neues Label erstellen",
diff --git a/locales/en/common.json b/locales/en/common.json
index 0eefae9e6..fedbaeb2b 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "All starred",
"unified_all_mail": "All mail",
"remove_account": "Remove account",
+ "remove_account_title": "Remove account?",
"remove_account_confirm": "Remove {account} from this device? You can add it back later.",
"labels": "Labels",
"create_label": "Create new label",
diff --git a/locales/es/common.json b/locales/es/common.json
index 9a8105aa1..572b3a824 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "Todo destacado",
"unified_all_mail": "Todo el correo",
"remove_account": "Remove account",
+ "remove_account_title": "¿Quitar la cuenta?",
"remove_account_confirm": "Remove {account} from this device? You can add it back later.",
"labels": "Etiquetas",
"create_label": "Crear etiqueta",
diff --git a/locales/et/common.json b/locales/et/common.json
index 5a9692f60..dd8aa0c6b 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "Kõik tähega tähistatud",
"unified_all_mail": "Kõik kirjad",
"remove_account": "Eemalda konto",
+ "remove_account_title": "Kas eemaldada konto?",
"remove_account_confirm": "Eemalda {account} sellest seadmest? Saad selle hiljem uuesti lisada.",
"labels": "Sildid",
"create_label": "Loo uus silt",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index da6a7f2cc..c45aa6ef0 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "همه ستارهدارها",
"unified_all_unread": "همه خواندهنشدهها",
"remove_account": "Remove account",
+ "remove_account_title": "حساب حذف شود؟",
"remove_account_confirm": "Remove {account} from this device? You can add it back later.",
"labels": "برچسبها",
"create_label": "ایجاد برچسب جدید",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 6f8761056..5e35bc960 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "Tous les favoris",
"unified_all_mail": "Tout le courrier",
"remove_account": "Remove account",
+ "remove_account_title": "Retirer le compte ?",
"remove_account_confirm": "Remove {account} from this device? You can add it back later.",
"labels": "Libellés",
"create_label": "Créer un libellé",
diff --git a/locales/he/common.json b/locales/he/common.json
index 011fde4e8..701d22da7 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -181,6 +181,7 @@
"unified_all_starred": "הכל מסומנים",
"unified_all_mail": "הכל דוא״ל",
"remove_account": "הסרת חשבון",
+ "remove_account_title": "להסיר את החשבון?",
"remove_account_confirm": "להסיר את {account} מהמכשיר הזה? ניתן להוסיף אותו שוב מאוחר יותר.",
"labels": "תוויות",
"create_label": "יצירת תווית חדשה",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 754305723..44f2ea9a1 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "Összes csillagozott",
"unified_all_mail": "Összes levél",
"remove_account": "Remove account",
+ "remove_account_title": "Eltávolítja a fiókot?",
"remove_account_confirm": "Remove {account} from this device? You can add it back later.",
"labels": "Címkék",
"create_label": "Új címke létrehozása",
diff --git a/locales/it/common.json b/locales/it/common.json
index 119871be5..24874ca00 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "Tutti gli speciali",
"unified_all_mail": "Tutta la posta",
"remove_account": "Remove account",
+ "remove_account_title": "Rimuovere l'account?",
"remove_account_confirm": "Remove {account} from this device? You can add it back later.",
"labels": "Etichette",
"create_label": "Crea nuova etichetta",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 489d7f48f..38922e1e6 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "すべてのスター付き",
"unified_all_mail": "すべてのメール",
"remove_account": "Remove account",
+ "remove_account_title": "アカウントを削除しますか?",
"remove_account_confirm": "Remove {account} from this device? You can add it back later.",
"labels": "ラベル",
"create_label": "新しいラベルを作成",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 667707691..95172af3e 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "모든 별표",
"unified_all_mail": "모든 메일",
"remove_account": "Remove account",
+ "remove_account_title": "계정을 제거할까요?",
"remove_account_confirm": "Remove {account} from this device? You can add it back later.",
"labels": "라벨",
"create_label": "새 라벨 만들기",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 53fe7511a..5294d6063 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "Visi ar zvaigzni",
"unified_all_mail": "Visas vēstules",
"remove_account": "Remove account",
+ "remove_account_title": "Noņemt kontu?",
"remove_account_confirm": "Remove {account} from this device? You can add it back later.",
"labels": "Etiķetes",
"create_label": "Izveidot jaunu etiķeti",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index c673150a5..5b13cf362 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "Бүх одтой",
"unified_all_mail": "Бүх шуудан",
"remove_account": "Бүртгэл устгах",
+ "remove_account_title": "Бүртгэлийг хасах уу?",
"remove_account_confirm": "Энэ төхөөрөмжөөс {account}-г устгах уу? Та дараа дахин нэмж болно.",
"labels": "Шошго",
"create_label": "Шинэ шошго үүсгэх",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 8b25396ba..f563da68b 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "Alle stjernemerkede",
"unified_all_mail": "All e-post",
"remove_account": "Fjern konto",
+ "remove_account_title": "Fjerne kontoen?",
"remove_account_confirm": "Vil du fjerne {account} fra denne enheten? Du kan legge den til igjen senere.",
"labels": "Etiketter",
"create_label": "Opprett ny etikett",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index d501ec9cd..afa37bff1 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "Alle met ster",
"unified_all_mail": "Alle e-mail",
"remove_account": "Remove account",
+ "remove_account_title": "Account verwijderen?",
"remove_account_confirm": "Remove {account} from this device? You can add it back later.",
"labels": "Labels",
"create_label": "Nieuw label maken",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 4ea8489d4..9b2c3f7f4 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "Wszystkie oznaczone gwiazdką",
"unified_all_mail": "Cała poczta",
"remove_account": "Remove account",
+ "remove_account_title": "Usunąć konto?",
"remove_account_confirm": "Remove {account} from this device? You can add it back later.",
"labels": "Etykiety",
"create_label": "Utwórz etykietę",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 175c6b5e2..d08ff7d30 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "Tudo com estrela",
"unified_all_mail": "Todo o correio",
"remove_account": "Remover conta",
+ "remove_account_title": "Remover a conta?",
"remove_account_confirm": "Remover {account} desse dispositivo? Você pode adicioná-la de volta depois.",
"labels": "Etiquetas",
"create_label": "Criar etiqueta",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 3ee0c0726..e9cb8c328 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "Toate cu stea",
"unified_all_mail": "Toată poșta",
"remove_account": "Remove account",
+ "remove_account_title": "Eliminați contul?",
"remove_account_confirm": "Remove {account} from this device? You can add it back later.",
"labels": "Etichete",
"create_label": "Creează o etichetă nouă",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index e9cba9a9d..c25814daa 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "Все помеченные",
"unified_all_mail": "Вся почта",
"remove_account": "Remove account",
+ "remove_account_title": "Удалить учётную запись?",
"remove_account_confirm": "Remove {account} from this device? You can add it back later.",
"labels": "Ярлыки",
"create_label": "Создать ярлык",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index ed206d5a0..c5ee5065f 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "Všetky s hviezdičkou",
"unified_all_mail": "Všetka pošta",
"remove_account": "Odstrániť účet",
+ "remove_account_title": "Odstrániť účet?",
"remove_account_confirm": "Odstrániť {account} z tohto zariadenia? Neskôr ho môžete znova pridať.",
"labels": "Štítky",
"create_label": "Vytvoriť nový štítok",
diff --git a/locales/th/common.json b/locales/th/common.json
index 14b8c55ef..34d52b387 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -283,6 +283,7 @@
"unified_all_starred": "ติดดาวทั้งหมด",
"unified_all_mail": "จดหมายทั้งหมด",
"remove_account": "นำบัญชีออก",
+ "remove_account_title": "นำบัญชีออกหรือไม่",
"remove_account_confirm": "ต้องการนำ {account} ออกจากอุปกรณ์นี้หรือไม่? คุณสามารถเพิ่มกลับเข้ามาใหม่ได้ในภายหลัง",
"labels": "ป้ายกำกับ",
"create_label": "สร้างป้ายกำกับใหม่",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 4139e1bb0..67d832661 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "Tüm yıldızlılar",
"unified_all_mail": "Tüm postalar",
"remove_account": "Remove account",
+ "remove_account_title": "Hesap kaldırılsın mı?",
"remove_account_confirm": "Remove {account} from this device? You can add it back later.",
"labels": "Etiketler",
"create_label": "Yeni etiket oluştur",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index f179ca05f..66d650572 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "Усі позначені",
"unified_all_mail": "Уся пошта",
"remove_account": "Remove account",
+ "remove_account_title": "Вилучити обліковий запис?",
"remove_account_confirm": "Remove {account} from this device? You can add it back later.",
"labels": "Мітки",
"create_label": "Створити мітку",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 160ad6140..53f7a2671 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "全部已加星號",
"unified_all_mail": "全部郵件",
"remove_account": "移除帳號",
+ "remove_account_title": "移除帳戶?",
"remove_account_confirm": "要從這部裝置移除 {account} 嗎?您稍後仍可重新新增。",
"labels": "標籤",
"create_label": "建立新標籤",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index e01ceb86d..1b6b532ef 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -182,6 +182,7 @@
"unified_all_starred": "全部加星标",
"unified_all_mail": "全部邮件",
"remove_account": "Remove account",
+ "remove_account_title": "移除账户?",
"remove_account_confirm": "Remove {account} from this device? You can add it back later.",
"labels": "标签",
"create_label": "新建标签",
From d3c6e7f5db53103338449b1888ad92ecb06d8a5e Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 03:42:37 +0100
Subject: [PATCH 15/29] fix: close the rail's storage popover with Escape
---
.../navigation-rail-storage.test.tsx | 38 +++++++++++++++++++
components/layout/navigation-rail.tsx | 12 +++++-
2 files changed, 49 insertions(+), 1 deletion(-)
create mode 100644 components/layout/__tests__/navigation-rail-storage.test.tsx
diff --git a/components/layout/__tests__/navigation-rail-storage.test.tsx b/components/layout/__tests__/navigation-rail-storage.test.tsx
new file mode 100644
index 000000000..60d7fe9b7
--- /dev/null
+++ b/components/layout/__tests__/navigation-rail-storage.test.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+// The rail's storage ring opens a popover that Escape closes, like every
+// other popover in the rail.
+
+vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/components/keyboard-shortcuts-modal', () => ({ KeyboardShortcutsModal: () => null }));
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
+vi.mock('@/hooks/use-stalwart-admin', () => ({ useStalwartAdmin: () => false }));
+vi.mock('@/i18n/navigation', () => ({
+ useRouter: () => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() }),
+ usePathname: () => '/',
+ Link: ({ children, href, ...rest }: { children: React.ReactNode; href: string }) => {children} ,
+}));
+
+const { NavigationRail } = await import('../navigation-rail');
+const { useUpdateStore } = await import('@/stores/update-store');
+
+beforeEach(() => {
+ useUpdateStore.setState({ startPolling: vi.fn() } as never);
+});
+
+describe('storage popover', () => {
+ it('closes on Escape and gives the focus back to the ring', () => {
+ render( );
+ const ring = screen.getByRole('button', { name: 'storage' });
+ fireEvent.click(ring);
+ expect(ring).toHaveAttribute('aria-expanded', 'true');
+ expect(screen.getByText('storage_total')).toBeInTheDocument();
+
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(screen.queryByText('storage_total')).toBeNull();
+ expect(ring).toHaveFocus();
+ });
+});
diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx
index 89971cfee..1ea2ad81f 100644
--- a/components/layout/navigation-rail.tsx
+++ b/components/layout/navigation-rail.tsx
@@ -120,8 +120,17 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: StorageQuota; usag
) return;
setOpen(false);
};
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key !== "Escape") return;
+ setOpen(false);
+ buttonRef.current?.focus();
+ };
document.addEventListener("mousedown", handleClick);
- return () => document.removeEventListener("mousedown", handleClick);
+ document.addEventListener("keydown", handleEscape);
+ return () => {
+ document.removeEventListener("mousedown", handleClick);
+ document.removeEventListener("keydown", handleEscape);
+ };
}, [open]);
// Usage can legitimately exceed the quota (e.g. limit lowered after the fact)
@@ -140,6 +149,7 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: StorageQuota; usag
onClick={() => setOpen(!open)}
className="relative w-8 h-8 flex items-center justify-center rounded-full hover:bg-muted transition-colors cursor-pointer"
aria-label={t("storage")}
+ aria-expanded={open}
>
From 1386b129082d7b4fff765ba8fbf441458a1da548 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 03:43:51 +0100
Subject: [PATCH 16/29] fix: open Out of Office from the vacation banner
instead of the last settings page
---
.../sidebar-vacation-banner.test.tsx | 37 +++++++++++++++++++
components/layout/sidebar.tsx | 2 +-
2 files changed, 38 insertions(+), 1 deletion(-)
create mode 100644 components/layout/__tests__/sidebar-vacation-banner.test.tsx
diff --git a/components/layout/__tests__/sidebar-vacation-banner.test.tsx b/components/layout/__tests__/sidebar-vacation-banner.test.tsx
new file mode 100644
index 000000000..1d2c8ff25
--- /dev/null
+++ b/components/layout/__tests__/sidebar-vacation-banner.test.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+
+// "Vacation responder is active" opens Settings > Out of Office, not
+// whichever settings page was open last.
+
+const nav = vi.hoisted(() => ({ push: vi.fn() }));
+vi.mock('@/i18n/navigation', () => ({
+ useRouter: () => ({ push: nav.push, back: vi.fn(), replace: vi.fn() }),
+ usePathname: () => '/',
+ Link: ({ children }: { children: React.ReactNode }) => children,
+}));
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/components/tour/tour-provider', () => ({
+ useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }),
+}));
+vi.mock('@/contexts/drag-drop-context', () => ({ useDragDropContext: () => ({ isDragging: false }) }));
+vi.mock('@/hooks/use-mailbox-drop', () => ({
+ useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }),
+}));
+vi.mock('@/hooks/use-tag-drop', () => ({ useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }) }));
+vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
+
+const { Sidebar } = await import('../sidebar');
+const { useVacationStore } = await import('@/stores/vacation-store');
+const { buildSettingsPath } = await import('@/lib/deep-links');
+
+describe('vacation banner', () => {
+ it('opens the Out of Office settings', () => {
+ useVacationStore.setState({ isEnabled: true, isSupported: true } as never);
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /vacation_active/ }));
+ expect(nav.push).toHaveBeenCalledWith(buildSettingsPath('vacation'));
+ expect(buildSettingsPath('vacation')).not.toBe('/settings');
+ });
+});
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx
index d9019376e..2ae285a74 100644
--- a/components/layout/sidebar.tsx
+++ b/components/layout/sidebar.tsx
@@ -894,7 +894,7 @@ function VacationBanner() {
return (
router.push('/settings')}
+ onClick={() => router.push(buildSettingsPath('vacation'))}
className={cn(
"flex items-center gap-2 w-full px-3 py-2 text-xs",
"bg-amber-500/10 dark:bg-amber-400/10 text-amber-700 dark:text-amber-400",
From 6994dc2ce669edf44ffef77092e4dd835a9b7261 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 03:46:40 +0100
Subject: [PATCH 17/29] fix: send a Lite link without a language to the same
page in the reader's language instead of a bare 404
---
lib/__tests__/lite-scripts.test.ts | 24 ++++++++++++++++-
scripts/lite/lib.mjs | 42 +++++++++++++++++++++++++-----
scripts/lite/postbuild.mjs | 2 +-
3 files changed, 60 insertions(+), 8 deletions(-)
diff --git a/lib/__tests__/lite-scripts.test.ts b/lib/__tests__/lite-scripts.test.ts
index e879ebb6f..7eb8bf3e2 100644
--- a/lib/__tests__/lite-scripts.test.ts
+++ b/lib/__tests__/lite-scripts.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
import { createHash } from 'node:crypto';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
@@ -208,6 +208,28 @@ describe('lite build helpers', () => {
expect(LITE_PENDING_PATH_KEY).toBe(APP_LITE_PENDING_PATH_KEY);
});
+ it('sends a path without a locale to the same path in the reader locale', () => {
+ const html = buildNotFoundShim({ basePath: '/webmail', locales: ['de', 'en', 'zh-TW'], defaultLocale: 'en' });
+ const script = html.match(/
diff --git a/scripts/lite/postbuild.mjs b/scripts/lite/postbuild.mjs
index 744b95726..dbe423c25 100644
--- a/scripts/lite/postbuild.mjs
+++ b/scripts/lite/postbuild.mjs
@@ -78,7 +78,7 @@ function staticFiles({ config, basePath, locales, defaultLocale, version, commit
"index.html": buildRootRedirect({ basePath, locales, defaultLocale }),
// Replaces Next's default not-found page: only this shim replays deep links
// on hosts without rewrite rules (see buildNotFoundShim).
- "404.html": buildNotFoundShim({ basePath, locales }),
+ "404.html": buildNotFoundShim({ basePath, locales, defaultLocale }),
"_redirects": buildRedirects({ basePath, locales }),
"_headers": buildHeaders({ basePath, connectSrc }),
"nginx.conf.example": buildNginxExample({ basePath, connectSrc }),
From 9ee767f5763b1f201800986aebd7156de0286441 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 03:52:40 +0100
Subject: [PATCH 18/29] fix: show a translated 404 to a signed-in user who
mistypes a URL instead of the login page
---
app/(main)/[locale]/[...rest]/page.tsx | 8 +++
app/(main)/[locale]/not-found.tsx | 10 +++
app/(main)/not-found.tsx | 47 +++---------
.../error/__tests__/not-found-view.test.tsx | 54 ++++++++++++++
components/error/not-found-view.tsx | 71 +++++++++++++++++++
locales/ar/common.json | 4 ++
locales/ca/common.json | 4 ++
locales/cs/common.json | 4 ++
locales/da/common.json | 4 ++
locales/de/common.json | 4 ++
locales/en/common.json | 4 ++
locales/es/common.json | 4 ++
locales/et/common.json | 4 ++
locales/fa/common.json | 4 ++
locales/fr/common.json | 4 ++
locales/he/common.json | 4 ++
locales/hu/common.json | 4 ++
locales/it/common.json | 4 ++
locales/ja/common.json | 4 ++
locales/ko/common.json | 4 ++
locales/lv/common.json | 4 ++
locales/mn/common.json | 4 ++
locales/nb/common.json | 4 ++
locales/nl/common.json | 4 ++
locales/pl/common.json | 4 ++
locales/pt/common.json | 4 ++
locales/ro/common.json | 4 ++
locales/ru/common.json | 4 ++
locales/sk/common.json | 4 ++
locales/th/common.json | 4 ++
locales/tr/common.json | 4 ++
locales/uk/common.json | 4 ++
locales/zh-TW/common.json | 4 ++
locales/zh/common.json | 4 ++
scripts/lite/lib.mjs | 1 +
35 files changed, 269 insertions(+), 38 deletions(-)
create mode 100644 app/(main)/[locale]/[...rest]/page.tsx
create mode 100644 app/(main)/[locale]/not-found.tsx
create mode 100644 components/error/__tests__/not-found-view.test.tsx
create mode 100644 components/error/not-found-view.tsx
diff --git a/app/(main)/[locale]/[...rest]/page.tsx b/app/(main)/[locale]/[...rest]/page.tsx
new file mode 100644
index 000000000..75c6193d9
--- /dev/null
+++ b/app/(main)/[locale]/[...rest]/page.tsx
@@ -0,0 +1,8 @@
+import { notFound } from 'next/navigation';
+
+// Unknown paths below a locale (/de/typo) end here, so the 404 renders
+// inside the locale layout and is translated ([locale]/not-found.tsx).
+// Removed from the static Lite export, like (main)/[...rest].
+export default function LocaleCatchAll() {
+ notFound();
+}
diff --git a/app/(main)/[locale]/not-found.tsx b/app/(main)/[locale]/not-found.tsx
new file mode 100644
index 000000000..e1bcd8fe4
--- /dev/null
+++ b/app/(main)/[locale]/not-found.tsx
@@ -0,0 +1,10 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { NotFoundView } from "@/components/error/not-found-view";
+
+export default function LocaleNotFound() {
+ const t = useTranslations("not_found");
+ const tErrors = useTranslations("errors");
+ return ;
+}
diff --git a/app/(main)/not-found.tsx b/app/(main)/not-found.tsx
index 8e9c79550..38b171963 100644
--- a/app/(main)/not-found.tsx
+++ b/app/(main)/not-found.tsx
@@ -1,14 +1,14 @@
"use client";
import { useEffect } from "react";
-import { useAuthStore } from "@/stores/auth-store";
import { getPathPrefix } from "@/lib/browser-navigation";
+import { NotFoundView } from "@/components/error/not-found-view";
import { IS_LITE } from "@/lib/lite";
import { liteSurfaceRootFor, stashPendingLitePath } from "@/lib/lite-link-segments";
+// Outside the locale tree there are no translations: paths below a locale
+// get the translated page in [locale]/not-found.tsx.
export default function NotFound() {
- const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
-
useEffect(() => {
// Static Lite build on a host without rewrite rules (GitHub Pages): a deep
// link such as /en/mail/thread/abc lands here as 404.html. Park the link
@@ -21,42 +21,13 @@ export default function NotFound() {
return;
}
}
- if (!isAuthenticated) {
- const prefix = getPathPrefix();
- // Don't redirect admin routes to the webmail login page. Admin paths
- // are mounted relative to the deployment prefix, so account for it.
- const adminBase = `${prefix}/admin`;
- const isAdminRoute = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
- if (!isAdminRoute) {
- window.location.href = `${prefix}/login`;
- }
- }
- }, [isAuthenticated]);
-
- if (!isAuthenticated) {
- let isAdmin = false;
- if (typeof window !== 'undefined') {
- const prefix = getPathPrefix();
- const adminBase = `${prefix}/admin`;
- isAdmin = window.location.pathname === adminBase || window.location.pathname.startsWith(`${adminBase}/`);
- }
- if (!isAdmin) return null;
- }
-
- const prefix = typeof window !== 'undefined' ? getPathPrefix() : '';
+ }, []);
return (
-
+
);
}
diff --git a/components/error/__tests__/not-found-view.test.tsx b/components/error/__tests__/not-found-view.test.tsx
new file mode 100644
index 000000000..f0869b3ac
--- /dev/null
+++ b/components/error/__tests__/not-found-view.test.tsx
@@ -0,0 +1,54 @@
+import { act, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// A mistyped URL with a still valid session: the 404 waits for the session
+// to be restored instead of sending the user to the login page.
+
+const { default: LocaleNotFound } = await import('@/app/(main)/[locale]/not-found');
+const { useAuthStore } = await import('@/stores/auth-store');
+
+const realLocation = window.location;
+let href = '';
+
+beforeEach(() => {
+ href = 'http://localhost/de/gibtsnicht';
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: {
+ ...realLocation,
+ pathname: '/de/gibtsnicht',
+ get href() { return href; },
+ set href(v: string) { href = v; },
+ },
+ });
+});
+afterEach(() => {
+ Object.defineProperty(window, 'location', { configurable: true, value: realLocation });
+});
+
+describe('404 page', () => {
+ it('restores the session before deciding, and shows the translated page to a signed-in user', async () => {
+ let finish: () => void = () => {};
+ const checkAuth = vi.fn(() => new Promise((resolve) => {
+ finish = () => { useAuthStore.setState({ isAuthenticated: true } as never); resolve(); };
+ }));
+ useAuthStore.setState({ isAuthenticated: false, checkAuth } as never);
+
+ render( );
+ expect(checkAuth).toHaveBeenCalled();
+ expect(href).not.toContain('/login');
+
+ await act(async () => { finish(); });
+ expect(href).not.toContain('/login');
+ expect(screen.getByRole('heading', { name: 'title' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'go_home' })).toBeInTheDocument();
+ });
+
+ it('sends a visitor without a session to the login page once that is known', async () => {
+ const checkAuth = vi.fn(() => Promise.resolve());
+ useAuthStore.setState({ isAuthenticated: false, checkAuth } as never);
+ render( );
+ await act(async () => {});
+ expect(href).toBe('/login');
+ });
+});
diff --git a/components/error/not-found-view.tsx b/components/error/not-found-view.tsx
new file mode 100644
index 000000000..aa42c06b4
--- /dev/null
+++ b/components/error/not-found-view.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { SearchX } from "@/components/icons";
+import { useAuthStore } from "@/stores/auth-store";
+import { getPathPrefix } from "@/lib/browser-navigation";
+
+/** Whether the current URL is an admin page, which has its own sign-in. */
+function isAdminPath(): boolean {
+ if (typeof window === "undefined") return false;
+ const adminBase = `${getPathPrefix()}/admin`;
+ const path = window.location.pathname;
+ return path === adminBase || path.startsWith(`${adminBase}/`);
+}
+
+/**
+ * Who is looking at a missing page: "checking" until the stored session has
+ * been restored, then signed in or not. Deciding before that sent a signed-in
+ * user with a mistyped URL to the login page.
+ */
+export function useNotFoundAuth(): "checking" | "signed-in" | "signed-out" {
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+ const [checked, setChecked] = useState(() => useAuthStore.getState().isAuthenticated);
+
+ useEffect(() => {
+ if (useAuthStore.getState().isAuthenticated) {
+ setChecked(true);
+ return;
+ }
+ let cancelled = false;
+ void useAuthStore.getState().checkAuth().finally(() => { if (!cancelled) setChecked(true); });
+ return () => { cancelled = true; };
+ }, []);
+
+ useEffect(() => {
+ if (!checked || isAuthenticated || isAdminPath()) return;
+ window.location.href = `${getPathPrefix()}/login`;
+ }, [checked, isAuthenticated]);
+
+ if (isAuthenticated) return "signed-in";
+ return checked ? "signed-out" : "checking";
+}
+
+export interface NotFoundViewProps {
+ title: string;
+ description: string;
+ homeLabel: string;
+}
+
+/** The 404 page: what happened and the way back. Signed out, it shows only on admin paths. */
+export function NotFoundView({ title, description, homeLabel }: NotFoundViewProps) {
+ const auth = useNotFoundAuth();
+ if (auth !== "signed-in" && !isAdminPath()) return null;
+
+ const prefix = typeof window !== "undefined" ? getPathPrefix() : "";
+ return (
+
+ );
+}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 85b15c79f..9680a164b 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -2625,6 +2625,10 @@
"retry": "إعادة المحاولة",
"go_home": "الانتقال إلى البريد الوارد"
},
+ "not_found": {
+ "title": "الصفحة غير موجودة",
+ "description": "هذه الصفحة غير موجودة أو نُقلت. تحقّق من العنوان أو عُد إلى صندوق الوارد."
+ },
"context_menu": {
"reply": "رد",
"reply_all": "الرد على الجميع",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index f0614d43e..76cc628b9 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -2625,6 +2625,10 @@
"retry": "Reintenta",
"go_home": "Vés a la safata d'entrada"
},
+ "not_found": {
+ "title": "No s'ha trobat la pàgina",
+ "description": "Aquesta pàgina no existeix o s'ha mogut. Comproveu l'adreça o torneu a la safata d'entrada."
+ },
"context_menu": {
"reply": "Respon",
"reply_all": "Respon a tots",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index b921779e1..b2a16fcb7 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -2625,6 +2625,10 @@
"retry": "Opakovat",
"go_home": "Přejít do doručené pošty"
},
+ "not_found": {
+ "title": "Stránka nenalezena",
+ "description": "Tato stránka neexistuje nebo byla přesunuta. Zkontrolujte adresu nebo se vraťte do doručené pošty."
+ },
"context_menu": {
"reply": "Odpovědět",
"reply_all": "Odpovědět všem",
diff --git a/locales/da/common.json b/locales/da/common.json
index 9e39f9bb6..6ba2d2f33 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -2625,6 +2625,10 @@
"retry": "Prøv igen",
"go_home": "Gå til indbakke"
},
+ "not_found": {
+ "title": "Siden blev ikke fundet",
+ "description": "Siden findes ikke eller er flyttet. Tjek adressen, eller gå tilbage til din indbakke."
+ },
"context_menu": {
"reply": "Svar",
"reply_all": "Svar alle",
diff --git a/locales/de/common.json b/locales/de/common.json
index 288cfb3c6..1461e4a8b 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -2625,6 +2625,10 @@
"retry": "Wiederholen",
"go_home": "Zum Posteingang"
},
+ "not_found": {
+ "title": "Seite nicht gefunden",
+ "description": "Diese Seite gibt es nicht oder sie wurde verschoben. Prüfen Sie die Adresse oder kehren Sie zu Ihrem Posteingang zurück."
+ },
"context_menu": {
"reply": "Antworten",
"reply_all": "Allen antworten",
diff --git a/locales/en/common.json b/locales/en/common.json
index fedbaeb2b..78719b817 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -2625,6 +2625,10 @@
"retry": "Retry",
"go_home": "Go to inbox"
},
+ "not_found": {
+ "title": "Page not found",
+ "description": "This page doesn't exist or has moved. Check the address, or go back to your inbox."
+ },
"context_menu": {
"reply": "Reply",
"reply_all": "Reply All",
diff --git a/locales/es/common.json b/locales/es/common.json
index 572b3a824..c98e82ab6 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -2625,6 +2625,10 @@
"retry": "Reintentar",
"go_home": "Ir a bandeja de entrada"
},
+ "not_found": {
+ "title": "No se ha encontrado la página",
+ "description": "Esta página no existe o se ha movido. Comprueba la dirección o vuelve a tu bandeja de entrada."
+ },
"context_menu": {
"reply": "Responder",
"reply_all": "Responder a Todos",
diff --git a/locales/et/common.json b/locales/et/common.json
index dd8aa0c6b..eaca8757c 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -2625,6 +2625,10 @@
"retry": "Proovi uuesti",
"go_home": "Mine postkasti"
},
+ "not_found": {
+ "title": "Lehte ei leitud",
+ "description": "Seda lehte pole olemas või see on teisaldatud. Kontrollige aadressi või minge tagasi postkasti."
+ },
"context_menu": {
"reply": "Vasta",
"reply_all": "Vasta kõigile",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index c45aa6ef0..2ee1e9747 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -2625,6 +2625,10 @@
"retry": "تلاش مجدد",
"go_home": "رفتن به صندوق ورودی"
},
+ "not_found": {
+ "title": "صفحه پیدا نشد",
+ "description": "این صفحه وجود ندارد یا جابهجا شده است. نشانی را بررسی کنید یا به صندوق ورودی برگردید."
+ },
"context_menu": {
"reply": "پاسخ",
"reply_all": "پاسخ به همه",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 5e35bc960..051a31a6c 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -2625,6 +2625,10 @@
"retry": "Réessayer",
"go_home": "Aller à la boîte de réception"
},
+ "not_found": {
+ "title": "Page introuvable",
+ "description": "Cette page n'existe pas ou a été déplacée. Vérifiez l'adresse ou revenez à votre boîte de réception."
+ },
"context_menu": {
"reply": "Répondre",
"reply_all": "Répondre à tous",
diff --git a/locales/he/common.json b/locales/he/common.json
index 701d22da7..bfa7059f4 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -2591,6 +2591,10 @@
"retry": "נסה שוב",
"go_home": "עבור לתיבת הדואר הנכנס"
},
+ "not_found": {
+ "title": "הדף לא נמצא",
+ "description": "הדף הזה לא קיים או שהועבר. בדקו את הכתובת או חזרו לתיבת הדואר הנכנס."
+ },
"context_menu": {
"reply": "השב",
"reply_all": "השב לכולם",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 44f2ea9a1..9efe67330 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -2625,6 +2625,10 @@
"retry": "Újra",
"go_home": "Ugrás a beérkezőhöz"
},
+ "not_found": {
+ "title": "Az oldal nem található",
+ "description": "Ez az oldal nem létezik, vagy áthelyezték. Ellenőrizze a címet, vagy térjen vissza a beérkezett üzenetekhez."
+ },
"context_menu": {
"reply": "Válasz",
"reply_all": "Válasz mindenkinek",
diff --git a/locales/it/common.json b/locales/it/common.json
index 24874ca00..38c756512 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -2625,6 +2625,10 @@
"retry": "Riprova",
"go_home": "Vai alla posta in arrivo"
},
+ "not_found": {
+ "title": "Pagina non trovata",
+ "description": "Questa pagina non esiste o è stata spostata. Controlla l'indirizzo o torna alla posta in arrivo."
+ },
"context_menu": {
"reply": "Rispondi",
"reply_all": "Rispondi a tutti",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 38922e1e6..d4978091c 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -2625,6 +2625,10 @@
"retry": "リトライ",
"go_home": "受信トレイに移動"
},
+ "not_found": {
+ "title": "ページが見つかりません",
+ "description": "このページは存在しないか、移動されました。アドレスを確認するか、受信トレイに戻ってください。"
+ },
"context_menu": {
"reply": "返信",
"reply_all": "全員に返信",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 95172af3e..a74721f69 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -2625,6 +2625,10 @@
"retry": "재시도",
"go_home": "받은편지함으로 이동"
},
+ "not_found": {
+ "title": "페이지를 찾을 수 없습니다",
+ "description": "이 페이지가 없거나 이동되었습니다. 주소를 확인하거나 받은편지함으로 돌아가세요."
+ },
"context_menu": {
"reply": "답장",
"reply_all": "전체 답장",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 5294d6063..5cb9cbe76 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -2625,6 +2625,10 @@
"retry": "Mēģināt vēlreiz",
"go_home": "Uz iesūtni"
},
+ "not_found": {
+ "title": "Lapa nav atrasta",
+ "description": "Šī lapa nepastāv vai ir pārvietota. Pārbaudiet adresi vai atgriezieties iesūtnē."
+ },
"context_menu": {
"reply": "Atbildēt",
"reply_all": "Atbildēt visiem",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 5b13cf362..5b62a975f 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -2625,6 +2625,10 @@
"retry": "Дахин оролдох",
"go_home": "Ирсэн имэйл рүү очих"
},
+ "not_found": {
+ "title": "Хуудас олдсонгүй",
+ "description": "Энэ хуудас байхгүй эсвэл зөөгдсөн байна. Хаягаа шалгах эсвэл ирсэн захидал руу буцна уу."
+ },
"context_menu": {
"reply": "Хариулах",
"reply_all": "Бүгдэд нь хариулах",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index f563da68b..1d646f905 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -2625,6 +2625,10 @@
"retry": "Prøv på nytt",
"go_home": "Gå til innboksen"
},
+ "not_found": {
+ "title": "Fant ikke siden",
+ "description": "Denne siden finnes ikke eller er flyttet. Sjekk adressen, eller gå tilbake til innboksen."
+ },
"context_menu": {
"reply": "Svar",
"reply_all": "Svar alle",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index afa37bff1..f4e4dd7f3 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -2625,6 +2625,10 @@
"retry": "Opnieuw proberen",
"go_home": "Ga naar postvak IN"
},
+ "not_found": {
+ "title": "Pagina niet gevonden",
+ "description": "Deze pagina bestaat niet of is verplaatst. Controleer het adres of ga terug naar je inbox."
+ },
"context_menu": {
"reply": "Beantwoorden",
"reply_all": "Allen beantwoorden",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 9b2c3f7f4..4a7f4ea29 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -2625,6 +2625,10 @@
"retry": "Ponów",
"go_home": "Przejdź do skrzynki odbiorczej"
},
+ "not_found": {
+ "title": "Nie znaleziono strony",
+ "description": "Ta strona nie istnieje lub została przeniesiona. Sprawdź adres albo wróć do skrzynki odbiorczej."
+ },
"context_menu": {
"reply": "Odpowiedz",
"reply_all": "Odpowiedz wszystkim",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index d08ff7d30..344c3d826 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -2625,6 +2625,10 @@
"retry": "Tentar novamente",
"go_home": "Ir para caixa de entrada"
},
+ "not_found": {
+ "title": "Página não encontrada",
+ "description": "Esta página não existe ou foi movida. Verifique o endereço ou volte à caixa de entrada."
+ },
"context_menu": {
"reply": "Responder",
"reply_all": "Responder a Todos",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index e9cb8c328..6daa65529 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -2625,6 +2625,10 @@
"retry": "Reîncearcă",
"go_home": "Accesați căsuța de intrare"
},
+ "not_found": {
+ "title": "Pagina nu a fost găsită",
+ "description": "Această pagină nu există sau a fost mutată. Verificați adresa sau reveniți la Inbox."
+ },
"context_menu": {
"reply": "Răspunde",
"reply_all": "Răspunde tuturor",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index c25814daa..bba4f47b5 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -2625,6 +2625,10 @@
"retry": "Повторить",
"go_home": "В папку «Входящие»"
},
+ "not_found": {
+ "title": "Страница не найдена",
+ "description": "Этой страницы нет или она перемещена. Проверьте адрес или вернитесь во «Входящие»."
+ },
"context_menu": {
"reply": "Ответить",
"reply_all": "Ответить всем",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index c5ee5065f..2f686d928 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -2625,6 +2625,10 @@
"retry": "Opakovať",
"go_home": "Prejsť do doručenej pošty"
},
+ "not_found": {
+ "title": "Stránka sa nenašla",
+ "description": "Táto stránka neexistuje alebo bola presunutá. Skontrolujte adresu alebo sa vráťte do doručenej pošty."
+ },
"context_menu": {
"reply": "Odpovedať",
"reply_all": "Odpovedať všetkým",
diff --git a/locales/th/common.json b/locales/th/common.json
index 34d52b387..896b8b600 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -101,6 +101,10 @@
"retry": "ลองใหม่",
"go_home": "ไปยังกล่องข้อความ"
},
+ "not_found": {
+ "title": "ไม่พบหน้านี้",
+ "description": "ไม่มีหน้านี้หรือหน้านี้ถูกย้ายแล้ว ตรวจสอบที่อยู่ หรือกลับไปที่กล่องจดหมายเข้า"
+ },
"login": {
"title": "เข้าสู่ระบบจดหมายอิเล็กทรอนิกส์",
"username_label": "อีเมล",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 67d832661..284279d5a 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -2625,6 +2625,10 @@
"retry": "Yeniden dene",
"go_home": "Gelen kutusuna git"
},
+ "not_found": {
+ "title": "Sayfa bulunamadı",
+ "description": "Bu sayfa yok veya taşınmış. Adresi kontrol edin ya da gelen kutunuza dönün."
+ },
"context_menu": {
"reply": "Yanıtla",
"reply_all": "Tümünü Yanıtla",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 66d650572..a44d9612d 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -2625,6 +2625,10 @@
"retry": "Повторіть спробу",
"go_home": "Перейти до папки \"Вхідні\"."
},
+ "not_found": {
+ "title": "Сторінку не знайдено",
+ "description": "Цієї сторінки немає або її переміщено. Перевірте адресу або поверніться до вхідних."
+ },
"context_menu": {
"reply": "Відповісти",
"reply_all": "Відповісти всім",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 53f7a2671..caf616f90 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -2625,6 +2625,10 @@
"retry": "重試",
"go_home": "前往收件匣"
},
+ "not_found": {
+ "title": "找不到頁面",
+ "description": "此頁面不存在或已移動。請檢查網址,或返回收件匣。"
+ },
"context_menu": {
"reply": "回覆",
"reply_all": "全部回覆",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 1b6b532ef..303e158b4 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -2625,6 +2625,10 @@
"retry": "重试",
"go_home": "前往收件箱"
},
+ "not_found": {
+ "title": "找不到页面",
+ "description": "此页面不存在或已移动。请检查地址,或返回收件箱。"
+ },
"context_menu": {
"reply": "回复",
"reply_all": "全部回复",
diff --git a/scripts/lite/lib.mjs b/scripts/lite/lib.mjs
index 6692f6c79..7cee6c0d0 100644
--- a/scripts/lite/lib.mjs
+++ b/scripts/lite/lib.mjs
@@ -39,6 +39,7 @@ export const LITE_REMOVED_PATHS = [
"instrumentation.ts",
"instrumentation.node.ts",
"app/(main)/[...rest]",
+ "app/(main)/[locale]/[...rest]",
"app/(main)/protocol",
"e2e",
"integration",
From 5d013f7669d9b347bff3e81dff489b42a02f7bfa Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 03:56:04 +0100
Subject: [PATCH 19/29] fix: give Calendar, Contacts, Files and Settings their
own error boundary and show one error fallback with Try again everywhere
---
.../calendar/[[...segments]]/page.tsx | 7 +-
.../contacts/[[...segments]]/page.tsx | 7 +-
app/(main)/[locale]/error.tsx | 36 +---
.../[locale]/files/[[...segments]]/page.tsx | 7 +-
.../settings/[[...segments]]/page.tsx | 7 +-
.../error/__tests__/error-fallbacks.test.tsx | 56 ++++++
components/error/app-error-boundary.tsx | 39 +++++
components/error/error-fallbacks.tsx | 164 ++++++++----------
components/error/index.ts | 2 +
9 files changed, 202 insertions(+), 123 deletions(-)
create mode 100644 components/error/__tests__/error-fallbacks.test.tsx
create mode 100644 components/error/app-error-boundary.tsx
diff --git a/app/(main)/[locale]/calendar/[[...segments]]/page.tsx b/app/(main)/[locale]/calendar/[[...segments]]/page.tsx
index 7ac1875fd..3419c08b2 100644
--- a/app/(main)/[locale]/calendar/[[...segments]]/page.tsx
+++ b/app/(main)/[locale]/calendar/[[...segments]]/page.tsx
@@ -1,5 +1,6 @@
import { CalendarApp } from "@/components/calendar/calendar-app";
import { generateLiteSegmentParams } from "@/lib/lite-static-params";
+import { AppErrorBoundary } from "@/components/error/app-error-boundary";
// Calendar permalinks (#733):
// /calendar the stored view, today
@@ -17,5 +18,9 @@ export default async function CalendarRoute({
params: Promise<{ segments?: string[] }>;
}) {
const { segments } = await params;
- return ;
+ return (
+
+
+
+ );
}
diff --git a/app/(main)/[locale]/contacts/[[...segments]]/page.tsx b/app/(main)/[locale]/contacts/[[...segments]]/page.tsx
index 7c18db300..da9f8ae44 100644
--- a/app/(main)/[locale]/contacts/[[...segments]]/page.tsx
+++ b/app/(main)/[locale]/contacts/[[...segments]]/page.tsx
@@ -1,5 +1,6 @@
import { ContactsApp } from "@/components/contacts/contacts-app";
import { generateLiteSegmentParams } from "@/lib/lite-static-params";
+import { AppErrorBoundary } from "@/components/error/app-error-boundary";
// Contact permalinks (#733): /contacts/[/edit], plus /contacts/new
// for the "add this sender" flow. The older query form (?contactId=, ?addEmail=)
@@ -15,5 +16,9 @@ export default async function ContactsRoute({
params: Promise<{ segments?: string[] }>;
}) {
const { segments } = await params;
- return ;
+ return (
+
+
+
+ );
}
diff --git a/app/(main)/[locale]/error.tsx b/app/(main)/[locale]/error.tsx
index e8a0caeac..f25e2ad46 100644
--- a/app/(main)/[locale]/error.tsx
+++ b/app/(main)/[locale]/error.tsx
@@ -2,9 +2,7 @@
import { useEffect } from "react";
import { useTranslations } from "next-intl";
-import { AlertCircle, RefreshCw, Home } from "@/components/icons";
-import { Button } from "@/components/ui/button";
-import { useRouter } from "@/i18n/navigation";
+import { ErrorState } from "@/components/error/error-fallbacks";
/**
* Route-level error boundary for locale pages.
@@ -18,35 +16,19 @@ export default function LocaleError({
reset: () => void;
}) {
const t = useTranslations("errors");
- const router = useRouter();
useEffect(() => {
console.error("Route error:", error);
}, [error]);
return (
-
-
-
-
- {t("page_error_title")}
-
-
- {t("page_error_description")}
-
-
- router.push('/')}>
-
- {t("go_home")}
-
-
-
- {t("try_again")}
-
-
-
-
+
);
}
diff --git a/app/(main)/[locale]/files/[[...segments]]/page.tsx b/app/(main)/[locale]/files/[[...segments]]/page.tsx
index a9782d6f6..a7a885744 100644
--- a/app/(main)/[locale]/files/[[...segments]]/page.tsx
+++ b/app/(main)/[locale]/files/[[...segments]]/page.tsx
@@ -1,5 +1,6 @@
import { FilesApp } from "@/components/files/files-app";
import { generateLiteSegmentParams } from "@/lib/lite-static-params";
+import { AppErrorBoundary } from "@/components/error/app-error-boundary";
// Files permalinks (#733): /files//, one path segment per
// level, with ?preview= to open a file straight into the preview modal.
@@ -14,5 +15,9 @@ export default async function FilesRoute({
params: Promise<{ segments?: string[] }>;
}) {
const { segments } = await params;
- return ;
+ return (
+
+
+
+ );
}
diff --git a/app/(main)/[locale]/settings/[[...segments]]/page.tsx b/app/(main)/[locale]/settings/[[...segments]]/page.tsx
index 8c9b582d5..e4dbc45e8 100644
--- a/app/(main)/[locale]/settings/[[...segments]]/page.tsx
+++ b/app/(main)/[locale]/settings/[[...segments]]/page.tsx
@@ -1,5 +1,6 @@
import { SettingsApp } from "@/components/settings/settings-app";
import { generateLiteSegmentParams } from "@/lib/lite-static-params";
+import { AppErrorBoundary } from "@/components/error/app-error-boundary";
// Settings permalinks (#733): /settings/. Without a tab the page falls
// back to the last tab the user had open, as before.
@@ -14,5 +15,9 @@ export default async function SettingsRoute({
params: Promise<{ segments?: string[] }>;
}) {
const { segments } = await params;
- return ;
+ return (
+
+
+
+ );
}
diff --git a/components/error/__tests__/error-fallbacks.test.tsx b/components/error/__tests__/error-fallbacks.test.tsx
new file mode 100644
index 000000000..da84607e8
--- /dev/null
+++ b/components/error/__tests__/error-fallbacks.test.tsx
@@ -0,0 +1,56 @@
+import React from 'react';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// One error fallback everywhere: the same "Try again", a way back to the
+// inbox outside mail, and each app has a boundary of its own that keeps the
+// navigation on screen.
+
+vi.mock('@/components/layout/navigation-rail', () => ({
+ NavigationRail: ({ orientation }: { orientation?: string }) => ,
+}));
+
+const { AppErrorBoundary } = await import('../app-error-boundary');
+const { ErrorBoundary } = await import('../error-boundary');
+const { EmailListErrorFallback, SidebarErrorFallback, SettingsErrorFallback } = await import('../error-fallbacks');
+
+let broken = true;
+function Flaky() {
+ if (broken) throw new Error('boom');
+ return calendar works
;
+}
+
+beforeEach(() => {
+ broken = true;
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+});
+afterEach(() => vi.restoreAllMocks());
+
+describe('app error boundary', () => {
+ it('shows the fallback next to the navigation and recovers on Try again', () => {
+ render( );
+ expect(screen.getByRole('alert')).toHaveTextContent('page_error_title');
+ expect(screen.getByRole('navigation', { name: 'rail-vertical' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'go_home' })).toBeInTheDocument();
+
+ broken = false;
+ fireEvent.click(screen.getByRole('button', { name: 'try_again' }));
+ expect(screen.getByText('calendar works')).toBeInTheDocument();
+ });
+});
+
+describe('section fallbacks', () => {
+ it.each([
+ ['message list', EmailListErrorFallback],
+ ['folder sidebar', SidebarErrorFallback],
+ ['settings', SettingsErrorFallback],
+ ])('the %s offers the same "Try again"', (_name, fallback) => {
+ render( );
+ expect(screen.getByRole('button', { name: 'try_again' })).toBeInTheDocument();
+ });
+
+ it('inside mail, does not offer to go to the inbox it is already in', () => {
+ render( );
+ expect(screen.queryByRole('link', { name: 'go_home' })).toBeNull();
+ });
+});
diff --git a/components/error/app-error-boundary.tsx b/components/error/app-error-boundary.tsx
new file mode 100644
index 000000000..5b36c5037
--- /dev/null
+++ b/components/error/app-error-boundary.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { NavigationRail } from "@/components/layout/navigation-rail";
+import { useIsMobile } from "@/hooks/use-media-query";
+import { ErrorBoundary, type FallbackProps } from "./error-boundary";
+import { PageErrorFallback, SettingsErrorFallback } from "./error-fallbacks";
+
+function AppErrorLayout({ kind, ...props }: FallbackProps & { kind: "page" | "settings" }) {
+ const isMobile = useIsMobile();
+ const Fallback = kind === "settings" ? SettingsErrorFallback : PageErrorFallback;
+ // The navigation stays, so one app crashing doesn't strand the user in it.
+ return (
+
+ {!isMobile && (
+
+
+
+ )}
+
+
+ {isMobile && }
+
+
+ );
+}
+
+/**
+ * Wraps one app (Calendar, Contacts, Files, Settings): a crash in it shows
+ * the shared error fallback next to the navigation, instead of the route
+ * error page in place of everything.
+ */
+export function AppErrorBoundary({ children, kind = "page" }: { children: ReactNode; kind?: "page" | "settings" }) {
+ return (
+ }>
+ {children}
+
+ );
+}
diff --git a/components/error/error-fallbacks.tsx b/components/error/error-fallbacks.tsx
index a76afffb1..d58d68633 100644
--- a/components/error/error-fallbacks.tsx
+++ b/components/error/error-fallbacks.tsx
@@ -1,127 +1,107 @@
"use client";
-import { AlertCircle, RefreshCw, Inbox, Mail, Settings, FolderOpen } from "@/components/icons";
+import { AlertCircle, RefreshCw } from "@/components/icons";
+import type { AppIcon } from "@/components/icons";
import { Button } from "@/components/ui/button";
+import { getPathPrefix } from "@/lib/browser-navigation";
+import { cn } from "@/lib/utils";
import type { FallbackProps } from "./error-boundary";
+export interface ErrorStateProps {
+ title: string;
+ description?: string;
+ /** Puts the part back together: re-renders it, or reloads the route. */
+ onRetry: () => void;
+ retryLabel: string;
+ /** Offers a way out to the inbox; left out inside mail itself. */
+ homeLabel?: string;
+ icon?: AppIcon;
+ className?: string;
+}
+
/**
- * Full-page error fallback for route-level errors.
+ * The one error fallback: icon, title, a line of text, "Try again", and "Go
+ * to inbox" where the inbox is somewhere else. Every error boundary draws
+ * this, at whatever size its pane has.
*/
-export function PageErrorFallback({ error: _error, resetError, t }: FallbackProps) {
+export function ErrorState({ title, description, onRetry, retryLabel, homeLabel, icon: Icon = AlertCircle, className }: ErrorStateProps) {
return (
-
-
-
-
- {t("page_error_title")}
-
-
- {t("page_error_description")}
-
-
+
+
+
+
+
{title}
+ {description &&
{description}
}
+
+ {homeLabel && (
+ // A full load: whatever broke starts over.
+
+ {homeLabel}
+
+ )}
+
- {t("try_again")}
+ {retryLabel}
);
}
-/**
- * Sidebar error fallback - matches sidebar width (256px).
- */
+/** Full-page error fallback, also used around each app (AppErrorBoundary). */
+export function PageErrorFallback({ resetError, t }: FallbackProps) {
+ return (
+
+ );
+}
+
+/** Mail's folder sidebar (256px wide). */
export function SidebarErrorFallback({ resetError, t }: FallbackProps) {
return (
-
-
-
- {t("sidebar_error")}
-
-
-
- {t("reload")}
-
-
+
);
}
-/**
- * Email list error fallback - matches email list panel width (384px).
- */
+/** Mail's message list. */
export function EmailListErrorFallback({ resetError, t }: FallbackProps) {
- return (
-
-
-
- {t("email_list_error")}
-
-
-
- {t("reload_emails")}
-
-
- );
+ return ;
}
-/**
- * Email viewer error fallback - fills remaining space (flex-1).
- */
+/** Mail's reading pane. */
export function EmailViewerErrorFallback({ resetError, t }: FallbackProps) {
return (
-
-
-
-
-
- {t("viewer_error_title")}
-
-
- {t("viewer_error_description")}
-
-
-
- {t("try_again")}
-
-
+
);
}
-/**
- * Email composer modal error fallback.
- */
+/** The composer. */
export function ComposerErrorFallback({ resetError, t }: FallbackProps) {
- return (
-
-
-
- {t("composer_error")}
-
-
- {t("retry")}
-
-
- );
+ return ;
}
-/**
- * Settings page error fallback.
- */
+/** Settings. */
export function SettingsErrorFallback({ resetError, t }: FallbackProps) {
return (
-
-
-
- {t("settings_error_title")}
-
-
- {t("settings_error_description")}
-
-
-
- {t("reload_settings")}
-
-
+
);
}
diff --git a/components/error/index.ts b/components/error/index.ts
index 2b70083be..fef3b8dcf 100644
--- a/components/error/index.ts
+++ b/components/error/index.ts
@@ -1,6 +1,8 @@
export { ErrorBoundary } from "./error-boundary";
+export { AppErrorBoundary } from "./app-error-boundary";
export type { FallbackProps } from "./error-boundary";
export {
+ ErrorState,
PageErrorFallback,
SidebarErrorFallback,
EmailListErrorFallback,
From a62317ba630589c69fd8844148c4b32a5f87f040 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:06:12 +0100
Subject: [PATCH 20/29] fix: translate the shell's resize, close, folder count
and update labels, and announce a waiting update on the admin entry
---
.../layout/__tests__/shell-strings.test.tsx | 55 +++++++++++++++++++
components/layout/inline-app-view.tsx | 13 ++---
components/layout/mv-top-bar.tsx | 5 +-
components/layout/navigation-rail.tsx | 9 ++-
components/layout/resize-handle.tsx | 4 +-
components/layout/sidebar.tsx | 5 +-
locales/ar/common.json | 7 +++
locales/ca/common.json | 7 +++
locales/cs/common.json | 7 +++
locales/da/common.json | 7 +++
locales/de/common.json | 7 +++
locales/en/common.json | 7 +++
locales/es/common.json | 7 +++
locales/et/common.json | 7 +++
locales/fa/common.json | 7 +++
locales/fr/common.json | 7 +++
locales/he/common.json | 7 +++
locales/hu/common.json | 7 +++
locales/it/common.json | 7 +++
locales/ja/common.json | 7 +++
locales/ko/common.json | 7 +++
locales/lv/common.json | 7 +++
locales/mn/common.json | 7 +++
locales/nb/common.json | 7 +++
locales/nl/common.json | 7 +++
locales/pl/common.json | 7 +++
locales/pt/common.json | 7 +++
locales/ro/common.json | 7 +++
locales/ru/common.json | 7 +++
locales/sk/common.json | 7 +++
locales/th/common.json | 7 +++
locales/tr/common.json | 7 +++
locales/uk/common.json | 7 +++
locales/zh-TW/common.json | 7 +++
locales/zh/common.json | 7 +++
35 files changed, 280 insertions(+), 14 deletions(-)
create mode 100644 components/layout/__tests__/shell-strings.test.tsx
diff --git a/components/layout/__tests__/shell-strings.test.tsx b/components/layout/__tests__/shell-strings.test.tsx
new file mode 100644
index 000000000..43d921dc7
--- /dev/null
+++ b/components/layout/__tests__/shell-strings.test.tsx
@@ -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 }) => {children} ,
+}));
+
+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( );
+ expect(screen.getByRole('link', { name: 'admin, update_important' })).toBeInTheDocument();
+ unmount();
+ const bar = render( );
+ expect(screen.getByRole('link', { name: /update_important/ })).toBeInTheDocument();
+ bar.unmount();
+ render( );
+ expect(screen.getByRole('link', { name: 'admin, update_important' })).toBeInTheDocument();
+ });
+
+ it('translates the resize handle and the inline app close button', () => {
+ render(
+ <>
+ {}} />
+ {}} />
+ >
+ );
+ expect(screen.getByRole('separator', { name: 'resize' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'close' })).toBeInTheDocument();
+ });
+});
diff --git a/components/layout/inline-app-view.tsx b/components/layout/inline-app-view.tsx
index 368d49c55..c74296236 100644
--- a/components/layout/inline-app-view.tsx
+++ b/components/layout/inline-app-view.tsx
@@ -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 */}
{activeApp?.name}
-
-
-
+
+
+
{/* Iframes - active one visible, rest hidden but alive */}
diff --git a/components/layout/mv-top-bar.tsx b/components/layout/mv-top-bar.tsx
index f3e74cf9a..9694e6763 100644
--- a/components/layout/mv-top-bar.tsx
+++ b/components/layout/mv-top-bar.tsx
@@ -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")}
>
{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"
/>
)}
diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx
index 1ea2ad81f..c3c0af96b 100644
--- a/components/layout/navigation-rail.tsx
+++ b/components/layout/navigation-rail.tsx
@@ -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"
/>
)}
{t("admin") || "Admin"}
+ {updateLabel &&
{`, ${updateLabel}`} }
)}
@@ -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}
>
@@ -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"
/>
)}
diff --git a/components/layout/resize-handle.tsx b/components/layout/resize-handle.tsx
index 81915c038..33e853d90 100644
--- a/components/layout/resize-handle.tsx
+++ b/components/layout/resize-handle.tsx
@@ -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
(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}
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx
index 2ae285a74..f8d48490d 100644
--- a/components/layout/sidebar.tsx
+++ b/components/layout/sidebar.tsx
@@ -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}
@@ -284,7 +285,7 @@ function SidebarRowCounts({
return (
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}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 9680a164b..fb4ddec8d 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -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": "هذه الصفحة غير متاحة",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 76cc628b9..e765a488e 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -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",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index b2a16fcb7..5acec63ea 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -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",
diff --git a/locales/da/common.json b/locales/da/common.json
index 6ba2d2f33..5cfebcf04 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -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",
diff --git a/locales/de/common.json b/locales/de/common.json
index 1461e4a8b..ba45b371c 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -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",
diff --git a/locales/en/common.json b/locales/en/common.json
index 78719b817..b34cacbbf 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -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",
diff --git a/locales/es/common.json b/locales/es/common.json
index c98e82ab6..672e7a0fc 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -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",
diff --git a/locales/et/common.json b/locales/et/common.json
index eaca8757c..e726eeff0 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -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",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 2ee1e9747..1840ca58d 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -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": "این صفحه در دسترس نیست",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 051a31a6c..421f256f9 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -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",
diff --git a/locales/he/common.json b/locales/he/common.json
index bfa7059f4..1fd39b1d0 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -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": "הדף הזה אינו זמין",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 9efe67330..b7a1127e6 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -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",
diff --git a/locales/it/common.json b/locales/it/common.json
index 38c756512..0b3a2be92 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -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",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index d4978091c..8424924fb 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -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": "このページは利用できません",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index a74721f69..e3d897ad0 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -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": "이 페이지를 사용할 수 없습니다",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 5cb9cbe76..f7d1846c6 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -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",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 5b62a975f..38c52b3fa 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -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": "Энэ хуудас боломжгүй байна",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 1d646f905..31a86f917 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -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",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index f4e4dd7f3..0f0255bb9 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -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",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 4a7f4ea29..b4ac17fbb 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -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",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 344c3d826..b629709cb 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -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",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 6daa65529..252f848be 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -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ă",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index bba4f47b5..9b671594a 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -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": "Эта страница недоступна",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 2f686d928..7a52faaec 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -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",
diff --git a/locales/th/common.json b/locales/th/common.json
index 896b8b600..65fc76c51 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -41,6 +41,13 @@
"top_bar": {
"more": "ตัวเลือกเพิ่มเติม"
},
+ "shell": {
+ "resize": "ปรับขนาด",
+ "update_available": "มีอัปเดต",
+ "update_important": "มีอัปเดตสำคัญ",
+ "folder_unread": "ยังไม่ได้อ่าน {count}",
+ "folder_counts": "ยังไม่ได้อ่าน {unread} จากทั้งหมด {total}"
+ },
"date": {
"today": "วันนี้",
"yesterday": "เมื่อวานนี้",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 284279d5a..57c52ea3b 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -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",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index a44d9612d..fa1fbdb0e 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -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": "Ця сторінка недоступна",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index caf616f90..c50de1edc 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -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": "此頁面無法使用",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 303e158b4..706996c36 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -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": "此页面不可用",
From 7b720dd22159531e8ab0d78d0e537d9a79c0ff01 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:10:00 +0100
Subject: [PATCH 21/29] fix: address the user formally in German everywhere
---
lib/__tests__/de-formal-address.test.ts | 27 +++++++++++++
locales/de/common.json | 50 ++++++++++++-------------
2 files changed, 52 insertions(+), 25 deletions(-)
create mode 100644 lib/__tests__/de-formal-address.test.ts
diff --git a/lib/__tests__/de-formal-address.test.ts b/lib/__tests__/de-formal-address.test.ts
new file mode 100644
index 000000000..ce3af2f5a
--- /dev/null
+++ b/lib/__tests__/de-formal-address.test.ts
@@ -0,0 +1,27 @@
+import { readFileSync } from 'node:fs';
+import { describe, expect, it } from 'vitest';
+
+// German addresses the user formally ("Sie") throughout (R10). Informal
+// pronouns and du-imperatives slipped into some strings.
+
+const INFORMAL = /\b(du|dich|dir|dein|deine|deinen|deinem|deiner|deines)\b|(^|[.!?]\s+|\()(Klicke|Tippe|Wähle|Prüfe|Melde|Lege|Entferne|Frag|Öffne|drücke)\b/i;
+
+function strings(node: unknown, path: string[] = []): Array<[string, string]> {
+ if (typeof node === 'string') return [[path.join('.'), node]];
+ if (node && typeof node === 'object') {
+ return Object.entries(node).flatMap(([key, value]) => strings(value, [...path, key]));
+ }
+ return [];
+}
+
+describe('German translations', () => {
+ it('use the formal "Sie" everywhere', () => {
+ const de = JSON.parse(readFileSync('locales/de/common.json', 'utf8'));
+ const informal = strings(de)
+ // Words the composer looks for in "[Dein Name]"-style fill-ins, not UI text.
+ .filter(([key]) => !key.includes('placeholder_words'))
+ .filter(([, text]) => INFORMAL.test(text))
+ .map(([key]) => key);
+ expect(informal).toEqual([]);
+ });
+});
diff --git a/locales/de/common.json b/locales/de/common.json
index ba45b371c..67aca327e 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -100,7 +100,7 @@
"compose": "Verfassen",
"compose_hint": "Verfassen (c)",
"search_placeholder": "E-Mails durchsuchen...",
- "search_placeholder_hint": "E-Mails suchen... (drücke /)",
+ "search_placeholder_hint": "E-Mails suchen... (drücken Sie /)",
"storage": "Speicher",
"storage_used": "Belegt",
"storage_free": "Frei",
@@ -206,7 +206,7 @@
"protocol_open_mode_active_session": "Wenn möglich in aktiver Sitzung öffnen",
"protocol_open_mode_new_tab": "Immer neuen Tab öffnen",
"focus_notification_title": "{appName} öffnen",
- "focus_notification_body": "Der Link wurde in {appName} geöffnet. Klicke hier, um das Fenster in den Vordergrund zu holen.",
+ "focus_notification_body": "Der Link wurde in {appName} geöffnet. Klicken Sie hier, um das Fenster in den Vordergrund zu holen.",
"webcal_label": "Kalender-Links",
"webcal_description": "Öffnet webcal:-Links in {appName} mit vorausgefülltem Kalender-Abo-Dialog.",
"register_mailto": "Als E-Mail-App registrieren",
@@ -267,7 +267,7 @@
"sections": {
"unread": "Ungelesen",
"everything_else": "Alles andere",
- "unread_empty": "Du hast alle Nachrichten im Posteingang gelesen."
+ "unread_empty": "Sie haben alle Nachrichten im Posteingang gelesen."
},
"columns": {
"from": "Von",
@@ -1162,7 +1162,7 @@
"label": "Auf alle Ordner anwenden",
"description": "Wenn ausgeschaltet, gilt die Reihenfolge nur für den Posteingang; andere Ordner bleiben chronologisch."
},
- "unsupported_note": "Dein Mailserver unterstützt keine Sortierung nach Lesestatus, Markierung oder Tags. Diese Ebenen werden ignoriert und die Liste bleibt chronologisch."
+ "unsupported_note": "Ihr Mailserver unterstützt keine Sortierung nach Lesestatus, Markierung oder Tags. Diese Ebenen werden ignoriert und die Liste bleibt chronologisch."
},
"title": "Darstellung",
"description": "Passen Sie das Aussehen Ihres Webmails an",
@@ -1472,7 +1472,7 @@
"email_behavior": {
"inbox_type": {
"label": "Posteingangstyp",
- "description": "„Ungelesene zuerst“ zeigt deine 25 neuesten ungelesenen Nachrichten in einem eigenen Abschnitt über dem restlichen Posteingang",
+ "description": "„Ungelesene zuerst“ zeigt Ihre 25 neuesten ungelesenen Nachrichten in einem eigenen Abschnitt über dem restlichen Posteingang",
"default": "Standard",
"unread_first": "Ungelesene zuerst"
},
@@ -1701,7 +1701,7 @@
},
"mention_adds_recipient": {
"label": "Kontakte bei @-Erwähnungen",
- "description": "Beim Tippen von @ werden auch deine Kontakte und Personen aus dem Verzeichnis angeboten. Wer noch kein Empfänger ist, wird beim Auswählen zur Nachricht hinzugefügt.",
+ "description": "Beim Tippen von @ werden auch Ihre Kontakte und Personen aus dem Verzeichnis angeboten. Wer noch kein Empfänger ist, wird beim Auswählen zur Nachricht hinzugefügt.",
"to": "Zu An hinzufügen",
"cc": "Zu CC hinzufügen",
"off": "Nur Empfänger anbieten"
@@ -2266,7 +2266,7 @@
},
"downloads": {
"title": "Downloads",
- "description": "Lege fest, wie heruntergeladene E-Mails und Anhänge benannt werden.",
+ "description": "Legen Sie fest, wie heruntergeladene E-Mails und Anhänge benannt werden.",
"reset": "Standard wiederherstellen",
"preview": "Vorschau:",
"email_template": {
@@ -2696,7 +2696,7 @@
"unmute_account_notifications": "Benachrichtigungen für dieses Konto einschalten",
"reset_folder_notifications": "Ordner-Benachrichtigungen zurücksetzen",
"mark_all_confirm_title": "Alle Ordner als gelesen markieren",
- "mark_all_confirm_message": "Jede ungelesene Nachricht in deinem persönlichen Konto als gelesen markieren?",
+ "mark_all_confirm_message": "Jede ungelesene Nachricht in Ihrem persönlichen Konto als gelesen markieren?",
"delete_confirm_title": "Ordner löschen",
"delete_confirm_message": "Den Ordner \"{name}\" dauerhaft löschen? Dies kann nicht rückgängig gemacht werden.",
"prompt_new_subfolder": "Gib einen Namen für den neuen Unterordner ein.",
@@ -2718,7 +2718,7 @@
"toast_error_create": "Ordner konnte nicht erstellt werden",
"toast_error_rename": "Ordner konnte nicht umbenannt werden",
"toast_error_delete": "Ordner konnte nicht gelöscht werden",
- "toast_error_delete_has_children": "Ordner enthält Unterordner. Entferne diese zuerst.",
+ "toast_error_delete_has_children": "Ordner enthält Unterordner. Entfernen Sie diese zuerst.",
"toast_error_delete_has_email": "Ordner ist nicht leer. Leere ihn zuerst.",
"delete_with_contents_title": "Ordner ist nicht leer",
"delete_with_contents_message": "Der Ordner „{name}“ enthält noch {count, plural, one {1 Nachricht} other {# Nachrichten}}. Ordner samt allen Nachrichten löschen? Das kann nicht rückgängig gemacht werden.",
@@ -2892,14 +2892,14 @@
"retry": "Erneut versuchen",
"loading": "Verzeichnis wird geladen…",
"unavailable_title": "Kein Verzeichnis",
- "unavailable_hint": "Dein Mailserver bietet kein Verzeichnis der Konten an.",
+ "unavailable_hint": "Ihr Mailserver bietet kein Verzeichnis der Konten an.",
"forbidden_title": "Verzeichnis nicht verfügbar",
- "forbidden_hint": "Dein Mailserver erlaubt diesem Konto nicht, das Verzeichnis zu durchsuchen. Frag deine Administration.",
+ "forbidden_hint": "Ihr Mailserver erlaubt diesem Konto nicht, das Verzeichnis zu durchsuchen. Fragen Sie Ihre Administration.",
"error_title": "Verzeichnis konnte nicht geladen werden",
- "error_hint": "Prüfe deine Verbindung und versuche es erneut.",
+ "error_hint": "Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
"empty_title": "Das Verzeichnis ist leer",
- "empty_hint": "Dein Mailserver listet keine Konten mit E-Mail-Adresse.",
- "no_match": "Niemand im Verzeichnis passt zu deiner Suche",
+ "empty_hint": "Ihr Mailserver listet keine Konten mit E-Mail-Adresse.",
+ "no_match": "Niemand im Verzeichnis passt zu Ihrer Suche",
"refresh_failed": "Verzeichnis konnte nicht aktualisiert werden. Angezeigt wird die zuletzt geladene Liste.",
"select_page": "Alle auf dieser Seite auswählen",
"deselect_page": "Auswahl auf dieser Seite aufheben",
@@ -2914,11 +2914,11 @@
"add_to": "Zu An",
"add_cc": "Zu Cc",
"add_bcc": "Zu Bcc",
- "select_hint": "Wähle jemanden aus, um die Details zu sehen",
+ "select_hint": "Wählen Sie jemanden aus, um die Details zu sehen",
"write_email": "E-Mail schreiben",
"add_to_contacts": "Zu Kontakten hinzufügen",
"email": "E-Mail",
- "read_only_note": "Verzeichniseinträge kommen von deinem Mailserver und können hier nicht bearbeitet werden.",
+ "read_only_note": "Verzeichniseinträge kommen von Ihrem Mailserver und können hier nicht bearbeitet werden.",
"type": {
"individual": "Person",
"group": "Gruppe",
@@ -3471,7 +3471,7 @@
"visible_hours_start": "Beginn der sichtbaren Stunden",
"visible_hours_end": "Ende der sichtbaren Stunden",
"hide_non_working_days": "Arbeitsfreie Tage ausblenden",
- "hide_non_working_days_desc": "Tage, an denen du nicht arbeitest, in der Wochenansicht weglassen. Ihre Termine erscheinen weiterhin in der Monats-, Tages- und Agendaansicht.",
+ "hide_non_working_days_desc": "Tage, an denen Sie nicht arbeiten, in der Wochenansicht weglassen. Ihre Termine erscheinen weiterhin in der Monats-, Tages- und Agendaansicht.",
"working_days": "Arbeitstage",
"title": "Kalendereinstellungen",
"default_view": "Standardansicht",
@@ -3505,10 +3505,10 @@
"hover_preview_delay_2s": "2 Sekunden Verzögerung",
"hover_preview_off": "Deaktiviert",
"organizer_identity": "Einladungen organisieren als",
- "organizer_identity_desc": "Die Kalenderadresse, die als Organisator verwendet wird, wenn du Personen zu Terminen einlädst.",
+ "organizer_identity_desc": "Die Kalenderadresse, die als Organisator verwendet wird, wenn Sie Personen zu Terminen einladen.",
"organizer_identity_failed": "Organisatoradresse konnte nicht geändert werden",
"show_birthday_calendar": "Geburtstags-Kalender",
- "show_birthday_calendar_desc": "Zeigt einen virtuellen Kalender mit Geburtstagen aus deinen Kontakten"
+ "show_birthday_calendar_desc": "Zeigt einen virtuellen Kalender mit Geburtstagen aus Ihren Kontakten"
},
"days": {
"monday": "Montag",
@@ -4071,7 +4071,7 @@
"restart_desc": "Geführte Tour durch die Oberfläche erneut abspielen",
"restart_button": "Tour neu starten",
"show_on_new_devices_title": "Auf neuen Geräten anzeigen",
- "show_on_new_devices_desc": "Zeige das Willkommensbanner und die Tour beim ersten Anmelden auf einem neuen Gerät erneut, auch wenn du sie bereits anderswo abgeschlossen hast",
+ "show_on_new_devices_desc": "Das Willkommensbanner und die Tour beim ersten Anmelden auf einem neuen Gerät erneut zeigen, auch wenn Sie sie bereits anderswo abgeschlossen haben",
"sidebar_title": "Ihre Postfächer",
"sidebar_desc": "Dies ist Ihre Ordner-Seitenleiste. Klicken Sie auf ein Postfach, um seine E-Mails anzuzeigen. Sie können Ordner erstellen, E-Mails zwischen ihnen verschieben und ungelesene Zähler auf einen Blick sehen.",
"compose_title": "E-Mail verfassen",
@@ -4117,16 +4117,16 @@
},
"calendar_event_notifications": {
"someone": "Jemand",
- "invited": "{name} hat dich zu „{title}“ eingeladen",
+ "invited": "{name} hat Sie zu „{title}“ eingeladen",
"updated": "{name} hat „{title}“ aktualisiert",
"cancelled": "{name} hat „{title}“ abgesagt",
"untitled": "Unbenannter Termin"
},
"share_notifications": {
"someone": "Jemand",
- "shared": "{name} hat {kind} „{object}“ mit dir geteilt",
- "changed": "{name} hat deinen Zugriff auf {kind} „{object}“ geändert",
- "revoked": "{name} hat deinen Zugriff auf {kind} „{object}“ entfernt",
+ "shared": "{name} hat {kind} „{object}“ mit Ihnen geteilt",
+ "changed": "{name} hat Ihren Zugriff auf {kind} „{object}“ geändert",
+ "revoked": "{name} hat Ihren Zugriff auf {kind} „{object}“ entfernt",
"object": {
"folder": "den Ordner",
"calendar": "den Kalender",
@@ -4182,7 +4182,7 @@
"folder_not_found": "Dieser Ordner ist nicht mehr verfügbar.",
"event_not_found": "Dieser Termin ist nicht mehr verfügbar.",
"file_not_found": "Diese Datei ist nicht mehr verfügbar.",
- "account_unavailable": "Melde dich bei diesem Konto an, um den Link zu öffnen.",
+ "account_unavailable": "Melden Sie sich bei diesem Konto an, um den Link zu öffnen.",
"copy_message": "Link zur Nachricht kopieren",
"copy_conversation": "Link zur Unterhaltung kopieren",
"copy_event": "Link zum Termin kopieren",
From d65cdba18139f8ab5610ed2e12180aa3b33a0b4a Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:12:58 +0100
Subject: [PATCH 22/29] fix: put the Apps dialog on the shared dialog shell,
with a named close button and a backdrop that closes it
---
.../__tests__/sidebar-apps-modal.test.tsx | 40 +++++++++
components/layout/sidebar-apps-modal.tsx | 89 +++++++------------
2 files changed, 74 insertions(+), 55 deletions(-)
create mode 100644 components/layout/__tests__/sidebar-apps-modal.test.tsx
diff --git a/components/layout/__tests__/sidebar-apps-modal.test.tsx b/components/layout/__tests__/sidebar-apps-modal.test.tsx
new file mode 100644
index 000000000..b39e09c6d
--- /dev/null
+++ b/components/layout/__tests__/sidebar-apps-modal.test.tsx
@@ -0,0 +1,40 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+// The "Apps" modal on the shared dialog shell: a named close button, a
+// backdrop that closes it, Escape from anywhere inside, named row buttons.
+
+vi.mock('@/hooks/use-resolved-sidebar-apps', () => ({ useManagedSidebarApps: () => [] }));
+
+const { SidebarAppsModal } = await import('../sidebar-apps-modal');
+const { useSettingsStore } = await import('@/stores/settings-store');
+
+describe('apps modal', () => {
+ it('closes from its named X and from the backdrop', () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'close' }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+
+ const backdrop = screen.getByRole('dialog').parentElement!;
+ fireEvent.mouseDown(backdrop);
+ fireEvent.click(backdrop);
+ expect(onClose).toHaveBeenCalledTimes(2);
+ });
+
+ it('closes on Escape after a click on its heading', () => {
+ const onClose = vi.fn();
+ render( );
+ const heading = screen.getByRole('heading', { name: 'modal_title' });
+ fireEvent.mouseDown(heading);
+ fireEvent.keyDown(heading, { key: 'Escape' });
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('names the edit and delete buttons of each app', () => {
+ useSettingsStore.setState({ sidebarApps: [{ id: 'a', name: 'Wiki', url: 'https://wiki.example', icon: 'Globe', openMode: 'tab' }] } as never);
+ render( );
+ expect(screen.getByRole('button', { name: 'edit_app' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'delete' })).toBeInTheDocument();
+ });
+});
diff --git a/components/layout/sidebar-apps-modal.tsx b/components/layout/sidebar-apps-modal.tsx
index cd1c0b0fa..a9d8e7043 100644
--- a/components/layout/sidebar-apps-modal.tsx
+++ b/components/layout/sidebar-apps-modal.tsx
@@ -2,15 +2,16 @@
import { useState, useCallback } from 'react';
import { useTranslations } from 'next-intl';
-import { X, Plus, Pencil, Trash2, ExternalLink, PanelRight, Lock } from '@/components/icons';
+import { Plus, Pencil, Trash2, ExternalLink, PanelRight, Lock } from '@/components/icons';
import { iconForName } from '@/components/icons';
import { cn, generateUUID } from '@/lib/utils';
import { Button } from '@/components/ui/button';
+import { IconButton } from '@/components/ui/icon-button';
import { Input } from '@/components/ui/input';
import { IconPicker } from './icon-picker';
import { useSettingsStore, type SidebarApp } from '@/stores/settings-store';
import { useManagedSidebarApps } from '@/hooks/use-resolved-sidebar-apps';
-import { useFocusTrap } from '@/hooks/use-focus-trap';
+import { Dialog } from '@/components/ui/dialog';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
@@ -240,18 +241,17 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
const [isCreating, setIsCreating] = useState(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
- const modalRef = useFocusTrap({
- isActive: isOpen,
- onEscape: () => {
- if (isCreating || editingId) {
- setIsCreating(false);
- setEditingId(null);
- } else {
- onClose();
- }
- },
- restoreFocus: true,
- });
+ // Escape, the X and an outside click leave an open app form first, then
+ // the dialog; a click outside is ignored while a form is open.
+ const editing = isCreating || !!editingId;
+ const handleClose = useCallback(() => {
+ if (editing) {
+ setIsCreating(false);
+ setEditingId(null);
+ } else {
+ onClose();
+ }
+ }, [editing, onClose]);
const handleCreate = useCallback((data: SidebarAppFormData) => {
const id = `app-${generateUUID()}`;
@@ -275,36 +275,17 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
removeSidebarApp(app.id);
}, [removeSidebarApp, confirmDialog, t]);
- if (!isOpen) return null;
-
return (
-
-
+
- {/* Header */}
-
-
-
-
-
-
-
- {/* Content */}
-
{/* Create form */}
@@ -368,22 +349,21 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
{app.openMode === 'inline' ? t('inline_badge') : t('tab_badge')}
-
setEditingId(app.id)}
disabled={!!editingId || isCreating}
>
-
-
-
+
+
handleDelete(app)}
disabled={!!editingId || isCreating}
+ className="text-destructive"
>
-
-
+
+
);
@@ -397,10 +377,9 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
)}
-
-
+
-
+ >
);
}
From 778d46fc3a9d48df2418833ea813dc60097a10a5 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:13:19 +0100
Subject: [PATCH 23/29] fix: give the plugin permission and connect dialogs and
the phone notification sheet the same title size as other dialogs
---
components/notifications/notification-center.tsx | 3 ++-
components/plugins/plugin-connect-dialog.tsx | 2 +-
components/plugins/plugin-consent-dialog.tsx | 2 +-
3 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/components/notifications/notification-center.tsx b/components/notifications/notification-center.tsx
index dd91dc95d..0d47f7978 100644
--- a/components/notifications/notification-center.tsx
+++ b/components/notifications/notification-center.tsx
@@ -58,7 +58,8 @@ export function NotificationPanel({ onClose, className, sheet = false }: { onClo
return (
-
{t("title")}
+ {/* Full screen on a phone it is a dialog of its own, titled like one. */}
+
{t("title")}
{ e.preventDefault(); void submit(); }}>
-
{t('title', { label: spec.label })}
+ {t('title', { label: spec.label })}
{intro}
diff --git a/components/plugins/plugin-consent-dialog.tsx b/components/plugins/plugin-consent-dialog.tsx
index 8699f05f4..4fd931004 100644
--- a/components/plugins/plugin-consent-dialog.tsx
+++ b/components/plugins/plugin-consent-dialog.tsx
@@ -38,7 +38,7 @@ export function PluginConsentDialog(): React.JSX.Element | null {
className="flex max-h-[90vh] w-full max-w-lg flex-col rounded-lg border border-border bg-background shadow-xl"
>
-
+
{t('title', { name: current.pluginName })}
{t('intro')}
From d78f245b40b38842062e466382cd7d1b683db228 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:19:21 +0100
Subject: [PATCH 24/29] fix: make the shell's small buttons easy to tap on
phones: notifications, account menu, welcome tips, tour, toasts and the
search clear button
---
.../layout/__tests__/touch-targets.test.tsx | 66 +++++++++++++++++++
components/layout/account-switcher.tsx | 10 +--
components/layout/mobile-header.tsx | 12 ++--
.../notifications/notification-center.tsx | 31 +++------
components/tour/tour-overlay.tsx | 28 ++------
components/ui/button.tsx | 2 +-
components/ui/toast.tsx | 3 +
components/ui/welcome-banner.tsx | 23 +++----
8 files changed, 107 insertions(+), 68 deletions(-)
create mode 100644 components/layout/__tests__/touch-targets.test.tsx
diff --git a/components/layout/__tests__/touch-targets.test.tsx b/components/layout/__tests__/touch-targets.test.tsx
new file mode 100644
index 000000000..3c05903e7
--- /dev/null
+++ b/components/layout/__tests__/touch-targets.test.tsx
@@ -0,0 +1,66 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+// Phone touch targets in the shell: every control gets the 44px hit area
+// Buttons have on a touch screen (R1).
+
+vi.mock('@/hooks/use-media-query', async (importOriginal) => ({
+ ...(await importOriginal
()),
+ useDeviceDetection: () => ({ isMobile: true, isTablet: false, isDesktop: false }),
+}));
+vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ startTour: vi.fn() }) }));
+vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push: vi.fn() }), usePathname: () => '/' }));
+
+const { NotificationPanel } = await import('@/components/notifications/notification-center');
+const { WelcomeBanner } = await import('@/components/ui/welcome-banner');
+const { ToastItem } = await import('@/components/ui/toast');
+const { AccountSwitcher } = await import('../account-switcher');
+const { useSettingsStore } = await import('@/stores/settings-store');
+const { useAccountStore } = await import('@/stores/account-store');
+const { useAuthStore } = await import('@/stores/auth-store');
+
+const HIT = 'pointer-coarse:after:min-h-11';
+
+describe('touch targets', () => {
+ it('notification sheet header buttons', () => {
+ render( );
+ for (const name of ['mark_all_read', 'clear', 'close']) {
+ expect(screen.getByRole('button', { name }).className).toContain(HIT);
+ }
+ });
+
+ it('welcome banner buttons', () => {
+ useSettingsStore.setState({ onboardingCompleted: false });
+ render( );
+ for (const name of ['dismiss', 'start_tour', 'settings', 'got_it']) {
+ expect(screen.getByRole('button', { name: new RegExp(name) }).className).toContain(HIT);
+ }
+ });
+
+ it('toast action and close', () => {
+ render(
+
+ );
+ expect(screen.getByRole('button', { name: 'Undo' }).className).toContain(HIT);
+ expect(screen.getByRole('button', { name: 'close' }).className).toContain(HIT);
+ });
+
+ it('account avatar and popover rows', () => {
+ useAccountStore.setState({ accounts: [
+ { id: 'a', label: 'a', email: 'a@x', username: 'a@x', serverUrl: 'https://x', isConnected: true, isDefault: true, cookieSlot: 0 },
+ { id: 'b', label: 'b', email: 'b@x', username: 'b@x', serverUrl: 'https://x', isConnected: true, isDefault: false, cookieSlot: 1 },
+ ] } as never);
+ useAuthStore.setState({ activeAccountId: 'b' } as never);
+ useSettingsStore.setState({ interfaceLayout: 'default' } as never);
+ render( );
+ const avatar = screen.getByTestId('account-switcher');
+ expect(avatar.className).toContain('pointer-coarse:h-11');
+ fireEvent.click(avatar);
+ for (const name of ['add_account', 'set_as_default', /sign_out_of/, 'sign_out_all']) {
+ expect(screen.getByRole('menuitem', { name }).className).toContain('pointer-coarse:min-h-11');
+ }
+ });
+});
diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx
index d771a2110..3ca375d16 100644
--- a/components/layout/account-switcher.tsx
+++ b/components/layout/account-switcher.tsx
@@ -284,7 +284,7 @@ export function AccountSwitcher({
"flex items-center gap-2 transition-colors",
mvPopover ? "rounded-full" : "rounded-md",
variant !== "expanded"
- ? "justify-center w-10 h-10 hover:bg-muted"
+ ? "justify-center w-10 h-10 pointer-coarse:w-11 pointer-coarse:h-11 hover:bg-muted"
: "w-full px-2 py-1.5 hover:bg-muted text-start min-w-0",
className
)}
@@ -557,7 +557,7 @@ export function AccountSwitcher({
@@ -572,7 +572,7 @@ export function AccountSwitcher({
{activeAccount && !activeAccount.isDefault && accounts.length > 1 && (
handleSetDefault(activeAccount.id)}
- className={cn("w-full flex items-center gap-2 py-2 text-sm text-foreground hover:bg-muted transition-colors", mvPopover ? "px-4" : "px-3")}
+ className={cn("w-full flex items-center gap-2 py-2 pointer-coarse:min-h-11 text-sm text-start text-foreground hover:bg-muted transition-colors", mvPopover ? "px-4" : "px-3")}
role="menuitem"
>
@@ -581,7 +581,7 @@ export function AccountSwitcher({
)}
@@ -590,7 +590,7 @@ export function AccountSwitcher({
{!mvPopover && accounts.length > 1 && (
diff --git a/components/layout/mobile-header.tsx b/components/layout/mobile-header.tsx
index 72b706e0e..bc0a1a33a 100644
--- a/components/layout/mobile-header.tsx
+++ b/components/layout/mobile-header.tsx
@@ -2,6 +2,7 @@
import { Menu, ArrowLeft, Plus, Search, X } from "@/components/icons";
import { Button } from "@/components/ui/button";
+import { IconButton } from "@/components/ui/icon-button";
import { useUIStore } from "@/stores/ui-store";
import { useShallow } from "zustand/react/shallow";
import { useSettingsStore } from "@/stores/settings-store";
@@ -131,14 +132,13 @@ export function MobileHeader({
{searchPlaceholder || title}
{searchActive && onClearSearch && (
-
-
-
+
+
)}
)}
diff --git a/components/notifications/notification-center.tsx b/components/notifications/notification-center.tsx
index 0d47f7978..3e7cf5840 100644
--- a/components/notifications/notification-center.tsx
+++ b/components/notifications/notification-center.tsx
@@ -9,6 +9,8 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
import { isDocumentRTL } from "@/i18n/direction";
import { toastHooks } from "@/lib/plugin-hooks";
import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { IconButton } from "@/components/ui/icon-button";
import {
selectUnreadCount,
useNotificationCenterStore,
@@ -60,33 +62,18 @@ export function NotificationPanel({ onClose, className, sheet = false }: { onClo
{/* Full screen on a phone it is a dialog of its own, titled like one. */}
{t("title")}
-
+
{t("mark_all_read")}
-
-
+
+
{t("clear")}
-
+
{sheet && (
-
-
-
+
+
+
)}
{entries.length === 0 ? (
diff --git a/components/tour/tour-overlay.tsx b/components/tour/tour-overlay.tsx
index a1b1a8fb3..83f789252 100644
--- a/components/tour/tour-overlay.tsx
+++ b/components/tour/tour-overlay.tsx
@@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
import { useTour } from "./tour-provider";
import { cn } from "@/lib/utils";
import { useFocusTrap } from "@/hooks/use-focus-trap";
+import { Button } from "@/components/ui/button";
import { isEditableEventTarget } from "@/lib/keyboard";
import { isDocumentRTL } from "@/i18n/direction";
@@ -392,31 +393,16 @@ export function TourOverlay() {
{/* Navigation buttons */}
-
+
{t("tour.skip")}
-
+
-
+
{t("tour.back")}
-
-
+
+
{isLast ? t("tour.finish") : t("tour.next")}
-
+
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
index 038b9c4d4..accbee32a 100644
--- a/components/ui/button.tsx
+++ b/components/ui/button.tsx
@@ -27,7 +27,7 @@ const SIZES: Record
= {
// that big. `relative` gives it something to centre on; a className with
// another position (absolute, fixed, sticky) replaces it through
// tailwind-merge and still holds the ::after.
-const TOUCH_HIT_AREA =
+export const TOUCH_HIT_AREA =
"relative pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 " +
"pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 " +
"pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2";
diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx
index 79636b10e..d2e1b91af 100644
--- a/components/ui/toast.tsx
+++ b/components/ui/toast.tsx
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
import { X, CheckCircle, AlertCircle, Info, AlertTriangle, Loader2 } from "@/components/icons";
import { cn } from "@/lib/utils";
+import { TOUCH_HIT_AREA } from "@/components/ui/button";
export type ToastType = "success" | "error" | "info" | "warning";
@@ -180,6 +181,7 @@ export function ToastItem({ toast, onClose }: ToastProps) {
}}
className={cn(
"text-[13px] font-semibold text-foreground px-1.5 py-0.5 rounded-md transition-colors",
+ TOUCH_HIT_AREA,
"underline underline-offset-[3px] hover:bg-foreground/5"
)}
>
@@ -262,6 +264,7 @@ export function ToastItem({ toast, onClose }: ToastProps) {
}}
className={cn(
"flex-shrink-0 p-1 rounded-md transition-all max-sm:hidden",
+ TOUCH_HIT_AREA,
"text-muted-foreground hover:text-foreground hover:bg-foreground/5",
"opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 pointer-coarse:opacity-100"
)}
diff --git a/components/ui/welcome-banner.tsx b/components/ui/welcome-banner.tsx
index 58678242b..e45338cf4 100644
--- a/components/ui/welcome-banner.tsx
+++ b/components/ui/welcome-banner.tsx
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { X, Lightbulb, Settings, PlayCircle } from "@/components/icons";
import { Button } from "@/components/ui/button";
+import { IconButton } from "@/components/ui/icon-button";
import { useRouter } from "@/i18n/navigation";
import { useTour } from "@/components/tour/tour-provider";
import { useSettingsStore } from "@/stores/settings-store";
@@ -94,38 +95,34 @@ export function WelcomeBanner() {
-
-
-
+
+
+
{ dismiss(); startTour(); }}
- className="text-xs h-7"
+ className="text-xs"
>
{t("start_tour")}
{ dismiss(); router.push('/settings'); }}
- className="text-xs h-7"
+ className="text-xs"
>
{t("settings")}
{t("got_it")}
From e33f95989db8f111aa131e73705380d318660b56 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:20:39 +0100
Subject: [PATCH 25/29] fix: use the vertical More icon on plugin menus like
everywhere else
---
components/plugins/__tests__/plugin-actions.test.tsx | 9 +++++++++
components/plugins/plugin-actions.tsx | 4 ++--
components/plugins/plugin-dialog-host.tsx | 4 ++--
3 files changed, 13 insertions(+), 4 deletions(-)
diff --git a/components/plugins/__tests__/plugin-actions.test.tsx b/components/plugins/__tests__/plugin-actions.test.tsx
index 5ddb87ba4..6c25aec0a 100644
--- a/components/plugins/__tests__/plugin-actions.test.tsx
+++ b/components/plugins/__tests__/plugin-actions.test.tsx
@@ -66,3 +66,12 @@ describe('PluginActions', () => {
expect(screen.getByRole('menuitem', { name: /Labels/ })).toHaveTextContent('99+');
});
});
+
+describe('PluginActions overflow button', () => {
+ it('uses the vertical "more" icon, as every other overflow menu does (R2)', () => {
+ act(() => setPluginActions('tasks', 'Email to task', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }]));
+ render(
);
+ const more = screen.getByRole('button', { name: 'more_actions' });
+ expect(more.querySelector('.tabler-icon-dots-vertical')).not.toBeNull();
+ });
+});
diff --git a/components/plugins/plugin-actions.tsx b/components/plugins/plugin-actions.tsx
index 7af509c2c..f0bd6b8e3 100644
--- a/components/plugins/plugin-actions.tsx
+++ b/components/plugins/plugin-actions.tsx
@@ -2,7 +2,7 @@
import { useSyncExternalStore, useState } from 'react';
import { useTranslations } from 'next-intl';
-import { iconForName, MoreHorizontal, Puzzle } from '@/components/icons';
+import { iconForName, MoreVertical, Puzzle } from '@/components/icons';
import { ContextMenu, ContextMenuItem } from '@/components/ui/context-menu';
import { useContextMenu } from '@/hooks/use-context-menu';
import { cn } from '@/lib/utils';
@@ -139,7 +139,7 @@ function PluginActionsOverflow({ surface, context, mailboxRole, mimeType, classN
'hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
-
+
title={t('plugin_menu', { name })}
className={cn('p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground', open && 'bg-muted text-foreground')}
>
-
+
{open && (
From 1de1d02f571ebbbbcd004eae0a628b6d8e265bf3 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:24:05 +0100
Subject: [PATCH 26/29] feat: list plugin pages in the command palette's Go to
commands
---
.../__tests__/command-palette.test.tsx | 12 ++++++++++++
components/command-palette/command-palette.tsx | 15 ++++++++++++++-
2 files changed, 26 insertions(+), 1 deletion(-)
diff --git a/components/command-palette/__tests__/command-palette.test.tsx b/components/command-palette/__tests__/command-palette.test.tsx
index e7e2d2b49..274da3805 100644
--- a/components/command-palette/__tests__/command-palette.test.tsx
+++ b/components/command-palette/__tests__/command-palette.test.tsx
@@ -211,3 +211,15 @@ describe('command palette on a phone', () => {
expect(screen.getByRole('option', { name: 'compose' })).toBeInTheDocument();
});
});
+
+describe('command palette and plugin pages', () => {
+ it('goes to a plugin page like to the built-in apps', async () => {
+ const pages = await import('@/lib/plugin-sandbox/pages');
+ act(() => pages.setPluginPages('kanban', 'Kanban', [{ id: 'board', title: 'Board' }]));
+ render(
);
+ pressCtrlK();
+ act(() => { fireEvent.click(screen.getByRole('option', { name: /go_to\(Board\)/ })); });
+ expect(nav.push).toHaveBeenCalledWith('/apps/kanban/board');
+ pages.resetPages();
+ });
+});
diff --git a/components/command-palette/command-palette.tsx b/components/command-palette/command-palette.tsx
index 298b9de77..9cd32331d 100644
--- a/components/command-palette/command-palette.tsx
+++ b/components/command-palette/command-palette.tsx
@@ -28,6 +28,7 @@ import { useCalendarStore } from "@/stores/calendar-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useThemeStore } from "@/stores/theme-store";
import { usePluginActions } from "@/components/plugins/plugin-actions";
+import { usePluginPages, pluginPageHref } from "@/hooks/use-plugin-pages";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { GlobalSearchPalette } from "@/components/global-search/global-search-palette";
import { isContextMenuOpen } from "@/components/ui/context-menu";
@@ -151,6 +152,7 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
const colorModeLocked = usePolicyStore((s) => s.getColorModePolicy().locked);
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const pluginActions = usePluginActions(surface);
+ const pluginPages = usePluginPages();
const registeredGroups = useRegisteredCommandGroups();
const accounts = useAccountStore((s) => s.accounts);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
@@ -193,6 +195,17 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
if ((client?.supportsFiles?.() ?? false) && isFeatureEnabled("filesEnabled")) {
list.push({ id: "go-files", group: navigation, icon: Folder, label: t("go_to", { name: tSidebar("files") }), run: go("/files") });
}
+ // Plugin pages sit next to the built-in apps in the rail and the tab bar.
+ for (const page of pluginPages) {
+ list.push({
+ id: `go-page:${page.pluginId}:${page.id}`,
+ group: navigation,
+ icon: iconForName(page.icon) ?? Puzzle,
+ label: t("go_to", { name: page.title }),
+ source: page.pluginName,
+ run: go(pluginPageHref(page)),
+ });
+ }
list.push(
{ id: "go-settings", group: navigation, icon: Settings, label: t("go_to", { name: tSidebar("settings") }), run: go("/settings") },
{ id: "compose", group: actions, icon: PenSquare, label: t("compose"), shortcut: "c", run: onMail("compose") },
@@ -244,7 +257,7 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
});
}
return list;
- }, [client, supportsCalendar, isFeatureEnabled, colorModeLocked, resolvedTheme, pluginActions, registeredGroups, accounts, activeAccountId, router, t, tSidebar, tGlobalSearch, onShowShortcuts, touch]);
+ }, [client, supportsCalendar, isFeatureEnabled, colorModeLocked, resolvedTheme, pluginActions, pluginPages, registeredGroups, accounts, activeAccountId, router, t, tSidebar, tGlobalSearch, onShowShortcuts, touch]);
const needle = query.trim();
const visible = needle
From 9b9cc4ca33320eb97c455ece5146513bb046bdaf Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:25:29 +0100
Subject: [PATCH 27/29] =?UTF-8?q?fix:=20show=20the=20extend-selection=20sh?=
=?UTF-8?q?ortcut=20as=20Shift=20+=20=E2=86=93=20or=20Shift=20+=20?=
=?UTF-8?q?=E2=86=91=20in=20the=20shortcuts=20help?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
lib/__tests__/shortcut-alternatives.test.ts | 22 +++++++++++++++++++++
lib/keyboard-shortcuts/mail.ts | 2 +-
2 files changed, 23 insertions(+), 1 deletion(-)
create mode 100644 lib/__tests__/shortcut-alternatives.test.ts
diff --git a/lib/__tests__/shortcut-alternatives.test.ts b/lib/__tests__/shortcut-alternatives.test.ts
new file mode 100644
index 000000000..73d202122
--- /dev/null
+++ b/lib/__tests__/shortcut-alternatives.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import { SHORTCUT_APPS } from '@/lib/keyboard-shortcuts';
+import { shortcutAlternatives } from '@/lib/shortcut-keys';
+
+// The shortcuts help shows " / " as "or" between whole keys. "Shift + ↓ / ↑"
+// read as "Shift + ↓ or ↑", which says plain ↑ also extends the selection.
+
+describe('shortcut alternatives', () => {
+ it('spell out the modifier in every alternative', () => {
+ const bad: string[] = [];
+ for (const app of SHORTCUT_APPS) {
+ for (const section of app.sections) {
+ for (const entry of section.entries) {
+ const alternatives = shortcutAlternatives(entry.key);
+ const modified = alternatives.some((key) => key.includes(' + '));
+ if (modified && alternatives.some((key) => !key.includes(' + '))) bad.push(`${app.id}: ${entry.key}`);
+ }
+ }
+ }
+ expect(bad).toEqual([]);
+ });
+});
diff --git a/lib/keyboard-shortcuts/mail.ts b/lib/keyboard-shortcuts/mail.ts
index 1ee3eec54..639e5b179 100644
--- a/lib/keyboard-shortcuts/mail.ts
+++ b/lib/keyboard-shortcuts/mail.ts
@@ -27,7 +27,7 @@ export const MAIL_SHORTCUTS = {
{ key: "/", description: "shortcuts.global.search" },
{ key: "Shift + G", description: "shortcuts.global.refresh" },
{ key: "Mod + A", description: "shortcuts.global.select_all" },
- { key: "Shift + ↓ / ↑", description: "shortcuts.global.extend_selection" },
+ { key: "Shift + ↓ / Shift + ↑", description: "shortcuts.global.extend_selection" },
],
threads: [
{ key: "x", description: "shortcuts.threads.expand_collapse", layout: "default" },
From b865569ce253e35304bde23366b2ce1cbe67e5f7 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:29:50 +0100
Subject: [PATCH 28/29] fix: skip tour steps whose target sits in a closed
drawer on the phone
---
components/tour/__tests__/tour.test.tsx | 5 +++++
components/tour/tour-overlay.tsx | 8 ++++++--
2 files changed, 11 insertions(+), 2 deletions(-)
diff --git a/components/tour/__tests__/tour.test.tsx b/components/tour/__tests__/tour.test.tsx
index 64bd27601..6c0b1de38 100644
--- a/components/tour/__tests__/tour.test.tsx
+++ b/components/tour/__tests__/tour.test.tsx
@@ -64,6 +64,11 @@ describe('tour targets', () => {
expect(findTourTarget('[data-tour="one"]')?.id).toBe('tabbar');
});
+ it('ignores a copy in a closed drawer', () => {
+ anchors('
');
+ expect(findTourTarget('[data-tour="one"]')).toBeNull();
+ });
+
it('skips a step whose target is in the page but not shown, instead of leaving the user behind the overlay', () => {
anchors('
');
render(
);
diff --git a/components/tour/tour-overlay.tsx b/components/tour/tour-overlay.tsx
index 83f789252..8751bbc03 100644
--- a/components/tour/tour-overlay.tsx
+++ b/components/tour/tour-overlay.tsx
@@ -24,12 +24,16 @@ const TOOLTIP_MAX_W = 360;
/**
* The first element for the selector that is on screen. The same anchor can
* exist twice (the rail and the phone's tab bar); one with no size is not
- * rendered in this layout and has nothing to point at.
+ * rendered in this layout, and one in a closed drawer (inert, or pushed off
+ * the side of the screen) can't be seen: neither has anything to point at.
*/
export function findTourTarget(selector: string): Element | null {
for (const el of document.querySelectorAll(selector)) {
+ if (el.closest("[inert]")) continue;
const r = el.getBoundingClientRect();
- if (r.width > 0 || r.height > 0) return el;
+ if (r.width === 0 && r.height === 0) continue;
+ if (r.right <= 0 || r.left >= window.innerWidth) continue;
+ return el;
}
return null;
}
From 5335c409a734ab7762a543b1b4f3930a8103c435 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:37:46 +0100
Subject: [PATCH 29/29] fix: translate the page a connector link lands on when
it can't be opened
---
app/(main)/connector/[target]/page.tsx | 8 ++++-
.../__tests__/connector-client.test.tsx | 29 +++++++++++++++++++
components/connector/connector-client.tsx | 18 ++++++------
locales/ar/common.json | 8 +++++
locales/ca/common.json | 8 +++++
locales/cs/common.json | 8 +++++
locales/da/common.json | 8 +++++
locales/de/common.json | 8 +++++
locales/en/common.json | 8 +++++
locales/es/common.json | 8 +++++
locales/et/common.json | 8 +++++
locales/fa/common.json | 8 +++++
locales/fr/common.json | 8 +++++
locales/he/common.json | 8 +++++
locales/hu/common.json | 8 +++++
locales/it/common.json | 8 +++++
locales/ja/common.json | 8 +++++
locales/ko/common.json | 8 +++++
locales/lv/common.json | 8 +++++
locales/mn/common.json | 8 +++++
locales/nb/common.json | 8 +++++
locales/nl/common.json | 8 +++++
locales/pl/common.json | 8 +++++
locales/pt/common.json | 8 +++++
locales/ro/common.json | 8 +++++
locales/ru/common.json | 8 +++++
locales/sk/common.json | 8 +++++
locales/th/common.json | 8 +++++
locales/tr/common.json | 8 +++++
locales/uk/common.json | 8 +++++
locales/zh-TW/common.json | 8 +++++
locales/zh/common.json | 8 +++++
32 files changed, 277 insertions(+), 10 deletions(-)
create mode 100644 components/connector/__tests__/connector-client.test.tsx
diff --git a/app/(main)/connector/[target]/page.tsx b/app/(main)/connector/[target]/page.tsx
index 62c6694b6..4ecfe2296 100644
--- a/app/(main)/connector/[target]/page.tsx
+++ b/app/(main)/connector/[target]/page.tsx
@@ -1,4 +1,6 @@
import { ConnectorClient } from '@/components/connector/connector-client';
+import { IntlProvider } from '@/components/providers/intl-provider';
+import enMessages from '@/locales/en/common.json';
import { TARGET_NAMES } from '@/lib/connector/registry';
import { IS_LITE } from '@/lib/lite';
@@ -23,5 +25,9 @@ export default async function ConnectorRoute({
params: Promise<{ target: string }>;
}) {
const { target } = await params;
- return
;
+ return (
+
+
+
+ );
}
diff --git a/components/connector/__tests__/connector-client.test.tsx b/components/connector/__tests__/connector-client.test.tsx
new file mode 100644
index 000000000..804f6a2fb
--- /dev/null
+++ b/components/connector/__tests__/connector-client.test.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+// A connector link that can't be opened explains why in the reader's
+// language (keys in tests), not in English only.
+
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Bulwark' }) }));
+vi.mock('@/lib/browser-navigation', async (importOriginal) => ({
+ ...(await importOriginal
()),
+ replaceWindowLocation: vi.fn(),
+}));
+
+const { ConnectorClient } = await import('../connector-client');
+
+describe('connector link page', () => {
+ it('translates an unknown link', async () => {
+ render( );
+ expect(await screen.findByRole('heading', { name: /unknown_title/ })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /open_app/ })).toBeInTheDocument();
+ expect(screen.queryByText(/newer than this/)).toBeNull();
+ });
+
+ it('translates a link with a broken parameter', async () => {
+ window.history.replaceState(null, '', '/connector/calendar?view=nope');
+ render( );
+ expect(await screen.findByRole('heading', { name: /malformed_title/ })).toBeInTheDocument();
+ window.history.replaceState(null, '', '/');
+ });
+});
diff --git a/components/connector/connector-client.tsx b/components/connector/connector-client.tsx
index f8c75f50c..53ef5ab91 100644
--- a/components/connector/connector-client.tsx
+++ b/components/connector/connector-client.tsx
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
+import { useTranslations } from 'next-intl';
import { useConfig } from '@/hooks/use-config';
import { resolveTarget, type ResolveResult } from '@/lib/connector/registry';
import { appPath } from '@/lib/deep-links';
@@ -26,6 +27,7 @@ const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '';
* to the deep link. The admin area is the exception - see `?next=` below.
*/
export function ConnectorClient({ target }: { target: string }) {
+ const t = useTranslations('connector_link');
const [result, setResult] = useState(null);
const name = productName(useConfig().appName);
@@ -47,7 +49,7 @@ export function ConnectorClient({ target }: { target: string }) {
}, [destination]);
if (result === null || result.kind === 'ok') {
- return Opening… ;
+ return {t('opening')} ;
}
// Neither case 404s. A link that this version cannot honour should say so
@@ -55,10 +57,9 @@ export function ConnectorClient({ target }: { target: string }) {
if (result.kind === 'unknown-target') {
return (
- This link is newer than this {name}
+ {t('unknown_title', { name })}
- It opens something {APP_VERSION ? `version ${APP_VERSION} ` : 'this version '}
- does not know about. Updating {name} should teach it.
+ {t('unknown_body', { name, version: APP_VERSION || '?' })}
@@ -67,12 +68,10 @@ export function ConnectorClient({ target }: { target: string }) {
return (
- That link is not quite right
+ {t('malformed_title')}
- It asks for {result.target.label}, but its{' '}
{/* The parameter is named, never echoed: its value is attacker-controlled. */}
- {result.param} is missing or
- malformed. Nothing was opened.
+ {t('malformed_body', { label: result.target.label, param: result.param })}
@@ -80,13 +79,14 @@ export function ConnectorClient({ target }: { target: string }) {
}
function Actions({ name }: { name: string }) {
+ const t = useTranslations('connector_link');
return (
- Open {name}
+ {t('open_app', { name })}
);
diff --git a/locales/ar/common.json b/locales/ar/common.json
index fb4ddec8d..4e96224fb 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -4189,6 +4189,14 @@
"copied": "تم نسخ الرابط",
"copy_failed": "تعذّر نسخ الرابط"
},
+ "connector_link": {
+ "opening": "جارٍ الفتح…",
+ "unknown_title": "هذا الرابط أحدث من {name} هذا",
+ "unknown_body": "يفتح شيئًا لا يعرفه الإصدار {version}. تحديث {name} سيجعله يعمل.",
+ "malformed_title": "هذا الرابط غير صحيح تمامًا",
+ "malformed_body": "يطلب {label}، لكن المعامل {param} مفقود أو غير صالح. لم يُفتح أي شيء.",
+ "open_app": "فتح {name}"
+ },
"plugins": {
"oauth_callback": {
"completing": "جارٍ إكمال التفويض…",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index e765a488e..3d960147f 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -4189,6 +4189,14 @@
"copied": "Enllaç copiat",
"copy_failed": "No s'ha pogut copiar l'enllaç"
},
+ "connector_link": {
+ "opening": "S'està obrint…",
+ "unknown_title": "Aquest enllaç és més nou que aquest {name}",
+ "unknown_body": "Obre una cosa que la versió {version} no coneix. Actualitzar {name} ho hauria de resoldre.",
+ "malformed_title": "Aquest enllaç no és del tot correcte",
+ "malformed_body": "Demana {label}, però el paràmetre {param} falta o no és vàlid. No s'ha obert res.",
+ "open_app": "Obre {name}"
+ },
"plugins": {
"oauth_callback": {
"completing": "Completant l'autorització…",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 5acec63ea..c43a0a481 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -4189,6 +4189,14 @@
"copied": "Odkaz zkopírován",
"copy_failed": "Odkaz se nepodařilo zkopírovat"
},
+ "connector_link": {
+ "opening": "Otevírání…",
+ "unknown_title": "Tento odkaz je novější než tento {name}",
+ "unknown_body": "Otevírá něco, co verze {version} nezná. Aktualizace {name} by to měla vyřešit.",
+ "malformed_title": "Tento odkaz není úplně v pořádku",
+ "malformed_body": "Žádá o {label}, ale jeho parametr {param} chybí nebo je neplatný. Nic nebylo otevřeno.",
+ "open_app": "Otevřít {name}"
+ },
"plugins": {
"oauth_callback": {
"completing": "Dokončování autorizace…",
diff --git a/locales/da/common.json b/locales/da/common.json
index 5cfebcf04..3a0da0c0a 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -4189,6 +4189,14 @@
"copied": "Link kopieret",
"copy_failed": "Kunne ikke kopiere linket"
},
+ "connector_link": {
+ "opening": "Åbner…",
+ "unknown_title": "Dette link er nyere end denne {name}",
+ "unknown_body": "Det åbner noget, som version {version} ikke kender. En opdatering af {name} bør løse det.",
+ "malformed_title": "Dette link er ikke helt rigtigt",
+ "malformed_body": "Det beder om {label}, men parameteren {param} mangler eller er ugyldig. Intet blev åbnet.",
+ "open_app": "Åbn {name}"
+ },
"plugins": {
"oauth_callback": {
"completing": "Fuldfører godkendelse…",
diff --git a/locales/de/common.json b/locales/de/common.json
index 67aca327e..70fbb1a5f 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -4189,6 +4189,14 @@
"copied": "Link kopiert",
"copy_failed": "Link konnte nicht kopiert werden"
},
+ "connector_link": {
+ "opening": "Wird geöffnet…",
+ "unknown_title": "Dieser Link ist neuer als dieses {name}",
+ "unknown_body": "Er öffnet etwas, das Version {version} nicht kennt. Nach einem Update von {name} sollte es funktionieren.",
+ "malformed_title": "Dieser Link stimmt nicht ganz",
+ "malformed_body": "Er verlangt {label}, aber sein Parameter {param} fehlt oder ist ungültig. Es wurde nichts geöffnet.",
+ "open_app": "{name} öffnen"
+ },
"plugins": {
"oauth_callback": {
"completing": "Autorisierung wird abgeschlossen…",
diff --git a/locales/en/common.json b/locales/en/common.json
index b34cacbbf..d04778d7a 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -4189,6 +4189,14 @@
"copied": "Link copied",
"copy_failed": "Couldn't copy the link"
},
+ "connector_link": {
+ "opening": "Opening…",
+ "unknown_title": "This link is newer than this {name}",
+ "unknown_body": "It opens something version {version} does not know about. Updating {name} should teach it.",
+ "malformed_title": "That link is not quite right",
+ "malformed_body": "It asks for {label}, but its {param} is missing or malformed. Nothing was opened.",
+ "open_app": "Open {name}"
+ },
"plugins": {
"oauth_callback": {
"completing": "Completing authorization…",
diff --git a/locales/es/common.json b/locales/es/common.json
index 672e7a0fc..b4afe797c 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -4189,6 +4189,14 @@
"copied": "Enlace copiado",
"copy_failed": "No se pudo copiar el enlace"
},
+ "connector_link": {
+ "opening": "Abriendo…",
+ "unknown_title": "Este enlace es más reciente que este {name}",
+ "unknown_body": "Abre algo que la versión {version} no conoce. Actualizar {name} debería solucionarlo.",
+ "malformed_title": "Este enlace no es del todo correcto",
+ "malformed_body": "Pide {label}, pero su parámetro {param} falta o no es válido. No se ha abierto nada.",
+ "open_app": "Abrir {name}"
+ },
"plugins": {
"oauth_callback": {
"completing": "Completando la autorización…",
diff --git a/locales/et/common.json b/locales/et/common.json
index e726eeff0..211232902 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -4189,6 +4189,14 @@
"copied": "Link kopeeritud",
"copy_failed": "Linki ei õnnestunud kopeerida"
},
+ "connector_link": {
+ "opening": "Avamine…",
+ "unknown_title": "See link on uuem kui see {name}",
+ "unknown_body": "See avab midagi, mida versioon {version} ei tunne. {name} värskendamine peaks selle lahendama.",
+ "malformed_title": "See link pole päris õige",
+ "malformed_body": "See küsib {label}, kuid selle parameeter {param} puudub või on vigane. Midagi ei avatud.",
+ "open_app": "Ava {name}"
+ },
"plugins": {
"oauth_callback": {
"completing": "Autoriseerimise lõpetamine…",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 1840ca58d..26317bc59 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -4189,6 +4189,14 @@
"copied": "پیوند کپی شد",
"copy_failed": "کپی پیوند ممکن نشد"
},
+ "connector_link": {
+ "opening": "در حال باز کردن…",
+ "unknown_title": "این پیوند از این {name} جدیدتر است",
+ "unknown_body": "چیزی را باز میکند که نسخهٔ {version} آن را نمیشناسد. بهروزرسانی {name} باید مشکل را حل کند.",
+ "malformed_title": "این پیوند کاملاً درست نیست",
+ "malformed_body": "{label} را میخواهد، اما پارامتر {param} آن وجود ندارد یا نادرست است. چیزی باز نشد.",
+ "open_app": "باز کردن {name}"
+ },
"plugins": {
"oauth_callback": {
"completing": "در حال تکمیل احراز هویت…",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 421f256f9..fdb5d4687 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -4189,6 +4189,14 @@
"copied": "Lien copié",
"copy_failed": "Impossible de copier le lien"
},
+ "connector_link": {
+ "opening": "Ouverture…",
+ "unknown_title": "Ce lien est plus récent que ce {name}",
+ "unknown_body": "Il ouvre quelque chose que la version {version} ne connaît pas. Mettre à jour {name} devrait régler le problème.",
+ "malformed_title": "Ce lien n'est pas tout à fait correct",
+ "malformed_body": "Il demande {label}, mais son paramètre {param} est absent ou mal formé. Rien n'a été ouvert.",
+ "open_app": "Ouvrir {name}"
+ },
"plugins": {
"oauth_callback": {
"completing": "Finalisation de l'autorisation…",
diff --git a/locales/he/common.json b/locales/he/common.json
index 1fd39b1d0..6ae21e955 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -4190,6 +4190,14 @@
"copied": "הקישור הועתק",
"copy_failed": "לא ניתן להעתיק את הקישור"
},
+ "connector_link": {
+ "opening": "פותח…",
+ "unknown_title": "הקישור הזה חדש יותר מ-{name} הזה",
+ "unknown_body": "הוא פותח משהו שגרסה {version} לא מכירה. עדכון של {name} אמור לפתור את זה.",
+ "malformed_title": "הקישור הזה לא לגמרי תקין",
+ "malformed_body": "הוא מבקש {label}, אבל הפרמטר {param} חסר או שגוי. לא נפתח דבר.",
+ "open_app": "פתיחת {name}"
+ },
"plugins": {
"oauth_callback": {
"completing": "משלים אימות…",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index b7a1127e6..4acc165c7 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -4189,6 +4189,14 @@
"copied": "Link másolva",
"copy_failed": "Nem sikerült másolni a linket"
},
+ "connector_link": {
+ "opening": "Megnyitás…",
+ "unknown_title": "Ez a hivatkozás újabb, mint ez a {name}",
+ "unknown_body": "Olyasmit nyit meg, amit a(z) {version} verzió nem ismer. A(z) {name} frissítése megoldja.",
+ "malformed_title": "Ez a hivatkozás nem egészen jó",
+ "malformed_body": "{label} megnyitását kéri, de a(z) {param} paraméter hiányzik vagy hibás. Semmi sem nyílt meg.",
+ "open_app": "{name} megnyitása"
+ },
"plugins": {
"oauth_callback": {
"completing": "Hitelesítés befejezése…",
diff --git a/locales/it/common.json b/locales/it/common.json
index 0b3a2be92..239a78061 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -4189,6 +4189,14 @@
"copied": "Link copiato",
"copy_failed": "Impossibile copiare il link"
},
+ "connector_link": {
+ "opening": "Apertura…",
+ "unknown_title": "Questo link è più recente di questo {name}",
+ "unknown_body": "Apre qualcosa che la versione {version} non conosce. Aggiornare {name} dovrebbe risolvere.",
+ "malformed_title": "Questo link non è del tutto corretto",
+ "malformed_body": "Richiede {label}, ma il parametro {param} manca o non è valido. Non è stato aperto nulla.",
+ "open_app": "Apri {name}"
+ },
"plugins": {
"oauth_callback": {
"completing": "Completamento dell'autorizzazione…",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 8424924fb..df11c03f3 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -4189,6 +4189,14 @@
"copied": "リンクをコピーしました",
"copy_failed": "リンクをコピーできませんでした"
},
+ "connector_link": {
+ "opening": "開いています…",
+ "unknown_title": "このリンクはこの {name} より新しいものです",
+ "unknown_body": "バージョン {version} が知らないものを開こうとしています。{name} を更新すると開けるはずです。",
+ "malformed_title": "このリンクは正しくありません",
+ "malformed_body": "{label} を開こうとしていますが、{param} がないか不正です。何も開いていません。",
+ "open_app": "{name} を開く"
+ },
"plugins": {
"oauth_callback": {
"completing": "認証を完了しています…",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index e3d897ad0..36678351e 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -4189,6 +4189,14 @@
"copied": "링크가 복사되었습니다",
"copy_failed": "링크를 복사하지 못했습니다"
},
+ "connector_link": {
+ "opening": "여는 중…",
+ "unknown_title": "이 링크는 이 {name}보다 최신입니다",
+ "unknown_body": "버전 {version}이 알지 못하는 항목을 엽니다. {name}을 업데이트하면 열 수 있습니다.",
+ "malformed_title": "이 링크가 올바르지 않습니다",
+ "malformed_body": "{label}을 요청하지만 {param} 값이 없거나 잘못되었습니다. 아무것도 열지 않았습니다.",
+ "open_app": "{name} 열기"
+ },
"plugins": {
"oauth_callback": {
"completing": "인증 완료 중…",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index f7d1846c6..bc0058440 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -4189,6 +4189,14 @@
"copied": "Saite nokopēta",
"copy_failed": "Neizdevās nokopēt saiti"
},
+ "connector_link": {
+ "opening": "Atver…",
+ "unknown_title": "Šī saite ir jaunāka par šo {name}",
+ "unknown_body": "Tā atver kaut ko, ko versija {version} nepazīst. {name} atjaunināšanai tas būtu jāatrisina.",
+ "malformed_title": "Šī saite nav gluži pareiza",
+ "malformed_body": "Tā pieprasa {label}, bet tās parametrs {param} trūkst vai ir nepareizs. Nekas netika atvērts.",
+ "open_app": "Atvērt {name}"
+ },
"plugins": {
"oauth_callback": {
"completing": "Pabeidz autorizāciju…",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 38c52b3fa..8487e4252 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -4189,6 +4189,14 @@
"copied": "Холбоос хуулагдлаа",
"copy_failed": "Холбоосыг хуулж чадсангүй"
},
+ "connector_link": {
+ "opening": "Нээж байна…",
+ "unknown_title": "Энэ холбоос энэ {name}-аас шинэ байна",
+ "unknown_body": "{version} хувилбарын мэдэхгүй зүйлийг нээж байна. {name}-ыг шинэчилбэл нээгдэнэ.",
+ "malformed_title": "Энэ холбоос бүрэн зөв биш байна",
+ "malformed_body": "{label}-г хүсэж байгаа ч {param} параметр байхгүй эсвэл буруу байна. Юу ч нээгдсэнгүй.",
+ "open_app": "{name}-ыг нээх"
+ },
"plugins": {
"oauth_callback": {
"completing": "Зөвшөөрлийг дуусгаж байна…",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 31a86f917..bea23badc 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -4189,6 +4189,14 @@
"copied": "Lenken er kopiert",
"copy_failed": "Kunne ikke kopiere lenken"
},
+ "connector_link": {
+ "opening": "Åpner…",
+ "unknown_title": "Denne lenken er nyere enn denne {name}",
+ "unknown_body": "Den åpner noe versjon {version} ikke kjenner til. En oppdatering av {name} bør løse det.",
+ "malformed_title": "Denne lenken er ikke helt riktig",
+ "malformed_body": "Den ber om {label}, men parameteren {param} mangler eller er ugyldig. Ingenting ble åpnet.",
+ "open_app": "Åpne {name}"
+ },
"plugins": {
"oauth_callback": {
"completing": "Fullfører autorisering…",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 0f0255bb9..d9a398e6b 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -4189,6 +4189,14 @@
"copied": "Link gekopieerd",
"copy_failed": "Kon de link niet kopiëren"
},
+ "connector_link": {
+ "opening": "Openen…",
+ "unknown_title": "Deze link is nieuwer dan deze {name}",
+ "unknown_body": "Hij opent iets wat versie {version} niet kent. Bijwerken van {name} zou dat moeten oplossen.",
+ "malformed_title": "Deze link klopt niet helemaal",
+ "malformed_body": "Hij vraagt om {label}, maar de parameter {param} ontbreekt of is ongeldig. Er is niets geopend.",
+ "open_app": "{name} openen"
+ },
"plugins": {
"oauth_callback": {
"completing": "Autorisatie voltooien…",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index b4ac17fbb..a423a2e96 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -4189,6 +4189,14 @@
"copied": "Link skopiowany",
"copy_failed": "Nie udało się skopiować linku"
},
+ "connector_link": {
+ "opening": "Otwieranie…",
+ "unknown_title": "Ten link jest nowszy niż ten {name}",
+ "unknown_body": "Otwiera coś, czego wersja {version} nie zna. Aktualizacja {name} powinna to rozwiązać.",
+ "malformed_title": "Ten link nie jest do końca poprawny",
+ "malformed_body": "Prosi o {label}, ale brakuje parametru {param} lub jest on błędny. Nic nie zostało otwarte.",
+ "open_app": "Otwórz {name}"
+ },
"plugins": {
"oauth_callback": {
"completing": "Kończenie autoryzacji…",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index b629709cb..fb8a22ffb 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -4189,6 +4189,14 @@
"copied": "Link copiado",
"copy_failed": "Não foi possível copiar o link"
},
+ "connector_link": {
+ "opening": "A abrir…",
+ "unknown_title": "Esta ligação é mais recente do que este {name}",
+ "unknown_body": "Abre algo que a versão {version} não conhece. Atualizar o {name} deve resolver.",
+ "malformed_title": "Esta ligação não está bem",
+ "malformed_body": "Pede {label}, mas o parâmetro {param} falta ou é inválido. Nada foi aberto.",
+ "open_app": "Abrir {name}"
+ },
"plugins": {
"oauth_callback": {
"completing": "A concluir a autorização…",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 252f848be..dd99d0195 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -4189,6 +4189,14 @@
"copied": "Link copiat",
"copy_failed": "Linkul nu a putut fi copiat"
},
+ "connector_link": {
+ "opening": "Se deschide…",
+ "unknown_title": "Acest link este mai nou decât acest {name}",
+ "unknown_body": "Deschide ceva ce versiunea {version} nu cunoaște. Actualizarea {name} ar trebui să rezolve asta.",
+ "malformed_title": "Acest link nu este chiar corect",
+ "malformed_body": "Cere {label}, dar parametrul {param} lipsește sau este greșit. Nu s-a deschis nimic.",
+ "open_app": "Deschide {name}"
+ },
"plugins": {
"oauth_callback": {
"completing": "Se finalizează autorizarea…",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 9b671594a..6f3f6bde9 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -4189,6 +4189,14 @@
"copied": "Ссылка скопирована",
"copy_failed": "Не удалось скопировать ссылку"
},
+ "connector_link": {
+ "opening": "Открываем…",
+ "unknown_title": "Эта ссылка новее, чем этот {name}",
+ "unknown_body": "Она открывает то, чего не знает версия {version}. Обновление {name} должно это исправить.",
+ "malformed_title": "С этой ссылкой что-то не так",
+ "malformed_body": "Она запрашивает {label}, но параметр {param} отсутствует или неверен. Ничего не открыто.",
+ "open_app": "Открыть {name}"
+ },
"plugins": {
"oauth_callback": {
"completing": "Завершение авторизации…",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 7a52faaec..f4108a9f2 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -4189,6 +4189,14 @@
"copied": "Odkaz skopírovaný",
"copy_failed": "Odkaz sa nepodarilo skopírovať"
},
+ "connector_link": {
+ "opening": "Otvára sa…",
+ "unknown_title": "Tento odkaz je novší ako tento {name}",
+ "unknown_body": "Otvára niečo, čo verzia {version} nepozná. Aktualizácia {name} by to mala vyriešiť.",
+ "malformed_title": "Tento odkaz nie je celkom v poriadku",
+ "malformed_body": "Žiada o {label}, ale jeho parameter {param} chýba alebo je neplatný. Nič sa neotvorilo.",
+ "open_app": "Otvoriť {name}"
+ },
"plugins": {
"oauth_callback": {
"completing": "Dokončovanie autorizácie…",
diff --git a/locales/th/common.json b/locales/th/common.json
index 65fc76c51..bcd87fdcb 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -372,6 +372,14 @@
"copied": "คัดลอกลิงก์แล้ว",
"copy_failed": "ไม่สามารถคัดลอกลิงก์ได้"
},
+ "connector_link": {
+ "opening": "กำลังเปิด…",
+ "unknown_title": "ลิงก์นี้ใหม่กว่า {name} นี้",
+ "unknown_body": "ลิงก์นี้เปิดสิ่งที่เวอร์ชัน {version} ไม่รู้จัก การอัปเดต {name} น่าจะแก้ได้",
+ "malformed_title": "ลิงก์นี้ไม่ถูกต้องนัก",
+ "malformed_body": "ลิงก์ขอ {label} แต่พารามิเตอร์ {param} หายไปหรือไม่ถูกต้อง ไม่มีอะไรถูกเปิด",
+ "open_app": "เปิด {name}"
+ },
"threads": {
"messages_one": "{count} ข้อความ",
"messages_other": "{count} ข้อความ",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 57c52ea3b..e9f6577d2 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -4189,6 +4189,14 @@
"copied": "Bağlantı kopyalandı",
"copy_failed": "Bağlantı kopyalanamadı"
},
+ "connector_link": {
+ "opening": "Açılıyor…",
+ "unknown_title": "Bu bağlantı bu {name} sürümünden daha yeni",
+ "unknown_body": "{version} sürümünün bilmediği bir şeyi açıyor. {name} güncellenince açılmalı.",
+ "malformed_title": "Bu bağlantı tam doğru değil",
+ "malformed_body": "{label} istiyor, ancak {param} parametresi eksik veya hatalı. Hiçbir şey açılmadı.",
+ "open_app": "{name} uygulamasını aç"
+ },
"plugins": {
"oauth_callback": {
"completing": "Yetkilendirme tamamlanıyor…",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index fa1fbdb0e..3f121a42a 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -4189,6 +4189,14 @@
"copied": "Посилання скопійовано",
"copy_failed": "Не вдалося скопіювати посилання"
},
+ "connector_link": {
+ "opening": "Відкриваємо…",
+ "unknown_title": "Це посилання новіше, ніж цей {name}",
+ "unknown_body": "Воно відкриває те, чого не знає версія {version}. Оновлення {name} має це виправити.",
+ "malformed_title": "Із цим посиланням щось не так",
+ "malformed_body": "Воно запитує {label}, але параметр {param} відсутній або неправильний. Нічого не відкрито.",
+ "open_app": "Відкрити {name}"
+ },
"plugins": {
"oauth_callback": {
"completing": "Завершення авторизації…",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index c50de1edc..5133194ba 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -4189,6 +4189,14 @@
"copied": "連結已複製",
"copy_failed": "無法複製連結"
},
+ "connector_link": {
+ "opening": "正在開啟…",
+ "unknown_title": "此連結比目前的 {name} 更新",
+ "unknown_body": "它要開啟的內容是版本 {version} 無法辨識的。更新 {name} 後即可開啟。",
+ "malformed_title": "此連結不太對",
+ "malformed_body": "它要求 {label},但參數 {param} 遺失或格式錯誤。未開啟任何內容。",
+ "open_app": "開啟 {name}"
+ },
"plugins": {
"oauth_callback": {
"completing": "正在完成授權…",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 706996c36..016c1c382 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -4189,6 +4189,14 @@
"copied": "链接已复制",
"copy_failed": "无法复制链接"
},
+ "connector_link": {
+ "opening": "正在打开…",
+ "unknown_title": "此链接比当前的 {name} 更新",
+ "unknown_body": "它要打开的内容是版本 {version} 无法识别的。更新 {name} 后即可打开。",
+ "malformed_title": "此链接不太对",
+ "malformed_body": "它请求 {label},但参数 {param} 缺失或格式错误。未打开任何内容。",
+ "open_app": "打开 {name}"
+ },
"plugins": {
"oauth_callback": {
"completing": "正在完成授权…",