mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Merge branch 'main' into ux/contacts-files
# Conflicts: # components/contacts/contact-list.tsx
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu, MoreVertical } from "@/components/icons";
|
||||
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";
|
||||
@@ -210,6 +211,7 @@ export function CalendarToolbar({
|
||||
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ms-0.5">
|
||||
{t("views.today")}
|
||||
</Button>
|
||||
<PhoneHeaderActions below="md" />
|
||||
</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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, '', '/');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ContactContextMenu } from "./contact-context-menu";
|
||||
import { useContactExportBusy } from "./contact-export";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { isContextMenuKey, 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";
|
||||
@@ -438,6 +439,7 @@ export function ContactList({
|
||||
</span>
|
||||
)}
|
||||
</IconButton>
|
||||
<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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { IconButton } from "@/components/ui/icon-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";
|
||||
@@ -366,6 +367,7 @@ export function FilesHeader({
|
||||
</IconButton>
|
||||
)}
|
||||
<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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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';
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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": "الرد على الجميع",
|
||||
@@ -4243,6 +4267,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": "جارٍ إكمال التفويض…",
|
||||
@@ -4634,6 +4666,13 @@
|
||||
"top_bar": {
|
||||
"more": "خيارات أخرى"
|
||||
},
|
||||
"shell": {
|
||||
"resize": "تغيير الحجم",
|
||||
"update_available": "يتوفر تحديث",
|
||||
"update_important": "يتوفر تحديث مهم",
|
||||
"folder_unread": "{count} غير مقروءة",
|
||||
"folder_counts": "{unread} غير مقروءة، {total} إجمالًا"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "جارٍ تحميل الصفحة",
|
||||
"not_available_title": "هذه الصفحة غير متاحة",
|
||||
|
||||
@@ -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",
|
||||
@@ -4243,6 +4267,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ó…",
|
||||
@@ -4634,6 +4666,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -4243,6 +4267,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…",
|
||||
@@ -4634,6 +4666,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -4243,6 +4267,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…",
|
||||
@@ -4634,6 +4666,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
@@ -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",
|
||||
@@ -3485,7 +3509,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",
|
||||
@@ -3519,10 +3543,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",
|
||||
@@ -4125,7 +4149,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",
|
||||
@@ -4171,16 +4195,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",
|
||||
@@ -4236,13 +4260,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…",
|
||||
@@ -4634,6 +4666,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -4243,6 +4267,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…",
|
||||
@@ -4634,6 +4666,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -4243,6 +4267,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…",
|
||||
@@ -4634,6 +4666,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -4243,6 +4267,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…",
|
||||
@@ -4634,6 +4666,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",
|
||||
|
||||
@@ -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": "پاسخ به همه",
|
||||
@@ -4243,6 +4267,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": "در حال تکمیل احراز هویت…",
|
||||
@@ -4634,6 +4666,13 @@
|
||||
"top_bar": {
|
||||
"more": "گزینههای بیشتر"
|
||||
},
|
||||
"shell": {
|
||||
"resize": "تغییر اندازه",
|
||||
"update_available": "بهروزرسانی موجود است",
|
||||
"update_important": "بهروزرسانی مهم موجود است",
|
||||
"folder_unread": "{count} خواندهنشده",
|
||||
"folder_counts": "{unread} خواندهنشده، {total} در مجموع"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "در حال بارگذاری صفحه",
|
||||
"not_available_title": "این صفحه در دسترس نیست",
|
||||
|
||||
@@ -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",
|
||||
@@ -4243,6 +4267,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…",
|
||||
@@ -4634,6 +4666,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",
|
||||
|
||||
@@ -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": "השב לכולם",
|
||||
@@ -4244,6 +4268,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": "משלים אימות…",
|
||||
@@ -4635,6 +4667,13 @@
|
||||
"top_bar": {
|
||||
"more": "אפשרויות נוספות"
|
||||
},
|
||||
"shell": {
|
||||
"resize": "שינוי גודל",
|
||||
"update_available": "יש עדכון",
|
||||
"update_important": "יש עדכון חשוב",
|
||||
"folder_unread": "{count} שלא נקראו",
|
||||
"folder_counts": "{unread} שלא נקראו, {total} בסך הכול"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "טוען את הדף",
|
||||
"not_available_title": "הדף הזה אינו זמין",
|
||||
|
||||
@@ -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",
|
||||
@@ -4243,6 +4267,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…",
|
||||
@@ -4634,6 +4666,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -4243,6 +4267,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…",
|
||||
@@ -4634,6 +4666,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",
|
||||
|
||||
@@ -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": "全員に返信",
|
||||
@@ -4243,6 +4267,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": "認証を完了しています…",
|
||||
@@ -4634,6 +4666,13 @@
|
||||
"top_bar": {
|
||||
"more": "その他のオプション"
|
||||
},
|
||||
"shell": {
|
||||
"resize": "サイズを変更",
|
||||
"update_available": "アップデートがあります",
|
||||
"update_important": "重要なアップデートがあります",
|
||||
"folder_unread": "未読 {count} 件",
|
||||
"folder_counts": "未読 {unread} 件 / 全 {total} 件"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "ページを読み込んでいます",
|
||||
"not_available_title": "このページは利用できません",
|
||||
|
||||
@@ -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": "전체 답장",
|
||||
@@ -4243,6 +4267,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": "인증 완료 중…",
|
||||
@@ -4634,6 +4666,13 @@
|
||||
"top_bar": {
|
||||
"more": "추가 옵션"
|
||||
},
|
||||
"shell": {
|
||||
"resize": "크기 조정",
|
||||
"update_available": "업데이트가 있습니다",
|
||||
"update_important": "중요한 업데이트가 있습니다",
|
||||
"folder_unread": "읽지 않음 {count}개",
|
||||
"folder_counts": "읽지 않음 {unread}개, 전체 {total}개"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "페이지를 불러오는 중",
|
||||
"not_available_title": "이 페이지를 사용할 수 없습니다",
|
||||
|
||||
@@ -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",
|
||||
@@ -4243,6 +4267,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…",
|
||||
@@ -4634,6 +4666,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",
|
||||
|
||||
@@ -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": "Бүгдэд нь хариулах",
|
||||
@@ -4243,6 +4267,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": "Зөвшөөрлийг дуусгаж байна…",
|
||||
@@ -4634,6 +4666,13 @@
|
||||
"top_bar": {
|
||||
"more": "Бусад сонголт"
|
||||
},
|
||||
"shell": {
|
||||
"resize": "Хэмжээ өөрчлөх",
|
||||
"update_available": "Шинэчлэл гарсан",
|
||||
"update_important": "Чухал шинэчлэл гарсан",
|
||||
"folder_unread": "{count} уншаагүй",
|
||||
"folder_counts": "{unread} уншаагүй, нийт {total}"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "Хуудсыг ачаалж байна",
|
||||
"not_available_title": "Энэ хуудас боломжгүй байна",
|
||||
|
||||
@@ -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",
|
||||
@@ -4243,6 +4267,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…",
|
||||
@@ -4634,6 +4666,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -4243,6 +4267,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…",
|
||||
@@ -4634,6 +4666,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -4243,6 +4267,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…",
|
||||
@@ -4634,6 +4666,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -4243,6 +4267,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…",
|
||||
@@ -4634,6 +4666,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -4243,6 +4267,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…",
|
||||
@@ -4634,6 +4666,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ă",
|
||||
|
||||
@@ -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": "Ответить всем",
|
||||
@@ -4243,6 +4267,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": "Завершение авторизации…",
|
||||
@@ -4634,6 +4666,13 @@
|
||||
"top_bar": {
|
||||
"more": "Другие действия"
|
||||
},
|
||||
"shell": {
|
||||
"resize": "Изменить размер",
|
||||
"update_available": "Доступно обновление",
|
||||
"update_important": "Доступно важное обновление",
|
||||
"folder_unread": "Непрочитанных: {count}",
|
||||
"folder_counts": "Непрочитанных: {unread}, всего: {total}"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "Загрузка страницы",
|
||||
"not_available_title": "Эта страница недоступна",
|
||||
|
||||
@@ -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",
|
||||
@@ -4243,6 +4267,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…",
|
||||
@@ -4634,6 +4666,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",
|
||||
|
||||
@@ -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} ข้อความ",
|
||||
|
||||
@@ -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",
|
||||
@@ -4243,6 +4267,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…",
|
||||
@@ -4634,6 +4666,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",
|
||||
|
||||
@@ -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": "Відповісти всім",
|
||||
@@ -4243,6 +4267,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": "Завершення авторизації…",
|
||||
@@ -4634,6 +4666,13 @@
|
||||
"top_bar": {
|
||||
"more": "Інші дії"
|
||||
},
|
||||
"shell": {
|
||||
"resize": "Змінити розмір",
|
||||
"update_available": "Доступне оновлення",
|
||||
"update_important": "Доступне важливе оновлення",
|
||||
"folder_unread": "Непрочитаних: {count}",
|
||||
"folder_counts": "Непрочитаних: {unread}, усього: {total}"
|
||||
},
|
||||
"plugin_pages": {
|
||||
"loading": "Завантаження сторінки",
|
||||
"not_available_title": "Ця сторінка недоступна",
|
||||
|
||||
@@ -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": "全部回覆",
|
||||
@@ -4243,6 +4267,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": "正在完成授權…",
|
||||
@@ -4634,6 +4666,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
Reference in New Issue
Block a user