Merge branch 'main' into ux/calendar

# Conflicts:
#	components/calendar/calendar-toolbar.tsx
This commit is contained in:
Linus Rath
2026-10-28 05:02:24 +01:00
104 changed files with 3234 additions and 704 deletions
+8
View File
@@ -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();
}
@@ -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 <CalendarApp linkSegments={segments ?? []} />;
return (
<AppErrorBoundary>
<CalendarApp linkSegments={segments ?? []} />
</AppErrorBoundary>
);
}
@@ -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/<contactId>[/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 <ContactsApp linkSegments={segments ?? []} />;
return (
<AppErrorBoundary>
<ContactsApp linkSegments={segments ?? []} />
</AppErrorBoundary>
);
}
+9 -27
View File
@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center max-w-md px-4">
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-red-600 dark:text-red-400" />
</div>
<h2 className="text-xl font-semibold text-foreground mb-2">
{t("page_error_title")}
</h2>
<p className="text-muted-foreground mb-6">
{t("page_error_description")}
</p>
<div className="flex gap-3 justify-center">
<Button variant="outline" onClick={() => router.push('/')}>
<Home className="w-4 h-4 me-2" />
{t("go_home")}
</Button>
<Button onClick={reset}>
<RefreshCw className="w-4 h-4 me-2" />
{t("try_again")}
</Button>
</div>
</div>
</div>
<ErrorState
className="min-h-screen"
title={t("page_error_title")}
description={t("page_error_description")}
onRetry={reset}
retryLabel={t("try_again")}
homeLabel={t("go_home")}
/>
);
}
@@ -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/<folder>/<subfolder>, one path segment per
// level, with ?preview=<name> 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 <FilesApp linkSegments={segments ?? []} />;
return (
<AppErrorBoundary>
<FilesApp linkSegments={segments ?? []} />
</AppErrorBoundary>
);
}
+2
View File
@@ -11,6 +11,7 @@ import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
import { CommandPaletteHost } from "@/components/command-palette/command-palette";
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
import { PluginConnectDialog } from "@/components/plugins/plugin-connect-dialog";
import { QuickRuleHost } from "@/components/filters/quick-rule-host";
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
@@ -64,6 +65,7 @@ export default async function LocaleLayout({
{children}
<PluginDialogHost />
<CommandPaletteHost />
<TotpReauthDialog />
<PluginConnectDialog />
<QuickRuleHost />
<PluginConsentDialog />
@@ -0,0 +1,65 @@
import React from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
// The login page in another language: the subtitle, the theme menu and the
// version details come from the translations, and the version details open
// from the keyboard, not only on hover.
vi.mock('next/navigation', () => ({
useParams: () => ({ locale: 'de' }),
useSearchParams: () => new URLSearchParams(),
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
usePathname: () => '/login',
}));
vi.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
usePathname: () => '/login',
Link: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock('@/hooks/use-config', () => ({
useConfig: () => ({
appName: 'Webmail',
isLoading: false,
error: null,
jmapServerUrl: 'https://mail.example.org',
jmapServers: [],
loginShowHeading: true,
loginShowSubtitle: true,
loginShowVersion: true,
loginShowTotp: true,
loginShowRememberMe: true,
}),
}));
const { default: LoginPage } = await import('../page');
describe('login page translations', () => {
it('shows the translated subtitle whatever the app is called', async () => {
render(<LoginPage />);
expect(await screen.findByText('subtitle', {}, { timeout: 10000 })).toBeInTheDocument();
expect(screen.queryByText('Sign in to your account')).toBeNull();
}, 30_000);
it('labels the theme menu from the translations', async () => {
render(<LoginPage />);
const button = await screen.findByRole('button', { name: 'label: system' }, { timeout: 10000 });
fireEvent.click(button);
expect(screen.getByRole('menu', { name: 'label' })).toBeInTheDocument();
expect(screen.getByRole('menuitemradio', { name: 'dark' })).toBeInTheDocument();
}, 30_000);
it('opens the version details from a button', async () => {
render(<LoginPage />);
const trigger = await screen.findByRole('button', { name: /^v\d/ }, { timeout: 10000 });
expect(trigger).toHaveAttribute('aria-expanded', 'false');
act(() => { fireEvent.click(trigger); });
expect(trigger).toHaveAttribute('aria-expanded', 'true');
const details = screen.getByRole('group', { name: 'details' });
expect(details).toHaveTextContent('version_line');
expect(screen.getByRole('button', { name: 'copy' })).toBeInTheDocument();
act(() => { fireEvent.keyDown(document, { key: 'Escape' }); });
expect(trigger).toHaveAttribute('aria-expanded', 'false');
}, 30_000);
});
+85 -45
View File
@@ -1,10 +1,11 @@
"use client";
import { Suspense, useState, useEffect, useRef, useCallback } from "react";
import { Suspense, useState, useEffect, useRef, useCallback, useId } from "react";
import { useRouter } from "@/i18n/navigation";
import { useParams, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { Input } from "@/components/ui/input";
import { useAuthStore, consumeSignedOut } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
@@ -57,19 +58,46 @@ function findServerByDomain(servers: PublicJmapServerEntry[], email: string | un
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
/** Labels are keys under settings.appearance.theme. */
const THEME_OPTIONS = [
{ value: "light" as const, icon: Sun, label: "Light" },
{ value: "dark" as const, icon: Moon, label: "Dark" },
{ value: "system" as const, icon: Monitor, label: "System" },
{ value: "light" as const, icon: Sun, label: "light" },
{ value: "dark" as const, icon: Moon, label: "dark" },
{ value: "system" as const, icon: Monitor, label: "system" },
];
function VersionBadge() {
const t = useTranslations("login.version");
const [copied, setCopied] = useState(false);
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const detailsId = useId();
const banner = useUpdateStore(useShallow(selectBanner));
const startPolling = useUpdateStore((s) => s.startPolling);
useEffect(() => { startPolling(); }, [startPolling]);
// Opened by click or keyboard (hover alone left keyboard and touch users
// without it): closed again by Escape or a click elsewhere.
useEffect(() => {
if (!open) return;
const onPointerDown = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
setOpen(false);
triggerRef.current?.focus();
};
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
// Stays English: it is pasted into bug reports.
const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}${banner?.latest ? `\nLatest: ${banner.latest}` : ""}`;
const handleCopy = () => {
@@ -83,10 +111,10 @@ function VersionBadge() {
const triggerText = !banner
? `v${APP_VERSION}`
: banner.severity === "security"
? "Security update available"
? t("security_update")
: banner.severity === "deprecated"
? "Version no longer supported"
: "New version available";
? t("unsupported")
: t("new_version");
const triggerColor = !banner
? "text-muted-foreground/40"
@@ -95,41 +123,52 @@ function VersionBadge() {
: "text-amber-600/80 dark:text-amber-400/80 hover:text-amber-600 dark:hover:text-amber-400";
const triggerClass = cn(
"peer text-center text-xs transition-colors",
"peer text-center text-xs transition-colors rounded-sm underline-offset-2 hover:underline",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
triggerColor,
banner?.url ? "cursor-pointer underline-offset-2 hover:underline" : "cursor-default",
);
const trigger = banner?.url ? (
<a href={banner.url} target="_blank" rel="noopener noreferrer" className={triggerClass}>
{triggerText}
</a>
) : (
<p className={triggerClass}>{triggerText}</p>
);
return (
<div className="relative inline-flex justify-center">
{trigger}
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 px-3 py-2 rounded-md bg-popover text-popover-foreground text-xs shadow-md border border-border opacity-0 peer-hover:opacity-100 hover:opacity-100 transition-opacity whitespace-nowrap z-10">
<div ref={rootRef} className="relative inline-flex justify-center">
<button
ref={triggerRef}
type="button"
className={triggerClass}
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={detailsId}
>
{triggerText}
</button>
<div
id={detailsId}
role="group"
aria-label={t("details")}
className={cn(
"absolute top-full left-1/2 -translate-x-1/2 mt-1.5 px-3 py-2 rounded-md bg-popover text-popover-foreground text-xs shadow-md border border-border transition-opacity whitespace-nowrap z-10",
// Hover still previews it; only an opened one takes the focus.
open ? "visible opacity-100" : "invisible opacity-0 peer-hover:visible peer-hover:opacity-100 hover:visible hover:opacity-100",
)}
>
<div className="flex items-center gap-2">
<div className="space-y-0.5">
<p>Version: <span className="font-medium">{APP_VERSION}</span></p>
<p>Build: <span className="font-medium">{GIT_COMMIT}</span></p>
<p>{t("version_line", { value: APP_VERSION })}</p>
<p>{t("build_line", { value: GIT_COMMIT })}</p>
{banner?.latest && (
<p>Latest: <span className="font-medium">{banner.latest}</span></p>
<p>{t("latest_line", { value: banner.latest })}</p>
)}
{banner?.advisory && (
<p className="text-red-500 dark:text-red-400">{banner.advisory}</p>
<p className="text-red-600 dark:text-red-400">{banner.advisory}</p>
)}
{banner?.url && (
<a href={banner.url} target="_blank" rel="noopener noreferrer" className="text-primary underline-offset-2 hover:underline">
{t("release_notes")}
</a>
)}
</div>
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label="Copy version info"
>
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
</button>
<IconButton label={t("copy")} onClick={handleCopy}>
{copied ? <Check className="w-3.5 h-3.5 text-green-600 dark:text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
</IconButton>
</div>
</div>
</div>
@@ -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";
@@ -977,7 +1017,7 @@ function LoginPageContent() {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
{/* Theme toggle, hidden while the theme policy locks the mode (#1280) */}
<div className="absolute top-5 right-5" ref={themeMenuRef} hidden={colorModeLocked} suppressHydrationWarning>
<div className="absolute top-5 end-5" ref={themeMenuRef} hidden={colorModeLocked} suppressHydrationWarning>
<button
type="button"
ref={themeButtonRef}
@@ -988,21 +1028,21 @@ 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"
>
<CurrentThemeIcon className="w-4 h-4" />
<span className="hidden sm:inline" suppressHydrationWarning>{currentThemeOption.label}</span>
<span className="hidden sm:inline" suppressHydrationWarning>{tTheme(currentThemeOption.label)}</span>
</button>
{showThemeMenu && (
<div
ref={themeListRef}
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"
className="absolute end-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() {
)}
>
<Icon className="w-4 h-4" />
<span className="flex-1 text-start">{option.label}</span>
<span className="flex-1 text-start">{tTheme(option.label)}</span>
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
</button>
);
@@ -1137,7 +1177,7 @@ function LoginPageContent() {
}}
/>
{/* Theme toggle - top right, dropdown style; hidden while the theme policy locks the mode (#1280) */}
<div className="absolute top-5 right-5" ref={themeMenuRef} hidden={colorModeLocked} suppressHydrationWarning>
<div className="absolute top-5 end-5" ref={themeMenuRef} hidden={colorModeLocked} suppressHydrationWarning>
<button
type="button"
ref={themeButtonRef}
@@ -1148,21 +1188,21 @@ 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"
>
<CurrentThemeIcon className="w-4 h-4" />
<span className="hidden sm:inline" suppressHydrationWarning>{currentThemeOption.label}</span>
<span className="hidden sm:inline" suppressHydrationWarning>{tTheme(currentThemeOption.label)}</span>
</button>
{showThemeMenu && (
<div
ref={themeListRef}
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"
className="absolute end-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() {
)}
>
<Icon className="w-4 h-4" />
<span className="flex-1 text-start">{option.label}</span>
<span className="flex-1 text-start">{tTheme(option.label)}</span>
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
</button>
);
@@ -1211,7 +1251,7 @@ function LoginPageContent() {
)}
{loginShowSubtitle && (
<p className="text-sm text-muted-foreground mt-1.5">
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
{isAddAccountMode ? t("add_account_subtitle") : t("subtitle")}
</p>
)}
</div>
@@ -1430,7 +1470,7 @@ function LoginPageContent() {
<button
type="button"
onClick={() => 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}
>
+10
View File
@@ -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 <NotFoundView title={t("title")} description={t("description")} homeLabel={tErrors("go_home")} />;
}
@@ -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/<tabId>. 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 <SettingsApp linkSegments={segments ?? []} />;
return (
<AppErrorBoundary kind="settings">
<SettingsApp linkSegments={segments ?? []} />
</AppErrorBoundary>
);
}
+7 -1
View File
@@ -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 <ConnectorClient target={target} />;
return (
<IntlProvider locale="en" messages={enMessages}>
<ConnectorClient target={target} />
</IntlProvider>
);
}
+9 -38
View File
@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center max-w-md px-4">
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
<p className="text-muted-foreground mb-6">This page could not be found.</p>
<a
href={`${prefix}/`}
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity"
>
Go home
</a>
</div>
</div>
<NotFoundView
title="Page not found"
description="This page doesn't exist or has moved. Check the address, or go back to your inbox."
homeLabel="Go to inbox"
/>
);
}
+7 -2
View File
@@ -730,7 +730,11 @@ optgroup {
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
/* Every element transitions `all` by default once it has a duration.
Visibility is left out: a transition from hidden would keep a newly
shown menu or dialog hidden for a frame, and focus() would miss it. */
transition-property: all, visibility !important;
transition-duration: 0.01ms, 0s !important;
scroll-behavior: auto !important;
}
}
@@ -741,7 +745,8 @@ optgroup {
[data-animations="off"] *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
transition-property: all, visibility !important;
transition-duration: 0.01ms, 0s !important;
scroll-behavior: auto !important;
}
@@ -0,0 +1,41 @@
import React from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
// The 2FA re-auth prompt opens in whichever app made the request (it is
// mounted in the locale layout, not inside mail) and is translated.
vi.mock('next-intl/server', () => ({ setRequestLocale: vi.fn() }));
const { TotpReauthDialog } = await import('../totp-reauth-dialog');
const { useTotpReauthStore } = await import('@/stores/totp-reauth-store');
function containsType(node: unknown, type: unknown): boolean {
if (!node || typeof node !== 'object') return false;
if (Array.isArray(node)) return node.some((child) => containsType(child, type));
const el = node as { type?: unknown; props?: { children?: unknown } };
if (el.type === type) return true;
return containsType(el.props?.children, type);
}
describe('TOTP re-auth dialog', () => {
it('is mounted in the locale layout, so every app can show it', async () => {
const { default: LocaleLayout } = await import('@/app/(main)/[locale]/layout');
const tree = await LocaleLayout({ children: null, params: Promise.resolve({ locale: 'en' }) });
expect(containsType(tree, TotpReauthDialog)).toBe(true);
}, 120_000);
it('asks in the user language and hands the code back', async () => {
render(<TotpReauthDialog />);
let answer: Promise<string | null> = Promise.resolve(null);
act(() => { answer = useTotpReauthStore.getState().requestTotp(); });
expect(screen.getByRole('dialog', { name: 'title' })).toBeInTheDocument();
const field = screen.getByRole('textbox', { name: 'code_label' });
expect(field).toHaveFocus();
fireEvent.change(field, { target: { value: '123456' } });
act(() => { fireEvent.click(screen.getByRole('button', { name: 'verify' })); });
await expect(answer).resolves.toBe('123456');
expect(screen.queryByRole('dialog')).toBeNull();
});
});
@@ -456,11 +456,13 @@ describe('CalendarApp creating in the month view', () => {
});
describe('CalendarApp on touch screens', () => {
it('has a ⋮ with Import, Subscribe and Refresh in the phone toolbar', async () => {
it("has Import, Subscribe and Refresh in the phone header's ⋮", async () => {
media.mobile = true;
media.desktop = false;
renderApp();
fireEvent.click(screen.getByRole('button', { name: 'toolbar.more' }));
// The shell's phone ⋮, labelled by its own namespace (top_bar.more).
// Error toasts of earlier tests add an unread count to its name.
fireEvent.click(screen.getByRole('button', { name: /^more/ }));
expect(await screen.findByRole('menuitem', { name: 'toolbar.import' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'toolbar.subscribe' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'refresh' })).toBeInTheDocument();
+12 -2
View File
@@ -8,6 +8,7 @@ import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDo
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { startOfWeek } from "date-fns";
import { PhoneHeaderActions } from "@/components/layout/phone-header-actions";
import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store";
import type { Calendar } from "@/lib/jmap/types";
@@ -167,7 +168,7 @@ export function CalendarToolbar({
<>
<Button
variant="ghost"
size={isMobile ? "icon-lg" : "icon-sm"}
size="icon-sm"
className={cn(moreMenu.isOpen && "bg-accent text-accent-foreground")}
onClick={(e) => openMoreMenuFrom(e.currentTarget, true, "end")}
aria-label={t("toolbar.more")}
@@ -185,6 +186,14 @@ export function CalendarToolbar({
</ContextMenu>
</>
) : null;
// The same entries in the phone header's ⋮.
const phoneMenuItems = hasMoreMenu ? (close: () => void) => (
<>
{onImport && <ContextMenuItem icon={Upload} label={t("toolbar.import")} onClick={() => { close(); onImport(); }} />}
{onSubscribe && <ContextMenuItem icon={Globe} label={t("toolbar.subscribe")} onClick={() => { close(); onSubscribe(); }} />}
{onRefresh && <ContextMenuItem icon={RefreshCw} label={tCommon("refresh")} onClick={() => { close(); onRefresh(); }} />}
</>
) : undefined;
@@ -225,7 +234,8 @@ export function CalendarToolbar({
</Button>
</>
)}
{moreButton}
{/* The shell's phone ⋮ (notifications, palette, account) with the calendar's own entries on top. */}
<PhoneHeaderActions below="md" menuItems={phoneMenuItems} />
</div>
{/* Row 2: View switcher pills + calendar toggle */}
@@ -184,3 +184,42 @@ describe('command palette', () => {
expect(screen.getByRole('option', { name: 'shortcuts' })).toBeInTheDocument();
});
});
describe('command palette on a phone', () => {
const realMatchMedia = window.matchMedia;
beforeEach(() => {
window.matchMedia = ((query: string) => ({
matches: query === '(pointer: coarse)',
media: query, onchange: null,
addListener: () => {}, removeListener: () => {}, addEventListener: () => {}, removeEventListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
});
afterEach(() => { window.matchMedia = realMatchMedia; });
it('has a close button, as it fills the screen with nothing to tap around it', () => {
render(<CommandPaletteHost />);
act(() => appCommands.openCommandPalette());
act(() => { fireEvent.click(screen.getByRole('button', { name: 'close' })); });
expect(screen.queryByRole('dialog')).toBeNull();
});
it('offers no keyboard shortcuts on a touch screen', () => {
render(<CommandPaletteHost />);
act(() => appCommands.openCommandPalette());
expect(screen.queryByRole('option', { name: 'shortcuts' })).toBeNull();
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(<CommandPaletteHost />);
pressCtrlK();
act(() => { fireEvent.click(screen.getByRole('option', { name: /go_to\(Board\)/ })); });
expect(nav.push).toHaveBeenCalledWith('/apps/kanban/board');
pages.resetPages();
});
});
+30 -3
View File
@@ -16,6 +16,7 @@ import {
Settings,
Sun,
UserCircle,
X,
iconForName,
} from "@/components/icons";
import type { AppIcon } from "@/components/icons";
@@ -27,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";
@@ -37,6 +39,8 @@ import { shortcutAlternatives } from "@/lib/shortcut-keys";
import { toUnicodeEmail } from "@/lib/idn";
import { isEditableEventTarget } from "@/lib/keyboard";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useMediaQuery } from "@/hooks/use-media-query";
import { IconButton } from "@/components/ui/icon-button";
import { cn } from "@/lib/utils";
interface PaletteCommand {
@@ -131,6 +135,7 @@ export function CommandPaletteHost() {
function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onShowShortcuts: () => void }) {
const t = useTranslations("command_palette");
const tCommon = useTranslations("common");
const tSidebar = useTranslations("sidebar");
const tGlobalSearch = useTranslations("global_search");
const router = useRouter();
@@ -147,11 +152,15 @@ 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);
// A touch screen has no keyboard to use the shortcuts with (R8).
const touch = useMediaQuery("(pointer: coarse)");
const dialogRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true });
const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true, initialFocus: () => inputRef.current });
const commands = useMemo<PaletteCommand[]>(() => {
const go = (href: string) => () => router.push(href);
@@ -186,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") },
@@ -209,7 +229,9 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
run: () => useThemeStore.getState().setTheme(resolvedTheme === "dark" ? "light" : "dark"),
});
}
list.push({ id: "shortcuts", group: actions, icon: Keyboard, label: t("shortcuts"), shortcut: "?", run: onShowShortcuts });
if (!touch) {
list.push({ id: "shortcuts", group: actions, icon: Keyboard, label: t("shortcuts"), shortcut: "?", run: onShowShortcuts });
}
// Account: switch to each other signed-in account, sign out.
for (const entry of accounts) {
if (entry.id === activeAccountId) continue;
@@ -235,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]);
}, [client, supportsCalendar, isFeatureEnabled, colorModeLocked, resolvedTheme, pluginActions, pluginPages, registeredGroups, accounts, activeAccountId, router, t, tSidebar, tGlobalSearch, onShowShortcuts, touch]);
const needle = query.trim();
const visible = needle
@@ -301,6 +323,7 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
<div className="flex items-center gap-2 border-b border-border px-3">
<Search className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded="true"
@@ -317,6 +340,10 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
className="h-12 flex-1 min-w-0 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
/>
<kbd className="hidden sm:inline-flex items-center rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">Esc</kbd>
{/* Full screen on a phone: no backdrop to tap and no Escape key (R4). */}
<IconButton label={tCommon("close")} size="icon-lg" className="sm:hidden -me-2" onClick={onClose}>
<X />
</IconButton>
</div>
<ul
ref={listRef}
@@ -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<typeof import('@/lib/browser-navigation')>()),
replaceWindowLocation: vi.fn(),
}));
const { ConnectorClient } = await import('../connector-client');
describe('connector link page', () => {
it('translates an unknown link', async () => {
render(<ConnectorClient target="no-such-target" />);
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(<ConnectorClient target="calendar" />);
expect(await screen.findByRole('heading', { name: /malformed_title/ })).toBeInTheDocument();
window.history.replaceState(null, '', '/');
});
});
+9 -9
View File
@@ -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<ResolveResult | null>(null);
const name = productName(useConfig().appName);
@@ -47,7 +49,7 @@ export function ConnectorClient({ target }: { target: string }) {
}, [destination]);
if (result === null || result.kind === 'ok') {
return <Shell>Opening…</Shell>;
return <Shell>{t('opening')}</Shell>;
}
// 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 (
<Shell>
<h1 className="text-xl font-medium text-foreground">This link is newer than this {name}</h1>
<h1 className="text-xl font-medium text-foreground">{t('unknown_title', { name })}</h1>
<p className="mt-3 text-sm text-muted-foreground">
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 || '?' })}
</p>
<Actions name={name} />
</Shell>
@@ -67,12 +68,10 @@ export function ConnectorClient({ target }: { target: string }) {
return (
<Shell>
<h1 className="text-xl font-medium text-foreground">That link is not quite right</h1>
<h1 className="text-xl font-medium text-foreground">{t('malformed_title')}</h1>
<p className="mt-3 text-sm text-muted-foreground">
It asks for {result.target.label}, but its{' '}
{/* The parameter is named, never echoed: its value is attacker-controlled. */}
<code className="rounded bg-muted px-1 py-0.5">{result.param}</code> is missing or
malformed. Nothing was opened.
{t('malformed_body', { label: result.target.label, param: result.param })}
</p>
<Actions name={name} />
</Shell>
@@ -80,13 +79,14 @@ export function ConnectorClient({ target }: { target: string }) {
}
function Actions({ name }: { name: string }) {
const t = useTranslations('connector_link');
return (
<p className="mt-6">
<a
href={appPath('/mail')}
className="inline-flex h-9 items-center rounded-sm border border-border px-4 text-sm hover:bg-muted"
>
Open {name}
{t('open_app', { name })}
</a>
</p>
);
+2
View File
@@ -10,6 +10,7 @@ import { ContactContextMenu } from "./contact-context-menu";
import { useContactExportBusy } from "./contact-export";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { useContextMenu } from "@/hooks/use-context-menu";
import { PhoneHeaderActions } from "@/components/layout/phone-header-actions";
import { cn } from "@/lib/utils";
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName, getContactSortName, hasContactPhoto } from "@/stores/contact-store";
@@ -352,6 +353,7 @@ export function ContactList({
</span>
)}
</button>
<PhoneHeaderActions below="md" />
</div>
</div>
@@ -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 }) => <nav aria-label={`rail-${orientation ?? 'vertical'}`} />,
}));
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 <p>calendar works</p>;
}
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(<AppErrorBoundary><Flaky /></AppErrorBoundary>);
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(<ErrorBoundary fallback={fallback}><Flaky /></ErrorBoundary>);
expect(screen.getByRole('button', { name: 'try_again' })).toBeInTheDocument();
});
it('inside mail, does not offer to go to the inbox it is already in', () => {
render(<ErrorBoundary fallback={EmailListErrorFallback}><Flaky /></ErrorBoundary>);
expect(screen.queryByRole('link', { name: 'go_home' })).toBeNull();
});
});
@@ -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<void>((resolve) => {
finish = () => { useAuthStore.setState({ isAuthenticated: true } as never); resolve(); };
}));
useAuthStore.setState({ isAuthenticated: false, checkAuth } as never);
render(<LocaleNotFound />);
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(<LocaleNotFound />);
await act(async () => {});
expect(href).toBe('/login');
});
});
+39
View File
@@ -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 (
<div className="flex h-dvh bg-background pt-[env(safe-area-inset-top)]">
{!isMobile && (
<div className="w-14 bg-secondary flex flex-col flex-shrink-0 border-e border-border">
<NavigationRail collapsed />
</div>
)}
<div className="flex flex-col flex-1 min-w-0">
<Fallback {...props} />
{isMobile && <NavigationRail orientation="horizontal" />}
</div>
</div>
);
}
/**
* 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 (
<ErrorBoundary fallback={(props) => <AppErrorLayout kind={kind} {...props} />}>
{children}
</ErrorBoundary>
);
}
+72 -92
View File
@@ -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 (
<div className="flex h-screen items-center justify-center bg-background">
<div className="text-center max-w-md px-4">
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-red-600 dark:text-red-400" />
</div>
<h2 className="text-xl font-semibold text-foreground mb-2">
{t("page_error_title")}
</h2>
<p className="text-muted-foreground mb-6">
{t("page_error_description")}
</p>
<Button onClick={resetError}>
<div role="alert" className={cn("flex flex-1 h-full flex-col items-center justify-center p-8 text-center bg-background", className)}>
<div className="w-12 h-12 mb-4 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
<Icon className="w-6 h-6 text-red-600 dark:text-red-400" aria-hidden="true" />
</div>
<h2 className="text-lg font-semibold text-foreground mb-2">{title}</h2>
{description && <p className="text-sm text-muted-foreground mb-6 max-w-md">{description}</p>}
<div className={cn("flex flex-wrap gap-3 justify-center", !description && "mt-4")}>
{homeLabel && (
// A full load: whatever broke starts over.
<a
href={`${getPathPrefix()}/`}
className="inline-flex items-center justify-center h-9 px-3 text-sm font-medium rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors"
>
{homeLabel}
</a>
)}
<Button size="sm" onClick={onRetry}>
<RefreshCw className="w-4 h-4 me-2" />
{t("try_again")}
{retryLabel}
</Button>
</div>
</div>
);
}
/**
* Sidebar error fallback - matches sidebar width (256px).
*/
/** Full-page error fallback, also used around each app (AppErrorBoundary). */
export function PageErrorFallback({ resetError, t }: FallbackProps) {
return (
<ErrorState
title={t("page_error_title")}
description={t("page_error_description")}
onRetry={resetError}
retryLabel={t("try_again")}
homeLabel={t("go_home")}
/>
);
}
/** Mail's folder sidebar (256px wide). */
export function SidebarErrorFallback({ resetError, t }: FallbackProps) {
return (
<div className="w-64 h-full border-e border-border bg-secondary flex flex-col items-center justify-center p-4">
<FolderOpen className="w-10 h-10 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground text-center mb-4">
{t("sidebar_error")}
</p>
<Button variant="outline" size="sm" onClick={resetError}>
<RefreshCw className="w-3 h-3 me-1" />
{t("reload")}
</Button>
</div>
<ErrorState className="w-64 flex-none border-e border-border bg-secondary p-4" title={t("sidebar_error")} onRetry={resetError} retryLabel={t("try_again")} />
);
}
/**
* Email list error fallback - matches email list panel width (384px).
*/
/** Mail's message list. */
export function EmailListErrorFallback({ resetError, t }: FallbackProps) {
return (
<div className="w-full h-full bg-background flex flex-col items-center justify-center p-4">
<Inbox className="w-12 h-12 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground text-center mb-4">
{t("email_list_error")}
</p>
<Button variant="outline" size="sm" onClick={resetError}>
<RefreshCw className="w-4 h-4 me-2" />
{t("reload_emails")}
</Button>
</div>
);
return <ErrorState className="w-full p-4" title={t("email_list_error")} onRetry={resetError} retryLabel={t("try_again")} />;
}
/**
* Email viewer error fallback - fills remaining space (flex-1).
*/
/** Mail's reading pane. */
export function EmailViewerErrorFallback({ resetError, t }: FallbackProps) {
return (
<div className="flex-1 flex flex-col items-center justify-center bg-muted/30 p-8">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-50 dark:bg-red-900/20 flex items-center justify-center">
<Mail className="w-8 h-8 text-red-500" />
</div>
<h3 className="text-lg font-medium text-foreground mb-2">
{t("viewer_error_title")}
</h3>
<p className="text-sm text-muted-foreground text-center mb-6 max-w-md">
{t("viewer_error_description")}
</p>
<Button onClick={resetError}>
<RefreshCw className="w-4 h-4 me-2" />
{t("try_again")}
</Button>
</div>
<ErrorState
title={t("viewer_error_title")}
description={t("viewer_error_description")}
onRetry={resetError}
retryLabel={t("try_again")}
/>
);
}
/**
* Email composer modal error fallback.
*/
/** The composer. */
export function ComposerErrorFallback({ resetError, t }: FallbackProps) {
return (
<div className="flex flex-col h-full bg-background border rounded-lg items-center justify-center p-8">
<AlertCircle className="w-10 h-10 text-warning mb-3" />
<p className="text-sm text-muted-foreground text-center mb-4">
{t("composer_error")}
</p>
<Button variant="outline" size="sm" onClick={resetError}>
{t("retry")}
</Button>
</div>
);
return <ErrorState className="border rounded-lg" title={t("composer_error")} onRetry={resetError} retryLabel={t("try_again")} />;
}
/**
* Settings page error fallback.
*/
/** Settings. */
export function SettingsErrorFallback({ resetError, t }: FallbackProps) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8">
<Settings className="w-12 h-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{t("settings_error_title")}
</h3>
<p className="text-sm text-muted-foreground text-center mb-6">
{t("settings_error_description")}
</p>
<Button onClick={resetError}>
<RefreshCw className="w-4 h-4 me-2" />
{t("reload_settings")}
</Button>
</div>
<ErrorState
title={t("settings_error_title")}
description={t("settings_error_description")}
onRetry={resetError}
retryLabel={t("try_again")}
homeLabel={t("go_home")}
/>
);
}
+2
View File
@@ -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,
+71
View File
@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center max-w-md px-4">
<SearchX className="w-12 h-12 mx-auto mb-4 text-muted-foreground" aria-hidden="true" />
<h1 className="text-xl font-semibold text-foreground mb-2">{title}</h1>
<p className="text-muted-foreground mb-6">{description}</p>
<a
href={`${prefix}/`}
className="inline-flex items-center justify-center h-9 px-3 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
{homeLabel}
</a>
</div>
</div>
);
}
+2
View File
@@ -10,6 +10,7 @@ import {
import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { PhoneHeaderActions } from "@/components/layout/phone-header-actions";
import { cn } from "@/lib/utils";
import { anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
import { PluginActions } from "@/components/plugins/plugin-actions";
@@ -333,6 +334,7 @@ export function FilesHeader({
</Button>
)}
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
<PhoneHeaderActions below="md" />
</div>
)}
@@ -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(<AccountSwitcher variant="rail" />);
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(<AccountSwitcher variant="rail" />);
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(<AccountSwitcher variant="rail" />);
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');
});
});
@@ -0,0 +1,67 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mountain View's right-hand cluster is the same in every app: the keyboard
// shortcuts button and the avatar's "All inboxes" row work outside mail too.
const nav = vi.hoisted(() => ({ push: vi.fn() }));
const switcher = vi.hoisted(() => ({ onSelectAllInboxes: undefined as undefined | (() => void) }));
vi.mock('../account-switcher', () => ({
AccountSwitcher: (props: { onSelectAllInboxes?: () => void }) => {
switcher.onSelectAllInboxes = props.onSelectAllInboxes;
return null;
},
}));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
vi.mock('@/hooks/use-stalwart-admin', () => ({ useStalwartAdmin: () => false }));
vi.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push: nav.push, back: vi.fn(), replace: vi.fn() }),
usePathname: () => '/calendar',
Link: ({ children }: { children: React.ReactNode }) => children,
}));
const { MvTopBar } = await import('../mv-top-bar');
const { useSettingsStore } = await import('@/stores/settings-store');
const { useAccountStore } = await import('@/stores/account-store');
const { usePolicyStore } = await import('@/stores/policy-store');
const { DEFAULT_POLICY } = await import('@/lib/admin/types');
const appCommands = await import('@/lib/app-commands');
const twoAccounts = [
{ id: 'a', isConnected: true },
{ id: 'b', isConnected: true },
];
beforeEach(() => {
nav.push.mockClear();
switcher.onSelectAllInboxes = undefined;
usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, features: { ...DEFAULT_POLICY.features, unifiedCrossAccountEnabled: true } } });
useSettingsStore.setState({ enableUnifiedMailbox: true, unifiedCrossAccount: true } as never);
useAccountStore.setState({ accounts: twoAccounts } as never);
});
describe('Mountain View top bar outside mail', () => {
it('opens the keyboard shortcuts without a handler from the app', () => {
const opened = vi.fn();
window.addEventListener(appCommands.KEYBOARD_SHORTCUTS_OPEN_EVENT, opened);
render(<MvTopBar showMenuButton={false} />);
fireEvent.click(screen.getByRole('button', { name: 'keyboard_shortcuts' }));
expect(opened).toHaveBeenCalled();
window.removeEventListener(appCommands.KEYBOARD_SHORTCUTS_OPEN_EVENT, opened);
});
it('offers "All inboxes" and opens mail on it', () => {
render(<MvTopBar showMenuButton={false} />);
expect(switcher.onSelectAllInboxes).toBeTypeOf('function');
act(() => switcher.onSelectAllInboxes!());
expect(nav.push).toHaveBeenCalledWith('/');
expect(appCommands.peekParkedAppCommand()).toBe('all-inboxes');
});
it('leaves "All inboxes" out when the merged inbox cannot be built', () => {
useAccountStore.setState({ accounts: [twoAccounts[0]] } as never);
render(<MvTopBar showMenuButton={false} />);
expect(switcher.onSelectAllInboxes).toBeUndefined();
});
});
@@ -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 }) => <a href={href} {...rest}>{children}</a>,
}));
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(<NavigationRail collapsed quota={{ used: 50, total: 100 } as never} />);
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();
});
});
@@ -0,0 +1,107 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// The phone header cluster (⋮ with notifications and the command palette,
// then the account avatar) that every app shows at phone width.
const media = vi.hoisted(() => ({ mobile: true, desktop: false }));
vi.mock('@/hooks/use-media-query', async (importOriginal) => ({
...(await importOriginal<typeof import('@/hooks/use-media-query')>()),
useIsMobile: () => media.mobile,
useIsDesktop: () => media.desktop,
}));
const { PhoneHeaderActions, PhoneAppHeader } = await import('../phone-header-actions');
const { ContactList } = await import('@/components/contacts/contact-list');
const { ContextMenuItem } = await import('@/components/ui/context-menu');
beforeEach(() => {
media.mobile = true;
media.desktop = false;
});
describe('PhoneHeaderActions', () => {
it('shows the menu and the account avatar on a phone', () => {
render(<PhoneHeaderActions />);
expect(screen.getByRole('button', { name: 'switch_account' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'more' }));
// Notifications and the command palette (both "title" as keys in tests).
expect(screen.getAllByRole('menuitem').map((m) => m.textContent)).toEqual(['title', 'title']);
});
it('opens the command palette from the menu', () => {
const opened = vi.fn();
window.addEventListener('bulwark:command-palette', opened);
render(<PhoneHeaderActions />);
fireEvent.click(screen.getByRole('button', { name: 'more' }));
fireEvent.click(screen.getAllByRole('menuitem')[1]);
expect(opened).toHaveBeenCalled();
window.removeEventListener('bulwark:command-palette', opened);
});
it('lists the app entries first and closes the menu when one is picked', () => {
const run = vi.fn();
render(
<PhoneHeaderActions
menuItems={(close) => <ContextMenuItem label="Import" onClick={() => { close(); run(); }} />}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'more' }));
const items = screen.getAllByRole('menuitem');
expect(items[0]).toHaveTextContent('Import');
fireEvent.click(items[0]);
expect(run).toHaveBeenCalled();
expect(screen.queryByRole('menu')).toBeNull();
});
it('draws nothing where the rail holds these controls', () => {
media.desktop = true;
media.mobile = false;
const { container } = render(<PhoneHeaderActions />);
expect(container).toBeEmptyDOMElement();
});
it('draws nothing on a tablet for an app that keeps the rail there', () => {
media.mobile = false;
const { container } = render(<PhoneHeaderActions below="md" />);
expect(container).toBeEmptyDOMElement();
render(<PhoneHeaderActions />);
expect(screen.getByRole('button', { name: 'more' })).toBeInTheDocument();
});
});
describe('PhoneAppHeader', () => {
it('gives a screen without a header a title and the cluster', () => {
render(<PhoneAppHeader title="Kanban" below="md" />);
expect(screen.getByRole('heading', { name: 'Kanban' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'more' })).toBeInTheDocument();
});
});
describe('apps use it', () => {
it('puts the cluster in the Contacts list header on a phone', () => {
render(
<ContactList
contacts={[]}
selectedContactId={null}
searchQuery=""
onSearchChange={vi.fn()}
onSelectContact={vi.fn()}
onCreateNew={vi.fn()}
selectedContactIds={new Set<string>()}
onToggleSelection={vi.fn()}
onSelectRangeContacts={vi.fn()}
onSelectAll={vi.fn()}
onClearSelection={vi.fn()}
onBulkDelete={vi.fn()}
onBulkAddToGroup={vi.fn()}
onBulkExport={vi.fn()}
onEditContact={vi.fn()}
onDeleteContact={vi.fn()}
onAddContactToGroup={vi.fn()}
/>
);
expect(screen.getByRole('button', { name: 'more' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'switch_account' })).toBeInTheDocument();
});
});
@@ -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([]);
});
});
@@ -0,0 +1,55 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Shell labels come from the translations (keys in tests), and the admin
// entry says that an update is waiting instead of a dot nobody hears.
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/keyboard-shortcuts-modal', () => ({ KeyboardShortcutsModal: () => null }));
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
vi.mock('@/hooks/use-stalwart-admin', () => ({ useStalwartAdmin: () => true }));
vi.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push: vi.fn(), back: vi.fn(), replace: vi.fn() }),
usePathname: () => '/',
Link: ({ children, href, ...rest }: { children: React.ReactNode; href: string }) => <a href={href} {...rest}>{children}</a>,
}));
const { NavigationRail } = await import('../navigation-rail');
const { MvTopBar } = await import('../mv-top-bar');
const { ResizeHandle } = await import('../resize-handle');
const { InlineAppView } = await import('../inline-app-view');
const { useUpdateStore } = await import('@/stores/update-store');
beforeEach(() => {
useUpdateStore.setState({
startPolling: vi.fn(),
status: { updateAvailable: true, severity: 'security', current: process.env.NEXT_PUBLIC_APP_VERSION ?? '', latest: '9.9.9' },
} as never);
});
describe('shell labels', () => {
it('names the admin entry with the waiting update, in the rail, the tab bar and Mountain View', () => {
const { unmount } = render(<NavigationRail collapsed />);
expect(screen.getByRole('link', { name: 'admin, update_important' })).toBeInTheDocument();
unmount();
const bar = render(<NavigationRail orientation="horizontal" />);
expect(screen.getByRole('link', { name: /update_important/ })).toBeInTheDocument();
bar.unmount();
render(<MvTopBar showMenuButton={false} />);
expect(screen.getByRole('link', { name: 'admin, update_important' })).toBeInTheDocument();
});
it('translates the resize handle and the inline app close button', () => {
render(
<>
<ResizeHandle onResize={() => {}} />
<InlineAppView apps={[]} activeAppId="x" onClose={() => {}} />
</>
);
expect(screen.getByRole('separator', { name: 'resize' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'close' })).toBeInTheDocument();
});
});
@@ -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(<SidebarAppsModal isOpen onClose={onClose} />);
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(<SidebarAppsModal isOpen onClose={onClose} />);
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(<SidebarAppsModal isOpen onClose={vi.fn()} />);
expect(screen.getByRole('button', { name: 'edit_app' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'delete' })).toBeInTheDocument();
});
});
@@ -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(<Sidebar mailboxes={[]} />);
fireEvent.click(screen.getByRole('button', { name: /vacation_active/ }));
expect(nav.push).toHaveBeenCalledWith(buildSettingsPath('vacation'));
expect(buildSettingsPath('vacation')).not.toBe('/settings');
});
});
@@ -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<typeof import('@/hooks/use-media-query')>()),
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(<NotificationPanel onClose={vi.fn()} sheet />);
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(<WelcomeBanner />);
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(
<ToastItem
toast={{ id: 't', type: 'success', title: 'Archived', action: { label: 'Undo', onClick: vi.fn() } } as never}
onClose={vi.fn()}
/>
);
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(<AccountSwitcher variant="header" />);
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');
}
});
});
+33 -19
View File
@@ -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 = () => {
@@ -273,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
)}
@@ -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}
@@ -411,7 +422,7 @@ export function AccountSwitcher({
<div className="relative flex-shrink-0">
<AccountAvatar account={account} size="md" />
{isActive && (
<div className="absolute -bottom-0.5 -right-0.5 w-4 h-4 rounded-full bg-primary flex items-center justify-center">
<div className="absolute -bottom-0.5 -end-0.5 w-4 h-4 rounded-full bg-primary flex items-center justify-center">
<Check className="w-2.5 h-2.5 text-primary-foreground" />
</div>
)}
@@ -449,22 +460,24 @@ export function AccountSwitcher({
{isDraggable && (
<span
aria-hidden
className="pointer-events-none absolute end-7 top-1/2 -translate-y-1/2 text-muted-foreground/50 opacity-0 transition-opacity group-hover/acct:opacity-100"
className={cn(
"pointer-events-none absolute top-1/2 -translate-y-1/2 text-muted-foreground/50 opacity-0 transition-opacity group-hover/acct:opacity-100",
!isActive && !account.isDefault ? "end-10" : "end-1.5"
)}
>
<GripVertical className="w-4 h-4" />
</span>
)}
{!isActive && !account.isDefault && (
<button
type="button"
onClick={(e) => handleRemove(e, account)}
// Shown on hover and focus, and always on a touch screen (R7).
<IconButton
onClick={(e) => 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"
>
<X className="w-3.5 h-3.5" />
</button>
<X />
</IconButton>
)}
</div>
);
@@ -544,7 +557,7 @@ export function AccountSwitcher({
<button
onClick={handleAddAccount}
data-testid="add-account"
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
className="w-full flex items-center gap-2 px-3 py-2 pointer-coarse:min-h-11 text-sm text-start text-foreground hover:bg-muted transition-colors"
role="menuitem"
>
<Plus className="w-4 h-4" />
@@ -559,7 +572,7 @@ export function AccountSwitcher({
{activeAccount && !activeAccount.isDefault && accounts.length > 1 && (
<button
onClick={() => 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"
>
<Star className="w-4 h-4" />
@@ -568,7 +581,7 @@ export function AccountSwitcher({
)}
<button
onClick={handleLogout}
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"
>
<LogOut className="w-4 h-4" />
@@ -577,7 +590,7 @@ export function AccountSwitcher({
{!mvPopover && accounts.length > 1 && (
<button
onClick={handleLogoutAll}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-muted transition-colors"
className="w-full flex items-center gap-2 px-3 py-2 pointer-coarse:min-h-11 text-sm text-start text-foreground hover:bg-muted transition-colors"
role="menuitem"
>
<LogOut className="w-4 h-4" />
@@ -588,6 +601,7 @@ export function AccountSwitcher({
</div>,
document.body
)}
<ConfirmDialog {...confirmDialogProps} />
</>
);
}
+6 -7
View File
@@ -1,6 +1,8 @@
'use client';
import { useTranslations } from 'next-intl';
import { X } from '@/components/icons';
import { IconButton } from '@/components/ui/icon-button';
import { cn } from '@/lib/utils';
import type { InlineAppState } from '@/hooks/use-sidebar-apps';
@@ -12,6 +14,7 @@ interface InlineAppViewProps {
}
export function InlineAppView({ apps, activeAppId, onClose, className }: InlineAppViewProps) {
const tCommon = useTranslations('common');
const activeApp = apps.find((a) => a.id === activeAppId);
return (
@@ -19,13 +22,9 @@ export function InlineAppView({ apps, activeAppId, onClose, className }: InlineA
{/* Header bar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-secondary/50 flex-shrink-0">
<h3 className="text-sm font-medium truncate">{activeApp?.name}</h3>
<button
onClick={onClose}
className="p-1 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
<IconButton label={tCommon("close")} onClick={onClose} className="text-muted-foreground">
<X />
</IconButton>
</div>
{/* Iframes - active one visible, rest hidden but alive */}
<div className="flex-1 relative">
+9 -87
View File
@@ -1,8 +1,8 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Menu, ArrowLeft, Plus, Search, X, MoreVertical, Terminal, Bell } from "@/components/icons";
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";
@@ -10,84 +10,7 @@ import { useIsDesktop } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { AccountSwitcher } from "@/components/layout/account-switcher";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { ContextMenuItem } from "@/components/ui/context-menu";
import { openCommandPalette } from "@/lib/app-commands";
import { NotificationSheet, formatBadgeCount, useUnreadNotificationCount } from "@/components/notifications/notification-center";
/** Plugin buttons in the top bar are not about any one item. */
const TOP_BAR_CONTEXT: Record<string, unknown> = {};
/**
* The phone header's overflow menu: the notifications (the desktop bell), the
* command palette (no Ctrl+K on a phone) and what the desktop top bar shows
* as buttons (plugin top-bar-actions) and has no room for here.
*/
function MobileHeaderMenu() {
const t = useTranslations('top_bar');
const tPalette = useTranslations('command_palette');
const tNotifications = useTranslations('notification_center');
const unread = useUnreadNotificationCount();
const [showNotifications, setShowNotifications] = useState(false);
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const close = useCallback(() => setOpen(false), []);
const { menuRef, onKeyDown } = useMenuNavigation<HTMLDivElement>({ open, onClose: close, triggerRef });
useEffect(() => {
if (!open) return;
const onPointerDown = (e: MouseEvent) => {
if (triggerRef.current?.contains(e.target as Node) || menuRef.current?.contains(e.target as Node)) return;
setOpen(false);
};
document.addEventListener('mousedown', onPointerDown);
return () => document.removeEventListener('mousedown', onPointerDown);
}, [open, menuRef]);
return (
<div className="relative">
<Button
ref={triggerRef}
variant="ghost"
size="icon"
onClick={() => setOpen((v) => !v)}
className={cn("relative h-11 w-11", open && "bg-accent")}
aria-label={unread > 0 ? `${t('more')} (${tNotifications('unread_count', { count: unread })})` : t('more')}
aria-haspopup="menu"
aria-expanded={open}
>
<MoreVertical className="h-5 w-5" />
{unread > 0 && (
<span className="absolute top-2 end-2 w-2 h-2 rounded-full bg-red-500 ring-2 ring-background" aria-hidden="true" />
)}
</Button>
{open && (
<div
ref={menuRef}
onKeyDown={onKeyDown}
role="menu"
aria-label={t('more')}
className="absolute end-0 top-full mt-1 z-50 min-w-[200px] max-w-[calc(100vw-2rem)] rounded-md border border-border bg-background py-1 shadow-lg"
>
<ContextMenuItem
icon={Bell}
label={tNotifications('title')}
shortcut={unread > 0 ? formatBadgeCount(unread) : undefined}
onClick={() => { close(); setShowNotifications(true); }}
/>
<ContextMenuItem
icon={Terminal}
label={tPalette('title')}
onClick={() => { close(); openCommandPalette(); }}
/>
<PluginActions surface="top-bar-actions" context={TOP_BAR_CONTEXT} variant="menu" onRan={close} />
</div>
)}
{showNotifications && <NotificationSheet onClose={() => setShowNotifications(false)} />}
</div>
);
}
import { PhoneHeaderMenu } from "@/components/layout/phone-header-actions";
interface MobileHeaderProps {
title: string;
@@ -209,14 +132,13 @@ export function MobileHeader({
<span className="truncate text-sm">{searchPlaceholder || title}</span>
</button>
{searchActive && onClearSearch && (
<button
type="button"
<IconButton
onClick={onClearSearch}
className="h-8 w-8 flex-shrink-0 grid place-items-center rounded-full text-muted-foreground hover:bg-background/60"
aria-label={t('clear_search')}
className="flex-shrink-0 rounded-full text-muted-foreground hover:bg-background/60"
label={t('clear_search')}
>
<X className="h-4 w-4" />
</button>
<X />
</IconButton>
)}
</div>
)}
@@ -246,7 +168,7 @@ export function MobileHeader({
</Button>
)}
<MobileHeaderMenu />
<PhoneHeaderMenu />
{/* Which account you are reading is otherwise only visible after
opening the drawer. Last in this group so it sits on the far
+43 -18
View File
@@ -6,7 +6,7 @@ import {
Folder,
Grid3x3,
Users,
HelpCircle,
Keyboard,
Mail,
Menu,
Puzzle,
@@ -17,7 +17,7 @@ import {
} from "@/components/icons";
import type { AppIcon as AppIconType } from "@/components/icons";
import { useTranslations } from "next-intl";
import { Link, usePathname } from "@/i18n/navigation";
import { Link, usePathname, useRouter } from "@/i18n/navigation";
import { useAuthStore } from "@/stores/auth-store";
import { useShallow } from "zustand/react/shallow";
import { useCalendarStore } from "@/stores/calendar-store";
@@ -31,6 +31,9 @@ import { getPathPrefix, withBasePath } from "@/lib/browser-navigation";
import { useStalwartAdmin } from "@/hooks/use-stalwart-admin";
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
import { cn } from "@/lib/utils";
import { openKeyboardShortcuts, parkAppCommand, requestAppCommand } from "@/lib/app-commands";
import { useSettingsStore } from "@/stores/settings-store";
import { useAccountStore } from "@/stores/account-store";
import { AccountSwitcher } from "@/components/layout/account-switcher";
import { SearchBox, type ContactSearchField } from "@/components/search/search-box";
import { PluginActions } from "@/components/plugins/plugin-actions";
@@ -76,11 +79,23 @@ interface MvTopBarProps {
onInlineApp?: (appId: string, url: string, name: string) => void;
onCloseInlineApp?: () => void;
activeAppId?: string | null;
/** Opens the merged across-accounts inbox from the account popover. */
/**
* Opens the merged across-accounts inbox from the account popover. Outside
* mail the bar opens mail on that inbox itself, when it can be built.
*/
onSelectAllInboxes?: () => void;
allInboxesSelected?: boolean;
}
/** Whether the merged inbox can be built: the terms the mail sidebar's unified section uses. */
function useAllInboxesReachable(): boolean {
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
const unifiedCrossAccount = useSettingsStore((s) => s.unifiedCrossAccount);
const gate = usePolicyStore((s) => s.isFeatureEnabled("unifiedCrossAccountEnabled"));
const connected = useAccountStore((s) => s.accounts.filter((a) => a.isConnected).length);
return enableUnifiedMailbox && unifiedCrossAccount && gate && connected > 1;
}
/**
* Mountain View's global header: one 64px row that owns the hamburger, the
* brand, the search field and the account cluster. It only renders on
@@ -102,7 +117,20 @@ 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();
// Every app gets the same cluster: shortcuts and "All inboxes" work from
// Calendar, Contacts, Files and Settings too, not only from mail.
const showShortcuts = onShowShortcuts ?? openKeyboardShortcuts;
const selectAllInboxes = onSelectAllInboxes ?? (allInboxesReachable
? () => {
if (requestAppCommand("all-inboxes")) return;
parkAppCommand("all-inboxes");
router.push("/");
}
: undefined);
const { appName, appLogoLightUrl, appLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const toggleSidebarCollapsed = useUIStore((s) => s.toggleSidebarCollapsed);
@@ -246,18 +274,15 @@ export function MvTopBar({
<div className="flex items-center gap-1 shrink-0">
<NotificationBell placement="below" className="w-12 h-12 rounded-full" />
{onShowShortcuts && (
<button
type="button"
onClick={onShowShortcuts}
className="grid place-items-center w-12 h-12 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={t("keyboard_shortcuts")}
aria-label={t("keyboard_shortcuts")}
>
{/* Gmail's help button, which is where it keeps the shortcuts too. */}
<HelpCircle className="w-5 h-5" />
</button>
)}
<button
type="button"
onClick={showShortcuts}
className="grid place-items-center w-12 h-12 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={t("keyboard_shortcuts")}
aria-label={t("keyboard_shortcuts")}
>
<Keyboard className="w-5 h-5" />
</button>
{/* Admin (Stalwart admins), as in the navigation rail this bar replaces.
A hard navigation because /admin lives outside the [locale] tree. */}
{isStalwartAdmin && (
@@ -265,7 +290,7 @@ export function MvTopBar({
href={`${getPathPrefix()}/admin`}
className="relative grid place-items-center w-12 h-12 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={t("admin")}
aria-label={t("admin")}
aria-label={hasUpdate ? `${t("admin")}, ${tShell(updateImportant ? "update_important" : "update_available")}` : t("admin")}
>
<Shield className="w-5 h-5" />
{hasUpdate && (
@@ -274,7 +299,7 @@ export function MvTopBar({
"absolute top-3 end-3 w-2 h-2 rounded-full ring-2 ring-background",
updateImportant ? "bg-red-500" : "bg-amber-500",
)}
aria-label={updateImportant ? "Important update available" : "Update available"}
aria-hidden="true"
/>
)}
</a>
@@ -407,7 +432,7 @@ export function MvTopBar({
<AccountSwitcher
variant="header"
className="ms-1"
onSelectAllInboxes={onSelectAllInboxes}
onSelectAllInboxes={selectAllInboxes}
allInboxesSelected={allInboxesSelected}
/>
</div>
+25 -9
View File
@@ -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}
>
<svg className="w-8 h-8 -rotate-90" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="12" fill="none" className="stroke-muted" strokeWidth="3" />
@@ -243,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);
@@ -371,6 +384,7 @@ export function NavigationRail({
key={item.id}
href={item.href}
onClick={handleNavClick(item.id as 'mail' | 'calendar' | 'contacts' | 'files' | 'settings')}
data-tour={`nav-${item.id}`}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
@@ -386,7 +400,7 @@ export function NavigationRail({
)}>
<Icon className="w-5 h-5" />
{item.badge != null && item.badge > 0 && (
<span aria-hidden="true" className="absolute -top-1.5 -right-2.5 flex items-center justify-center min-w-[16px] h-4 text-[10px] font-bold rounded-full bg-red-500 text-white px-1">
<span aria-hidden="true" className="absolute -top-1.5 -end-2.5 flex items-center justify-center min-w-[16px] h-4 text-[10px] font-bold rounded-full bg-red-500 text-white px-1">
{item.badge > 99 ? "99+" : item.badge}
</span>
)}
@@ -487,14 +501,15 @@ export function NavigationRail({
{hasUpdate && (
<span
className={cn(
"absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ring-2 ring-background",
"absolute -top-0.5 -end-0.5 w-2 h-2 rounded-full ring-2 ring-background",
updateImportant ? "bg-red-500" : "bg-amber-500",
)}
aria-label={updateImportant ? "Important update available" : "Update available"}
aria-hidden="true"
/>
)}
</span>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">{t("admin") || "Admin"}</span>
{updateLabel && <span className="sr-only">{`, ${updateLabel}`}</span>}
</a>
)}
@@ -601,7 +616,7 @@ export function NavigationRail({
{item.badge != null && item.badge > 0 && (
<span aria-hidden="true" className={cn(
"absolute flex items-center justify-center min-w-[16px] h-4 text-[10px] font-bold rounded-full bg-red-500 text-white px-1",
collapsed ? "-top-0.5 -right-0.5" : "right-1.5"
collapsed ? "-top-0.5 -end-0.5" : "end-1.5"
)}>
{item.badge > 99 ? "99+" : item.badge}
</span>
@@ -718,16 +733,17 @@ export function NavigationRail({
adminActive ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
title={t("admin") || "Admin"}
aria-label={updateLabel ? `${t("admin") || "Admin"}, ${updateLabel}` : undefined}
aria-current={adminActive ? "page" : undefined}
>
<Shield className="w-[18px] h-[18px]" />
{hasUpdate && (
<span
className={cn(
"absolute top-2 right-2 w-2 h-2 rounded-full ring-2 ring-background",
"absolute top-2 end-2 w-2 h-2 rounded-full ring-2 ring-background",
updateImportant ? "bg-red-500" : "bg-amber-500",
)}
aria-label={updateImportant ? "Important update available" : "Update available"}
aria-hidden="true"
/>
)}
</a>
@@ -816,7 +832,7 @@ export function NavigationRail({
fallbackColor={account.avatarColor}
/>
{isActive && (
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-primary flex items-center justify-center">
<span className="absolute -bottom-0.5 -end-0.5 w-3 h-3 rounded-full bg-primary flex items-center justify-center">
<Check className="w-2 h-2 text-primary-foreground" />
</span>
)}
@@ -868,7 +884,7 @@ export function NavigationRail({
{accounts.length > 1 && (
<button
onClick={() => { 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"
>
<LogOut className="w-4 h-4" />
+174
View File
@@ -0,0 +1,174 @@
"use client";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslations } from "next-intl";
import { MoreVertical, Terminal, Bell } from "@/components/icons";
import { IconButton } from "@/components/ui/icon-button";
import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { AccountSwitcher } from "@/components/layout/account-switcher";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import { openCommandPalette } from "@/lib/app-commands";
import { cn } from "@/lib/utils";
import {
NotificationSheet,
formatBadgeCount,
useUnreadNotificationCount,
} from "@/components/notifications/notification-center";
/** Plugin buttons in the top bar are not about any one item. */
const TOP_BAR_CONTEXT: Record<string, unknown> = {};
export interface PhoneHeaderMenuProps {
/**
* The app's own entries, listed above the shell's. They get the function
* that closes the menu, to call when one is picked.
*/
menuItems?: (close: () => void) => ReactNode;
}
/**
* The phone header's overflow menu: the app's own entries if it has any, the
* notifications (the desktop bell), the command palette (no Ctrl+K on a
* phone) and what the desktop top bar shows as buttons (plugin
* top-bar-actions) and has no room for here.
*/
export function PhoneHeaderMenu({ menuItems }: PhoneHeaderMenuProps) {
const t = useTranslations("top_bar");
const tPalette = useTranslations("command_palette");
const tNotifications = useTranslations("notification_center");
const unread = useUnreadNotificationCount();
const [showNotifications, setShowNotifications] = useState(false);
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const close = useCallback(() => setOpen(false), []);
const { menuRef, onKeyDown } = useMenuNavigation<HTMLDivElement>({ open, onClose: close, triggerRef });
useEffect(() => {
if (!open) return;
const onPointerDown = (e: MouseEvent) => {
if (triggerRef.current?.contains(e.target as Node) || menuRef.current?.contains(e.target as Node)) return;
setOpen(false);
};
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, [open, menuRef]);
const appItems = menuItems?.(close);
return (
<div className="relative">
<IconButton
ref={triggerRef}
size="icon-lg"
onClick={() => setOpen((v) => !v)}
className={cn(open && "bg-accent")}
label={unread > 0 ? `${t("more")} (${tNotifications("unread_count", { count: unread })})` : t("more")}
title=""
aria-haspopup="menu"
aria-expanded={open}
>
<MoreVertical />
{unread > 0 && (
<span className="absolute top-2 end-2 w-2 h-2 rounded-full bg-red-500 ring-2 ring-background" aria-hidden="true" />
)}
</IconButton>
{open && (
<div
ref={menuRef}
onKeyDown={onKeyDown}
role="menu"
aria-label={t("more")}
className="absolute end-0 top-full mt-1 z-50 min-w-[200px] max-w-[calc(100vw-2rem)] rounded-md border border-border bg-background py-1 shadow-lg"
>
{appItems && (
<>
{appItems}
<ContextMenuSeparator />
</>
)}
<ContextMenuItem
icon={Bell}
label={tNotifications("title")}
shortcut={unread > 0 ? formatBadgeCount(unread) : undefined}
onClick={() => { close(); setShowNotifications(true); }}
/>
<ContextMenuItem
icon={Terminal}
label={tPalette("title")}
onClick={() => { close(); openCommandPalette(); }}
/>
<PluginActions surface="top-bar-actions" context={TOP_BAR_CONTEXT} variant="menu" onRan={close} />
</div>
)}
{showNotifications && <NotificationSheet onClose={() => setShowNotifications(false)} />}
</div>
);
}
export interface PhoneHeaderActionsProps extends PhoneHeaderMenuProps {
/**
* Below which width the app shows the phone shell (bottom tab bar, no
* rail): "md" for apps that keep the rail on a tablet, "lg" for those that
* use the phone shell up to the desktop. Above it nothing is drawn, as the
* rail or the Mountain View top bar holds these controls there.
*/
below?: "md" | "lg";
className?: string;
/** Mountain View: opens the merged across-accounts inbox from the avatar's popover. */
onSelectAllInboxes?: () => void;
allInboxesSelected?: boolean;
}
/**
* The cluster at the end of every app's phone header (answer 12 of the
* 2026-10-27 usability test): a ⋮ with notifications and the command palette,
* then the account avatar. Drop it in as the last child of the header row.
*/
export function PhoneHeaderActions({
below = "lg",
className,
menuItems,
onSelectAllInboxes,
allInboxesSelected = false,
}: PhoneHeaderActionsProps) {
const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
const shown = below === "md" ? isMobile : !isDesktop;
if (!shown) return null;
return (
<div data-phone-header-actions="" className={cn("flex items-center gap-1 flex-shrink-0 ms-auto", className)}>
<PhoneHeaderMenu menuItems={menuItems} />
{/* Which account you are in is otherwise only visible in Settings.
Last, so it sits on the far edge, mirrored under RTL by the flex row. */}
<AccountSwitcher
variant="header"
onSelectAllInboxes={onSelectAllInboxes}
allInboxesSelected={allInboxesSelected}
/>
</div>
);
}
export interface PhoneAppHeaderProps extends PhoneHeaderActionsProps {
title: string;
/** Controls drawn before the title, such as a back or menu button. */
leading?: ReactNode;
}
/** A whole phone header for a screen that has none of its own: title, then the shared cluster. */
export function PhoneAppHeader({ title, leading, ...actions }: PhoneAppHeaderProps) {
const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
const shown = (actions.below ?? "lg") === "md" ? isMobile : !isDesktop;
if (!shown) return null;
return (
<header className="flex items-center gap-2 h-14 shrink-0 border-b border-border bg-background px-4">
{leading}
<h1 className="font-semibold text-lg truncate min-w-0" title={title}>{title}</h1>
<PhoneHeaderActions {...actions} />
</header>
);
}
+3 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
interface ResizeHandleProps {
@@ -17,6 +18,7 @@ interface ResizeHandleProps {
const KEYBOARD_STEP = 10;
export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleClick, orientation = "vertical", className, testId }: ResizeHandleProps) {
const t = useTranslations("shell");
const handleRef = useRef<HTMLDivElement>(null);
// Id of the pointer that started the drag, or null when idle. Only that
// pointer may move or end the drag, so a second finger or button can't
@@ -120,7 +122,7 @@ export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleCli
ref={handleRef}
role="separator"
aria-orientation={isHorizontal ? "horizontal" : "vertical"}
aria-label="Resize"
aria-label={t("resize")}
data-testid={testId}
tabIndex={0}
onPointerDown={handlePointerDown}
+34 -55
View File
@@ -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 (
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="sidebar-apps-modal-title"
className={cn(
'bg-background border border-border rounded-lg shadow-xl',
'w-full max-w-2xl max-h-[90vh] overflow-hidden',
'animate-in zoom-in-95 duration-200'
)}
<>
<Dialog
isOpen={isOpen}
onClose={handleClose}
title={t('modal_title')}
size="full-on-phone"
dirty={editing}
className="max-h-[90vh]"
bodyClassName="pt-4"
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 id="sidebar-apps-modal-title" className="text-lg font-semibold text-foreground">
{t('modal_title')}
</h2>
<button
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
<ManagedAppsList />
{/* Create form */}
@@ -368,22 +349,21 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
{app.openMode === 'inline' ? t('inline_badge') : t('tab_badge')}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
<IconButton
label={t('edit_app')}
onClick={() => setEditingId(app.id)}
disabled={!!editingId || isCreating}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
<Pencil />
</IconButton>
<IconButton
label={t('delete')}
onClick={() => handleDelete(app)}
disabled={!!editingId || isCreating}
className="text-destructive"
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
<Trash2 />
</IconButton>
</div>
</div>
);
@@ -397,10 +377,9 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
</div>
)}
</div>
</div>
</div>
</Dialog>
<ConfirmDialog {...confirmDialogProps} />
</div>
</>
);
}
+4 -3
View File
@@ -222,6 +222,7 @@ function SidebarRowCounts({
isSelected: boolean;
onUnreadClick?: () => void;
}) {
const tShell = useTranslations('shell');
const showFolderTotalCount = useSettingsStore(s => s.showFolderTotalCount);
const mountainView = useSettingsStore(s => s.interfaceLayout === 'mountain-view');
// Per-folder override from the folder settings (#982).
@@ -272,7 +273,7 @@ function SidebarRowCounts({
}
}}
className={cn(unreadClass, "cursor-pointer hover:underline")}
title={`${unreadCount} unread`}
title={tShell("folder_unread", { count: unreadCount })}
>
{unreadCount}
</span>
@@ -284,7 +285,7 @@ function SidebarRowCounts({
return (
<span
className="ms-2 flex-shrink-0 flex items-baseline gap-1"
title={totalCount > 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}
title={totalCount > 0 ? tShell("folder_counts", { unread: unreadCount, total: totalCount }) : tShell("folder_unread", { count: unreadCount })}
data-testid="folder-counts"
data-unread={unreadCount}
data-total={totalCount}
@@ -894,7 +895,7 @@ function VacationBanner() {
return (
<button
onClick={() => 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",
+1 -2
View File
@@ -81,7 +81,6 @@ import {
} from "@/components/error";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
import { DragDropProvider } from "@/contexts/drag-drop-context";
import { jmapErrorType } from "@/lib/jmap/jmap-error";
import { isFilterEmpty, activeFilterCount, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
@@ -1148,6 +1147,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (isMobile) setMobileSearchOpen(true);
else keyboardHandlers.onFocusSearch?.();
},
'all-inboxes': () => handleMailboxSelect(UNIFIED_MAILBOX_IDS.inbox),
}, isAuthenticated && !!client);
// Shared mail refresh: re-fetch mailboxes (unread counts) + the active list
@@ -5587,7 +5587,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
)}
<ShareNotificationToaster client={client} />
<CalendarEventNotificationToaster client={client} />
<TotpReauthDialog />
{floatingComposer && <MvComposeWindow>{composerNode}</MvComposeWindow>}
</div>
</DragDropProvider>
@@ -138,3 +138,34 @@ describe('notification bell', () => {
expect(screen.queryByRole('dialog')).toBeNull();
});
});
describe('notification panel and the keyboard', () => {
it('moves the focus into the panel when it opens', () => {
act(() => add({ level: 'error', title: 'a', body: '' }));
render(<NavigationRail collapsed />);
fireEvent.click(bell());
expect(screen.getByRole('button', { name: 'mark_all_read' })).toHaveFocus();
});
it('focuses the panel itself when it has nothing to act on', () => {
render(<NavigationRail collapsed />);
fireEvent.click(bell());
expect(screen.getByRole('dialog', { name: 'title' })).toHaveFocus();
});
it('closes and returns to the bell when Tab leaves the panel', () => {
act(() => add({ level: 'error', title: 'a', body: '' }));
render(<NavigationRail collapsed />);
fireEvent.click(bell());
const clear = screen.getByRole('button', { name: 'clear' });
clear.focus();
act(() => { fireEvent.keyDown(clear, { key: 'Tab' }); });
expect(screen.queryByRole('dialog')).toBeNull();
expect(bell()).toHaveFocus();
fireEvent.click(bell());
const markAll = screen.getByRole('button', { name: 'mark_all_read' });
act(() => { fireEvent.keyDown(markAll, { key: 'Tab', shiftKey: true }); });
expect(screen.queryByRole('dialog')).toBeNull();
});
});
@@ -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,
@@ -58,34 +60,20 @@ export function NotificationPanel({ onClose, className, sheet = false }: { onClo
return (
<div className={cn("flex flex-col bg-background text-foreground", className)} data-notification-panel="">
<div className="flex items-center gap-1 px-3 h-12 border-b border-border shrink-0">
<h2 className="flex-1 min-w-0 truncate text-sm font-semibold">{t("title")}</h2>
<button
type="button"
onClick={markAllRead}
disabled={unread === 0}
className="inline-flex items-center gap-1 h-8 px-2 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50 disabled:pointer-events-none transition-colors"
>
{/* Full screen on a phone it is a dialog of its own, titled like one. */}
<h2 className={cn("flex-1 min-w-0 truncate font-semibold", sheet ? "text-lg" : "text-sm")}>{t("title")}</h2>
<Button variant="ghost" size="xs" onClick={markAllRead} disabled={unread === 0} className="gap-1 px-2 text-xs text-muted-foreground">
<CheckCheck className="w-4 h-4" />
<span>{t("mark_all_read")}</span>
</button>
<button
type="button"
onClick={clear}
disabled={entries.length === 0}
className="inline-flex items-center gap-1 h-8 px-2 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50 disabled:pointer-events-none transition-colors"
>
</Button>
<Button variant="ghost" size="xs" onClick={clear} disabled={entries.length === 0} className="gap-1 px-2 text-xs text-muted-foreground">
<Trash2 className="w-4 h-4" />
<span>{t("clear")}</span>
</button>
</Button>
{sheet && (
<button
type="button"
onClick={onClose}
className="grid place-items-center h-10 w-10 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
aria-label={t("close")}
>
<X className="w-5 h-5" />
</button>
<IconButton size="icon-lg" label={t("close")} title="" onClick={onClose} className="text-muted-foreground">
<X />
</IconButton>
)}
</div>
{entries.length === 0 ? (
@@ -153,6 +141,11 @@ export function NotificationPanel({ onClose, className, sheet = false }: { onClo
);
}
/** The panel's enabled controls, in tab order. */
function panelControls(panel: HTMLElement): HTMLElement[] {
return Array.from(panel.querySelectorAll<HTMLElement>("button:not(:disabled), a[href]"));
}
/** The phone version: the panel over the whole screen. */
export function NotificationSheet({ onClose }: { onClose: () => void }) {
const t = useTranslations("notification_center");
@@ -205,6 +198,32 @@ export function NotificationBell({ placement, className }: { placement: "side" |
if (open) position();
}, [open, position]);
// The panel is portalled to the end of <body>, so Tab from the bell would
// never reach it: focus moves in on open, to its first control (or the
// panel itself while every control is disabled).
useEffect(() => {
if (!open) return;
const panel = panelRef.current;
if (!panel) return;
(panelControls(panel)[0] ?? panel).focus();
}, [open]);
// Tab past either end of the panel closes it and goes back to the bell, so
// the next Tab carries on from where the panel was opened.
const onPanelKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key !== "Tab" || !panelRef.current) return;
const controls = panelControls(panelRef.current);
const first = controls[0];
const last = controls[controls.length - 1];
const active = document.activeElement;
const leaving = controls.length === 0
|| (e.shiftKey ? active === first || active === panelRef.current : active === last);
if (!leaving) return;
e.preventDefault();
setOpen(false);
buttonRef.current?.focus();
};
useEffect(() => {
if (!open) return;
const onPointerDown = (e: MouseEvent) => {
@@ -264,6 +283,8 @@ export function NotificationBell({ placement, className }: { placement: "side" |
ref={panelRef}
role="dialog"
aria-label={t("title")}
tabIndex={-1}
onKeyDown={onPanelKeyDown}
style={style}
className="z-50 w-[22rem] max-w-[calc(100vw-1rem)] max-h-[min(32rem,80vh)] flex flex-col overflow-hidden rounded-lg border border-border bg-background shadow-lg"
>
@@ -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(<PluginActions surface="message-bulk-actions" context={{ emailIds: ['e1'] }} overflow />);
const more = screen.getByRole('button', { name: 'more_actions' });
expect(more.querySelector('.tabler-icon-dots-vertical')).not.toBeNull();
});
});
+2 -2
View File
@@ -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',
)}
>
<MoreHorizontal className="h-4 w-4" />
<MoreVertical className="h-4 w-4" />
</button>
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
<PluginActions
+1 -1
View File
@@ -100,7 +100,7 @@ export function PluginConnectDialog() {
<form className="p-5" onSubmit={(e) => { e.preventDefault(); void submit(); }}>
<div className="mb-3 flex items-center gap-2">
<KeyRound className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
<h2 id={titleId} className="text-base font-semibold">{t('title', { label: spec.label })}</h2>
<h2 id={titleId} className="text-lg font-semibold text-foreground">{t('title', { label: spec.label })}</h2>
</div>
<p className="mb-4 text-sm text-muted-foreground [overflow-wrap:anywhere]">{intro}</p>
<div className="mb-4 flex flex-col gap-3">
+1 -1
View File
@@ -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"
>
<div className="p-5 pb-3">
<h2 id={`${id}-title`} className="text-base font-semibold text-foreground">
<h2 id={`${id}-title`} className="text-lg font-semibold text-foreground">
{t('title', { name: current.pluginName })}
</h2>
<p className="mt-1 text-sm text-muted-foreground">{t('intro')}</p>
+2 -2
View File
@@ -19,7 +19,7 @@ import { usePluginStore } from '@/stores/plugin-store';
import { toast } from '@/stores/toast-store';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { iconForName, Loader2, MoreHorizontal, Power, Puzzle, X } from '@/components/icons';
import { iconForName, Loader2, MoreVertical, Power, Puzzle, X } from '@/components/icons';
import { cn } from '@/lib/utils';
import { PluginIframeSlot } from './plugin-iframe-slot';
import type { SlotName } from '@/lib/plugin-types';
@@ -339,7 +339,7 @@ function PluginDialogMenu({ name, onTurnOff }: { name: string; onTurnOff: () =>
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')}
>
<MoreHorizontal className="w-5 h-5" />
<MoreVertical className="w-5 h-5" />
</button>
{open && (
<div role="menu" className="absolute end-0 top-full z-10 mt-1 min-w-[200px] rounded-md border border-border bg-background py-1 shadow-lg">
+2
View File
@@ -8,6 +8,7 @@ import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from "@/stores/
import { useEmailStore } from "@/stores/email-store";
import { usePluginStore } from "@/stores/plugin-store";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { PhoneAppHeader } from "@/components/layout/phone-header-actions";
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
import { InlineAppView } from "@/components/layout/inline-app-view";
import { MvTopBar } from "@/components/layout/mv-top-bar";
@@ -144,6 +145,7 @@ export function PluginPageApp({ linkSegments = [] }: PluginPageAppProps) {
</div>
)}
<div className="flex flex-col flex-1 min-w-0">
{!isEmbedded && <PhoneAppHeader title={page?.title ?? ""} below="md" />}
{inlineApp && <InlineAppView apps={loadedApps} activeAppId={inlineApp.id} onClose={closeInlineApp} />}
<div className={cn("flex flex-col flex-1 min-h-0", inlineApp && "hidden")}>
{content}
@@ -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 ? (
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0 rtl:-scale-x-100" />
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
) : (
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
)}
+18 -36
View File
@@ -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 (
<div className="fixed bottom-4 right-4 z-50 bg-white dark:bg-neutral-900 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-800 p-4 max-w-sm animate-in slide-in-from-bottom-4">
<div className="fixed bottom-4 end-4 z-50 max-w-sm rounded-lg border border-border bg-popover text-popover-foreground shadow-lg p-4 animate-in slide-in-from-bottom-4 max-sm:inset-x-3 max-sm:max-w-none max-sm:bottom-[calc(var(--bottom-controls-height,3.75rem)+0.75rem)]">
<div className="flex items-start justify-between mb-3">
<div className="flex items-start gap-3">
<Bell className="w-5 h-5 mt-0.5 text-blue-600 dark:text-blue-400 shrink-0" />
<Bell className="w-5 h-5 mt-0.5 text-primary shrink-0" />
<div>
<h3 className="font-semibold text-sm text-neutral-900 dark:text-white">
<h3 className="font-semibold text-sm text-foreground">
{tPush("title")}
</h3>
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
<p className="text-xs text-muted-foreground mt-1">
{tPush("description")}
</p>
</div>
</div>
<button
type="button"
onClick={handleDismiss}
disabled={isEnabling}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors disabled:opacity-50"
aria-label={tPush("dismiss_aria")}
>
<X className="w-4 h-4" />
</button>
<IconButton label={tPush("dismiss_aria")} onClick={handleDismiss} disabled={isEnabling} className="-me-2 -mt-2 text-muted-foreground">
<X />
</IconButton>
</div>
<div className="flex flex-col gap-2">
{error ? (
@@ -357,32 +354,17 @@ export function PushNotificationPrompt() {
</p>
) : null}
<div className="flex gap-2">
<button
type="button"
onClick={handleDismiss}
disabled={isEnabling}
className="flex-1 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors disabled:opacity-50"
>
<Button type="button" variant="outline" size="sm" className="flex-1" onClick={handleDismiss} disabled={isEnabling}>
{tInstall("not_now")}
</button>
<button
type="button"
onClick={() => 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 ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
</Button>
<Button type="button" size="sm" className="flex-1 gap-2" onClick={() => void handleEnable()} disabled={isEnabling}>
{isEnabling ? <Spinner size="sm" className="text-current" /> : null}
{tPush("enable")}
</button>
</Button>
</div>
<button
type="button"
onClick={handleDismissForever}
disabled={isEnabling}
className="w-full text-xs text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors text-center disabled:opacity-50"
>
<Button type="button" variant="ghost" size="xs" className="w-full text-xs text-muted-foreground" onClick={handleDismissForever} disabled={isEnabling}>
{tInstall("dont_remind")}
</button>
</Button>
</div>
</div>
);
+15 -26
View File
@@ -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 (
<div className="fixed bottom-4 right-4 z-50 bg-white dark:bg-neutral-900 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-800 p-4 max-w-sm animate-in slide-in-from-bottom-4">
<div className="fixed bottom-4 end-4 z-50 max-w-sm rounded-lg border border-border bg-popover text-popover-foreground shadow-lg p-4 animate-in slide-in-from-bottom-4 max-sm:inset-x-3 max-sm:max-w-none max-sm:bottom-[calc(var(--bottom-controls-height,3.75rem)+0.75rem)]">
<div className="flex items-start justify-between mb-3">
<div className="flex items-start gap-3">
{logoSrc ? (
@@ -98,7 +100,7 @@ export function PWAInstallPrompt() {
className="w-8 h-8 shrink-0 object-contain dark:hidden"
/>
) : (
<Download className="w-5 h-5 mt-0.5 text-blue-600 dark:text-blue-400 shrink-0" />
<Download className="w-5 h-5 mt-0.5 text-primary shrink-0" />
)}
{logoSrc && (
<img
@@ -108,43 +110,30 @@ export function PWAInstallPrompt() {
/>
)}
<div>
<h3 className="font-semibold text-sm text-neutral-900 dark:text-white">
<h3 className="font-semibold text-sm text-foreground">
{t("title", { appName })}
</h3>
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
<p className="text-xs text-muted-foreground mt-1">
{t("description")}
</p>
</div>
</div>
<button
onClick={handleDismiss}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
aria-label={t("dismiss_aria")}
>
<X className="w-4 h-4" />
</button>
<IconButton label={t("dismiss_aria")} onClick={handleDismiss} className="-me-2 -mt-2 text-muted-foreground">
<X />
</IconButton>
</div>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<button
onClick={handleDismiss}
className="flex-1 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors"
>
<Button type="button" variant="outline" size="sm" className="flex-1" onClick={handleDismiss}>
{t("not_now")}
</button>
<button
onClick={handleInstall}
className="flex-1 px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
>
</Button>
<Button type="button" size="sm" className="flex-1" onClick={handleInstall}>
{t("install")}
</button>
</Button>
</div>
<button
onClick={handleDismissForever}
className="w-full text-xs text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors text-center"
>
<Button type="button" variant="ghost" size="xs" className="w-full text-xs text-muted-foreground" onClick={handleDismissForever}>
{t("dont_remind")}
</button>
</Button>
</div>
</div>
);
+3
View File
@@ -63,6 +63,7 @@ import { useSettingsStore } from '@/stores/settings-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import { useIsDesktop } from '@/hooks/use-media-query';
import { NavigationRail } from '@/components/layout/navigation-rail';
import { PhoneHeaderActions } from '@/components/layout/phone-header-actions';
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
import { InlineAppView } from '@/components/layout/inline-app-view';
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
@@ -844,6 +845,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
<ArrowLeft className="w-5 h-5" />
</Button>
<h1 className="font-semibold text-lg truncate">{activeTabLabel}</h1>
<PhoneHeaderActions />
</div>
<div className="flex-1 overflow-y-auto p-4">
@@ -880,6 +882,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
<SettingsIcon className="w-5 h-5 text-muted-foreground" />
<h1 className="font-semibold text-lg">{t('title')}</h1>
</div>
<PhoneHeaderActions />
</div>
<div className="flex-1 overflow-y-auto">
+52 -80
View File
@@ -1,107 +1,79 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { useTotpReauthStore } from "@/stores/totp-reauth-store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog } from "@/components/ui/dialog";
import { Shield } from "@/components/icons";
/**
* Modal dialog that prompts the user for a fresh TOTP code when their
* 2FA session expires (TOTP rotates every ~30 seconds).
* Asks for a fresh TOTP code when a 2FA session expires (TOTP rotates every
* ~30 seconds).
*
* Rendered once at the app root level. The JMAP client triggers it via
* the useTotpReauthStore when a 401 is received on a TOTP-authenticated session.
* Mounted once in the locale layout, so the prompt opens in whichever app
* made the request. The JMAP client opens it through useTotpReauthStore
* when a 401 comes back on a TOTP-authenticated session.
*/
export function TotpReauthDialog() {
const t = useTranslations("totp_reauth");
const tCommon = useTranslations("common");
const { isOpen, submit, cancel } = useTotpReauthStore();
const [code, setCode] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useFocusTrap({
isActive: isOpen,
onEscape: cancel,
restoreFocus: true,
});
useEffect(() => {
if (isOpen) {
setCode("");
setTimeout(() => inputRef.current?.focus(), 50);
}
if (isOpen) setCode("");
}, [isOpen]);
if (!isOpen) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (code.length >= 6) {
submit(code);
}
if (code.length >= 6) submit(code);
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={cancel} />
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Two-factor authentication required"
className="relative z-10 w-full max-w-sm mx-4 bg-background rounded-2xl shadow-xl border border-border p-6"
>
<div className="flex items-center gap-3 mb-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-primary/10">
<Shield className="w-5 h-5 text-primary" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">Session Expired</h2>
<p className="text-sm text-muted-foreground">Your 2FA code has rotated</p>
</div>
<Dialog
isOpen={isOpen}
onClose={cancel}
title={t("title")}
description={t("description")}
icon={
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-primary/10 flex-shrink-0">
<Shield className="w-5 h-5 text-primary" />
</div>
<p className="text-sm text-muted-foreground mb-4">
Enter a fresh authentication code from your authenticator app to continue.
</p>
<p className="text-xs text-amber-600 dark:text-amber-400 mb-4 leading-relaxed">
To avoid being prompted repeatedly, ask your administrator to enable OAuth authentication
(either Stalwart&apos;s built-in OAuth or an external identity provider).
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
ref={inputRef}
type="text"
inputMode="numeric"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
className="h-12 text-center font-mono tracking-widest text-lg bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
placeholder="000000"
autoComplete="one-time-code"
aria-label="Authentication code"
/>
<div className="flex gap-3">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={cancel}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={code.length < 6}
>
Verify
</Button>
</div>
</form>
</div>
</div>
}
size="sm"
showClose={false}
dirty={code.length > 0}
initialFocus={inputRef}
onSubmit={handleSubmit}
footer={
<>
<Button type="button" variant="outline" onClick={cancel}>
{tCommon("cancel")}
</Button>
<Button type="submit" disabled={code.length < 6}>
{t("verify")}
</Button>
</>
}
>
<p className="mt-3 mb-4 text-xs text-amber-600 dark:text-amber-400 leading-relaxed">
{t("oauth_hint")}
</p>
<Input
ref={inputRef}
type="text"
inputMode="numeric"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
className="h-12 text-center font-mono tracking-widest text-lg"
placeholder="000000"
autoComplete="one-time-code"
aria-label={t("code_label")}
/>
</Dialog>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// The tour on a phone, where the rail's anchors are not rendered, and its
// keyboard handling.
const tour = vi.hoisted(() => ({
currentStep: 0,
nextStep: vi.fn(),
prevStep: vi.fn(),
stopTour: vi.fn(),
}));
vi.mock('../tour-provider', () => ({
useTour: () => ({
currentStep: tour.currentStep,
totalSteps: 3,
steps: [
{ id: 'one', target: '[data-tour="one"]', titleKey: 'one', descriptionKey: 'one_desc', placement: 'right' },
{ id: 'two', target: '[data-tour="two"]', titleKey: 'two', descriptionKey: 'two_desc', placement: 'right' },
{ id: 'three', target: '[data-tour="three"]', titleKey: 'three', descriptionKey: 'three_desc', placement: 'right' },
],
nextStep: tour.nextStep,
prevStep: tour.prevStep,
stopTour: tour.stopTour,
}),
}));
const { TourOverlay, findTourTarget } = await import('../tour-overlay');
const { getTourSteps } = await import('../tour-steps');
/** jsdom lays nothing out: give the elements marked `data-size` a box. */
const realRect = HTMLElement.prototype.getBoundingClientRect;
beforeEach(() => {
vi.useFakeTimers();
tour.currentStep = 0;
tour.nextStep.mockClear();
tour.prevStep.mockClear();
tour.stopTour.mockClear();
HTMLElement.prototype.getBoundingClientRect = function () {
const sized = this.hasAttribute('data-size');
return { top: 10, left: 10, width: sized ? 40 : 0, height: sized ? 40 : 0, right: 50, bottom: 50, x: 10, y: 10, toJSON: () => ({}) } as DOMRect;
};
Element.prototype.scrollIntoView = vi.fn();
});
afterEach(() => {
vi.useRealTimers();
HTMLElement.prototype.getBoundingClientRect = realRect;
hosts.splice(0).forEach((h) => h.remove());
document.documentElement.removeAttribute('dir');
});
const hosts: HTMLElement[] = [];
function anchors(html: string) {
const host = document.createElement('div');
hosts.push(host);
host.innerHTML = html;
document.body.appendChild(host);
}
describe('tour targets', () => {
it('points at the copy of an anchor that is on screen', () => {
anchors('<a data-tour="one" id="rail"></a><a data-tour="one" id="tabbar" data-size></a>');
expect(findTourTarget('[data-tour="one"]')?.id).toBe('tabbar');
});
it('ignores a copy in a closed drawer', () => {
anchors('<div inert><a data-tour="one" id="drawer" data-size></a></div>');
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('<a data-tour="one"></a>');
render(<TourOverlay />);
act(() => { vi.advanceTimersByTime(1100); });
expect(tour.nextStep).toHaveBeenCalledTimes(1);
});
it('shows a step whose target is on screen', () => {
anchors('<a data-tour="one" data-size></a>');
render(<TourOverlay />);
act(() => { vi.advanceTimersByTime(1100); });
expect(tour.nextStep).not.toHaveBeenCalled();
expect(screen.getByRole('dialog', { name: 'one' })).toBeInTheDocument();
});
it('leaves out the shortcuts step on a touch screen and contacts when they are off', () => {
const ids = (o: Partial<Parameters<typeof getTourSteps>[0]>) =>
getTourSteps({ isDemoMode: false, supportsCalendar: true, supportsWebDAV: true, ...o }).map((s) => s.id);
expect(ids({})).toContain('shortcuts');
expect(ids({ touch: true })).not.toContain('shortcuts');
expect(ids({})).toContain('nav-contacts');
expect(ids({ supportsContacts: false })).not.toContain('nav-contacts');
});
});
describe('tour keyboard', () => {
function showStep() {
anchors('<a data-tour="one" data-size></a>');
render(<TourOverlay />);
act(() => { vi.advanceTimersByTime(400); });
}
it('lets Enter press the focused Skip button instead of going to the next step', () => {
showStep();
const skip = screen.getByRole('button', { name: 'tour.skip' });
skip.focus();
act(() => { fireEvent.keyDown(skip, { key: 'Enter' }); });
expect(tour.nextStep).not.toHaveBeenCalled();
});
it('still goes on with Enter when no button has the focus', () => {
showStep();
act(() => { fireEvent.keyDown(document.body, { key: 'Enter' }); });
expect(tour.nextStep).toHaveBeenCalledTimes(1);
});
it('leaves Enter and the arrows to a field being typed in', () => {
showStep();
const field = document.createElement('input');
hosts[0].appendChild(field);
act(() => { fireEvent.keyDown(field, { key: 'Enter' }); });
act(() => { fireEvent.keyDown(field, { key: 'ArrowRight' }); });
expect(tour.nextStep).not.toHaveBeenCalled();
});
it('mirrors the arrows in right-to-left languages', () => {
document.documentElement.setAttribute('dir', 'rtl');
showStep();
act(() => { fireEvent.keyDown(document.body, { key: 'ArrowLeft' }); });
expect(tour.nextStep).toHaveBeenCalledTimes(1);
act(() => { fireEvent.keyDown(document.body, { key: 'ArrowRight' }); });
expect(tour.prevStep).toHaveBeenCalledTimes(1);
});
});
+64 -47
View File
@@ -6,6 +6,9 @@ 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";
interface Rect {
top: number;
@@ -18,15 +21,35 @@ const PADDING = 8;
const TOOLTIP_GAP = 12;
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 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) continue;
if (r.right <= 0 || r.left >= window.innerWidth) continue;
return el;
}
return null;
}
function getTargetRect(selector: string): Rect | null {
const el = document.querySelector(selector);
const el = findTourTarget(selector);
if (!el) return null;
const r = el.getBoundingClientRect();
// Element might exist but be hidden (zero dimensions)
if (r.width === 0 && r.height === 0) return null;
return { top: r.top, left: r.left, width: r.width, height: r.height };
}
/** Polls (100 ms apart) before a step whose target is in the DOM but has no size is skipped. */
const HIDDEN_TARGET_ATTEMPTS = 10;
/** Polls before a step whose target never appeared (another page loading) is skipped. */
const MISSING_TARGET_ATTEMPTS = 50;
function computeTooltipPosition(
target: Rect,
placement: "top" | "bottom" | "left" | "right",
@@ -90,7 +113,13 @@ export function TourOverlay() {
// Use refs for callbacks to avoid stale closures in timers/intervals
const updatePositionRef = useRef<() => void>(() => {});
const nextStepRef = useRef<() => void>(() => {});
const prevStepRef = useRef<() => void>(() => {});
// A step with nothing to point at is skipped in the direction the user went.
const directionRef = useRef<1 | -1>(1);
const goNext = useCallback(() => { directionRef.current = 1; nextStep(); }, [nextStep]);
const goPrev = useCallback(() => { directionRef.current = -1; prevStep(); }, [prevStep]);
nextStepRef.current = nextStep;
prevStepRef.current = prevStep;
const focusTrapRef = useFocusTrap({
isActive: visible,
@@ -123,7 +152,6 @@ export function TourOverlay() {
// Wait for target element to appear, then show
useEffect(() => {
if (!step) return;
console.log(`[Tour] Step ${currentStep + 1}/${totalSteps}: "${step.id}" - target: ${step.target}, placement: ${step.placement}, interactive: ${!!step.interactive}`);
setVisible(false);
// Keep old targetRect and tooltipPos so the cutout/tooltip animate to the new position
// instead of disappearing and reappearing
@@ -140,20 +168,16 @@ export function TourOverlay() {
}
let attempts = 0;
const maxAttempts = 50; // 5 seconds
let cancelled = false;
const tryFind = () => {
if (cancelled) return true;
const el = document.querySelector(step.target);
const el = findTourTarget(step.target);
if (el) {
const rect = el.getBoundingClientRect();
console.log(`[Tour] Step ${currentStep + 1} "${step.id}": element FOUND (${rect.width}x${rect.height} at ${Math.round(rect.left)},${Math.round(rect.top)})`);
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
// Delay after scroll for layout to settle
pendingTimerRef.current = setTimeout(() => {
if (cancelled) return;
console.log(`[Tour] Step ${currentStep + 1} "${step.id}": showing tooltip`);
updatePositionRef.current();
setVisible(true);
// Second position update after tooltip renders with final dimensions
@@ -163,24 +187,26 @@ export function TourOverlay() {
}, 200);
return true;
}
if (attempts % 10 === 0) {
console.log(`[Tour] Step ${currentStep + 1} "${step.id}": element NOT found (attempt ${attempts + 1}/${maxAttempts})`);
}
return false;
};
if (tryFind()) return () => { cancelled = true; };
// Poll for element appearance (for page navigation)
// Poll for the element to appear (another page loading). One that is in
// the DOM but not rendered in this layout (the rail on a phone) is given
// up on sooner.
const interval = setInterval(() => {
attempts++;
if (tryFind() || attempts >= maxAttempts) {
if (tryFind()) {
clearInterval(interval);
if (attempts >= maxAttempts && !cancelled) {
// Skip this step if element never appears
console.warn(`[Tour] Step ${currentStep + 1} "${step.id}": SKIPPED - element never appeared after ${maxAttempts} attempts`);
nextStepRef.current();
}
return;
}
const limit = document.querySelector(step.target) ? HIDDEN_TARGET_ATTEMPTS : MISSING_TARGET_ATTEMPTS;
if (attempts >= limit) {
clearInterval(interval);
if (cancelled) return;
if (directionRef.current === -1 && currentStep > 0) prevStepRef.current();
else nextStepRef.current();
}
}, 100);
@@ -192,7 +218,7 @@ export function TourOverlay() {
pendingTimerRef.current = null;
}
};
}, [step, currentStep]); // eslint-disable-line react-hooks/exhaustive-deps
}, [step, currentStep]);
// Recalculate on resize/scroll (debounced)
useEffect(() => {
@@ -213,17 +239,23 @@ export function TourOverlay() {
};
}, [visible, updatePosition]);
// Keyboard navigation
// Keyboard navigation. Enter on a focused button (Skip, Back) and keys
// typed into a field (the search step) are theirs, not the tour's.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "ArrowRight" || e.key === "Enter") {
const onControl = e.target instanceof HTMLElement && !!e.target.closest("button, a[href]");
const typing = isEditableEventTarget(e);
// The arrow pointing the way you read goes on: → in LTR, ← in RTL.
const forward = isDocumentRTL() ? "ArrowLeft" : "ArrowRight";
const back = isDocumentRTL() ? "ArrowRight" : "ArrowLeft";
if ((e.key === forward && !typing) || (e.key === "Enter" && !onControl && !typing)) {
e.preventDefault();
e.stopPropagation();
nextStep();
} else if (e.key === "ArrowLeft") {
goNext();
} else if (e.key === back && !typing) {
e.preventDefault();
e.stopPropagation();
prevStep();
goPrev();
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
@@ -232,7 +264,7 @@ export function TourOverlay() {
};
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [nextStep, prevStep, stopTour]);
}, [goNext, goPrev, stopTour]);
// Re-position after tooltip content renders with new dimensions
useEffect(() => {
@@ -365,31 +397,16 @@ export function TourOverlay() {
{/* Navigation buttons */}
<div className="flex items-center justify-between mt-3">
<button
onClick={stopTour}
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded hover:bg-muted"
>
<Button variant="ghost" size="xs" onClick={stopTour} className="text-xs text-muted-foreground -ms-2">
{t("tour.skip")}
</button>
</Button>
<div className="flex gap-2">
<button
onClick={prevStep}
disabled={isFirst}
className={cn(
"text-xs px-3 py-1.5 rounded-md border border-border transition-colors",
isFirst
? "opacity-40 cursor-not-allowed"
: "hover:bg-muted"
)}
>
<Button variant="outline" size="xs" onClick={goPrev} disabled={isFirst} className="text-xs">
{t("tour.back")}
</button>
<button
onClick={nextStep}
className="text-xs px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
</Button>
<Button size="xs" onClick={goNext} className="text-xs">
{isLast ? t("tour.finish") : t("tour.next")}
</button>
</Button>
</div>
</div>
+12 -1
View File
@@ -9,6 +9,7 @@ import { useWebDAVStore } from "@/stores/webdav-store";
import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { getTourSteps, type TourStep } from "./tour-steps";
import { useMediaQuery } from "@/hooks/use-media-query";
import { TourOverlay } from "./tour-overlay";
const TOUR_COMPLETED_KEY = "tour_completed";
@@ -50,7 +51,17 @@ export function TourProvider({ children }: { children: ReactNode }) {
const [currentStep, setCurrentStep] = useState(0);
const [hasCompletedTour, setHasCompletedTour] = useState(false);
const steps = getTourSteps({ isDemoMode, supportsCalendar: supportsCalendar && calendarEnabled, supportsWebDAV: supportsWebDAV !== false });
const client = useAuthStore((s) => s.client);
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
const touch = useMediaQuery("(pointer: coarse)");
const steps = getTourSteps({
isDemoMode,
supportsCalendar: supportsCalendar && calendarEnabled,
supportsWebDAV: supportsWebDAV !== false,
supportsContacts: (client?.supportsContacts?.() ?? false) && contactsEnabled,
touch,
});
useEffect(() => {
// One-time migration: if the legacy per-device flag is set but the synced
+10
View File
@@ -201,12 +201,22 @@ export function getTourSteps(options: {
isDemoMode: boolean;
supportsCalendar: boolean;
supportsWebDAV: boolean;
/** Contacts available and allowed; defaults to true. */
supportsContacts?: boolean;
/** A touch screen: no keyboard to use the shortcuts with. */
touch?: boolean;
}): TourStep[] {
let steps = [...BASE_TOUR_STEPS];
if (!options.supportsCalendar) {
steps = steps.filter((s) => s.id !== "nav-calendar");
}
if (options.supportsContacts === false) {
steps = steps.filter((s) => s.id !== "nav-contacts");
}
if (options.touch) {
steps = steps.filter((s) => s.id !== "shortcuts");
}
if (options.isDemoMode) {
const demoSteps = DEMO_TOUR_STEPS.filter((s) => {
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
@@ -35,3 +35,18 @@ describe('WelcomeBanner tips', () => {
expect(screen.queryByText('tip_shortcuts')).toBeNull();
});
});
describe('WelcomeBanner and Escape', () => {
beforeEach(() => {
useSettingsStore.setState({ onboardingCompleted: false });
});
it('stays when Escape is pressed elsewhere, and is not marked as seen', () => {
device.isMobile = false;
render(<WelcomeBanner />);
fireEvent.keyDown(window, { key: 'Escape' });
fireEvent.keyDown(document.body, { key: 'Escape' });
expect(screen.getByRole('complementary')).toBeInTheDocument();
expect(useSettingsStore.getState().onboardingCompleted).toBe(false);
});
});
+1 -1
View File
@@ -27,7 +27,7 @@ const SIZES: Record<ButtonSize, string> = {
// 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";
+3
View File
@@ -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"
)}
+13 -21
View File
@@ -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";
@@ -59,14 +60,9 @@ export function WelcomeBanner() {
} catch { /* localStorage unavailable */ }
}, [updateSetting]);
useEffect(() => {
if (!visible) return;
const handle = (e: KeyboardEvent) => {
if (e.key === "Escape") dismiss();
};
window.addEventListener("keydown", handle);
return () => window.removeEventListener("keydown", handle);
}, [visible, dismiss]);
// No Escape handler: Escape closes whatever has the focus (a message, a
// dialog, a selection) and must not hide these tips for good (R8). The X
// and "Got it" dismiss them.
if (!visible) return null;
@@ -99,38 +95,34 @@ export function WelcomeBanner() {
</ul>
</div>
</div>
<button
onClick={dismiss}
className="flex-shrink-0 p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
aria-label={t("dismiss")}
>
<X className="w-4 h-4" />
</button>
<IconButton label={t("dismiss")} onClick={dismiss} className="flex-shrink-0 text-muted-foreground">
<X />
</IconButton>
</div>
<div className="mt-2.5 flex justify-end gap-2">
<Button
variant="outline"
size="sm"
size="xs"
onClick={() => { dismiss(); startTour(); }}
className="text-xs h-7"
className="text-xs"
>
<PlayCircle className="w-3.5 h-3.5 me-1" />
{t("start_tour")}
</Button>
<Button
variant="outline"
size="sm"
size="xs"
onClick={() => { dismiss(); router.push('/settings'); }}
className="text-xs h-7"
className="text-xs"
>
<Settings className="w-3.5 h-3.5 me-1" />
{t("settings")}
</Button>
<Button
variant="default"
size="sm"
size="xs"
onClick={dismiss}
className="text-xs h-7"
className="text-xs"
>
{t("got_it")}
</Button>
+27
View File
@@ -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([]);
});
});
+23 -1
View File
@@ -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(/<script>([\s\S]*?)<\/script>/)![1];
const run = (pathname: string, languages: string[], stored: string | null = null) => {
const replace = vi.fn();
const location = { pathname, search: '?a=1', hash: '', replace };
const storage = { getItem: () => stored, setItem: () => {} };
new Function('location', 'navigator', 'localStorage', 'sessionStorage', script)(
location, { languages, language: languages[0] }, storage, storage,
);
return replace.mock.calls[0]?.[0];
};
expect(run('/webmail/login', ['de-DE'])).toBe('/webmail/de/login?a=1');
expect(run('/webmail/mail/thread/x', ['fr-FR'])).toBe('/webmail/en/mail/thread/x?a=1');
expect(run('/webmail/login', ['en'], JSON.stringify({ state: { locale: 'zh-TW' } }))).toBe('/webmail/zh-TW/login?a=1');
// With a locale the surface logic applies as before; unknown pages stay a 404.
expect(run('/webmail/en/nope', ['de'])).toBeUndefined();
expect(run('/webmail/de/mail/thread/x', ['en'])).toBe('/webmail/de/mail/');
// Outside the mount nothing happens.
expect(run('/other/login', ['de'])).toBeUndefined();
});
it('renders host snippets and the README for the built variant', () => {
expect(buildNginxExample({ basePath: '' })).toContain('(?<surface>mail|calendar|contacts|files|settings)');
// Root-relative everywhere: a sub-path mount means the files live in <root><basePath>
@@ -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([]);
});
});
+1 -1
View File
@@ -8,7 +8,7 @@
import { useEffect, useRef } from 'react';
export type AppCommand = 'compose' | 'search';
export type AppCommand = 'compose' | 'search' | 'all-inboxes';
export const COMMAND_PALETTE_OPEN_EVENT = 'bulwark:command-palette';
+1 -1
View File
@@ -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" },
+39
View File
@@ -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": "بيانات الناشر",
@@ -76,6 +88,13 @@
"back_to_login": "العودة إلى تسجيل الدخول"
}
},
"totp_reauth": {
"title": "انتهت الجلسة",
"description": "تغيّر رمز المصادقة الثنائية. أدخل رمزًا جديدًا من تطبيق المصادقة للمتابعة.",
"oauth_hint": "لكي لا تظهر هذه المطالبة مجددًا، اطلب من المسؤول تفعيل تسجيل الدخول عبر OAuth (خدمة OAuth المدمجة في Stalwart أو موفّر هوية خارجي).",
"code_label": "رمز المصادقة",
"verify": "تحقّق"
},
"sidebar": {
"close": "إغلاق",
"compose": "إنشاء رسالة",
@@ -163,6 +182,7 @@
"unified_all_starred": "كل المميز بنجمة",
"unified_all_mail": "كل البريد",
"remove_account": "إزالة الحساب",
"remove_account_title": "إزالة الحساب؟",
"remove_account_confirm": "هل تريد إزالة {account} من هذا الجهاز؟ يمكنك إضافته مرة أخرى لاحقًا.",
"labels": "التصنيفات",
"create_label": "إنشاء تصنيف جديد",
@@ -2605,6 +2625,10 @@
"retry": "إعادة المحاولة",
"go_home": "الانتقال إلى البريد الوارد"
},
"not_found": {
"title": "الصفحة غير موجودة",
"description": "هذه الصفحة غير موجودة أو نُقلت. تحقّق من العنوان أو عُد إلى صندوق الوارد."
},
"context_menu": {
"reply": "رد",
"reply_all": "الرد على الجميع",
@@ -4214,6 +4238,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": "جارٍ إكمال التفويض…",
@@ -4605,6 +4637,13 @@
"top_bar": {
"more": "خيارات أخرى"
},
"shell": {
"resize": "تغيير الحجم",
"update_available": "يتوفر تحديث",
"update_important": "يتوفر تحديث مهم",
"folder_unread": "{count} غير مقروءة",
"folder_counts": "{unread} غير مقروءة، {total} إجمالًا"
},
"plugin_pages": {
"loading": "جارٍ تحميل الصفحة",
"not_available_title": "هذه الصفحة غير متاحة",
+39
View File
@@ -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",
@@ -76,6 +88,13 @@
"back_to_login": "Torna a l'inici de sessió"
}
},
"totp_reauth": {
"title": "La sessió ha caducat",
"description": "El codi de doble factor ha canviat. Introduïu un codi nou de l'aplicació d'autenticació per continuar.",
"oauth_hint": "Perquè aquest avís no torni a aparèixer, demaneu a l'administrador que activi l'inici de sessió amb OAuth (l'OAuth integrat de Stalwart o un proveïdor d'identitat extern).",
"code_label": "Codi d'autenticació",
"verify": "Verifica"
},
"sidebar": {
"close": "Tanca",
"compose": "Redacta",
@@ -163,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",
@@ -2605,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",
@@ -4214,6 +4238,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ó…",
@@ -4605,6 +4637,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",
+39
View File
@@ -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áž",
@@ -76,6 +88,13 @@
"back_to_login": "Zpět na přihlášení"
}
},
"totp_reauth": {
"title": "Relace vypršela",
"description": "Váš dvoufázový kód se změnil. Pro pokračování zadejte nový kód z ověřovací aplikace.",
"oauth_hint": "Aby se tato výzva neopakovala, požádejte správce o zapnutí přihlašování přes OAuth (vestavěné OAuth ve Stalwartu nebo externího poskytovatele identity).",
"code_label": "Ověřovací kód",
"verify": "Ověřit"
},
"sidebar": {
"close": "Zavřít",
"compose": "Napsat",
@@ -163,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",
@@ -2605,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",
@@ -4214,6 +4238,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…",
@@ -4605,6 +4637,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",
+39
View File
@@ -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",
@@ -76,6 +88,13 @@
"back_to_login": "Tilbage til login"
}
},
"totp_reauth": {
"title": "Sessionen er udløbet",
"description": "Din tofaktorkode er ændret. Indtast en ny kode fra din godkendelsesapp for at fortsætte.",
"oauth_hint": "Bed din administrator om at slå login med OAuth til (Stalwarts indbyggede OAuth eller en ekstern identitetsudbyder), så du ikke bliver spurgt igen.",
"code_label": "Godkendelseskode",
"verify": "Bekræft"
},
"sidebar": {
"close": "Luk",
"compose": "Ny besked",
@@ -163,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",
@@ -2605,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",
@@ -4214,6 +4238,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…",
@@ -4605,6 +4637,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",
+64 -25
View File
@@ -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",
@@ -76,12 +88,19 @@
"back_to_login": "Zurück zur Anmeldung"
}
},
"totp_reauth": {
"title": "Sitzung abgelaufen",
"description": "Ihr Zwei-Faktor-Code hat sich geändert. Geben Sie einen neuen Code aus Ihrer Authenticator-App ein, um fortzufahren.",
"oauth_hint": "Damit diese Abfrage nicht wiederkommt, bitten Sie Ihre Administration, die Anmeldung über OAuth einzuschalten (das integrierte OAuth von Stalwart oder einen externen Identitätsanbieter).",
"code_label": "Authentifizierungscode",
"verify": "Bestätigen"
},
"sidebar": {
"close": "Schließen",
"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",
@@ -163,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",
@@ -186,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",
@@ -247,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",
@@ -1142,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",
@@ -1452,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"
},
@@ -1681,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"
@@ -2246,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": {
@@ -2605,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",
@@ -2672,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.",
@@ -2694,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.",
@@ -2868,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",
@@ -2890,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",
@@ -3470,7 +3494,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",
@@ -3504,10 +3528,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",
@@ -4096,7 +4120,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",
@@ -4142,16 +4166,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",
@@ -4207,13 +4231,21 @@
"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",
"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…",
@@ -4605,6 +4637,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",
+39
View File
@@ -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",
@@ -76,6 +88,13 @@
"back_to_login": "Back to login"
}
},
"totp_reauth": {
"title": "Session expired",
"description": "Your two-factor code has changed. Enter a new code from your authenticator app to continue.",
"oauth_hint": "To stop this prompt from coming back, ask your administrator to turn on OAuth sign-in (Stalwart's built-in OAuth or an external identity provider).",
"code_label": "Authentication code",
"verify": "Verify"
},
"sidebar": {
"close": "Close",
"compose": "Compose",
@@ -163,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",
@@ -2605,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",
@@ -4214,6 +4238,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…",
@@ -4605,6 +4637,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",
+39
View File
@@ -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",
@@ -76,6 +88,13 @@
"back_to_login": "Volver al inicio de sesión"
}
},
"totp_reauth": {
"title": "La sesión ha caducado",
"description": "Tu código de doble factor ha cambiado. Introduce un código nuevo de tu aplicación de autenticación para continuar.",
"oauth_hint": "Para que este aviso no vuelva a aparecer, pide a tu administrador que active el inicio de sesión con OAuth (el OAuth integrado de Stalwart o un proveedor de identidad externo).",
"code_label": "Código de autenticación",
"verify": "Verificar"
},
"sidebar": {
"close": "Cerrar",
"compose": "Redactar",
@@ -163,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",
@@ -2605,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",
@@ -4214,6 +4238,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…",
@@ -4605,6 +4637,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",
+39
View File
@@ -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",
@@ -76,6 +88,13 @@
"back_to_login": "Tagasi"
}
},
"totp_reauth": {
"title": "Seanss on aegunud",
"description": "Teie kaheastmelise tuvastuse kood on muutunud. Jätkamiseks sisestage autentimisrakendusest uus kood.",
"oauth_hint": "Et see küsimus ei korduks, paluge administraatoril lülitada sisse OAuth-sisselogimine (Stalwarti sisseehitatud OAuth või väline identiteedipakkuja).",
"code_label": "Autentimiskood",
"verify": "Kinnita"
},
"sidebar": {
"close": "Sulge",
"compose": "Koosta",
@@ -163,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",
@@ -2605,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",
@@ -4214,6 +4238,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…",
@@ -4605,6 +4637,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",
+39
View File
@@ -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": "اطلاعات حقوقی",
@@ -76,6 +88,13 @@
"back_to_login": "بازگشت به صفحه ورود"
}
},
"totp_reauth": {
"title": "نشست منقضی شد",
"description": "کد احراز هویت دومرحله‌ای شما تغییر کرده است. برای ادامه، کد جدیدی از برنامهٔ احراز هویت وارد کنید.",
"oauth_hint": "برای اینکه این درخواست دوباره نمایش داده نشود، از مدیر بخواهید ورود با OAuth را فعال کند (OAuth داخلی Stalwart یا یک ارائه‌دهندهٔ هویت خارجی).",
"code_label": "کد احراز هویت",
"verify": "تأیید"
},
"sidebar": {
"close": "بستن",
"compose": "ایمیل جدید",
@@ -163,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": "ایجاد برچسب جدید",
@@ -2605,6 +2625,10 @@
"retry": "تلاش مجدد",
"go_home": "رفتن به صندوق ورودی"
},
"not_found": {
"title": "صفحه پیدا نشد",
"description": "این صفحه وجود ندارد یا جابه‌جا شده است. نشانی را بررسی کنید یا به صندوق ورودی برگردید."
},
"context_menu": {
"reply": "پاسخ",
"reply_all": "پاسخ به همه",
@@ -4214,6 +4238,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": "در حال تکمیل احراز هویت…",
@@ -4605,6 +4637,13 @@
"top_bar": {
"more": "گزینه‌های بیشتر"
},
"shell": {
"resize": "تغییر اندازه",
"update_available": "به‌روزرسانی موجود است",
"update_important": "به‌روزرسانی مهم موجود است",
"folder_unread": "{count} خوانده‌نشده",
"folder_counts": "{unread} خوانده‌نشده، {total} در مجموع"
},
"plugin_pages": {
"loading": "در حال بارگذاری صفحه",
"not_available_title": "این صفحه در دسترس نیست",
+39
View File
@@ -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",
@@ -76,6 +88,13 @@
"back_to_login": "Retour à la connexion"
}
},
"totp_reauth": {
"title": "Session expirée",
"description": "Votre code à deux facteurs a changé. Saisissez un nouveau code de votre application d'authentification pour continuer.",
"oauth_hint": "Pour ne plus voir cette demande, demandez à votre administrateur d'activer la connexion OAuth (l'OAuth intégré de Stalwart ou un fournisseur d'identité externe).",
"code_label": "Code d'authentification",
"verify": "Vérifier"
},
"sidebar": {
"close": "Fermer",
"compose": "Composer",
@@ -163,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é",
@@ -2605,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",
@@ -4214,6 +4238,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…",
@@ -4605,6 +4637,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",
+39
View File
@@ -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": "פרטי מוציא לאור",
@@ -75,6 +87,13 @@
"jmap_server_label": "שרת",
"jmap_server_auto_picked": "שרת שנבחר מדומיין הדוא״ל שלך."
},
"totp_reauth": {
"title": "פג תוקף ההפעלה",
"description": "קוד האימות הדו-שלבי השתנה. כדי להמשיך, הזינו קוד חדש מאפליקציית האימות.",
"oauth_hint": "כדי שהבקשה הזו לא תחזור, בקשו מהמנהל להפעיל כניסה עם OAuth (ה-OAuth המובנה של Stalwart או ספק זהויות חיצוני).",
"code_label": "קוד אימות",
"verify": "אימות"
},
"sidebar": {
"unified_mailbox": "תיבת דואר מאוחדת",
"close": "סגור",
@@ -162,6 +181,7 @@
"unified_all_starred": "הכל מסומנים",
"unified_all_mail": "הכל דוא״ל",
"remove_account": "הסרת חשבון",
"remove_account_title": "להסיר את החשבון?",
"remove_account_confirm": "להסיר את {account} מהמכשיר הזה? ניתן להוסיף אותו שוב מאוחר יותר.",
"labels": "תוויות",
"create_label": "יצירת תווית חדשה",
@@ -2571,6 +2591,10 @@
"retry": "נסה שוב",
"go_home": "עבור לתיבת הדואר הנכנס"
},
"not_found": {
"title": "הדף לא נמצא",
"description": "הדף הזה לא קיים או שהועבר. בדקו את הכתובת או חזרו לתיבת הדואר הנכנס."
},
"context_menu": {
"reply": "השב",
"reply_all": "השב לכולם",
@@ -4215,6 +4239,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": "משלים אימות…",
@@ -4606,6 +4638,13 @@
"top_bar": {
"more": "אפשרויות נוספות"
},
"shell": {
"resize": "שינוי גודל",
"update_available": "יש עדכון",
"update_important": "יש עדכון חשוב",
"folder_unread": "{count} שלא נקראו",
"folder_counts": "{unread} שלא נקראו, {total} בסך הכול"
},
"plugin_pages": {
"loading": "טוען את הדף",
"not_available_title": "הדף הזה אינו זמין",
+39
View File
@@ -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",
@@ -76,6 +88,13 @@
"back_to_login": "Vissza a bejelentkezéshez"
}
},
"totp_reauth": {
"title": "A munkamenet lejárt",
"description": "A kétlépcsős kód megváltozott. A folytatáshoz adjon meg egy új kódot a hitelesítő alkalmazásból.",
"oauth_hint": "Hogy ez a kérés ne jelenjen meg újra, kérje meg a rendszergazdát, hogy kapcsolja be az OAuth-bejelentkezést (a Stalwart beépített OAuth-ját vagy egy külső identitásszolgáltatót).",
"code_label": "Hitelesítő kód",
"verify": "Ellenőrzés"
},
"sidebar": {
"close": "Bezárás",
"compose": "Új levél",
@@ -163,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",
@@ -2605,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",
@@ -4214,6 +4238,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…",
@@ -4605,6 +4637,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",
+39
View File
@@ -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",
@@ -76,6 +88,13 @@
"back_to_login": "Torna al login"
}
},
"totp_reauth": {
"title": "Sessione scaduta",
"description": "Il codice a due fattori è cambiato. Inserisci un nuovo codice dall'app di autenticazione per continuare.",
"oauth_hint": "Per non ricevere più questa richiesta, chiedi all'amministratore di attivare l'accesso con OAuth (l'OAuth integrato di Stalwart o un provider di identità esterno).",
"code_label": "Codice di autenticazione",
"verify": "Verifica"
},
"sidebar": {
"close": "Chiudi",
"compose": "Scrivi",
@@ -163,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",
@@ -2605,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",
@@ -4214,6 +4238,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…",
@@ -4605,6 +4637,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",
+39
View File
@@ -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": "サイト運営者情報",
@@ -76,6 +88,13 @@
"back_to_login": "ログインに戻る"
}
},
"totp_reauth": {
"title": "セッションの有効期限が切れました",
"description": "2 段階認証コードが変わりました。続けるには、認証アプリの新しいコードを入力してください。",
"oauth_hint": "この確認を繰り返さないようにするには、OAuth によるサインイン(Stalwart の組み込み OAuth または外部の ID プロバイダー)を有効にするよう管理者に依頼してください。",
"code_label": "認証コード",
"verify": "確認"
},
"sidebar": {
"close": "閉じる",
"compose": "作成",
@@ -163,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": "新しいラベルを作成",
@@ -2605,6 +2625,10 @@
"retry": "リトライ",
"go_home": "受信トレイに移動"
},
"not_found": {
"title": "ページが見つかりません",
"description": "このページは存在しないか、移動されました。アドレスを確認するか、受信トレイに戻ってください。"
},
"context_menu": {
"reply": "返信",
"reply_all": "全員に返信",
@@ -4214,6 +4238,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": "認証を完了しています…",
@@ -4605,6 +4637,13 @@
"top_bar": {
"more": "その他のオプション"
},
"shell": {
"resize": "サイズを変更",
"update_available": "アップデートがあります",
"update_important": "重要なアップデートがあります",
"folder_unread": "未読 {count} 件",
"folder_counts": "未読 {unread} 件 / 全 {total} 件"
},
"plugin_pages": {
"loading": "ページを読み込んでいます",
"not_available_title": "このページは利用できません",
+39
View File
@@ -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": "회사 소개",
@@ -76,6 +88,13 @@
"back_to_login": "로그인 화면으로 돌아가기"
}
},
"totp_reauth": {
"title": "세션이 만료되었습니다",
"description": "2단계 인증 코드가 바뀌었습니다. 계속하려면 인증 앱의 새 코드를 입력하세요.",
"oauth_hint": "이 요청이 다시 나타나지 않게 하려면 관리자에게 OAuth 로그인(Stalwart 내장 OAuth 또는 외부 ID 공급자)을 켜 달라고 요청하세요.",
"code_label": "인증 코드",
"verify": "확인"
},
"sidebar": {
"close": "닫기",
"compose": "메일 쓰기",
@@ -163,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": "새 라벨 만들기",
@@ -2605,6 +2625,10 @@
"retry": "재시도",
"go_home": "받은편지함으로 이동"
},
"not_found": {
"title": "페이지를 찾을 수 없습니다",
"description": "이 페이지가 없거나 이동되었습니다. 주소를 확인하거나 받은편지함으로 돌아가세요."
},
"context_menu": {
"reply": "답장",
"reply_all": "전체 답장",
@@ -4214,6 +4238,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": "인증 완료 중…",
@@ -4605,6 +4637,13 @@
"top_bar": {
"more": "추가 옵션"
},
"shell": {
"resize": "크기 조정",
"update_available": "업데이트가 있습니다",
"update_important": "중요한 업데이트가 있습니다",
"folder_unread": "읽지 않음 {count}개",
"folder_counts": "읽지 않음 {unread}개, 전체 {total}개"
},
"plugin_pages": {
"loading": "페이지를 불러오는 중",
"not_available_title": "이 페이지를 사용할 수 없습니다",
+39
View File
@@ -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",
@@ -76,6 +88,13 @@
"back_to_login": "Atgriezties pie pierakstīšanās"
}
},
"totp_reauth": {
"title": "Sesija ir beigusies",
"description": "Jūsu divfaktoru kods ir mainījies. Lai turpinātu, ievadiet jaunu kodu no autentifikācijas lietotnes.",
"oauth_hint": "Lai šis pieprasījums vairs neparādītos, lūdziet administratoram ieslēgt pieteikšanos ar OAuth (Stalwart iebūvēto OAuth vai ārēju identitātes nodrošinātāju).",
"code_label": "Autentifikācijas kods",
"verify": "Apstiprināt"
},
"sidebar": {
"close": "Aizvērt",
"compose": "Rakstīt",
@@ -163,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",
@@ -2605,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",
@@ -4214,6 +4238,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…",
@@ -4605,6 +4637,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",
+39
View File
@@ -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": "Хуулийн мэдээлэл",
@@ -76,6 +88,13 @@
"back_to_login": "Нэвтрэх рүү буцах"
}
},
"totp_reauth": {
"title": "Сесс дууссан",
"description": "Таны хоёр шатлалт кодыг сольсон байна. Үргэлжлүүлэхийн тулд баталгаажуулах аппаас шинэ код оруулна уу.",
"oauth_hint": "Энэ хүсэлт дахин гарахгүй байлгахын тулд админаас OAuth нэвтрэлтийг (Stalwart-ын суурилуулсан OAuth эсвэл гадны таних үйлчилгээ) асаахыг хүснэ үү.",
"code_label": "Баталгаажуулах код",
"verify": "Баталгаажуулах"
},
"sidebar": {
"close": "Хаах",
"compose": "Бичилэх",
@@ -163,6 +182,7 @@
"unified_all_starred": "Бүх одтой",
"unified_all_mail": "Бүх шуудан",
"remove_account": "Бүртгэл устгах",
"remove_account_title": "Бүртгэлийг хасах уу?",
"remove_account_confirm": "Энэ төхөөрөмжөөс {account}-г устгах уу? Та дараа дахин нэмж болно.",
"labels": "Шошго",
"create_label": "Шинэ шошго үүсгэх",
@@ -2605,6 +2625,10 @@
"retry": "Дахин оролдох",
"go_home": "Ирсэн имэйл рүү очих"
},
"not_found": {
"title": "Хуудас олдсонгүй",
"description": "Энэ хуудас байхгүй эсвэл зөөгдсөн байна. Хаягаа шалгах эсвэл ирсэн захидал руу буцна уу."
},
"context_menu": {
"reply": "Хариулах",
"reply_all": "Бүгдэд нь хариулах",
@@ -4214,6 +4238,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": "Зөвшөөрлийг дуусгаж байна…",
@@ -4605,6 +4637,13 @@
"top_bar": {
"more": "Бусад сонголт"
},
"shell": {
"resize": "Хэмжээ өөрчлөх",
"update_available": "Шинэчлэл гарсан",
"update_important": "Чухал шинэчлэл гарсан",
"folder_unread": "{count} уншаагүй",
"folder_counts": "{unread} уншаагүй, нийт {total}"
},
"plugin_pages": {
"loading": "Хуудсыг ачаалж байна",
"not_available_title": "Энэ хуудас боломжгүй байна",
+39
View File
@@ -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",
@@ -76,6 +88,13 @@
"back_to_login": "Tilbake til pålogging"
}
},
"totp_reauth": {
"title": "Økten er utløpt",
"description": "Tofaktorkoden din er endret. Skriv inn en ny kode fra autentiseringsappen for å fortsette.",
"oauth_hint": "Be administratoren om å slå på pålogging med OAuth (Stalwarts innebygde OAuth eller en ekstern identitetsleverandør), så slipper du denne forespørselen.",
"code_label": "Autentiseringskode",
"verify": "Bekreft"
},
"sidebar": {
"close": "Lukk",
"compose": "Skriv",
@@ -163,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",
@@ -2605,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",
@@ -4214,6 +4238,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…",
@@ -4605,6 +4637,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",
+39
View File
@@ -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",
@@ -76,6 +88,13 @@
"back_to_login": "Terug naar inloggen"
}
},
"totp_reauth": {
"title": "Sessie verlopen",
"description": "Je tweestapscode is veranderd. Voer een nieuwe code uit je authenticator-app in om door te gaan.",
"oauth_hint": "Vraag je beheerder om aanmelden via OAuth aan te zetten (de ingebouwde OAuth van Stalwart of een externe identiteitsprovider), dan krijg je deze vraag niet meer.",
"code_label": "Verificatiecode",
"verify": "Bevestigen"
},
"sidebar": {
"close": "Sluiten",
"compose": "Nieuw bericht",
@@ -163,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",
@@ -2605,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",
@@ -4214,6 +4238,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…",
@@ -4605,6 +4637,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",
+39
View File
@@ -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",
@@ -76,6 +88,13 @@
"back_to_login": "Powrót do logowania"
}
},
"totp_reauth": {
"title": "Sesja wygasła",
"description": "Twój kod dwuskładnikowy się zmienił. Aby kontynuować, wpisz nowy kod z aplikacji uwierzytelniającej.",
"oauth_hint": "Aby ta prośba się nie powtarzała, poproś administratora o włączenie logowania przez OAuth (wbudowany OAuth w Stalwart lub zewnętrzny dostawca tożsamości).",
"code_label": "Kod uwierzytelniający",
"verify": "Zweryfikuj"
},
"sidebar": {
"close": "Zamknij",
"compose": "Napisz",
@@ -163,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ę",
@@ -2605,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",
@@ -4214,6 +4238,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…",
@@ -4605,6 +4637,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",
+39
View File
@@ -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",
@@ -76,6 +88,13 @@
"back_to_login": "Voltar ao login"
}
},
"totp_reauth": {
"title": "Sessão expirada",
"description": "O seu código de dois fatores mudou. Introduza um código novo da aplicação de autenticação para continuar.",
"oauth_hint": "Para deixar de ver este pedido, peça ao administrador que ative o início de sessão com OAuth (o OAuth integrado do Stalwart ou um fornecedor de identidade externo).",
"code_label": "Código de autenticação",
"verify": "Verificar"
},
"sidebar": {
"close": "Fechar",
"compose": "Escrever",
@@ -163,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",
@@ -2605,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",
@@ -4214,6 +4238,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…",
@@ -4605,6 +4637,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",
+39
View File
@@ -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",
@@ -76,6 +88,13 @@
"back_to_login": "Înapoi la autentificare"
}
},
"totp_reauth": {
"title": "Sesiunea a expirat",
"description": "Codul de autentificare în doi pași s-a schimbat. Introduceți un cod nou din aplicația de autentificare pentru a continua.",
"oauth_hint": "Ca această solicitare să nu mai apară, cereți administratorului să activeze conectarea prin OAuth (OAuth-ul integrat în Stalwart sau un furnizor de identitate extern).",
"code_label": "Cod de autentificare",
"verify": "Verifică"
},
"sidebar": {
"close": "Închide",
"compose": "Compune",
@@ -163,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ă",
@@ -2605,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",
@@ -4214,6 +4238,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…",
@@ -4605,6 +4637,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ă",
+39
View File
@@ -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": "Выходные данные",
@@ -76,6 +88,13 @@
"back_to_login": "Вернуться ко входу"
}
},
"totp_reauth": {
"title": "Сеанс истёк",
"description": "Код двухфакторной аутентификации сменился. Чтобы продолжить, введите новый код из приложения-аутентификатора.",
"oauth_hint": "Чтобы этот запрос больше не появлялся, попросите администратора включить вход через OAuth (встроенный OAuth Stalwart или внешний поставщик удостоверений).",
"code_label": "Код аутентификации",
"verify": "Подтвердить"
},
"sidebar": {
"close": "Закрыть",
"compose": "Создать",
@@ -163,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": "Создать ярлык",
@@ -2605,6 +2625,10 @@
"retry": "Повторить",
"go_home": "В папку «Входящие»"
},
"not_found": {
"title": "Страница не найдена",
"description": "Этой страницы нет или она перемещена. Проверьте адрес или вернитесь во «Входящие»."
},
"context_menu": {
"reply": "Ответить",
"reply_all": "Ответить всем",
@@ -4214,6 +4238,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": "Завершение авторизации…",
@@ -4605,6 +4637,13 @@
"top_bar": {
"more": "Другие действия"
},
"shell": {
"resize": "Изменить размер",
"update_available": "Доступно обновление",
"update_important": "Доступно важное обновление",
"folder_unread": "Непрочитанных: {count}",
"folder_counts": "Непрочитанных: {unread}, всего: {total}"
},
"plugin_pages": {
"loading": "Загрузка страницы",
"not_available_title": "Эта страница недоступна",
+39
View File
@@ -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",
@@ -76,6 +88,13 @@
"back_to_login": "Späť na prihlásenie"
}
},
"totp_reauth": {
"title": "Relácia vypršala",
"description": "Váš dvojfaktorový kód sa zmenil. Ak chcete pokračovať, zadajte nový kód z overovacej aplikácie.",
"oauth_hint": "Aby sa táto výzva neopakovala, požiadajte správcu o zapnutie prihlasovania cez OAuth (vstavané OAuth v Stalwarte alebo externého poskytovateľa identity).",
"code_label": "Overovací kód",
"verify": "Overiť"
},
"sidebar": {
"unified_mailbox": "Zjednotená schránka",
"close": "Zavrieť",
@@ -163,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",
@@ -2605,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",
@@ -4214,6 +4238,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…",
@@ -4605,6 +4637,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",
+39
View File
@@ -41,6 +41,13 @@
"top_bar": {
"more": "ตัวเลือกเพิ่มเติม"
},
"shell": {
"resize": "ปรับขนาด",
"update_available": "มีอัปเดต",
"update_important": "มีอัปเดตสำคัญ",
"folder_unread": "ยังไม่ได้อ่าน {count}",
"folder_counts": "ยังไม่ได้อ่าน {unread} จากทั้งหมด {total}"
},
"date": {
"today": "วันนี้",
"yesterday": "เมื่อวานนี้",
@@ -101,6 +108,10 @@
"retry": "ลองใหม่",
"go_home": "ไปยังกล่องข้อความ"
},
"not_found": {
"title": "ไม่พบหน้านี้",
"description": "ไม่มีหน้านี้หรือหน้านี้ถูกย้ายแล้ว ตรวจสอบที่อยู่ หรือกลับไปที่กล่องจดหมายเข้า"
},
"login": {
"title": "เข้าสู่ระบบจดหมายอิเล็กทรอนิกส์",
"username_label": "อีเมล",
@@ -151,6 +162,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": "ข้อมูลทางกฎหมาย",
@@ -177,6 +200,13 @@
"back_to_login": "กลับไปหน้าเข้าสู่ระบบ"
}
},
"totp_reauth": {
"title": "เซสชันหมดอายุ",
"description": "รหัสยืนยันตัวตนสองขั้นตอนของคุณเปลี่ยนแล้ว ป้อนรหัสใหม่จากแอปยืนยันตัวตนเพื่อดำเนินการต่อ",
"oauth_hint": "หากไม่ต้องการให้ระบบถามอีก โปรดขอให้ผู้ดูแลระบบเปิดการลงชื่อเข้าใช้ด้วย OAuth (OAuth ในตัวของ Stalwart หรือผู้ให้บริการข้อมูลประจำตัวภายนอก)",
"code_label": "รหัสยืนยันตัวตน",
"verify": "ยืนยัน"
},
"sidebar": {
"close": "ปิดแถบด้านข้าง",
"compose": "เขียนจดหมาย",
@@ -264,6 +294,7 @@
"unified_all_starred": "ติดดาวทั้งหมด",
"unified_all_mail": "จดหมายทั้งหมด",
"remove_account": "นำบัญชีออก",
"remove_account_title": "นำบัญชีออกหรือไม่",
"remove_account_confirm": "ต้องการนำ {account} ออกจากอุปกรณ์นี้หรือไม่? คุณสามารถเพิ่มกลับเข้ามาใหม่ได้ในภายหลัง",
"labels": "ป้ายกำกับ",
"create_label": "สร้างป้ายกำกับใหม่",
@@ -341,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} ข้อความ",
+39
View File
@@ -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",
@@ -76,6 +88,13 @@
"back_to_login": "Giriş sayfasına dön"
}
},
"totp_reauth": {
"title": "Oturumun süresi doldu",
"description": "İki adımlı doğrulama kodunuz değişti. Devam etmek için kimlik doğrulama uygulamanızdan yeni bir kod girin.",
"oauth_hint": "Bu isteğin tekrar çıkmaması için yöneticinizden OAuth ile oturum açmayı (Stalwart'ın yerleşik OAuth'u veya harici bir kimlik sağlayıcı) açmasını isteyin.",
"code_label": "Doğrulama kodu",
"verify": "Doğrula"
},
"sidebar": {
"close": "Kapat",
"compose": "Yeni E-posta",
@@ -163,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",
@@ -2605,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",
@@ -4214,6 +4238,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…",
@@ -4605,6 +4637,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",
+39
View File
@@ -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": "Вихідні дані",
@@ -76,6 +88,13 @@
"back_to_login": "Назад до входу"
}
},
"totp_reauth": {
"title": "Сеанс завершився",
"description": "Код двофакторної автентифікації змінився. Щоб продовжити, введіть новий код із застосунку автентифікації.",
"oauth_hint": "Щоб цей запит більше не з'являвся, попросіть адміністратора ввімкнути вхід через OAuth (вбудований OAuth Stalwart або зовнішнього постачальника ідентичності).",
"code_label": "Код автентифікації",
"verify": "Підтвердити"
},
"sidebar": {
"close": "Закрити",
"compose": "Скласти",
@@ -163,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": "Створити мітку",
@@ -2605,6 +2625,10 @@
"retry": "Повторіть спробу",
"go_home": "Перейти до папки \"Вхідні\"."
},
"not_found": {
"title": "Сторінку не знайдено",
"description": "Цієї сторінки немає або її переміщено. Перевірте адресу або поверніться до вхідних."
},
"context_menu": {
"reply": "Відповісти",
"reply_all": "Відповісти всім",
@@ -4214,6 +4238,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": "Завершення авторизації…",
@@ -4605,6 +4637,13 @@
"top_bar": {
"more": "Інші дії"
},
"shell": {
"resize": "Змінити розмір",
"update_available": "Доступне оновлення",
"update_important": "Доступне важливе оновлення",
"folder_unread": "Непрочитаних: {count}",
"folder_counts": "Непрочитаних: {unread}, усього: {total}"
},
"plugin_pages": {
"loading": "Завантаження сторінки",
"not_available_title": "Ця сторінка недоступна",

Some files were not shown because too many files have changed in this diff Show More