mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
Merge branch 'feat/do-native-ui' into feat/desktop-offline
This commit is contained in:
@@ -39,6 +39,8 @@ import { FreeBusyAutoShare } from "@/components/calendar/free-busy-auto-share";
|
||||
import { MainWindowOnly } from "@/components/popout/main-window-only";
|
||||
import { PopoutHost } from "@/components/popout/popout-host";
|
||||
import { WindowSyncHost } from "@/components/popout/window-sync-host";
|
||||
import { DesktopHost } from "@/components/desktop/desktop-host";
|
||||
import { WindowMenuBar } from "@/components/layout/window-menu-bar";
|
||||
import { IS_LITE } from "@/lib/lite";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { locales } from "@/i18n/routing";
|
||||
@@ -92,6 +94,8 @@ export default async function LocaleLayout({
|
||||
{!IS_LITE && <PopoutHost />}
|
||||
</MainWindowOnly>
|
||||
{!IS_LITE && <WindowSyncHost />}
|
||||
{!IS_LITE && <WindowMenuBar />}
|
||||
{!IS_LITE && <DesktopHost />}
|
||||
<SessionTimeoutGuard />
|
||||
<AccountTemplateSync />
|
||||
{children}
|
||||
|
||||
@@ -1262,7 +1262,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 end-5" ref={themeMenuRef} hidden={colorModeLocked} suppressHydrationWarning>
|
||||
<div data-window-beside-controls="" className="absolute top-5 end-5" ref={themeMenuRef} hidden={colorModeLocked} suppressHydrationWarning>
|
||||
<button
|
||||
type="button"
|
||||
ref={themeButtonRef}
|
||||
@@ -1428,7 +1428,7 @@ function LoginPageContent() {
|
||||
}}
|
||||
/>
|
||||
{/* Theme toggle - top right, dropdown style; hidden while the theme policy locks the mode (#1280) */}
|
||||
<div className="absolute top-5 end-5" ref={themeMenuRef} hidden={colorModeLocked} suppressHydrationWarning>
|
||||
<div data-window-beside-controls="" className="absolute top-5 end-5" ref={themeMenuRef} hidden={colorModeLocked} suppressHydrationWarning>
|
||||
<button
|
||||
type="button"
|
||||
ref={themeButtonRef}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { PopoutSpareApp } from "@/components/popout/popout-spare-app";
|
||||
|
||||
// The desktop app's warm spare (components/popout/popout-spare-app.tsx). Not in
|
||||
// Lite: scripts/lite removes this tree.
|
||||
export default function PopoutSparePage() {
|
||||
return <PopoutSpareApp />;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { ThemeProvider } from '@/components/providers/theme-provider';
|
||||
import { TourProvider } from '@/components/tour/tour-provider';
|
||||
import { defaultLocale } from '@/i18n/routing';
|
||||
import { AdminLayoutClient } from './_layout-client';
|
||||
import { WindowMenuBar } from '@/components/layout/window-menu-bar';
|
||||
|
||||
/**
|
||||
* /admin sits outside the [locale] tree, so it loads the message catalog
|
||||
@@ -25,6 +26,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<ThemeProvider>
|
||||
<TourProvider>
|
||||
<WindowMenuBar />
|
||||
<AdminLayoutClient>{children}</AdminLayoutClient>
|
||||
</TourProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { locales, defaultLocale } from "@/i18n/routing";
|
||||
import { IS_LITE, LITE_MOUNT_GLOBAL } from "@/lib/lite";
|
||||
import { accessibilityBootScript } from "@/lib/accessibility-settings";
|
||||
import { bootSkeletonScript } from "@/lib/boot-skeleton/boot-script";
|
||||
import { WindowTitleBar } from "@/components/layout/window-title-bar";
|
||||
import { WindowControlsHost } from "@/components/layout/window-controls-host";
|
||||
import "../globals.css";
|
||||
import "../print.css";
|
||||
import "../desktop.css";
|
||||
@@ -334,7 +334,7 @@ export default async function RootLayout({
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${hankenGrotesk.variable} antialiased`}
|
||||
>
|
||||
<ServiceWorkerRegistration />
|
||||
<WindowTitleBar />
|
||||
<WindowControlsHost />
|
||||
{!themeColorConfigured && <ThemeColorSync />}
|
||||
<FaviconBadge />
|
||||
<ErrorReportingRoot />
|
||||
|
||||
+1
-2
@@ -36,8 +36,7 @@ html[data-desktop] :is(
|
||||
[role="treeitem"],
|
||||
[role="row"],
|
||||
[data-composer-header],
|
||||
[data-mv-compose-titlebar],
|
||||
[data-window-title-bar]
|
||||
[data-mv-compose-titlebar]
|
||||
) {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
+130
-10
@@ -569,24 +569,144 @@ optgroup {
|
||||
/* Mobile Responsive Utilities */
|
||||
|
||||
/*
|
||||
* The top of the page in an installed app without a native title bar. 0
|
||||
* without one: then --app-top-inset is exactly env(safe-area-inset-top), what
|
||||
* the page roots used before. env() and not the display-mode media query,
|
||||
* which is false in Electron with titleBarOverlay. Set at the root, so it
|
||||
* holds from the first paint, for the boot skeletons too;
|
||||
* lib/window-controls-overlay.ts keeps it up to date afterwards.
|
||||
* An installed app or the desktop app with no native title bar. No row is kept
|
||||
* for it: --titlebar-inset is 0, and the app's columns run to the top of the
|
||||
* window. The window's controls sit in the top row of the column beneath them,
|
||||
* which leaves them their room. env() and not the display-mode media query,
|
||||
* which is false in Electron with titleBarOverlay. Set at the root, so it holds
|
||||
* from the first paint, for the boot skeletons too;
|
||||
* lib/window-controls-overlay.ts keeps it exact afterwards, and marks the rows.
|
||||
*/
|
||||
:root {
|
||||
--titlebar-inset: env(titlebar-area-height, 0px);
|
||||
--app-top-inset: max(env(safe-area-inset-top), var(--titlebar-inset));
|
||||
--titlebar-inset: 0px;
|
||||
--app-top-inset: env(safe-area-inset-top, 0px);
|
||||
--titlebar-right-inset: calc(100vw - env(titlebar-area-x, 0px) - env(titlebar-area-width, 100vw));
|
||||
--titlebar-left-inset: env(titlebar-area-x, 0px);
|
||||
--titlebar-row-height: env(titlebar-area-height, 0px);
|
||||
}
|
||||
|
||||
/* components/layout/window-title-bar.tsx: what the window is dragged by. */
|
||||
.window-title-bar {
|
||||
/* A skeleton's row that will hold the controls has them from the first frame. */
|
||||
html[data-window-controls] [data-window-row-right] {
|
||||
padding-right: calc(var(--titlebar-right-inset) + 0.5rem) !important;
|
||||
}
|
||||
|
||||
/* Something floating at the top end of a page with no row: next to the controls, lined up. */
|
||||
html[data-window-controls]:not([dir="rtl"]) [data-window-beside-controls] {
|
||||
right: calc(var(--titlebar-right-inset) + 0.5rem);
|
||||
top: calc(var(--titlebar-row-height) / 2 - 1.25rem);
|
||||
}
|
||||
|
||||
/* A column docked at the window's end starts below the controls, level with its neighbours' rows. */
|
||||
html[data-window-controls]:not([data-interface-layout="mountain-view"]) [data-window-end-column] {
|
||||
padding-top: var(--titlebar-row-height);
|
||||
}
|
||||
|
||||
/* A composer that is its own window closes with the window's button, one row over. */
|
||||
html[data-window-controls] [data-own-window-close] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* A top row is at least as tall as the controls, so they don't hang over what's below it. */
|
||||
html[data-window-controls] [data-window-row] {
|
||||
min-height: var(--titlebar-row-height);
|
||||
}
|
||||
|
||||
/*
|
||||
* Mountain View's bar is the top row there, and lines up with the controls by being as
|
||||
* tall. An installed PWA's controls are shorter than what's in that bar.
|
||||
*/
|
||||
html[data-window-controls] [data-mv-topbar] {
|
||||
height: max(var(--titlebar-row-height), 3.5rem);
|
||||
}
|
||||
|
||||
/* Pro's tab strip is the top row there: as tall as the controls, its tabs along the bottom edge. */
|
||||
html[data-window-controls] [data-pane-strip] {
|
||||
height: max(var(--titlebar-row-height), 2.25rem);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
html[data-window-controls] [data-window-row-left] {
|
||||
padding-left: calc(var(--titlebar-left-inset) + 0.5rem) !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* The empty parts of the app's top rows are what the window is dragged by, and a
|
||||
* double-click there maximises. Anything to click stays clickable, and so does
|
||||
* whatever floats over a row: a region's drag wins over what's painted above it.
|
||||
*
|
||||
* Under the rows lies the band the controls have, invisible and first in the page, so
|
||||
* a page with no row of its own (the login, an error) can still be moved. Everything
|
||||
* later in the page cuts its own shape out of it.
|
||||
*/
|
||||
html[data-window-controls] body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
top: 0;
|
||||
height: var(--titlebar-row-height);
|
||||
pointer-events: none;
|
||||
app-region: drag;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
html[data-window-controls] :is([data-window-row], [data-window-rail]) {
|
||||
app-region: drag;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
/* A row that's only there to hold the controls: the settings page's name, for one. */
|
||||
[data-window-only] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html[data-window-controls]:not([data-interface-layout="mountain-view"]) [data-window-only] {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
html[data-window-controls] :is(
|
||||
a,
|
||||
button,
|
||||
input,
|
||||
label,
|
||||
select,
|
||||
summary,
|
||||
textarea,
|
||||
[contenteditable="true"],
|
||||
[role="button"],
|
||||
iframe,
|
||||
[role="checkbox"],
|
||||
[role="combobox"],
|
||||
[role="link"],
|
||||
[role="menu"],
|
||||
[role="menuitem"],
|
||||
[role="option"],
|
||||
[role="searchbox"],
|
||||
[role="switch"],
|
||||
[role="tab"],
|
||||
[role="treeitem"],
|
||||
[tabindex]:not([tabindex="-1"]),
|
||||
[draggable="true"]
|
||||
) {
|
||||
app-region: no-drag;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
html[data-window-controls] :is(.fixed, [role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"], [role="tooltip"]) {
|
||||
app-region: no-drag;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* A dialog's own top row, when it fills the window, is a row like any other. */
|
||||
html[data-window-controls] :is(.fixed, [role="dialog"]) [data-window-row] {
|
||||
app-region: drag;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
/* macOS: the traffic lights sit at the top of the rail, with room there only. */
|
||||
html[data-window-controls-left] [data-window-rail] {
|
||||
padding-top: var(--titlebar-row-height);
|
||||
}
|
||||
|
||||
/* Safe area insets for notched devices (iPhone X+, etc.) */
|
||||
.safe-area-inset-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
|
||||
+1
-1
@@ -126,7 +126,7 @@ export default async function manifest(): Promise<ExtendedManifest> {
|
||||
scope: withBase("/"),
|
||||
display: "standalone",
|
||||
// The page draws up to the top of the window, next to the native controls
|
||||
// (components/layout/window-title-bar.tsx). `display` is the fallback.
|
||||
// (components/layout/window-controls-host.tsx). `display` is the fallback.
|
||||
display_override: ["window-controls-overlay", "standalone"],
|
||||
orientation: "portrait-primary",
|
||||
theme_color: themeColor,
|
||||
|
||||
@@ -442,6 +442,10 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
|
||||
</aside>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
{/* Only for the window's controls: the sidebar says which page is open. */}
|
||||
<div data-window-only="" aria-hidden="true" className="h-14 box-content shrink-0 items-center border-b border-border px-6 text-sm font-semibold">
|
||||
<span className="truncate">{pageName ?? 'Admin'}</span>
|
||||
</div>
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-3xl px-6 py-6">
|
||||
{readOnlyBanner}
|
||||
|
||||
@@ -227,6 +227,9 @@ export function AdminSkeleton({ label, page, backLabel, searchLabel, labels, cla
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div data-window-only="" data-window-row-right="" className="h-14 box-content shrink-0 items-center border-b border-border px-6">
|
||||
<TextLine className="text-sm" w="6rem" />
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="mx-auto max-w-3xl px-6 py-6">
|
||||
<AdminContentSkeleton page={page} />
|
||||
|
||||
@@ -176,7 +176,7 @@ function ToolbarSkeleton({ text }: { text?: CalendarSkeletonText }) {
|
||||
const views = text?.views;
|
||||
return (
|
||||
<>
|
||||
<div className="hidden md:flex items-center gap-1 px-3 h-14 box-content border-b border-border flex-shrink-0">
|
||||
<div data-window-row-right="" className="hidden md:flex items-center gap-1 px-3 h-14 box-content border-b border-border flex-shrink-0">
|
||||
<ToolbarIcon className="me-1">
|
||||
<Menu />
|
||||
</ToolbarIcon>
|
||||
|
||||
@@ -199,7 +199,7 @@ function FilesSidebarSkeleton() {
|
||||
function FilesHeaderSkeleton({ picker }: { picker: boolean }) {
|
||||
const on = "bg-primary/10 text-primary";
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-3 h-14 box-content border-b border-border min-w-0 flex-shrink-0">
|
||||
<div data-window-row-right="" className="flex items-center gap-1 px-3 h-14 box-content border-b border-border min-w-0 flex-shrink-0">
|
||||
{picker ? null : <ToolIcon icon={Menu} className="lg:hidden" />}
|
||||
<div className="flex-1 min-w-0 flex items-center text-sm">
|
||||
<span className="flex items-center px-1.5 py-1 h-7">
|
||||
|
||||
@@ -65,7 +65,7 @@ export function LoginSkeleton({ label, accessibilityButton = false, className }:
|
||||
)}
|
||||
>
|
||||
{/* The theme menu button: its icon, and the theme's name from sm up. */}
|
||||
<div aria-hidden="true" className="absolute top-5 end-5">
|
||||
<div aria-hidden="true" data-window-beside-controls="" className="absolute top-5 end-5">
|
||||
<span className="flex items-center gap-2 px-3 py-2 rounded-xl border text-sm bg-background/60 border-border/50">
|
||||
<Block className="w-4 h-4 rounded" />
|
||||
<TextLine className="hidden sm:block w-11" w="100%" />
|
||||
|
||||
@@ -411,7 +411,7 @@ export function MailMessageToolbarSkeleton({ end }: { end?: ReactNode } = {}) {
|
||||
const button = "h-8 w-20 rounded-md";
|
||||
return (
|
||||
<div className="bg-background flex-shrink-0 border-b border-border">
|
||||
<div className="px-2 sm:px-4 lg:px-6 flex items-center h-14">
|
||||
<div data-window-row-right="" className="px-2 sm:px-4 lg:px-6 flex items-center h-14">
|
||||
{/* Phones: back, then icon-over-label actions. */}
|
||||
<div className="sm:hidden flex items-center justify-between gap-0.5 w-full">
|
||||
<span className="flex items-center justify-center h-9 w-9 -ms-1 flex-shrink-0">
|
||||
|
||||
@@ -34,6 +34,8 @@ function TabStripSkeleton() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-pane-strip=""
|
||||
data-window-row-right=""
|
||||
className="relative flex items-stretch h-9 bg-secondary px-1 overflow-hidden flex-shrink-0"
|
||||
style={STRIP_BORDER}
|
||||
>
|
||||
|
||||
@@ -163,7 +163,7 @@ function SettingsPage({ sections }: { sections: number }) {
|
||||
/** A phone header row: back arrow, title, then the ⋮ menu and the avatar (PhoneHeaderActions). */
|
||||
function PhoneHeader({ list }: { list: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||
<div data-window-row-right="" className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
|
||||
<span className="grid place-items-center h-11 w-11 shrink-0">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</span>
|
||||
@@ -270,6 +270,11 @@ export function SettingsSkeletonBody({ embedded = false }: { embedded?: boolean
|
||||
<div className={cn("flex-1 min-w-0 min-h-0", embedded ? "flex" : "hidden lg:flex")}>
|
||||
<SettingsNav embedded={embedded} />
|
||||
<div aria-hidden="true" className="flex-1 min-w-0 overflow-hidden">
|
||||
{embedded ? null : (
|
||||
<div data-window-only="" data-window-row-right="" className="items-center gap-2 h-14 box-content border-b border-border px-2 sm:px-4 lg:px-6 shrink-0">
|
||||
<Line className="text-sm" bar="h-3.5" w="6rem" />
|
||||
</div>
|
||||
)}
|
||||
<div className="max-w-3xl mx-auto px-6 py-6">
|
||||
<SettingsPage sections={3} />
|
||||
</div>
|
||||
|
||||
@@ -225,7 +225,7 @@ export function CalendarToolbar({
|
||||
<div className="border-b border-border">
|
||||
{/* ── MOBILE TOOLBAR ── */}
|
||||
{isMobile && (
|
||||
<div className="flex flex-col gap-1 px-2 py-2">
|
||||
<div data-window-top-row="" className="flex flex-col gap-1 px-2 py-2">
|
||||
{/* Row 1 while searching: the search box with a way back. */}
|
||||
{mobileSearchField ? (
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -1037,7 +1037,7 @@ export function EventModal({
|
||||
<div className="h-1 w-full flex-shrink-0" style={{ backgroundColor: color }} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2 px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<div data-window-top-row="" className="flex items-start justify-between gap-2 px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: color }} />
|
||||
|
||||
@@ -222,7 +222,7 @@ export function TaskModal({
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<div data-window-top-row="" className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h2 id={`${id}-heading`} className="text-sm font-semibold">
|
||||
{isEdit ? t("tasks.edit") : t("tasks.create")}
|
||||
</h2>
|
||||
|
||||
@@ -99,7 +99,9 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center justify-center h-full text-muted-foreground", className)}>
|
||||
<div className={cn("relative flex flex-col items-center justify-center h-full text-muted-foreground", className)}>
|
||||
{/* The window's controls lie over this end, and its empty top is something to drag by. */}
|
||||
<div aria-hidden="true" data-window-top-row="" className="absolute inset-x-0 top-0 h-14" />
|
||||
<BookUser className="w-16 h-16 mb-4 opacity-20" />
|
||||
<p className="text-sm">{t("detail.no_contact_selected")}</p>
|
||||
</div>
|
||||
@@ -139,7 +141,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
|
||||
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
|
||||
<div data-window-top-row="" className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
|
||||
<div className={cn("flex gap-4", isMobile ? "flex-col" : "flex-wrap items-start justify-between")}>
|
||||
<div className={cn("flex items-center gap-4 min-w-0 flex-1", !isMobile && "basis-60")}>
|
||||
<Avatar name={name} email={email} contactPhoto={photo} size={isMobile ? "md" : "lg"} />
|
||||
|
||||
@@ -704,7 +704,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
// The app checks the fields itself and says what is wrong inline; the
|
||||
// browser's own bubble covered that message.
|
||||
<form onSubmit={handleSubmit} noValidate className="flex flex-col h-full bg-background">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<div data-window-top-row="" className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isEditing ? t("edit_title") : t("create_title")}
|
||||
</h2>
|
||||
|
||||
@@ -48,7 +48,7 @@ export function ContactGroupDetail({
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
|
||||
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
|
||||
<div data-window-top-row="" className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
|
||||
<div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={cn("rounded-full bg-primary/10 flex items-center justify-center", isMobile ? "w-12 h-12" : "w-14 h-14")}>
|
||||
|
||||
@@ -88,7 +88,7 @@ export function ContactGroupForm({
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col h-full">
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<div data-window-top-row="" className="px-6 py-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isEditing ? t("groups.edit") : t("groups.create")}
|
||||
</h2>
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { BulwarkDesktopBridge } from '@/lib/desktop/types';
|
||||
import { registerDesktopSurface, resetDesktopCommands } from '@/lib/desktop/commands';
|
||||
import { readDesktopPrefs, resetDesktopPrefsCache, setDesktopPref } from '@/lib/desktop/preferences';
|
||||
import { resetDesktopLanguages } from '@/lib/desktop/languages';
|
||||
import { registerComposeFlush, resetComposeFlushRegistry } from '@/lib/compose-flush-registry';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import { useOfflineStore } from '@/lib/offline/state';
|
||||
|
||||
const push = vi.fn();
|
||||
vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push }) }));
|
||||
|
||||
const canPrint = vi.fn(() => true);
|
||||
const canDownload = vi.fn(() => true);
|
||||
vi.mock('@/lib/device-policy', () => ({ canPrintAnywhere: () => canPrint(), canDownloadAnywhere: () => canDownload() }));
|
||||
vi.mock('@/hooks/use-device-policy', () => ({ useStrictestDevices: () => strictest.current }));
|
||||
const strictest = vi.hoisted(() => ({ current: {} as object }));
|
||||
|
||||
const resolveExternalLink = vi.fn(async (ctx: { href: string }) => ctx.href as string | null);
|
||||
vi.mock('@/lib/plugin-hook-result', () => ({ resolveExternalLink: (ctx: { href: string }) => resolveExternalLink(ctx) }));
|
||||
const requestInternalMailto = vi.fn((_raw: string) => true);
|
||||
vi.mock('@/lib/protocol-handlers/mailto', () => ({ requestInternalMailto: (raw: string) => requestInternalMailto(raw) }));
|
||||
const allowed = vi.hoisted(() => ({ dictionary: true }));
|
||||
vi.mock('@/lib/desktop/dictionary-download', () => ({ dictionaryDownloadAllowed: () => allowed.dictionary }));
|
||||
const signOut = vi.hoisted(() => ({ full: [] as (() => void)[], before: [] as (() => Promise<void>)[] }));
|
||||
vi.mock('@/lib/sign-out-cleanup', async (original) => ({
|
||||
...(await original<object>()),
|
||||
onFullSignOut: (task: () => void) => signOut.full.push(task),
|
||||
onBeforeSignOut: (task: () => Promise<void>) => signOut.before.push(task),
|
||||
}));
|
||||
|
||||
const { DesktopHost } = await import('../desktop-host');
|
||||
|
||||
function fakeBridge() {
|
||||
let command: ((id: string) => void) | null = null;
|
||||
let flush: (() => Promise<unknown>) | null = null;
|
||||
let missing: ((language: string) => void) | null = null;
|
||||
const parts = {
|
||||
setState: vi.fn(),
|
||||
setLanguages: vi.fn(),
|
||||
sessionReady: vi.fn(),
|
||||
setRestoreEnabled: vi.fn(),
|
||||
setTheme: vi.fn(),
|
||||
openExternal: vi.fn(),
|
||||
allowDownload: vi.fn(),
|
||||
clearFiles: vi.fn(),
|
||||
};
|
||||
const bridge = {
|
||||
version: 1,
|
||||
platform: 'win32',
|
||||
menu: { setState: parts.setState, onCommand: (listener: (id: string) => void) => { command = listener; return () => {}; } },
|
||||
shell: { openExternal: parts.openExternal, setLanguages: parts.setLanguages },
|
||||
spellcheck: { missing: async () => [], onMissing: (listener: (language: string) => void) => { missing = listener; return () => {}; }, allowDownload: parts.allowDownload },
|
||||
clipboard: { copyFile: async () => true, clearFiles: parts.clearFiles },
|
||||
windows: { sessionReady: parts.sessionReady, setRestoreEnabled: parts.setRestoreEnabled },
|
||||
theme: { set: parts.setTheme, system: async () => ({ dark: false, highContrast: false }) },
|
||||
lifecycle: { onFlushRequest: (handler: () => Promise<unknown>) => { flush = handler; return () => {}; } },
|
||||
};
|
||||
window.bulwarkDesktop = bridge as unknown as BulwarkDesktopBridge;
|
||||
return { ...parts, run: (id: string) => act(() => command!(id)), flush: () => flush!(), missing: (language: string) => act(() => missing!(language)) };
|
||||
}
|
||||
|
||||
const original = useAuthStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
push.mockClear();
|
||||
resolveExternalLink.mockClear();
|
||||
resolveExternalLink.mockImplementation(async (ctx) => ctx.href);
|
||||
requestInternalMailto.mockClear();
|
||||
requestInternalMailto.mockReturnValue(true);
|
||||
allowed.dictionary = true;
|
||||
signOut.full.length = 0;
|
||||
signOut.before.length = 0;
|
||||
resetDesktopLanguages();
|
||||
canPrint.mockReturnValue(true);
|
||||
canDownload.mockReturnValue(true);
|
||||
localStorage.clear();
|
||||
resetDesktopPrefsCache();
|
||||
useAuthStore.setState({ isAuthenticated: true, getAllConnectedClients: () => new Map() } as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.bulwarkDesktop = undefined;
|
||||
resetDesktopCommands();
|
||||
useAuthStore.setState(original);
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
describe('DesktopHost', () => {
|
||||
it('does nothing in a browser', () => {
|
||||
expect(() => render(<DesktopHost />)).not.toThrow();
|
||||
});
|
||||
|
||||
it('runs a command from what\'s on the page first', () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
const onPage = vi.fn();
|
||||
registerDesktopSurface({ handlers: { 'go.inbox': onPage }, state: {} });
|
||||
shell.run('go.inbox');
|
||||
expect(onPage).toHaveBeenCalledTimes(1);
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('with nothing there, the commands that work anywhere still do', () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
shell.run('go.calendar');
|
||||
expect(push).toHaveBeenLastCalledWith('/calendar');
|
||||
shell.run('go.contacts');
|
||||
expect(push).toHaveBeenLastCalledWith('/contacts');
|
||||
});
|
||||
|
||||
it('a link to a folder goes through the router, encoded', () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
shell.run('link.folder:sent');
|
||||
expect(push).toHaveBeenLastCalledWith('/mail/folder/sent');
|
||||
});
|
||||
|
||||
it('an unknown command does nothing', () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
expect(() => shell.run('nonsense')).not.toThrow();
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tells the shell what the organisation allows', async () => {
|
||||
canPrint.mockReturnValue(false);
|
||||
canDownload.mockReturnValue(false);
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
await waitFor(() => expect(shell.setState).toHaveBeenCalled());
|
||||
expect(shell.setState.mock.lastCall![0]).toMatchObject({ 'file.print': false, 'file.savePdf': false, 'policy.download': false });
|
||||
});
|
||||
|
||||
it('signed in: it says so once, for windows from last time and waiting links', async () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
await waitFor(() => expect(shell.sessionReady).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('not signed in, or in a pop-out with a borrowed session: it doesn\'t', async () => {
|
||||
const shell = fakeBridge();
|
||||
useAuthStore.setState({ isAuthenticated: false } as never);
|
||||
const first = render(<DesktopHost />);
|
||||
await act(async () => {});
|
||||
expect(shell.sessionReady).not.toHaveBeenCalled();
|
||||
first.unmount();
|
||||
useAuthStore.setState({ isAuthenticated: true } as never);
|
||||
window.history.replaceState(null, '', '/popout/compose');
|
||||
render(<DesktopHost />);
|
||||
await act(async () => {});
|
||||
expect(shell.sessionReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the restore setting on', async () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
await waitFor(() => expect(shell.setRestoreEnabled).toHaveBeenLastCalledWith(true));
|
||||
act(() => setDesktopPref('restoreWindows', false));
|
||||
await waitFor(() => expect(shell.setRestoreEnabled).toHaveBeenLastCalledWith(false));
|
||||
});
|
||||
|
||||
it('says which languages are in use', async () => {
|
||||
const shell = fakeBridge();
|
||||
act(() => useLocaleStore.setState({ locale: 'de' }));
|
||||
render(<DesktopHost />);
|
||||
await waitFor(() => expect(shell.setLanguages).toHaveBeenCalled());
|
||||
expect(shell.setLanguages.mock.lastCall![0]).toMatchObject({ ui: 'de' });
|
||||
});
|
||||
|
||||
describe('Open Link in a native menu', () => {
|
||||
const openLink = (url: string) => `link.open:${JSON.stringify({ url, text: 'click' })}`;
|
||||
|
||||
it('goes past the same check as a click on it', async () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
shell.run(openLink('https://example.org/a'));
|
||||
await waitFor(() => expect(shell.openExternal).toHaveBeenCalledWith('https://example.org/a'));
|
||||
expect(resolveExternalLink).toHaveBeenCalledWith(expect.objectContaining({ href: 'https://example.org/a', text: 'click' }));
|
||||
});
|
||||
|
||||
it('a plugin can stop it', async () => {
|
||||
resolveExternalLink.mockResolvedValue(null);
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
shell.run(openLink('https://example.org/a'));
|
||||
await waitFor(() => expect(resolveExternalLink).toHaveBeenCalled());
|
||||
await act(async () => {});
|
||||
expect(shell.openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a plugin can send it elsewhere', async () => {
|
||||
resolveExternalLink.mockResolvedValue('https://safe.example.org/');
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
shell.run(openLink('https://example.org/a'));
|
||||
await waitFor(() => expect(shell.openExternal).toHaveBeenCalledWith('https://safe.example.org/'));
|
||||
});
|
||||
|
||||
it('a mail address goes to the app\'s composer', async () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
shell.run(openLink('mailto:a@example.org'));
|
||||
await waitFor(() => expect(requestInternalMailto).toHaveBeenCalledWith('mailto:a@example.org'));
|
||||
expect(shell.openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tells the shell it has this path, so an older page keeps the direct way', async () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
await waitFor(() => expect(shell.setState).toHaveBeenCalled());
|
||||
expect(shell.setState.mock.lastCall![0]).toMatchObject({ 'link.check': true });
|
||||
});
|
||||
});
|
||||
|
||||
it('hands the shell its menus in the interface language', async () => {
|
||||
const shell = fakeBridge();
|
||||
act(() => useLocaleStore.setState({ locale: 'fr' }));
|
||||
render(<DesktopHost />);
|
||||
await waitFor(() => expect(shell.setLanguages).toHaveBeenCalled());
|
||||
const { labels } = shell.setLanguages.mock.lastCall![0];
|
||||
// The test's next-intl gives the key back.
|
||||
expect(labels.menuFile).toBe('desktopUi.menu.file');
|
||||
expect(labels.menuReply).toBe('context_menu.reply');
|
||||
expect(labels.unsentMany).toBe('desktopUi.closing.unsent_many');
|
||||
});
|
||||
|
||||
describe('a language with no checker in the OS', () => {
|
||||
afterEach(() => resetComposeFlushRegistry());
|
||||
const toasts = () => useToastStore.getState().toasts;
|
||||
|
||||
it('the composer asks once, and only a yes opens the downloads', async () => {
|
||||
useToastStore.setState({ toasts: [] });
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
await act(async () => {});
|
||||
shell.missing('nl-NL');
|
||||
// No composer open: nobody is writing yet.
|
||||
expect(toasts()).toHaveLength(0);
|
||||
const stop = registerComposeFlush(async () => {});
|
||||
await waitFor(() => expect(toasts()).toHaveLength(1));
|
||||
expect(shell.allowDownload).not.toHaveBeenCalled();
|
||||
act(() => toasts()[0].action!.onClick());
|
||||
expect(shell.allowDownload).toHaveBeenCalledTimes(1);
|
||||
expect(readDesktopPrefs().dictionaryDownload).toBe('yes');
|
||||
stop();
|
||||
});
|
||||
|
||||
it('never a second time', async () => {
|
||||
useToastStore.setState({ toasts: [] });
|
||||
setDesktopPref('dictionaryDownload', 'no');
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
await act(async () => {});
|
||||
const stop = registerComposeFlush(async () => {});
|
||||
shell.missing('nl-NL');
|
||||
await act(async () => {});
|
||||
expect(toasts()).toHaveLength(0);
|
||||
stop();
|
||||
});
|
||||
|
||||
it('never in no-third-party mode, even after an old yes', async () => {
|
||||
useToastStore.setState({ toasts: [] });
|
||||
allowed.dictionary = false;
|
||||
setDesktopPref('dictionaryDownload', 'yes');
|
||||
const first = fakeBridge();
|
||||
const view = render(<DesktopHost />);
|
||||
await act(async () => {});
|
||||
expect(first.allowDownload).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
setDesktopPref('dictionaryDownload', 'ask');
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
await act(async () => {});
|
||||
const stop = registerComposeFlush(async () => {});
|
||||
shell.missing('nl-NL');
|
||||
await act(async () => {});
|
||||
expect(toasts()).toHaveLength(0);
|
||||
stop();
|
||||
});
|
||||
|
||||
it('a yes from before opens them at the next start', async () => {
|
||||
setDesktopPref('dictionaryDownload', 'yes');
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
await act(async () => {});
|
||||
expect(shell.allowDownload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('signing out takes the files copied to the clipboard with it', async () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
await act(async () => {});
|
||||
signOut.full[0]();
|
||||
expect(shell.clearFiles).toHaveBeenCalledTimes(1);
|
||||
await signOut.before[0]();
|
||||
expect(shell.clearFiles).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('a quit is told how much mail is waiting', async () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
act(() => useOfflineStore.setState({ outbox: [{ id: 1 }, { id: 2 }] } as never));
|
||||
await expect(shell.flush()).resolves.toEqual({ unsent: 2 });
|
||||
act(() => useOfflineStore.setState({ outbox: [] } as never));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useStrictestDevices } from "@/hooks/use-device-policy";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { openKeyboardShortcuts, parkAppCommand, requestAppCommand, type AppCommand } from "@/lib/app-commands";
|
||||
import { onComposerOpened, composerIsOpen } from "@/lib/compose-flush-registry";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import { runDesktopCommand, setDesktopBaseState } from "@/lib/desktop/commands";
|
||||
import { dictionaryDownloadAllowed } from "@/lib/desktop/dictionary-download";
|
||||
import { noteComposerLanguage, setDesktopInterfaceLanguage, shellLabels } from "@/lib/desktop/languages";
|
||||
import { openLinkFromNativeMenu } from "@/lib/desktop/open-link";
|
||||
import { readDesktopPrefs, setDesktopPref, useDesktopPrefs } from "@/lib/desktop/preferences";
|
||||
import { applySystemAccent } from "@/lib/desktop/system-accent";
|
||||
import type { DesktopSystemTheme } from "@/lib/desktop/types";
|
||||
import { canDownloadAnywhere, canPrintAnywhere } from "@/lib/device-policy";
|
||||
import { useOfflineStore } from "@/lib/offline/state";
|
||||
import { openNewComposerWindow } from "@/lib/popout/open";
|
||||
import { isPopoutWindow } from "@/lib/popout/window-role";
|
||||
import { onBeforeSignOut, onFullSignOut } from "@/lib/sign-out-cleanup";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useLocaleStore } from "@/stores/locale-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
/**
|
||||
* The webmail's side of what every desktop window needs: runs menu commands nothing
|
||||
* else on the page took, tells the shell the language and the device policy, and
|
||||
* says how much mail is waiting when the app quits. Renders nothing and does nothing
|
||||
* in a browser, where there's no bridge.
|
||||
*/
|
||||
export function DesktopHost() {
|
||||
const router = useRouter();
|
||||
const routerRef = useRef(router);
|
||||
routerRef.current = router;
|
||||
const t = useTranslations();
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const singleKeys = useSettingsStore((s) => s.singleKeyShortcuts);
|
||||
const writingLanguage = useSettingsStore((s) => s.writingLanguage);
|
||||
const uiLocale = useLocaleStore((s) => s.locale);
|
||||
const strictest = useStrictestDevices();
|
||||
const { restoreWindows } = useDesktopPrefs();
|
||||
const theme = useThemeStore((s) => s.theme);
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const activeThemeId = useThemeStore((s) => s.activeThemeId);
|
||||
|
||||
// Commands no surface on the page answers: those work from anywhere in the app.
|
||||
useEffect(() => {
|
||||
const menu = getDesktopBridge()?.menu;
|
||||
if (!menu?.onCommand) return;
|
||||
const toMail = (command: AppCommand) => {
|
||||
if (requestAppCommand(command)) return;
|
||||
parkAppCommand(command);
|
||||
routerRef.current.push("/");
|
||||
};
|
||||
const fallback: Record<string, () => void> = {
|
||||
"file.newMessage": () => {
|
||||
if (isPopoutWindow()) openNewComposerWindow(useAuthStore.getState().activeAccountId);
|
||||
else toMail("compose");
|
||||
},
|
||||
"edit.search": () => toMail("search"),
|
||||
"go.inbox": () => routerRef.current.push("/"),
|
||||
"go.mail": () => routerRef.current.push("/"),
|
||||
"go.calendar": () => routerRef.current.push("/calendar"),
|
||||
"go.contacts": () => routerRef.current.push("/contacts"),
|
||||
"go.files": () => routerRef.current.push("/files"),
|
||||
"help.shortcuts": () => openKeyboardShortcuts(),
|
||||
};
|
||||
return menu.onCommand((id) => {
|
||||
// A bulwark:// link to a folder.
|
||||
if (id.startsWith("link.folder:")) {
|
||||
routerRef.current.push(`/mail/folder/${encodeURIComponent(id.slice("link.folder:".length))}`);
|
||||
return;
|
||||
}
|
||||
// Open Link in a native menu.
|
||||
if (id.startsWith("link.open:")) {
|
||||
try {
|
||||
const { url, text } = JSON.parse(id.slice("link.open:".length)) as { url: string; text: string };
|
||||
void openLinkFromNativeMenu(url, text, useEmailStore.getState().selectedEmail?.id);
|
||||
} catch {
|
||||
// Only the shell sends this, and it sends JSON.
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!runDesktopCommand(id)) fallback[id]?.();
|
||||
});
|
||||
}, []);
|
||||
|
||||
// What's on offer anywhere, and what the organisation allows.
|
||||
useEffect(() => {
|
||||
if (!getDesktopBridge()?.menu) return;
|
||||
const canPrint = canPrintAnywhere();
|
||||
const signedIn = isAuthenticated;
|
||||
const popout = isPopoutWindow();
|
||||
setDesktopBaseState({
|
||||
"link.check": true,
|
||||
"shortcuts.singleKeys": singleKeys,
|
||||
"policy.download": canDownloadAnywhere(),
|
||||
"file.print": canPrint,
|
||||
"file.savePdf": canPrint,
|
||||
"file.newMessage": signedIn,
|
||||
"edit.search": signedIn && !popout,
|
||||
"go.inbox": signedIn,
|
||||
"go.mail": signedIn,
|
||||
"go.calendar": signedIn,
|
||||
"go.contacts": signedIn,
|
||||
"go.files": signedIn,
|
||||
"help.shortcuts": true,
|
||||
});
|
||||
}, [isAuthenticated, singleKeys, strictest]);
|
||||
|
||||
// Menus and spellcheck follow the interface.
|
||||
useEffect(() => {
|
||||
const ui = uiLocale || document.documentElement.lang;
|
||||
if (ui) setDesktopInterfaceLanguage(ui, shellLabels(tRef.current as unknown as (key: string) => string));
|
||||
}, [uiLocale]);
|
||||
|
||||
// What the user writes in, whichever composer asks for it too.
|
||||
useEffect(() => {
|
||||
noteComposerLanguage("settings", writingLanguage && writingLanguage !== "auto" ? writingLanguage : null);
|
||||
}, [writingLanguage]);
|
||||
|
||||
// A language with no checker in the OS needs a dictionary from Google: the composer
|
||||
// asks once, and never in no-third-party mode. Until a yes, the shell has nowhere to
|
||||
// fetch from at all.
|
||||
useEffect(() => {
|
||||
const spellcheck = getDesktopBridge()?.spellcheck;
|
||||
if (!spellcheck) return;
|
||||
if (readDesktopPrefs().dictionaryDownload === "yes" && dictionaryDownloadAllowed()) spellcheck.allowDownload();
|
||||
const waiting = new Set<string>();
|
||||
const offer = () => {
|
||||
const [language] = waiting;
|
||||
if (!language || !composerIsOpen() || !dictionaryDownloadAllowed()) return;
|
||||
if (readDesktopPrefs().dictionaryDownload !== "ask") return;
|
||||
// Asked once, whatever comes of it.
|
||||
setDesktopPref("dictionaryDownload", "no");
|
||||
const translate = tRef.current;
|
||||
let name = language;
|
||||
try {
|
||||
name = new Intl.DisplayNames([document.documentElement.lang || "en"], { type: "language" }).of(language) ?? language;
|
||||
} catch {
|
||||
// The tag itself will do.
|
||||
}
|
||||
toast.info(translate("desktopUi.spellcheck.offer", { language: name }), {
|
||||
duration: 0,
|
||||
action: {
|
||||
label: translate("desktopUi.spellcheck.download"),
|
||||
onClick: () => {
|
||||
setDesktopPref("dictionaryDownload", "yes");
|
||||
if (dictionaryDownloadAllowed()) spellcheck.allowDownload();
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
const heard = (language: string) => {
|
||||
waiting.add(language);
|
||||
offer();
|
||||
};
|
||||
void spellcheck.missing().then((list) => list.forEach(heard));
|
||||
const stopMissing = spellcheck.onMissing(heard);
|
||||
const stopComposer = onComposerOpened(offer);
|
||||
return () => {
|
||||
stopMissing();
|
||||
stopComposer();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Signed out: nothing of that account stays on the file clipboard.
|
||||
useEffect(() => {
|
||||
const clipboard = getDesktopBridge()?.clipboard;
|
||||
if (!clipboard?.clearFiles) return;
|
||||
const clear = () => clipboard.clearFiles?.();
|
||||
onFullSignOut(clear);
|
||||
onBeforeSignOut(async () => clear());
|
||||
}, []);
|
||||
|
||||
// The windows' behaviour lives here, the shell only needs its copy.
|
||||
useEffect(() => {
|
||||
getDesktopBridge()?.windows?.setRestoreEnabled?.(restoreWindows);
|
||||
}, [restoreWindows]);
|
||||
|
||||
// Signed in: this window has a session to lend, for pop-outs from last time and links.
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || isPopoutWindow()) return;
|
||||
getDesktopBridge()?.windows?.sessionReady?.();
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// A quit asks for this: mail that hasn't left is worth a question.
|
||||
useEffect(() => {
|
||||
const lifecycle = getDesktopBridge()?.lifecycle;
|
||||
if (!lifecycle || isPopoutWindow()) return;
|
||||
return lifecycle.onFlushRequest(async () => ({ unsent: useOfflineStore.getState().outbox.length }));
|
||||
}, []);
|
||||
|
||||
// The OS accent colour, while the theme follows the OS.
|
||||
useEffect(() => {
|
||||
const bridge = getDesktopBridge()?.theme;
|
||||
if (!bridge) return;
|
||||
const followsSystem = theme === "system" && !activeThemeId;
|
||||
const apply = (system: DesktopSystemTheme) => {
|
||||
applySystemAccent({
|
||||
accent: system.accent,
|
||||
highContrast: system.highContrast,
|
||||
dark: document.documentElement.classList.contains("dark"),
|
||||
followsSystem,
|
||||
});
|
||||
};
|
||||
let cancelled = false;
|
||||
void bridge.system?.().then((system) => {
|
||||
if (!cancelled) apply(system);
|
||||
});
|
||||
const stop = bridge.onSystemChange?.(apply);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stop?.();
|
||||
};
|
||||
}, [theme, resolvedTheme, activeThemeId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
@@ -235,6 +235,39 @@ describe('composer close guard with a follow-up', () => {
|
||||
expect(afterClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('in the desktop app', () => {
|
||||
afterEach(() => {
|
||||
window.bulwarkDesktop = undefined;
|
||||
});
|
||||
|
||||
const nativeBridge = (answer: number) => {
|
||||
const confirm = vi.fn(async () => answer);
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32', dialogs: { confirm } } as never;
|
||||
return confirm;
|
||||
};
|
||||
|
||||
it('asks through the OS, and shows no dialog of its own', async () => {
|
||||
const confirm = nativeBridge(2);
|
||||
const { requestCloseRef, afterClose, onClose } = renderWithCloseRef();
|
||||
act(() => requestCloseRef.current!(afterClose));
|
||||
await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1));
|
||||
expect(confirm.mock.calls[0]).toMatchObject([{ message: 'close_draft_title', buttons: ['save', 'discard', 'cancel'], defaultId: 0 }]);
|
||||
expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument();
|
||||
// The last button is Cancel: nothing happens.
|
||||
await act(async () => {});
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(afterClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Discard discards, and the follow-up runs', async () => {
|
||||
nativeBridge(1);
|
||||
const { requestCloseRef, afterClose, onClose } = renderWithCloseRef();
|
||||
act(() => requestCloseRef.current!(afterClose));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
||||
it('runs the follow-up after discarding', async () => {
|
||||
const { requestCloseRef, afterClose, onClose } = renderWithCloseRef();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { ReaderAttachments, splitFileName, type ReaderAttachmentItem } from '../reader-attachments';
|
||||
|
||||
// The reader's attachment bar: equal tiles that keep the extension in view,
|
||||
@@ -24,6 +24,47 @@ function renderBar(attachments: ReaderAttachmentItem[], overrides: Partial<Param
|
||||
return props;
|
||||
}
|
||||
|
||||
describe('the right-click menu in the desktop app', () => {
|
||||
afterEach(() => {
|
||||
window.bulwarkDesktop = undefined;
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const openMenu = async (popupAnswer: (items: { id?: string; label?: string }[]) => string | null, overrides: Partial<Parameters<typeof ReaderAttachments>[0]> = {}) => {
|
||||
const popup = vi.fn(async (items: { id?: string; label?: string }[]) => popupAnswer(items));
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup } } as never;
|
||||
const props = renderBar([file('1', 'report.pdf')], { onCopyFile: vi.fn(), ...overrides });
|
||||
fireEvent.contextMenu(await screen.findByTestId('attachment'), { clientX: 5, clientY: 5 });
|
||||
await waitFor(() => expect(popup).toHaveBeenCalled());
|
||||
return { props, popup };
|
||||
};
|
||||
|
||||
it('has Open, Save as and Copy, natively', async () => {
|
||||
const { popup } = await openMenu(() => null);
|
||||
expect(popup.mock.calls[0][0].map((item) => item.label)).toEqual(['open', 'save_as', 'copy']);
|
||||
});
|
||||
|
||||
it.each([['open', 'onOpen'], ['save_as', 'onDownload'], ['copy', 'onCopyFile']] as const)('%s runs the action', async (label, handler) => {
|
||||
const { props } = await openMenu((items) => items.find((item) => item.label === label)!.id!);
|
||||
await waitFor(() => expect((props as unknown as Record<string, ReturnType<typeof vi.fn>>)[handler]).toHaveBeenCalledTimes(1));
|
||||
expect((props as unknown as Record<string, ReturnType<typeof vi.fn>>)[handler].mock.calls[0][0]).toMatchObject({ id: '1' });
|
||||
});
|
||||
|
||||
it('with downloads off there\'s nothing to save or copy', async () => {
|
||||
const { popup } = await openMenu(() => null, { downloadsOff: true, opensPreview: () => true });
|
||||
expect(popup.mock.calls[0][0].map((item) => item.label)).toEqual(['open']);
|
||||
});
|
||||
|
||||
it('a browser keeps its own menu', async () => {
|
||||
window.bulwarkDesktop = undefined;
|
||||
renderBar([file('1', 'report.pdf')], { onCopyFile: vi.fn() });
|
||||
const event = fireEvent.contextMenu(await screen.findByTestId('attachment'));
|
||||
// Not prevented: the browser's menu shows, and no HTML one opens.
|
||||
expect(event).toBe(true);
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitFileName', () => {
|
||||
it('splits off a short extension', () => {
|
||||
expect(splitFileName('prototype-v3.png')).toEqual(['prototype-v3', '.png']);
|
||||
|
||||
@@ -75,6 +75,14 @@ describe('EmailViewer mark as read', () => {
|
||||
await waitFor(() => expect(onMarkAsRead).toHaveBeenCalledWith('e1', true, { automatic: true }));
|
||||
});
|
||||
|
||||
it('a message opened from a link stays unread until the user says', async () => {
|
||||
useSettingsStore.setState({ markAsReadDelay: 0 });
|
||||
const onMarkAsRead = vi.fn();
|
||||
render(<EmailViewer email={unread} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onMarkAsRead={onMarkAsRead} noAutomaticActions />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(onMarkAsRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the toolbar button on as the user's own change", () => {
|
||||
useSettingsStore.setState({ markAsReadDelay: -1 });
|
||||
const onMarkAsRead = vi.fn();
|
||||
|
||||
@@ -271,6 +271,14 @@ describe('viewer send failures', () => {
|
||||
useAuthStore.setState({ client: null, identities: [] } as never);
|
||||
});
|
||||
|
||||
it('a message opened from a link sends none by itself', async () => {
|
||||
render(<EmailViewer email={makeEmail({ headers: MDN_HEADERS })} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" noAutomaticActions />);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
expect(sendReadReceipt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tries once per opening, not once per refresh, when the quota is full on Stalwart 1.0', async () => {
|
||||
const props = { mailboxes, selectedMailbox: 'inbox', currentMailboxRole: 'inbox' };
|
||||
const { rerender } = render(<EmailViewer email={makeEmail({ headers: MDN_HEADERS })} {...props} />);
|
||||
|
||||
@@ -169,6 +169,9 @@ import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
|
||||
import { fileStorage } from "@/lib/plugin-storage";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { registerComposeFlush } from "@/lib/compose-flush-registry";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import { noteComposerLanguage } from "@/lib/desktop/languages";
|
||||
import { noteOpenDraft } from "@/lib/desktop/window-restore";
|
||||
import type { RescuedDraft } from "@/lib/popout/channel";
|
||||
import { toUnicodeEmail } from "@/lib/idn";
|
||||
import { setComposerFileTarget } from "@/lib/desktop/window-files";
|
||||
@@ -384,7 +387,7 @@ interface EmailComposerProps {
|
||||
/** The state as the window unloads, for a reload to start from. */
|
||||
onUnloadState?: (data: ComposerDraftData) => void;
|
||||
/** A save reached the server, for other windows showing the Drafts folder. */
|
||||
onDraftSaved?: () => void;
|
||||
onDraftSaved?: (draftId: string) => void;
|
||||
/**
|
||||
* Hands a draft to another window to save as this one closes: a request
|
||||
* started now wouldn't survive. False when that can't be done safely.
|
||||
@@ -1944,6 +1947,14 @@ export function EmailComposer({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// A composer that comes back with the app: a draft it opened on counts from the start.
|
||||
// Gone with the composer: sent, discarded or closed.
|
||||
useEffect(() => {
|
||||
if (draftIdRef.current) noteOpenDraft(effectiveSessionKey, { draftId: draftIdRef.current, localAccountId: draftLocalAccountIdRef.current ?? useAuthStore.getState().activeAccountId, ownerAccountId: draftAccountIdRef.current });
|
||||
return () => noteOpenDraft(effectiveSessionKey, null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [effectiveSessionKey]);
|
||||
|
||||
// Auto-save draft to server on page close (best-effort)
|
||||
const ownWindowRef = useRef(ownWindow);
|
||||
ownWindowRef.current = ownWindow;
|
||||
@@ -3024,6 +3035,8 @@ export function EmailComposer({
|
||||
draftIdRef.current = savedDraftId;
|
||||
draftAccountIdRef.current = draftAccountId;
|
||||
draftLocalAccountIdRef.current = savingLogin;
|
||||
// Before a save's promise settles: quitting waits for it, then keeps this list.
|
||||
noteOpenDraft(effectiveSessionKey, { draftId: savedDraftId, localAccountId: savingLogin ?? useAuthStore.getState().activeAccountId, ownerAccountId: draftAccountId });
|
||||
setDraftId(savedDraftId);
|
||||
setDraftAccountId(draftAccountId);
|
||||
setDraftLocalAccountId(savingLogin);
|
||||
@@ -3104,7 +3117,7 @@ export function EmailComposer({
|
||||
// Only the save the user asked for is read out: autosave would say it
|
||||
// every minute, and the save before sending is followed by "Sent".
|
||||
if (opts.announce && id && !saveFailedRef.current) announceStatus(t('draft_saved'));
|
||||
if (id && !saveFailedRef.current) ownWindowRef.current?.onDraftSaved?.();
|
||||
if (id && !saveFailedRef.current) ownWindowRef.current?.onDraftSaved?.(id);
|
||||
return id;
|
||||
})();
|
||||
inflightSaveRef.current = promise;
|
||||
@@ -3367,6 +3380,11 @@ export function EmailComposer({
|
||||
const uiLocale = useLocale();
|
||||
const writingLanguage = useSettingsStore((s) => s.writingLanguage);
|
||||
const textLang = writingLanguage && writingLanguage !== 'auto' ? writingLanguage : uiLocale;
|
||||
// The desktop app's spellcheck follows what's being written.
|
||||
useEffect(() => {
|
||||
noteComposerLanguage(effectiveSessionKey, textLang);
|
||||
return () => noteComposerLanguage(effectiveSessionKey, null);
|
||||
}, [effectiveSessionKey, textLang]);
|
||||
const proofreading = useProofreading({
|
||||
composerId: pluginComposerId,
|
||||
sessionKey: effectiveSessionKey,
|
||||
@@ -4440,6 +4458,29 @@ export function EmailComposer({
|
||||
emitClose();
|
||||
};
|
||||
|
||||
/** The question before closing or discarding: the OS's own in the desktop app. */
|
||||
const openCloseDialog = (kind: 'discard' | 'close') => {
|
||||
const dialogs = getDesktopBridge()?.dialogs;
|
||||
if (!dialogs) {
|
||||
setCloseDialogKind(kind);
|
||||
setShowCloseDialog(true);
|
||||
return;
|
||||
}
|
||||
const discarding = kind === 'discard';
|
||||
const buttons = discarding ? [t('discard'), t('cancel')] : [tCommon('save'), t('discard'), t('cancel')];
|
||||
void dialogs.confirm({
|
||||
type: 'warning',
|
||||
message: discarding ? t('discard_draft_title') : t('close_draft_title'),
|
||||
detail: discarding ? t('discard_draft_message') : t('close_draft_message'),
|
||||
buttons,
|
||||
defaultId: discarding ? 1 : 0,
|
||||
}).then((choice) => {
|
||||
if (buttons[choice] === t('discard')) handleDiscardAndClose();
|
||||
else if (!discarding && choice === 0) void handleSaveDraftAndClose();
|
||||
else afterCloseRef.current = null;
|
||||
});
|
||||
};
|
||||
|
||||
// Discard deletes the draft, also one that has no unsaved changes, such as
|
||||
// a scheduled message reopened with "Cancel and edit". It used to share the
|
||||
// X button's path, which closed such a composer and kept the draft.
|
||||
@@ -4451,8 +4492,7 @@ export function EmailComposer({
|
||||
return;
|
||||
}
|
||||
afterCloseRef.current = null;
|
||||
setCloseDialogKind('discard');
|
||||
setShowCloseDialog(true);
|
||||
openCloseDialog('discard');
|
||||
};
|
||||
|
||||
const handleClose = (afterClose?: () => void) => {
|
||||
@@ -4464,8 +4504,7 @@ export function EmailComposer({
|
||||
&& !saveFailedRef.current
|
||||
&& !attachmentsRef.current.some(att => att.uploading || att.error);
|
||||
if (isDirtyRef.current && !savedAlready) {
|
||||
setCloseDialogKind('close');
|
||||
setShowCloseDialog(true);
|
||||
openCloseDialog('close');
|
||||
} else {
|
||||
cleanClose();
|
||||
}
|
||||
@@ -4751,6 +4790,7 @@ export function EmailComposer({
|
||||
className="-me-2 max-md:order-first max-md:me-0 max-md:-ms-2 max-md:h-11 max-md:w-11"
|
||||
label={tCommon('close')}
|
||||
data-testid="composer-close"
|
||||
data-own-window-close={ownWindow ? "" : undefined}
|
||||
>
|
||||
<X className="w-5 h-5 md:w-4 md:h-4" />
|
||||
</IconButton>
|
||||
|
||||
@@ -160,6 +160,7 @@ import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { imageBlobUrl, inertBlobType, isFilePreviewable, isMimeTypeSafeForInlinePreview, toInertBlob } from "@/lib/file-preview";
|
||||
import { openPreviewInNewWindow } from "@/lib/standalone-display";
|
||||
import { downloadAllowed, saveBlobAs } from "@/lib/download-guard";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import { canPickFolder, createUniqueNamer, isPickerCancelled, pickFolder, saveFilesToFolder, type FolderHandle } from "@/lib/attachments/save-to-folder";
|
||||
import { useDevicePolicy } from "@/hooks/use-device-policy";
|
||||
import { Watermark, useWatermarkText } from "@/components/device/watermark";
|
||||
@@ -290,6 +291,11 @@ interface EmailViewerProps {
|
||||
onToggleFullscreen?: () => void;
|
||||
/** Opens this message in its own window; left out where that isn't on offer. */
|
||||
onOpenInWindow?: () => void;
|
||||
/**
|
||||
* Opened by something outside the app (a bulwark:// link), not by the user in it.
|
||||
* It shows the message and does nothing on its own: no mark as read, no read receipt.
|
||||
*/
|
||||
noAutomaticActions?: boolean;
|
||||
isFullscreen?: boolean;
|
||||
currentUserEmail?: string;
|
||||
currentUserName?: string;
|
||||
@@ -1338,6 +1344,7 @@ export function EmailViewer({
|
||||
onCompose,
|
||||
onToggleFullscreen,
|
||||
onOpenInWindow,
|
||||
noAutomaticActions = false,
|
||||
isFullscreen = false,
|
||||
currentUserEmail,
|
||||
currentUserName,
|
||||
@@ -2217,7 +2224,7 @@ export function EmailViewer({
|
||||
|
||||
useEffect(() => {
|
||||
// Mark as read when email is viewed, respecting the delay setting
|
||||
if (!email || !onMarkAsRead) {
|
||||
if (!email || !onMarkAsRead || noAutomaticActions) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3136,6 +3143,8 @@ export function EmailViewer({
|
||||
|
||||
// What the attachment bar and the attachment cards share.
|
||||
const attachmentCardsRef = useRef<HTMLDivElement>(null);
|
||||
const desktopClipboard = getDesktopBridge()?.clipboard;
|
||||
const tDesktopUi = useTranslations('desktopUi.attachment');
|
||||
const readerAttachmentProps = {
|
||||
attachments: effectiveAttachments,
|
||||
thumbUrls: imageThumbUrls,
|
||||
@@ -3144,6 +3153,13 @@ export function EmailViewer({
|
||||
onDownload: handleEffectiveAttachmentDownload,
|
||||
onDownloadAll: handleDownloadAllAttachments,
|
||||
onSaveAllToFolder: canPickFolder() ? () => void handleSaveAllToFolder() : undefined,
|
||||
onCopyFile: desktopClipboard && blobClient ? (attachment: EffectiveAttachment) => {
|
||||
if (!attachment.blobId || !downloadAllowed('attachment', email)) return;
|
||||
void blobClient.fetchBlobArrayBuffer(attachment.blobId, undefined, undefined, blobAccountId)
|
||||
.then((data) => desktopClipboard.copyFile(resolveAttachmentName(attachment), data))
|
||||
.then((copied) => { if (!copied) toast.error(tDesktopUi('copy_failed')); })
|
||||
.catch(() => toast.error(tDesktopUi('copy_failed')));
|
||||
} : undefined,
|
||||
downloadingAll: isDownloadingAll,
|
||||
downloadsOff,
|
||||
// Save to Files would get around the policy: Files downloads are off too, but the copy would stay.
|
||||
@@ -3965,7 +3981,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
if (autoMdnRef.current !== email?.id) autoMdnRef.current = null;
|
||||
}, [email?.id]);
|
||||
useEffect(() => {
|
||||
if (readReceiptResponse !== 'always') return;
|
||||
if (readReceiptResponse !== 'always' || noAutomaticActions) return;
|
||||
if (!shouldOfferReadReceipt || !email) return;
|
||||
if (autoMdnRef.current === email.id) return;
|
||||
autoMdnRef.current = email.id;
|
||||
@@ -4122,7 +4138,9 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
// in a split pane - the empty reading pane should always say what it is
|
||||
// and offer a way forward.
|
||||
return shell(
|
||||
<div className="flex-1 min-w-0 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50">
|
||||
<div className="relative flex-1 min-w-0 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50">
|
||||
{/* An empty reading pane's top is still the window's top, to drag by. */}
|
||||
<div aria-hidden="true" data-window-top-row="" className="absolute inset-x-0 top-0 h-14" />
|
||||
<div className="text-center p-8">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
|
||||
<Mail className="w-10 h-10 text-muted-foreground" />
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type DragEvent, type ReactNode } from "react";
|
||||
import { useEffect, useState, type DragEvent, type ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronDown, ChevronUp, Copy, Download, FolderInput, Loader2, Paperclip } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContextMenu, ContextMenuItem } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { nativeMenusAvailable } from "@/lib/desktop/native-menu";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { getAttachmentDisplayName } from "@/lib/attachment-name";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
@@ -50,6 +53,11 @@ interface ReaderAttachmentsProps<T extends ReaderAttachmentItem> {
|
||||
layout?: ReaderAttachmentLayout;
|
||||
/** Object URLs of image attachments, keyed by attachment id. */
|
||||
thumbUrls: Record<string, string>;
|
||||
/**
|
||||
* Copies the file to the clipboard. Comes with the right-click menu the desktop app
|
||||
* has on an attachment; a browser has its own menu there and never gets this one.
|
||||
*/
|
||||
onCopyFile?: (attachment: T) => void;
|
||||
/** Whether a click on the attachment previews it instead of downloading it. */
|
||||
opensPreview: (attachment: T) => boolean;
|
||||
onOpen: (attachment: T) => void;
|
||||
@@ -97,6 +105,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
|
||||
onDownload,
|
||||
onDownloadAll,
|
||||
onSaveAllToFolder,
|
||||
onCopyFile,
|
||||
downloadingAll,
|
||||
downloadsOff = false,
|
||||
onSaveToFiles,
|
||||
@@ -108,6 +117,11 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
|
||||
const tFiles = useTranslations("files");
|
||||
const tDevice = useTranslations("device_policy");
|
||||
const tWindows = useTranslations("pwaWindows");
|
||||
const tDesktop = useTranslations("desktopUi.attachment");
|
||||
const menu = useContextMenu<T>();
|
||||
const [desktopMenu, setDesktopMenu] = useState(false);
|
||||
useEffect(() => setDesktopMenu(nativeMenusAvailable()), []);
|
||||
const menuTarget = menu.contextMenu.data;
|
||||
// Opens nothing: no preview, and downloads are off.
|
||||
const locked = (attachment: T) => downloadsOff && !opensPreview(attachment);
|
||||
const openLabel = (attachment: T, name: string) =>
|
||||
@@ -297,7 +311,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
|
||||
layout === "cards" ? "grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-3" : "grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-2",
|
||||
)}>
|
||||
{shown.map((attachment) => (
|
||||
<div key={attachment.id} className="flex min-w-0 flex-col gap-1">
|
||||
<div key={attachment.id} className="flex min-w-0 flex-col gap-1" onContextMenu={desktopMenu ? (event) => menu.openContextMenu(event, attachment) : undefined}>
|
||||
{renderItem
|
||||
? renderItem(attachment, (drag) => draw(attachment, drag))
|
||||
: draw(attachment, NO_DRAG)}
|
||||
@@ -329,6 +343,19 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{desktopMenu && menuTarget && (
|
||||
<ContextMenu ref={menu.menuRef} isOpen={menu.contextMenu.isOpen} position={menu.contextMenu.position} onClose={menu.closeContextMenu}>
|
||||
{!locked(menuTarget) && (
|
||||
<ContextMenuItem label={tDesktop("open")} onClick={() => { menu.closeContextMenu(); onOpen(menuTarget); }} />
|
||||
)}
|
||||
{!downloadsOff && (
|
||||
<ContextMenuItem label={tDesktop("save_as")} onClick={() => { menu.closeContextMenu(); onDownload(menuTarget); }} />
|
||||
)}
|
||||
{!downloadsOff && onCopyFile && (
|
||||
<ContextMenuItem label={tDesktop("copy")} onClick={() => { menu.closeContextMenu(); onCopyFile(menuTarget); }} />
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c
|
||||
{/* Full-screen under `viewport-fit=cover`: the bar pads itself out of
|
||||
the iOS status bar (#936). The insets are physical, so pl/pr. */}
|
||||
<div
|
||||
data-window-top-row=""
|
||||
className="flex items-center justify-between gap-2 pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))] pt-[calc(0.75rem+var(--app-top-inset))] pb-3 bg-background/90 backdrop-blur border-b border-border"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function InlineAppView({ apps, activeAppId, onClose, className }: InlineA
|
||||
return (
|
||||
<div className={cn('flex flex-col h-full bg-background', className)}>
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-secondary/50 flex-shrink-0">
|
||||
<div data-window-top-row="" 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>
|
||||
<IconButton label={tCommon("close")} onClick={onClose} className="text-muted-foreground">
|
||||
<X />
|
||||
|
||||
@@ -596,6 +596,7 @@ export function NavigationRail({
|
||||
})()}
|
||||
|
||||
<nav
|
||||
data-window-rail=""
|
||||
className={cn(
|
||||
"flex flex-col flex-1 min-h-0 overflow-y-auto scroll-hidden",
|
||||
collapsed ? "items-center gap-1 py-3 px-1" : "gap-0.5 py-2 px-2",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { applyControlsArea, watchTitlebarArea, watchWindowRows, type TitlebarArea } from "@/lib/window-controls-overlay";
|
||||
|
||||
/**
|
||||
* The window's controls over the app, with no title bar row: see
|
||||
* lib/window-controls-overlay.ts. Renders nothing, and changes nothing on the
|
||||
* page, without an overlay. In the root layout, so that the pages outside [locale]
|
||||
* (admin, setup) get it too; there are no messages up there.
|
||||
*/
|
||||
export function WindowControlsHost() {
|
||||
const [area, setArea] = useState<TitlebarArea | null>(null);
|
||||
|
||||
useEffect(() => watchTitlebarArea((next) => {
|
||||
applyControlsArea(next);
|
||||
setArea(next);
|
||||
}), []);
|
||||
|
||||
const key = area ? `${area.x},${area.width},${area.height}` : "";
|
||||
useEffect(() => {
|
||||
if (!area) return;
|
||||
return watchWindowRows(area);
|
||||
// `key` stands for area, a new object at each change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import type { DesktopMenuBarEntry } from "@/lib/desktop/types";
|
||||
|
||||
/**
|
||||
* Alt floats the menu's names over the top left for a moment, since a window with no
|
||||
* frame has no native bar. Each name opens the app's menu as a native one
|
||||
* (src/main/menu.ts in the desktop repo). Dismissed, nothing is left of it.
|
||||
*/
|
||||
export function WindowMenuBar() {
|
||||
const t = useTranslations("desktopUi.menu");
|
||||
const [entries, setEntries] = useState<DesktopMenuBarEntry[] | null>(null);
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
const previousFocus = useRef<HTMLElement | null>(null);
|
||||
// What to do once the names are on screen: open that menu, or null to focus the first name.
|
||||
const afterShow = useRef<string | null | undefined>(undefined);
|
||||
|
||||
const openMenu = useCallback((id: string) => {
|
||||
const button = stripRef.current?.querySelector<HTMLElement>(`[data-menu-id="${CSS.escape(id)}"]`);
|
||||
if (!button) return;
|
||||
const rect = button.getBoundingClientRect();
|
||||
getDesktopBridge()?.menu?.bar?.open(id, { x: rect.left, y: rect.bottom });
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setEntries(null);
|
||||
previousFocus.current?.focus?.();
|
||||
previousFocus.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const bar = getDesktopBridge()?.menu?.bar;
|
||||
if (!bar) return;
|
||||
return bar.onEvent((event) => {
|
||||
if (!event.visible) {
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
void bar.entries().then((list) => {
|
||||
previousFocus.current ??= document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
afterShow.current = event.open ?? null;
|
||||
setEntries(list);
|
||||
});
|
||||
});
|
||||
}, [dismiss]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = afterShow.current;
|
||||
if (!entries || next === undefined) return;
|
||||
afterShow.current = undefined;
|
||||
if (next) openMenu(next);
|
||||
else stripRef.current?.querySelector<HTMLElement>("button")?.focus();
|
||||
}, [entries, openMenu]);
|
||||
|
||||
if (!entries) return null;
|
||||
|
||||
const onStripKey = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const buttons = Array.from(stripRef.current?.querySelectorAll<HTMLElement>("button") ?? []);
|
||||
const at = buttons.indexOf(document.activeElement as HTMLElement);
|
||||
if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
const delta = event.key === "ArrowRight" ? 1 : -1;
|
||||
buttons[(at + delta + buttons.length) % buttons.length]?.focus();
|
||||
} else if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
buttons[at]?.click();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
dismiss();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={stripRef}
|
||||
role="menubar"
|
||||
aria-label={t("bar_label")}
|
||||
data-window-menu-bar=""
|
||||
onKeyDown={onStripKey}
|
||||
className="fixed start-2 top-2 z-[100002] flex items-center gap-0.5 rounded-lg border border-border bg-popover p-1 text-sm text-popover-foreground shadow-lg select-none"
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-haspopup="menu"
|
||||
data-menu-id={entry.id}
|
||||
onClick={() => openMenu(entry.id)}
|
||||
className="rounded px-2.5 py-1 hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { applyTitlebarInset, watchTitlebarArea, type TitlebarArea } from "@/lib/window-controls-overlay";
|
||||
|
||||
/**
|
||||
* The strip above the app in a window without a native title bar: the part
|
||||
* the user drags the window by, with the window's title in small text. The
|
||||
* native window controls sit next to it, outside the area it is given. The
|
||||
* page roots leave this row empty through --titlebar-inset.
|
||||
*
|
||||
* Renders nothing, and changes nothing on the page, without an overlay. It
|
||||
* repeats the title for sighted users only, so it stays out of the
|
||||
* accessibility tree and out of the tab order.
|
||||
*/
|
||||
export function WindowTitleBar() {
|
||||
const [area, setArea] = useState<TitlebarArea | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
|
||||
useEffect(() => watchTitlebarArea((next) => {
|
||||
applyTitlebarInset(next);
|
||||
setArea(next);
|
||||
}), []);
|
||||
|
||||
const visible = area !== null;
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const read = () => setTitle(document.title);
|
||||
read();
|
||||
// Each surface sets its own title, and Next puts <title> back on navigation.
|
||||
const observer = new MutationObserver(read);
|
||||
observer.observe(document.head, { childList: true, subtree: true, characterData: true });
|
||||
return () => observer.disconnect();
|
||||
}, [visible]);
|
||||
|
||||
if (!area) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-window-title-bar=""
|
||||
className="window-title-bar fixed z-[100002] flex items-center bg-background ps-3 pe-3 text-xs text-muted-foreground select-none"
|
||||
style={{ top: area.y, left: area.x, width: area.width, height: area.height }}
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -63,6 +63,10 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { usePaneSize } from "@/hooks/use-pane-size";
|
||||
import { usePaneId } from "@/hooks/use-pane-context";
|
||||
import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useDesktopCommands } from "@/lib/desktop/commands";
|
||||
import { useDesktopPrefs } from "@/lib/desktop/preferences";
|
||||
import { hasDesktopWindows } from "@/lib/popout/support";
|
||||
import { composerDataFromDraft } from "@/lib/draft-composer-data";
|
||||
import { useUndoShortcut } from "@/hooks/use-undo-shortcut";
|
||||
import { confirmMailRemoval } from "@/lib/mail-removal-confirm";
|
||||
import { findNextUnreadIndex } from "@/lib/next-unread-email";
|
||||
@@ -1217,6 +1221,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Mountain View keeps the ticked rows while a message is read (#1231). The
|
||||
// shortcuts then act on the message on screen, as its toolbar does, and
|
||||
// reach the selection again back in the list.
|
||||
const openEmailInWindow = useOpenEmailInWindow();
|
||||
const shortcutsTargetSelection = selectedEmailKeys.size > 0 && !(mvShell && !cursorOnly && selectedEmail);
|
||||
const singleKeyShortcuts = useSettingsStore((s) => s.singleKeyShortcuts);
|
||||
useKeyboardShortcuts({
|
||||
@@ -1260,6 +1265,59 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
},
|
||||
}, isAuthenticated && !!client);
|
||||
|
||||
// The desktop app's menu bar: the same handlers as the keys, and what's enabled
|
||||
// from what's on screen (lib/desktop/commands.ts).
|
||||
const desktopTarget = !showComposer && (!!selectedEmail || selectedEmailKeys.size > 0);
|
||||
const desktopSingle = !showComposer && !!selectedEmail;
|
||||
const mailLayoutSetting = useSettingsStore((s) => s.mailLayout);
|
||||
const layoutFixed = interfaceLayout === 'mountain-view';
|
||||
useDesktopCommands({
|
||||
'file.newMessage': keyboardHandlers.onCompose,
|
||||
'edit.search': keyboardHandlers.onFocusSearch,
|
||||
'view.refresh': () => void handleManualRefresh(),
|
||||
'view.layout.split': () => useSettingsStore.getState().updateSetting('mailLayout', 'split'),
|
||||
'view.layout.horizontal': () => useSettingsStore.getState().updateSetting('mailLayout', 'horizontal'),
|
||||
'view.layout.focus': () => useSettingsStore.getState().updateSetting('mailLayout', 'focus'),
|
||||
'message.reply': keyboardHandlers.onReply,
|
||||
'message.replyAll': keyboardHandlers.onReplyAll,
|
||||
'message.forward': keyboardHandlers.onForward,
|
||||
'message.markRead': () => void keyboardHandlers.onMarkAsRead?.(),
|
||||
'message.markUnread': () => void keyboardHandlers.onMarkAsUnread?.(),
|
||||
'message.star': () => void keyboardHandlers.onToggleStar?.(),
|
||||
'message.move': () => { if (selectedEmail) setMoveDialogEmail(selectedEmail); },
|
||||
'message.archive': keyboardHandlers.onArchive ? () => void keyboardHandlers.onArchive?.() : undefined,
|
||||
'message.spam': () => void keyboardHandlers.onToggleSpam?.(),
|
||||
'message.delete': () => void keyboardHandlers.onDelete?.(),
|
||||
'message.openWindow': () => { if (selectedEmail) openEmailInWindow?.(selectedEmail); },
|
||||
'go.inbox': () => keyboardHandlers.onGoToMailbox('inbox'),
|
||||
'go.sent': () => keyboardHandlers.onGoToMailbox('sent'),
|
||||
'go.drafts': () => keyboardHandlers.onGoToMailbox('drafts'),
|
||||
'go.nextMessage': keyboardHandlers.onNextEmail,
|
||||
'go.previousMessage': keyboardHandlers.onPreviousEmail,
|
||||
'go.nextUnread': keyboardHandlers.onNextUnread,
|
||||
}, {
|
||||
'view.refresh': true,
|
||||
'view.layout.split': { enabled: !layoutFixed, checked: mailLayoutSetting === 'split' },
|
||||
'view.layout.horizontal': { enabled: !layoutFixed, checked: mailLayoutSetting === 'horizontal' },
|
||||
'view.layout.focus': { enabled: !layoutFixed, checked: mailLayoutSetting === 'focus' },
|
||||
'message.reply': desktopSingle,
|
||||
'message.replyAll': desktopSingle,
|
||||
'message.forward': desktopSingle,
|
||||
'message.markRead': desktopTarget && !(selectedEmail && selectedEmailKeys.size === 0 && selectedEmail.keywords?.$seen),
|
||||
'message.markUnread': desktopTarget && !(selectedEmail && selectedEmailKeys.size === 0 && !selectedEmail.keywords?.$seen),
|
||||
'message.star': desktopSingle,
|
||||
'message.move': desktopSingle,
|
||||
'message.archive': desktopTarget && archiveActionEnabled,
|
||||
'message.spam': desktopTarget,
|
||||
'message.delete': desktopTarget,
|
||||
'message.openWindow': desktopSingle && !!openEmailInWindow,
|
||||
'go.sent': true,
|
||||
'go.drafts': true,
|
||||
'go.nextMessage': !showComposer,
|
||||
'go.previousMessage': !showComposer,
|
||||
'go.nextUnread': !showComposer,
|
||||
}, isAuthenticated && !isEmbedded);
|
||||
|
||||
// Shared mail refresh: re-fetch mailboxes (unread counts) + the active list
|
||||
// via JMAP. Used by both the browser refresh gestures and the toolbar button.
|
||||
const [isManualRefreshing, setIsManualRefreshing] = useState(false);
|
||||
@@ -2267,7 +2325,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const composerSource = usePinnedPerSession(composerSessionId, selectedEmail ? emailWriteSource(selectedEmail, client) : undefined);
|
||||
|
||||
const popoutsOn = usePopoutsAvailable();
|
||||
const openEmailInWindow = useOpenEmailInWindow();
|
||||
const desktopDoubleClick = useDesktopPrefs().openOnDoubleClick && hasDesktopWindows() && !isMobile && !!openEmailInWindow;
|
||||
// Text from a closing pop-out that couldn't be saved: the "Continue draft"
|
||||
// slot, once it is free.
|
||||
useEffect(() => {
|
||||
@@ -2605,17 +2663,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
draft = await emailHooks.onBeforeEditDraft.transform(draft);
|
||||
|
||||
const { body: draftBody, bodyText, plainTextMode: draftPlainTextMode } = getDraftComposerBody(draft);
|
||||
const { bodyText } = getDraftComposerBody(draft);
|
||||
|
||||
// Restore the identity the draft was composed with. Match the saved From
|
||||
// (name + address) against the same list the composer renders — the flat
|
||||
// cross-account list when multi-account is on — so a draft from a non-active
|
||||
// account, or one of two identities sharing an address, is restored rather
|
||||
// than reset to the default.
|
||||
// The list the composer renders: the flat cross-account one when multi-account is on.
|
||||
const composerIdentities = multiAccountIdentities.enabled
|
||||
? multiAccountIdentities.allIdentities
|
||||
: identities;
|
||||
const matchedIdentityId = findDraftIdentityIdForLogin(composerIdentities, draft.from?.[0], localAccountId);
|
||||
|
||||
const draftRecipients = (list: typeof draft.to) => (list ?? []).map(a => a.email).filter(Boolean);
|
||||
if (!(await withComposeGate('compose', {
|
||||
@@ -2628,46 +2681,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Increment session ID to force the composer to remount with fresh state,
|
||||
// even if it was already open (e.g. right-clicking a draft while composing).
|
||||
setComposerSessionId(id => id + 1);
|
||||
setPendingDraft({
|
||||
to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '',
|
||||
cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '',
|
||||
bcc: draft.bcc?.map(a => a.email).filter(Boolean).join(', ') || '',
|
||||
// Drafts saved before #1189 stored an empty subject as the composer's
|
||||
// "(No Subject)" placeholder; reopen those with an empty field.
|
||||
subject: draft.subject && draft.subject !== t('email_composer.no_subject') ? draft.subject : '',
|
||||
body: draftBody,
|
||||
// Re-open in the format the draft was written in (#1022).
|
||||
plainTextMode: draftPlainTextMode,
|
||||
showCc: (draft.cc?.length || 0) > 0,
|
||||
showBcc: (draft.bcc?.length || 0) > 0,
|
||||
selectedIdentityId: matchedIdentityId,
|
||||
subAddressTag: '',
|
||||
mode: 'compose',
|
||||
draftId: draft.id,
|
||||
draftAccountId: accountId,
|
||||
draftLocalAccountId: localAccountId,
|
||||
// A reply draft re-opens in compose mode; its threading headers must
|
||||
// come along or the reply leaves its thread.
|
||||
inReplyTo: draft.inReplyTo ?? undefined,
|
||||
references: draft.references ?? undefined,
|
||||
priority: messagePriorityFromHeaders(draft.headers),
|
||||
// A sent message brought back (Undo, Cancel and edit) has no draft
|
||||
// marker: its SIO-Label says which label it went out with.
|
||||
classification: reopenedLabelChoice(draft.headers, localAccountId),
|
||||
// Existing server-side attachments must ride along, or the composer
|
||||
// starts empty and the next save/send silently rebuilds the draft
|
||||
// without them (#849).
|
||||
attachments: (draft.attachments ?? [])
|
||||
.filter(a => !!a.blobId)
|
||||
.map(a => ({
|
||||
blobId: a.blobId,
|
||||
name: a.name,
|
||||
type: a.type,
|
||||
size: a.size,
|
||||
cid: a.cid,
|
||||
disposition: a.disposition,
|
||||
})),
|
||||
});
|
||||
setPendingDraft(composerDataFromDraft(draft, {
|
||||
accountId,
|
||||
localAccountId,
|
||||
identities: composerIdentities,
|
||||
noSubject: t('email_composer.no_subject'),
|
||||
}));
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
@@ -5551,7 +5570,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
onEmailSelect={handleEmailSelect}
|
||||
onOpenSearchResult={handleEmailSelect}
|
||||
onEmailPrefetch={handleEmailPrefetch}
|
||||
onEmailDoubleClick={isEmbedded ? ((email) => {
|
||||
onEmailDoubleClick={!isEmbedded ? (desktopDoubleClick ? (email) => {
|
||||
// A draft goes back in the composer, as it does from a single click.
|
||||
if (email.keywords?.$draft) void handleEditDraft(email);
|
||||
else openEmailInWindow?.(email);
|
||||
} : undefined) : ((email) => {
|
||||
const store = useProTabStore.getState();
|
||||
const hasSplit = store.tabs.some((tab) => tab.paneId === 'split');
|
||||
// In a split, open on the *other* side so this list stays
|
||||
@@ -5582,7 +5605,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
mailboxId: selectedMailbox,
|
||||
title: email.subject?.trim() || t('email_composer.new_message'),
|
||||
}, { pane: targetPane, reuseReader: hasSplit });
|
||||
}) : undefined}
|
||||
})}
|
||||
onOpenConversation={handleOpenConversation}
|
||||
// Context menu handlers
|
||||
onReply={(email) => {
|
||||
|
||||
@@ -348,6 +348,7 @@ export function useAppSidePanel({ view, context, dock = true, hidden = false, on
|
||||
aria-label={panelLabelOf(shownIds, t('plugin_panel'))}
|
||||
data-app-side-panel={overlay ? 'overlay' : 'docked'}
|
||||
data-app-side-panel-view={view}
|
||||
data-window-end-column=""
|
||||
className={cn(
|
||||
'flex flex-col h-full border-s border-border bg-background overflow-hidden shrink-0',
|
||||
overlay && 'absolute inset-y-0 end-0 z-30 shadow-xl',
|
||||
|
||||
@@ -142,6 +142,7 @@ export function PluginSidePanel() {
|
||||
{active && (
|
||||
<aside
|
||||
data-plugin-side-panel={active.pluginId}
|
||||
data-window-end-column=""
|
||||
className="flex w-[300px] shrink-0 flex-col overflow-hidden border-s border-border bg-background"
|
||||
aria-label={active.title}
|
||||
>
|
||||
@@ -150,6 +151,7 @@ export function PluginSidePanel() {
|
||||
)}
|
||||
{inRail.length > 0 && <nav
|
||||
data-plugin-side-rail=""
|
||||
data-window-end-column=""
|
||||
className="flex w-12 shrink-0 flex-col items-center gap-1 border-s border-border bg-background pt-2"
|
||||
aria-label={t('mv_side_panel')}
|
||||
>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel';
|
||||
import { openPopoutChannel, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel';
|
||||
import { createComposeHandoff, rememberComposePayload } from '@/lib/popout/compose-handoff';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { emailHooks } from '@/lib/plugin-hooks';
|
||||
|
||||
// The composer's window: when it tells the opening window to let go, and what
|
||||
// happens on a reload. The composer itself is stubbed; its own tests cover it.
|
||||
@@ -25,9 +27,29 @@ vi.mock('@/components/error', () => ({ ErrorBoundary: ({ children }: { children:
|
||||
vi.mock('../popout-shell', () => ({
|
||||
PopoutShell: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
PopoutSkeleton: () => <div data-testid="skeleton" />,
|
||||
PopoutNotice: ({ title, children }: { title: string; children: React.ReactNode }) => <div><h1>{title}</h1>{children}</div>,
|
||||
}));
|
||||
vi.mock('@/hooks/use-document-title', () => ({ useDocumentTitle: vi.fn() }));
|
||||
|
||||
const identityMocks = vi.hoisted(() => ({
|
||||
multi: { enabled: false, allIdentities: [] as unknown[], groups: [] as unknown[] },
|
||||
bump: undefined as undefined | (() => void),
|
||||
}));
|
||||
vi.mock('@/hooks/use-pro-multi-account-identities', async () => {
|
||||
const { useReducer } = await import('react');
|
||||
return {
|
||||
useProMultiAccountIdentities: () => {
|
||||
const [, bump] = useReducer((n: number) => n + 1, 0);
|
||||
identityMocks.bump = bump;
|
||||
return identityMocks.multi;
|
||||
},
|
||||
stripCrossAccountIdentityPrefix: (id: string) => {
|
||||
const at = id.indexOf('::');
|
||||
return at >= 0 ? { localAccountId: id.slice(0, at), rawId: id.slice(at + 2) } : { localAccountId: null, rawId: id };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { PopoutComposeApp } = await import('../popout-compose-app');
|
||||
|
||||
const original = useAuthStore.getState();
|
||||
@@ -67,6 +89,153 @@ function openAt(handoffId: string) {
|
||||
render(<PopoutComposeApp />);
|
||||
}
|
||||
|
||||
describe('a composer coming back on its draft', () => {
|
||||
const closeSelf = vi.fn();
|
||||
const setRestore = vi.fn();
|
||||
const LOGIN = 'alice@example.org@mail.example.org';
|
||||
const MAILBOXES = [
|
||||
{ id: 'mDrafts', role: 'drafts' },
|
||||
{ id: 'mSent', role: 'sent' },
|
||||
{ id: 'mTrash', role: 'trash' },
|
||||
{ id: 'mInbox', role: 'inbox' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
closeSelf.mockClear();
|
||||
setRestore.mockClear();
|
||||
identityMocks.multi = { enabled: false, allIdentities: [], groups: [] };
|
||||
useIdentityStore.setState({ identities: [{ id: 'id-1', email: 'me@example.org', name: 'Me' }] } as never);
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { closeSelf, setRestore } } as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.bulwarkDesktop = undefined;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const draft = {
|
||||
id: 'd1', subject: 'Half-written', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ name: 'Me', email: 'me@example.org' }],
|
||||
keywords: { $draft: true }, mailboxIds: { mDrafts: true },
|
||||
bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [{ blobId: 'b1', name: 'a.pdf', type: 'application/pdf', size: 3 }],
|
||||
};
|
||||
|
||||
const openDraft = (getEmail: ReturnType<typeof vi.fn>, search = `draft=d1&account=${encodeURIComponent(LOGIN)}&owner=group`) => {
|
||||
const client = { getEmail, getMailboxes: vi.fn(async () => MAILBOXES) };
|
||||
useAuthStore.setState({ client: { other: true } as never, getClientForAccount: (id: string) => (id === LOGIN ? client : undefined) } as never);
|
||||
window.history.replaceState(null, '', `/en/popout/compose?${search}`);
|
||||
render(<PopoutComposeApp />);
|
||||
return client;
|
||||
};
|
||||
|
||||
it('reads the draft from where it lives, and opens the composer on it, id and all', async () => {
|
||||
const getEmail = vi.fn(async () => draft);
|
||||
openDraft(getEmail);
|
||||
await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Half-written'));
|
||||
expect(getEmail).toHaveBeenCalledWith('d1', 'group');
|
||||
// Saving goes over this draft, not next to it, and its files stay.
|
||||
expect(composer?.initialData).toMatchObject({ draftId: 'd1', draftAccountId: 'group', draftLocalAccountId: LOGIN, to: 'a@example.org' });
|
||||
expect((composer?.initialData as unknown as { attachments: unknown[] }).attachments).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Sending keeps the id. A composer on it would destroy the Sent copy at the next
|
||||
// autosave, Send would mail it twice, and Discard would delete it.
|
||||
it.each([
|
||||
['a message that was sent', { keywords: { $seen: true }, mailboxIds: { mSent: true } }],
|
||||
['a sent message still marked as a draft', { keywords: { $draft: true }, mailboxIds: { mSent: true } }],
|
||||
['a draft in the Trash', { keywords: { $draft: true }, mailboxIds: { mTrash: true } }],
|
||||
['mail in the Inbox that is no draft at all', { keywords: {}, mailboxIds: { mInbox: true } }],
|
||||
])('%s is not opened', async (_name, shape) => {
|
||||
openDraft(vi.fn(async () => ({ ...draft, ...shape })));
|
||||
await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1));
|
||||
expect(screen.queryByTestId('composer')).toBeNull();
|
||||
});
|
||||
|
||||
it('a send brought back has no $draft, and in Drafts it opens', async () => {
|
||||
openDraft(vi.fn(async () => ({ ...draft, keywords: {}, mailboxIds: { mDrafts: true } })));
|
||||
await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('an address with no login opens nothing, not the active account\'s mail under the same id', async () => {
|
||||
const getEmail = vi.fn(async () => draft);
|
||||
useAuthStore.setState({ client: { getEmail, getMailboxes: async () => MAILBOXES } as never, getClientForAccount: () => undefined } as never);
|
||||
window.history.replaceState(null, '', '/en/popout/compose?draft=d1');
|
||||
render(<PopoutComposeApp />);
|
||||
await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1));
|
||||
expect(getEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a draft gone since closes the window', async () => {
|
||||
openDraft(vi.fn(async () => null));
|
||||
await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1));
|
||||
expect(screen.queryByTestId('composer')).toBeNull();
|
||||
});
|
||||
|
||||
it('a server not answering keeps the window and the entry, and Try again works', async () => {
|
||||
let up = false;
|
||||
const getEmail = vi.fn(async () => {
|
||||
if (!up) throw new Error('offline');
|
||||
return draft;
|
||||
});
|
||||
openDraft(getEmail);
|
||||
await screen.findByText('unavailable_title');
|
||||
expect(closeSelf).not.toHaveBeenCalled();
|
||||
// On the list for next time.
|
||||
expect(setRestore.mock.lastCall![0]).toEqual([expect.stringContaining('draft=d1')]);
|
||||
up = true;
|
||||
fireEvent.click(screen.getByText('try_again'));
|
||||
await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('plugins get their say on the draft, as when it opens from the list', async () => {
|
||||
const hook = emailHooks.onBeforeEditDraft.register('test', ((value: unknown) => ({ ...(value as object), subject: 'Changed by a plugin' })) as never);
|
||||
try {
|
||||
openDraft(vi.fn(async () => draft));
|
||||
await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Changed by a plugin'));
|
||||
} finally {
|
||||
hook.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
describe('with several accounts', () => {
|
||||
const OTHER = { id: 'other@x@h::id-9', email: 'other@example.org', name: 'Other', localAccountId: LOGIN };
|
||||
|
||||
it('waits for the identity that wrote it, however late it loads, and starts on that', async () => {
|
||||
identityMocks.multi = { enabled: true, allIdentities: [], groups: [] };
|
||||
const fromOther = { ...draft, from: [{ name: 'Other', email: 'other@example.org' }] };
|
||||
openDraft(vi.fn(async () => fromOther));
|
||||
await settle();
|
||||
// Not on the default in the meantime: it would save that.
|
||||
expect(screen.queryByTestId('composer')).toBeNull();
|
||||
act(() => {
|
||||
identityMocks.multi = { enabled: true, allIdentities: [OTHER], groups: [] };
|
||||
identityMocks.bump?.();
|
||||
});
|
||||
await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy());
|
||||
expect(composer?.initialData).toMatchObject({ selectedIdentityId: OTHER.id });
|
||||
expect((composer?.initialData as unknown as { fromOverrideEnabled?: boolean }).fromOverrideEnabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it('an address no identity has any more stays as the From, not the default', async () => {
|
||||
identityMocks.multi = { enabled: true, allIdentities: [], groups: [] };
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
openDraft(vi.fn(async () => ({ ...draft, from: [{ name: 'Gone', email: 'gone@example.org' }] })));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(4100);
|
||||
});
|
||||
await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy());
|
||||
expect(composer?.initialData).toMatchObject({ selectedIdentityId: null, fromOverrideEnabled: true, fromOverrideEmail: 'gone@example.org', fromOverrideName: 'Gone' });
|
||||
});
|
||||
});
|
||||
|
||||
it('a save moves the address on, or a reload would look for a version that is gone', async () => {
|
||||
openDraft(vi.fn(async () => draft));
|
||||
await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy());
|
||||
act(() => (composer as unknown as { ownWindow: { onDraftSaved: (id: string) => void } }).ownWindow.onDraftSaved('d2'));
|
||||
expect(new URLSearchParams(window.location.search).get('draft')).toBe('d2');
|
||||
expect(new URLSearchParams(window.location.search).get('account')).toBe(LOGIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PopoutComposeApp', () => {
|
||||
it('tells the opening window to let go only with the composer on screen', async () => {
|
||||
const handoff = createComposeHandoff()!;
|
||||
|
||||
@@ -78,6 +78,21 @@ afterEach(() => {
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
describe('a message opened from a bulwark:// link', () => {
|
||||
it('asks for nothing automatic: such a link may show a message, not act on it', async () => {
|
||||
window.history.replaceState(null, '', '/en/popout/message/m%2F1?account=login-b&via=link');
|
||||
render(<PopoutMessageApp id="m/1" />);
|
||||
await settle();
|
||||
expect((view as unknown as { noAutomaticActions?: boolean }).noAutomaticActions).toBe(true);
|
||||
});
|
||||
|
||||
it('an ordinary pop-out acts as ever', async () => {
|
||||
render(<PopoutMessageApp id="m/1" />);
|
||||
await settle();
|
||||
expect((view as unknown as { noAutomaticActions?: boolean }).noAutomaticActions).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PopoutMessageApp', () => {
|
||||
it('opens the message of the address, through its login and account', async () => {
|
||||
render(<PopoutMessageApp id="rendered-for-another-id" />);
|
||||
|
||||
@@ -18,7 +18,15 @@ import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watch
|
||||
import { acknowledgeComposeHandoff, receiveComposeHandoff, rememberComposePayload, rememberedComposePayload, watchHandoffCancel } from "@/lib/popout/compose-handoff";
|
||||
import type { ComposePopoutPayload } from "@/lib/popout/compose-payload";
|
||||
import { closePopoutWindow } from "@/lib/popout/window-role";
|
||||
import { PopoutShell, PopoutSkeleton } from "./popout-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { composerDataFromDraft, isRestorableDraft } from "@/lib/draft-composer-data";
|
||||
import { noteOpenDraft } from "@/lib/desktop/window-restore";
|
||||
import { findDraftIdentityIdForLogin } from "@/lib/draft-source";
|
||||
import { emailHooks } from "@/lib/plugin-hooks";
|
||||
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
import { PopoutNotice, PopoutShell, PopoutSkeleton } from "./popout-shell";
|
||||
|
||||
/** A warning with no main window left to show it stays this long before the window goes. */
|
||||
const WARNING_READ_MS = 8000;
|
||||
@@ -31,10 +39,126 @@ const EMPTY_PAYLOAD: ComposePopoutPayload = { mode: "compose" };
|
||||
/** How long a reloaded window waits to hear what became of the text it handed over on the way out. */
|
||||
const RESCUE_ANSWER_MS = 2000;
|
||||
|
||||
function readLaunch(): { handoffId: string | null; accountId?: string } {
|
||||
function readLaunch(): { handoffId: string | null; accountId?: string; draftId?: string; ownerAccountId?: string } {
|
||||
if (typeof window === "undefined") return { handoffId: null };
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
return { handoffId: search.get("handoff"), accountId: search.get("account") ?? undefined };
|
||||
return {
|
||||
handoffId: search.get("handoff"),
|
||||
accountId: search.get("account") ?? undefined,
|
||||
draftId: search.get("draft") ?? undefined,
|
||||
ownerAccountId: search.get("owner") ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** How long a restored draft waits for the identity that wrote it to load. */
|
||||
const IDENTITY_WAIT_MS = 4000;
|
||||
|
||||
/**
|
||||
* The composer that was open when the desktop app quit, back on its draft. A draft
|
||||
* that has gone since (sent or deleted elsewhere) means there's nothing to show.
|
||||
* A server that doesn't answer says nothing about it: the window stays, offers to try
|
||||
* again, and the draft stays on the list for the next start.
|
||||
*
|
||||
* Only through the login the address names, and never without one: ids repeat across
|
||||
* accounts, so another login could hold someone else's message under this id.
|
||||
*
|
||||
* The From stays what it was. With several accounts the other accounts' identities
|
||||
* load later, and the composer would otherwise start on the default and save that.
|
||||
*/
|
||||
function RestoredDraft({ draftId, accountId, ownerAccountId, onReady }: {
|
||||
draftId: string;
|
||||
accountId?: string;
|
||||
ownerAccountId?: string;
|
||||
onReady: (payload: ComposePopoutPayload) => void;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const tRestore = useTranslations("desktopUi.restore");
|
||||
const tWindows = useTranslations("pwaWindows");
|
||||
const client = useAuthStore((s) => (accountId ? s.getClientForAccount(accountId) : undefined));
|
||||
const ownIdentities = useIdentityStore((s) => s.identities);
|
||||
const multi = useProMultiAccountIdentities();
|
||||
const identities = multi.enabled ? multi.allIdentities : ownIdentities;
|
||||
const [draft, setDraft] = useState<Email | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [waitOver, setWaitOver] = useState(false);
|
||||
|
||||
// Until the composer takes over, this window still stands for the draft.
|
||||
useEffect(() => {
|
||||
const key = `restored-${draftId}`;
|
||||
noteOpenDraft(key, { draftId, localAccountId: accountId, ownerAccountId });
|
||||
return () => noteOpenDraft(key, null);
|
||||
}, [draftId, accountId, ownerAccountId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountId) {
|
||||
closePopoutWindow();
|
||||
return;
|
||||
}
|
||||
if (!client) return;
|
||||
let cancelled = false;
|
||||
setFailed(false);
|
||||
void Promise.all([client.getEmail(draftId, ownerAccountId), client.getMailboxes(ownerAccountId)]).then(async ([found, mailboxes]) => {
|
||||
if (cancelled) return;
|
||||
if (!found || !isRestorableDraft(found, mailboxes)) {
|
||||
closePopoutWindow();
|
||||
return;
|
||||
}
|
||||
const transformed = await emailHooks.onBeforeEditDraft.transform(found);
|
||||
if (!cancelled) setDraft(transformed);
|
||||
}).catch((error) => {
|
||||
debug.error("Failed to reopen a draft:", error);
|
||||
if (!cancelled) setFailed(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [client, accountId, draftId, ownerAccountId, attempt]);
|
||||
|
||||
// Back online: try by itself.
|
||||
useEffect(() => {
|
||||
if (!failed) return;
|
||||
const retry = () => setAttempt((n) => n + 1);
|
||||
window.addEventListener("online", retry);
|
||||
return () => window.removeEventListener("online", retry);
|
||||
}, [failed]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setWaitOver(true), IDENTITY_WAIT_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft) return;
|
||||
const from = draft.from?.[0];
|
||||
const matched = findDraftIdentityIdForLogin(identities, from, accountId);
|
||||
// The identity may still be on its way: wait for it, then keep the address as it was.
|
||||
if (!matched && from && !waitOver) return;
|
||||
const data = composerDataFromDraft(draft, {
|
||||
accountId: ownerAccountId,
|
||||
localAccountId: accountId,
|
||||
identities,
|
||||
noSubject: t("email_composer.no_subject"),
|
||||
});
|
||||
onReady({
|
||||
mode: "compose",
|
||||
initialData: !matched && from?.email
|
||||
? { ...data, fromOverrideEnabled: true, fromOverrideEmail: from.email, fromOverrideName: from.name ?? "" }
|
||||
: data,
|
||||
});
|
||||
// onReady swaps this component out; `t` changes nothing here.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [draft, identities, waitOver]);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<PopoutNotice title={tRestore("unavailable_title")} body={tRestore("unavailable_body")}>
|
||||
<Button onClick={() => setAttempt((n) => n + 1)}>{tRestore("try_again")}</Button>
|
||||
<Button variant="ghost" onClick={closePopoutWindow}>{tRestore("close")}</Button>
|
||||
</PopoutNotice>
|
||||
);
|
||||
}
|
||||
return <PopoutSkeleton label={tWindows("opening")} />;
|
||||
}
|
||||
|
||||
/** The composer alone, in its own window: sending, autosave and discard go through the shared stores, as in a Pro tab. */
|
||||
@@ -153,7 +277,15 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload;
|
||||
onUnloadState: (state: ComposerDraftData) => {
|
||||
if (handoffId) rememberComposePayload(handoffId, { ...payload, initialData: state, prefill: null });
|
||||
},
|
||||
onDraftSaved: () => announceMailChanged(useAuthStore.getState().activeAccountId),
|
||||
onDraftSaved: (draftId: string) => {
|
||||
announceMailChanged(useAuthStore.getState().activeAccountId);
|
||||
// A save makes a new id, and this address is what a reload starts from.
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.has("draft")) {
|
||||
url.searchParams.set("draft", draftId);
|
||||
window.history.replaceState(null, "", `${url.pathname}${url.search}`);
|
||||
}
|
||||
},
|
||||
}), [handoffId, mainWindow, payload]);
|
||||
|
||||
return (
|
||||
@@ -181,8 +313,8 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload;
|
||||
/** `/popout/compose`: a draft handed over by another window, or a new message. */
|
||||
export function PopoutComposeApp() {
|
||||
const t = useTranslations("pwaWindows");
|
||||
const [{ handoffId, accountId }] = useState(readLaunch);
|
||||
const [payload, setPayload] = useState<ComposePopoutPayload | null>(handoffId ? null : EMPTY_PAYLOAD);
|
||||
const [{ handoffId, accountId, draftId, ownerAccountId }] = useState(readLaunch);
|
||||
const [payload, setPayload] = useState<ComposePopoutPayload | null>(handoffId || draftId ? null : EMPTY_PAYLOAD);
|
||||
|
||||
useEffect(() => {
|
||||
if (!handoffId) return;
|
||||
@@ -216,7 +348,9 @@ export function PopoutComposeApp() {
|
||||
|
||||
return (
|
||||
<PopoutShell kind="compose" accountId={accountId}>
|
||||
{payload ? <PopoutComposer payload={payload} handoffId={handoffId} /> : <PopoutSkeleton label={t("opening")} />}
|
||||
{payload ? <PopoutComposer payload={payload} handoffId={handoffId} />
|
||||
: draftId ? <RestoredDraft draftId={draftId} accountId={accountId} ownerAccountId={ownerAccountId} onReady={setPayload} />
|
||||
: <PopoutSkeleton label={t("opening")} />}
|
||||
</PopoutShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const CHECK_INTERVAL_MS = 60_000;
|
||||
* The id from the address bar: a page the service worker kept for a start
|
||||
* without the network was rendered for another id.
|
||||
*/
|
||||
function readLaunch(paramId: string): { emailId: string; accountId?: string; ownerAccountId?: string } {
|
||||
function readLaunch(paramId: string): { emailId: string; accountId?: string; ownerAccountId?: string; fromLink?: boolean } {
|
||||
if (typeof window === "undefined") return { emailId: paramId };
|
||||
const segments = window.location.pathname.split("/").filter(Boolean);
|
||||
const at = segments.lastIndexOf("message");
|
||||
@@ -35,7 +35,7 @@ function readLaunch(paramId: string): { emailId: string; accountId?: string; own
|
||||
// A malformed address: the route's own value.
|
||||
}
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
return { emailId, accountId: search.get("account") ?? undefined, ownerAccountId: search.get("owner") ?? undefined };
|
||||
return { emailId, accountId: search.get("account") ?? undefined, ownerAccountId: search.get("owner") ?? undefined, fromLink: search.get("via") === "link" };
|
||||
}
|
||||
|
||||
type Presence = "present" | "gone";
|
||||
@@ -96,7 +96,7 @@ function useMessagePresence(emailId: string, ownerAccountId: string | undefined)
|
||||
return { presence, mailboxIds, liveState };
|
||||
}
|
||||
|
||||
function PopoutMessage({ emailId, accountId, ownerAccountId }: { emailId: string; accountId?: string; ownerAccountId?: string }) {
|
||||
function PopoutMessage({ emailId, accountId, ownerAccountId, fromLink }: { emailId: string; accountId?: string; ownerAccountId?: string; fromLink?: boolean }) {
|
||||
const t = useTranslations("pwaWindows");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const login = useAuthStore((s) => accountId ?? s.activeAccountId ?? undefined);
|
||||
@@ -194,6 +194,7 @@ function PopoutMessage({ emailId, accountId, ownerAccountId }: { emailId: string
|
||||
onOpenCompose={openCompose}
|
||||
liveState={liveState}
|
||||
onMailChanged={handleMailChanged}
|
||||
noAutomaticActions={fromLink}
|
||||
className="min-h-0 w-full flex-1"
|
||||
/>
|
||||
</>
|
||||
@@ -205,7 +206,7 @@ export function PopoutMessageApp({ id }: { id: string }) {
|
||||
const [launch] = useState(() => readLaunch(id));
|
||||
return (
|
||||
<PopoutShell kind="message" accountId={launch.accountId}>
|
||||
<PopoutMessage emailId={launch.emailId} accountId={launch.accountId} ownerAccountId={launch.ownerAccountId} />
|
||||
<PopoutMessage emailId={launch.emailId} accountId={launch.accountId} ownerAccountId={launch.ownerAccountId} fromLink={launch.fromLink} />
|
||||
</PopoutShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { PopoutComposeApp } from "./popout-compose-app";
|
||||
import { PopoutMessageApp } from "./popout-message-app";
|
||||
|
||||
/** The message id a pop-out address names. */
|
||||
function messageIdOf(pathname: string): string {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const at = segments.lastIndexOf("message");
|
||||
return at >= 0 && segments[at + 1] ? decodeURIComponent(segments[at + 1]) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* The desktop app's warm spare: a hidden window with the pop-out code loaded and
|
||||
* nothing signed in. When a pop-out is wanted the shell gives it an address. The
|
||||
* address bar changes without a load, which is where the pop-outs read their
|
||||
* arguments from, and the right one renders in a window that's already up.
|
||||
*/
|
||||
export function PopoutSpareApp() {
|
||||
const [target, setTarget] = useState<URL | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const spare = getDesktopBridge()?.windows?.spare;
|
||||
if (!spare) {
|
||||
// Someone typed the address, or a browser followed a link.
|
||||
window.location.replace(withBasePath("/"));
|
||||
return;
|
||||
}
|
||||
const stop = spare.onActivate((url) => {
|
||||
const next = new URL(url, window.location.href);
|
||||
window.history.replaceState(null, "", `${next.pathname}${next.search}`);
|
||||
setTarget(next);
|
||||
spare.taken();
|
||||
});
|
||||
spare.ready();
|
||||
return stop;
|
||||
}, []);
|
||||
|
||||
if (!target) return <div data-popout-spare="" className="h-dvh bg-background" />;
|
||||
if (/\/popout\/compose\/?$/.test(target.pathname)) return <PopoutComposeApp />;
|
||||
return <PopoutMessageApp id={messageIdOf(target.pathname)} />;
|
||||
}
|
||||
@@ -34,6 +34,8 @@ import { saveQuickReplyDraft } from '@/lib/outgoing/quick-reply-envelope';
|
||||
import { canDownload } from '@/lib/device-policy';
|
||||
import { useOpenEmailInWindow } from '@/hooks/use-popouts-available';
|
||||
import { isFilePreviewable } from '@/lib/file-preview';
|
||||
import { useDesktopCommands } from '@/lib/desktop/commands';
|
||||
import { isPopoutWindow } from '@/lib/popout/window-role';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
// Only needed when the device policy turns downloads off.
|
||||
@@ -77,6 +79,8 @@ interface ProEmailViewProps {
|
||||
liveState?: Pick<Email, 'keywords' | 'mailboxIds'> | null;
|
||||
/** This view changed the message's flags, for a host other windows should hear from. */
|
||||
onMailChanged?: () => void;
|
||||
/** See EmailViewer. */
|
||||
noAutomaticActions?: boolean;
|
||||
/**
|
||||
* The message left this view: deleted, archived, moved, or reopened in the
|
||||
* composer. The host decides what that means (close the tab / clear the
|
||||
@@ -132,7 +136,7 @@ function buildReplyContext(email: Email): ProReplyContext {
|
||||
* Hosted by `ProEmailTabBody` (a dedicated email tab) and by the folder tab's
|
||||
* reading pane.
|
||||
*/
|
||||
export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onOpenCompose, liveState, onMailChanged, onClose, className }: ProEmailViewProps) {
|
||||
export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onOpenCompose, liveState, onMailChanged, noAutomaticActions, onClose, className }: ProEmailViewProps) {
|
||||
const t = useTranslations();
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const tRemove = useTranslations('email_list.confirm_remove');
|
||||
@@ -450,6 +454,29 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
setEmail({ ...email, keywords });
|
||||
}, [email, setEmailKeywordsLocal]);
|
||||
|
||||
// In a pop-out this message is all there is, so the menu bar's Message menu means it.
|
||||
const inOwnWindow = typeof window !== 'undefined' && isPopoutWindow();
|
||||
const seen = !!email?.keywords?.$seen;
|
||||
useDesktopCommands({
|
||||
'message.reply': () => handleReply(),
|
||||
'message.replyAll': handleReplyAll,
|
||||
'message.forward': handleForward,
|
||||
'message.markRead': () => { if (email) void handleMarkAsRead(email.id, true); },
|
||||
'message.markUnread': () => { if (email) void handleMarkAsRead(email.id, false); },
|
||||
'message.star': () => void handleToggleStar(),
|
||||
'message.archive': archiveActionEnabled ? () => void handleArchive() : undefined,
|
||||
'message.delete': () => void handleDelete(),
|
||||
}, {
|
||||
'message.reply': !!email,
|
||||
'message.replyAll': !!email,
|
||||
'message.forward': !!email,
|
||||
'message.markRead': !!email && !seen,
|
||||
'message.markUnread': !!email && seen,
|
||||
'message.star': !!email,
|
||||
'message.archive': !!email && archiveActionEnabled,
|
||||
'message.delete': !!email,
|
||||
}, inOwnWindow);
|
||||
|
||||
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
|
||||
if (!client || !email) return;
|
||||
try {
|
||||
@@ -549,6 +576,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
<div className={cn("flex h-full flex-col bg-background", className)}>
|
||||
<ErrorBoundary fallback={EmailViewerErrorFallback}>
|
||||
<EmailViewer
|
||||
noAutomaticActions={noAutomaticActions}
|
||||
email={email}
|
||||
blobSource={blobSource}
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -228,6 +228,7 @@ export function ProTabBar({
|
||||
style={{ borderBottom: '1px solid rgba(128, 128, 128, 0.3)' }}
|
||||
role="tablist"
|
||||
data-pane-strip={paneId}
|
||||
data-window-top-row
|
||||
onDragLeave={handleStripDragLeave}
|
||||
>
|
||||
{tabs.map((tab, index) => {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { hasDesktopWindows } from "@/lib/popout/support";
|
||||
import { setDesktopPref, useDesktopPrefs } from "@/lib/desktop/preferences";
|
||||
import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section";
|
||||
|
||||
/** How the desktop app's windows behave. Only there: a browser has nothing to set. */
|
||||
export function DesktopWindowSettings() {
|
||||
const t = useTranslations("desktopUi.settings");
|
||||
const prefs = useDesktopPrefs();
|
||||
const [shown, setShown] = useState(false);
|
||||
useEffect(() => setShown(hasDesktopWindows()), []);
|
||||
if (!shown) return null;
|
||||
return (
|
||||
<SettingsSection id="desktop-windows" title={t("title")}>
|
||||
<SettingItem label={t("double_click_label")} description={t("double_click_description")}>
|
||||
<ToggleSwitch checked={prefs.openOnDoubleClick} onChange={(checked) => setDesktopPref("openOnDoubleClick", checked)} />
|
||||
</SettingItem>
|
||||
<SettingItem label={t("restore_label")} description={t("restore_description")}>
|
||||
<ToggleSwitch checked={prefs.restoreWindows} onChange={(checked) => setDesktopPref("restoreWindows", checked)} />
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { useTour } from '@/components/tour/tour-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PlayCircle } from '@/components/icons';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { DesktopWindowSettings } from './desktop-window-settings';
|
||||
|
||||
/**
|
||||
* The General page: language and time, which links the browser hands to
|
||||
@@ -33,6 +34,7 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea
|
||||
<DesktopKeystoreSettings />
|
||||
<DesktopTelemetrySettings />
|
||||
|
||||
<DesktopWindowSettings />
|
||||
<SettingsSection id="tour" title={tTour('settings_section')}>
|
||||
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
|
||||
<Button
|
||||
|
||||
@@ -1208,8 +1208,8 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
row. The same element with or without the panel, so a page keeps
|
||||
what was typed when the panel starts or stops docking. */}
|
||||
<div className="flex-1 min-w-0 flex flex-col" data-settings-side-panel-column="">
|
||||
{sidePanel.docks && (
|
||||
<div className="flex items-center gap-2 h-14 box-content border-b border-border px-2 sm:px-4 lg:px-6 shrink-0" data-settings-panel-row="">
|
||||
{(sidePanel.docks || !isEmbedded) && (
|
||||
<div data-window-only={sidePanel.docks ? undefined : ""} className={cn("items-center gap-2 h-14 box-content border-b border-border px-2 sm:px-4 lg:px-6 shrink-0", sidePanel.docks ? "flex" : "hidden")} data-settings-panel-row="">
|
||||
{/* The page's h1 below names it for screen readers. */}
|
||||
<div className="flex-1 min-w-0 truncate text-sm font-semibold" aria-hidden="true">{activeTabLabel}</div>
|
||||
{sidePanel.toggle}
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRight } from "@/components/icons";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { nativeMenusAvailable, readSettledMenu, showNativeMenu } from "@/lib/desktop/native-menu";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
@@ -199,6 +202,9 @@ interface MenuLevel {
|
||||
const MenuLevelContext = createContext<MenuLevel | null>(null);
|
||||
const neverTouched = () => false;
|
||||
|
||||
/** The desktop app shows this menu natively: it renders hidden, submenus and all, to be read. */
|
||||
const NativeMenuContext = createContext(false);
|
||||
|
||||
function useMenuLevel(recentTouch: () => boolean): MenuLevel {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -245,11 +251,55 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
const recentTouch = useCallback(() => Date.now() - lastTouchAt.current < 800, []);
|
||||
const level = useMenuLevel(recentTouch);
|
||||
const { request: requestLevel } = level;
|
||||
const [nativeRefused, setNativeRefused] = useState(false);
|
||||
const nativeMode = isOpen && mounted && !nativeRefused && nativeMenusAvailable();
|
||||
const positionRef = useRef(position);
|
||||
positionRef.current = position;
|
||||
const tDesktop = useTranslations("desktopUi.menu");
|
||||
const tDesktopRef = useRef(tDesktop);
|
||||
tDesktopRef.current = tDesktop;
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) setNativeRefused(false);
|
||||
}, [isOpen]);
|
||||
|
||||
// In the desktop app: read what rendered and show that natively. See lib/desktop/native-menu.ts.
|
||||
useEffect(() => {
|
||||
if (!nativeMode) return;
|
||||
const root = localRef.current;
|
||||
if (!root) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const read = await readSettledMenu(root, () => cancelled);
|
||||
if (cancelled) return;
|
||||
if (read.unsupported) {
|
||||
setNativeRefused(true);
|
||||
return;
|
||||
}
|
||||
const { anchor: at } = positionRef.current;
|
||||
// From a right-click it opens at the pointer, which the OS knows best.
|
||||
const place = at
|
||||
? { x: at.align === "end" ? Math.max(0, at.right - root.getBoundingClientRect().width) : at.left, y: at.bottom + ANCHOR_GAP }
|
||||
: undefined;
|
||||
const outcome = await showNativeMenu(read, root, place, tDesktopRef.current("from_plugins"));
|
||||
if (cancelled) return;
|
||||
// "More from plugins": the menu itself, with what only it can show.
|
||||
if (outcome === "html") {
|
||||
setNativeRefused(true);
|
||||
return;
|
||||
}
|
||||
if (outcome === "gone") toast.warning(tDesktopRef.current("pick_gone"));
|
||||
onCloseRef.current();
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [nativeMode]);
|
||||
|
||||
const anchor = position.anchor;
|
||||
const anchorKey = anchor ? `${anchor.left},${anchor.top},${anchor.right},${anchor.bottom},${anchor.align ?? ""}` : "";
|
||||
|
||||
@@ -443,11 +493,11 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
// Once placed (it is invisible, and can't take the focus, before), the
|
||||
// first item gets the focus.
|
||||
useEffect(() => {
|
||||
if (!isOpen || !isPositioned) return;
|
||||
if (!isOpen || !isPositioned || nativeMode) return;
|
||||
const root = localRef.current;
|
||||
if (!root || root.contains(document.activeElement)) return;
|
||||
return focusFirstItem(root);
|
||||
}, [isOpen, isPositioned]);
|
||||
}, [isOpen, isPositioned, nativeMode]);
|
||||
|
||||
const setRefs = (node: HTMLDivElement | null) => {
|
||||
localRef.current = node;
|
||||
@@ -463,6 +513,7 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
const renderPosition = adjustedPosition ?? position;
|
||||
|
||||
return createPortal(
|
||||
<NativeMenuContext.Provider value={nativeMode}>
|
||||
<MenuLevelContext.Provider value={level}>
|
||||
<div
|
||||
ref={setRefs}
|
||||
@@ -478,7 +529,7 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
left: renderPosition.x,
|
||||
top: renderPosition.y,
|
||||
maxWidth: `calc(100vw - ${2 * VIEWPORT_MARGIN}px)`,
|
||||
visibility: isPositioned ? "visible" : "hidden",
|
||||
visibility: isPositioned && !nativeMode ? "visible" : "hidden",
|
||||
}}
|
||||
role="menu"
|
||||
aria-label={label}
|
||||
@@ -493,7 +544,8 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</MenuLevelContext.Provider>,
|
||||
</MenuLevelContext.Provider>
|
||||
</NativeMenuContext.Provider>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -578,11 +630,11 @@ export function ContextMenuItem({
|
||||
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
|
||||
{hint ? (
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block">{label}</span>
|
||||
<span className="block" data-menu-label="">{label}</span>
|
||||
<span className="block text-xs text-muted-foreground">{hint}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex-1">{label}</span>
|
||||
<span className="flex-1" data-menu-label="">{label}</span>
|
||||
)}
|
||||
{/* Key hints mean nothing without a keyboard: hidden on touch (R8). */}
|
||||
{shortcut && (
|
||||
@@ -625,6 +677,7 @@ export function ContextMenuSubMenu({
|
||||
hint,
|
||||
}: ContextMenuSubMenuProps) {
|
||||
const parent = useContext(MenuLevelContext);
|
||||
const native = useContext(NativeMenuContext);
|
||||
const id = useId();
|
||||
// Outside a ContextMenu (a host's own panel) it keeps its own state.
|
||||
const [localOpen, setLocalOpen] = useState(false);
|
||||
@@ -704,16 +757,21 @@ export function ContextMenuSubMenu({
|
||||
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
|
||||
{hint ? (
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block">{label}</span>
|
||||
<span className="block" data-menu-label="">{label}</span>
|
||||
<span className="block text-xs text-muted-foreground">{hint}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex-1">{label}</span>
|
||||
<span className="flex-1" data-menu-label="">{label}</span>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{isOpen && !disabled && (
|
||||
{native && !disabled && (
|
||||
<div data-submenu-panel="" role="menu" aria-label={label} hidden={false}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{!native && isOpen && !disabled && (
|
||||
<MenuLevelContext.Provider value={ownLevel}>
|
||||
<SubMenuPanel
|
||||
ref={panelRef}
|
||||
@@ -822,7 +880,7 @@ interface ContextMenuHeaderProps {
|
||||
|
||||
export function ContextMenuHeader({ children }: ContextMenuHeaderProps) {
|
||||
return (
|
||||
<div className="px-3 py-2 text-xs font-medium text-muted-foreground border-b border-border">
|
||||
<div data-context-menu-header="" className="px-3 py-2 text-xs font-medium text-muted-foreground border-b border-border">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -155,8 +155,8 @@ export function Dialog({
|
||||
return createPortal(
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 pt-[calc(1rem+var(--titlebar-inset))] animate-in fade-in duration-150",
|
||||
size === "full-on-phone" && "max-sm:p-0 max-sm:pt-[var(--titlebar-inset)]"
|
||||
"fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 pt-[calc(1rem+var(--titlebar-row-height))] animate-in fade-in duration-150",
|
||||
size === "full-on-phone" && "max-sm:p-0 max-sm:pt-[var(--titlebar-row-height)]"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
pressedOnBackdrop.current = e.target === e.currentTarget;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import type { ContextMenuPosition } from "@/components/ui/context-menu";
|
||||
import { nativeMenuIsOpen } from "@/lib/desktop/native-menu";
|
||||
|
||||
interface ContextMenuState<T> {
|
||||
isOpen: boolean;
|
||||
@@ -134,12 +135,14 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
||||
};
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (nativeMenuIsOpen()) return;
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
closeContextMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = (e: Event) => {
|
||||
if (nativeMenuIsOpen()) return;
|
||||
if (menuRef.current && e.target instanceof Node && menuRef.current.contains(e.target)) {
|
||||
return;
|
||||
}
|
||||
@@ -150,6 +153,8 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
||||
// A focused control in the menu may have opened a system dialog that
|
||||
// took the focus from the window, such as a color input's (#1366).
|
||||
if (menuRef.current?.contains(document.activeElement)) return;
|
||||
// In the desktop app the menu is native: it closes itself, and says what was picked.
|
||||
if (nativeMenuIsOpen()) return;
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ async function draftsCalled(subject: string): Promise<Array<{ id: string; subjec
|
||||
|
||||
test.beforeAll(() => requireEntEnv());
|
||||
|
||||
test.describe('the manifest and the title bar', () => {
|
||||
test.describe('the manifest and the window controls', () => {
|
||||
test('the manifest asks for the overlay and falls back to standalone', async ({ request, baseURL }) => {
|
||||
const res = await request.get(`${baseURL ?? process.env.IT_WEBMAIL_URL}/manifest.webmanifest`);
|
||||
const manifest = await res.json();
|
||||
@@ -186,7 +186,8 @@ test.describe('the manifest and the title bar', () => {
|
||||
|
||||
test('without an overlay nothing changes', async ({ browser }) => {
|
||||
const page = await appPage(browser);
|
||||
await expect(page.locator('[data-window-title-bar]')).toHaveCount(0);
|
||||
await expect(page.locator('html[data-window-controls]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-window-row]')).toHaveCount(0);
|
||||
const padding = await page.evaluate(() => {
|
||||
const root = document.querySelector('[class*="--app-top-inset"]') as HTMLElement;
|
||||
return { padding: getComputedStyle(root).paddingTop, top: root.getBoundingClientRect().top };
|
||||
@@ -195,27 +196,24 @@ test.describe('the manifest and the title bar', () => {
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('with one, the app starts below a drag strip that leaves the controls free', async ({ browser }) => {
|
||||
test('with one, the app runs to the top and its rightmost row leaves the controls free', async ({ browser }) => {
|
||||
const page = await appPage(browser, OVERLAY);
|
||||
const strip = page.locator('[data-window-title-bar]');
|
||||
await expect(strip).toBeVisible();
|
||||
const box = await strip.boundingBox();
|
||||
expect(box).toMatchObject({ x: 0, y: 0, height: 40 });
|
||||
expect(box!.width).toBe(1440 - 140);
|
||||
const region = await strip.evaluate((el) => {
|
||||
await expect(page.locator('html[data-window-controls]')).toHaveCount(1);
|
||||
const root = await page.evaluate(() => {
|
||||
const el = document.querySelector('[class*="--app-top-inset"]') as HTMLElement;
|
||||
return { padding: getComputedStyle(el).paddingTop, top: el.getBoundingClientRect().top };
|
||||
});
|
||||
expect(root).toEqual({ padding: '0px', top: 0 });
|
||||
const row = page.locator('[data-window-row-right]').first();
|
||||
await expect(row).toBeVisible();
|
||||
const seen = await row.evaluate((el) => {
|
||||
const style = getComputedStyle(el) as CSSStyleDeclaration & { appRegion?: string; webkitAppRegion?: string };
|
||||
return style.appRegion ?? style.webkitAppRegion;
|
||||
return { region: style.appRegion ?? style.webkitAppRegion, padding: parseFloat(style.paddingRight), right: el.getBoundingClientRect().right };
|
||||
});
|
||||
expect(region).toBe('drag');
|
||||
// The window title, in small text.
|
||||
await expect(strip).toContainText(/\S/);
|
||||
const rail = await page.evaluate(() => {
|
||||
const root = document.querySelector('[class*="--app-top-inset"]') as HTMLElement;
|
||||
return { padding: getComputedStyle(root).paddingTop };
|
||||
});
|
||||
expect(rail.padding).toBe('40px');
|
||||
await shot(page, 'pwa-windows-title-bar');
|
||||
// A full-page dialog keeps clear of it too.
|
||||
expect(seen.region).toBe('drag');
|
||||
expect(seen.right).toBe(1440);
|
||||
expect(seen.padding).toBeGreaterThanOrEqual(140);
|
||||
await shot(page, 'pwa-windows-controls');
|
||||
await page.context().close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,10 @@ const probes: Record<string, () => Promise<boolean>> = {
|
||||
await GET(new NextRequest('https://webmail.example/api/bimi?domain=probe.org'));
|
||||
return bimiResolve.mock.calls.length > 0;
|
||||
},
|
||||
'spellcheck-dictionaries': async () => {
|
||||
const { dictionaryDownloadAllowed } = await import('@/lib/desktop/dictionary-download');
|
||||
return dictionaryDownloadAllowed();
|
||||
},
|
||||
translate: async () => {
|
||||
const { POST } = await import('@/app/api/translate/route');
|
||||
await POST(new NextRequest('https://webmail.example/api/translate', {
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WindowControlsHost } from '@/components/layout/window-controls-host';
|
||||
import { WindowMenuBar } from '@/components/layout/window-menu-bar';
|
||||
import { CONTROLS_ATTRIBUTE, CONTROLS_LEFT_ATTRIBUTE, ROW_ATTRIBUTE, ROW_LEFT_ATTRIBUTE, ROW_RIGHT_ATTRIBUTE, TITLEBAR_RIGHT_VAR, TITLEBAR_ROW_VAR, markWindowRows, readTitlebarArea, watchWindowRows } from '@/lib/window-controls-overlay';
|
||||
|
||||
class FakeOverlay extends EventTarget {
|
||||
visible = true;
|
||||
rect = { x: 0, y: 0, width: 867, height: 40 };
|
||||
getTitlebarAreaRect() {
|
||||
return this.rect;
|
||||
}
|
||||
change(next: Partial<FakeOverlay>) {
|
||||
Object.assign(this, next);
|
||||
this.dispatchEvent(new Event('geometrychange'));
|
||||
}
|
||||
}
|
||||
|
||||
function installOverlay(overlay: FakeOverlay | undefined) {
|
||||
Object.defineProperty(navigator, 'windowControlsOverlay', { value: overlay, configurable: true });
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
const rightInset = () => root.style.getPropertyValue(TITLEBAR_RIGHT_VAR);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
installOverlay(undefined);
|
||||
root.style.removeProperty(TITLEBAR_RIGHT_VAR);
|
||||
root.style.removeProperty(TITLEBAR_ROW_VAR);
|
||||
root.removeAttribute(CONTROLS_ATTRIBUTE);
|
||||
root.removeAttribute(CONTROLS_LEFT_ATTRIBUTE);
|
||||
});
|
||||
|
||||
describe('the menu strip in the desktop app', () => {
|
||||
type BarEvent = { visible: boolean; open?: string };
|
||||
|
||||
function withBar() {
|
||||
let send: (event: BarEvent) => void = () => {};
|
||||
const open = vi.fn();
|
||||
window.bulwarkDesktop = {
|
||||
version: 1,
|
||||
platform: 'win32',
|
||||
menu: {
|
||||
setState: () => {},
|
||||
bar: {
|
||||
entries: async () => [{ id: 'file', label: 'File', mnemonic: 'f' }, { id: 'edit', label: 'Edit', mnemonic: 'e' }],
|
||||
open,
|
||||
onEvent: (listener: (event: BarEvent) => void) => {
|
||||
send = listener;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
return { open, send: (event: BarEvent) => send(event) };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.bulwarkDesktop = undefined;
|
||||
});
|
||||
|
||||
const overlayOn = () => {
|
||||
Object.defineProperty(navigator, 'windowControlsOverlay', {
|
||||
configurable: true,
|
||||
value: Object.assign(new EventTarget(), { visible: true, getTitlebarAreaRect: () => ({ x: 0, y: 0, width: 800, height: 36 }) }),
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
delete (navigator as unknown as { windowControlsOverlay?: unknown }).windowControlsOverlay;
|
||||
});
|
||||
|
||||
it('Alt shows the names with the focus on the first, and Escape gives it back', async () => {
|
||||
overlayOn();
|
||||
const bar = withBar();
|
||||
const before = document.createElement('button');
|
||||
document.body.appendChild(before);
|
||||
before.focus();
|
||||
render(<WindowMenuBar />);
|
||||
await act(async () => bar.send({ visible: true }));
|
||||
const file = await screen.findByRole('menuitem', { name: 'File' });
|
||||
await waitFor(() => expect(document.activeElement).toBe(file));
|
||||
fireEvent.keyDown(file, { key: 'ArrowRight' });
|
||||
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Edit' }));
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
|
||||
expect(screen.queryByRole('menubar')).toBeNull();
|
||||
expect(document.activeElement).toBe(before);
|
||||
before.remove();
|
||||
});
|
||||
|
||||
it('a name opens that menu natively', async () => {
|
||||
overlayOn();
|
||||
const bar = withBar();
|
||||
render(<WindowMenuBar />);
|
||||
await act(async () => bar.send({ visible: true }));
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Edit' }));
|
||||
expect(bar.open).toHaveBeenCalledWith('edit', expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) }));
|
||||
});
|
||||
|
||||
it('Alt and a letter opens that one right away', async () => {
|
||||
overlayOn();
|
||||
const bar = withBar();
|
||||
render(<WindowMenuBar />);
|
||||
await act(async () => bar.send({ visible: true, open: 'file' }));
|
||||
await waitFor(() => expect(bar.open).toHaveBeenCalledWith('file', expect.anything()));
|
||||
});
|
||||
|
||||
it('the shell can take it away again', async () => {
|
||||
overlayOn();
|
||||
const bar = withBar();
|
||||
render(<WindowMenuBar />);
|
||||
await act(async () => bar.send({ visible: true }));
|
||||
await screen.findByRole('menubar');
|
||||
await act(async () => bar.send({ visible: false }));
|
||||
expect(screen.queryByRole('menubar')).toBeNull();
|
||||
});
|
||||
|
||||
it('with no bridge there is none, and the title stays', async () => {
|
||||
overlayOn();
|
||||
render(<WindowMenuBar />);
|
||||
expect(screen.queryByRole('menubar')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the window controls over the app', () => {
|
||||
const overlayAt = (x: number, width: number, height = 56) => {
|
||||
const fake = Object.assign(new EventTarget(), { visible: true, getTitlebarAreaRect: () => ({ x, y: 0, width, height }) });
|
||||
installOverlay(fake as never);
|
||||
return fake;
|
||||
};
|
||||
|
||||
it('the root layout has no messages: the host there reads none, and the bar sits below', () => {
|
||||
const read = (file: string) => readFileSync(join(process.cwd(), file), 'utf8');
|
||||
expect(read('components/layout/window-controls-host.tsx')).not.toContain('next-intl');
|
||||
expect(read('app/(main)/layout.tsx')).toContain('<WindowControlsHost />');
|
||||
expect(read('app/(main)/layout.tsx')).not.toContain('<WindowMenuBar');
|
||||
for (const layout of ['app/(main)/[locale]/layout.tsx', 'app/(main)/admin/layout.tsx']) expect(read(layout)).toContain('<WindowMenuBar />');
|
||||
});
|
||||
|
||||
it('does nothing without the API', () => {
|
||||
installOverlay(undefined);
|
||||
render(<WindowControlsHost />);
|
||||
expect(root.hasAttribute(CONTROLS_ATTRIBUTE)).toBe(false);
|
||||
expect(rightInset()).toBe('');
|
||||
expect(readTitlebarArea()).toBeNull();
|
||||
});
|
||||
|
||||
it('does nothing when the overlay is there but hidden', () => {
|
||||
installOverlay(Object.assign(new EventTarget(), { visible: false, getTitlebarAreaRect: () => ({ x: 0, y: 0, width: 800, height: 56 }) }) as never);
|
||||
render(<WindowControlsHost />);
|
||||
expect(root.hasAttribute(CONTROLS_ATTRIBUTE)).toBe(false);
|
||||
});
|
||||
|
||||
it('draws no row of its own: no title, no strip, nothing on the page at all', () => {
|
||||
overlayAt(0, window.innerWidth - 138);
|
||||
document.title = 'A subject - Bulwark';
|
||||
const { container } = render(<WindowControlsHost />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
expect(document.body.textContent).not.toContain('A subject');
|
||||
});
|
||||
|
||||
it('says how much room the controls take, on the right', () => {
|
||||
overlayAt(0, window.innerWidth - 138);
|
||||
render(<WindowControlsHost />);
|
||||
expect(root.hasAttribute(CONTROLS_ATTRIBUTE)).toBe(true);
|
||||
expect(rightInset()).toBe('138px');
|
||||
expect(root.style.getPropertyValue(TITLEBAR_ROW_VAR)).toBe('56px');
|
||||
expect(root.hasAttribute(CONTROLS_LEFT_ATTRIBUTE)).toBe(false);
|
||||
});
|
||||
|
||||
it('and on the left, on macOS', () => {
|
||||
overlayAt(78, window.innerWidth - 78);
|
||||
render(<WindowControlsHost />);
|
||||
expect(rightInset()).toBe('0px');
|
||||
expect(root.hasAttribute(CONTROLS_LEFT_ATTRIBUTE)).toBe(true);
|
||||
});
|
||||
|
||||
it('follows geometrychange, and takes everything back when the overlay goes', () => {
|
||||
const fake = overlayAt(0, window.innerWidth - 138);
|
||||
render(<WindowControlsHost />);
|
||||
act(() => {
|
||||
(fake as unknown as { getTitlebarAreaRect: () => object }).getTitlebarAreaRect = () => ({ x: 0, y: 0, width: window.innerWidth - 100, height: 56 });
|
||||
fake.dispatchEvent(new Event('geometrychange'));
|
||||
});
|
||||
expect(rightInset()).toBe('100px');
|
||||
act(() => {
|
||||
(fake as unknown as { visible: boolean }).visible = false;
|
||||
fake.dispatchEvent(new Event('geometrychange'));
|
||||
});
|
||||
expect(root.hasAttribute(CONTROLS_ATTRIBUTE)).toBe(false);
|
||||
expect(rightInset()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('which rows hold the controls', () => {
|
||||
const W = window.innerWidth;
|
||||
const AREA = { x: 0, y: 0, width: W - 138, height: 56 };
|
||||
const row = (className: string, rect: { left: number; right: number; top?: number; height?: number }, parent: HTMLElement = document.body, attrs: Record<string, string> = {}) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = className;
|
||||
for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value);
|
||||
const top = rect.top ?? 0;
|
||||
const height = rect.height ?? 56;
|
||||
el.getBoundingClientRect = () => ({ left: rect.left, right: rect.right, top, bottom: top + height, width: rect.right - rect.left, height, x: rect.left, y: top, toJSON: () => ({}) });
|
||||
parent.appendChild(el);
|
||||
return el;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('three panes: all are rows to drag by, and only the reader\'s toolbar makes room', () => {
|
||||
const sidebar = row('h-14', { left: 56, right: 300 });
|
||||
const list = row('h-14', { left: 300, right: 700 });
|
||||
const reader = row('h-14', { left: 700, right: W });
|
||||
markWindowRows(AREA);
|
||||
for (const el of [sidebar, list, reader]) expect(el.hasAttribute(ROW_ATTRIBUTE)).toBe(true);
|
||||
expect(sidebar.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
|
||||
expect(list.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
|
||||
expect(reader.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
|
||||
});
|
||||
|
||||
it('with no reader, the list\'s header is the one', () => {
|
||||
const list = row('h-14', { left: 300, right: W });
|
||||
markWindowRows(AREA);
|
||||
expect(list.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
|
||||
});
|
||||
|
||||
it('a row further down is no row of the window\'s', () => {
|
||||
const lower = row('h-14', { left: 0, right: W, top: 100 });
|
||||
markWindowRows(AREA);
|
||||
expect(lower.hasAttribute(ROW_ATTRIBUTE)).toBe(false);
|
||||
expect(lower.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
|
||||
});
|
||||
|
||||
it('a tall box at the top is a pane, unless it says otherwise', () => {
|
||||
const pane = row('h-14', { left: 0, right: W, height: 600 });
|
||||
const header = row('', { left: 0, right: W, height: 72 }, document.body, { 'data-window-top-row': '' });
|
||||
markWindowRows(AREA);
|
||||
expect(pane.hasAttribute(ROW_ATTRIBUTE)).toBe(false);
|
||||
expect(header.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
|
||||
});
|
||||
|
||||
it('the outermost box of a row takes it, not one inside it too', () => {
|
||||
const outer = row('h-14', { left: 0, right: W });
|
||||
const inner = row('h-14', { left: 0, right: W }, outer);
|
||||
markWindowRows(AREA);
|
||||
expect(outer.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
|
||||
expect(inner.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
|
||||
});
|
||||
|
||||
it('macOS: the row under the traffic lights makes room on the left', () => {
|
||||
const sidebar = row('h-14', { left: 56, right: 300 });
|
||||
const list = row('h-14', { left: 300, right: W });
|
||||
markWindowRows({ x: 78, y: 0, width: W - 78, height: 56 });
|
||||
expect(sidebar.hasAttribute(ROW_LEFT_ATTRIBUTE)).toBe(true);
|
||||
expect(list.hasAttribute(ROW_LEFT_ATTRIBUTE)).toBe(false);
|
||||
expect(list.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
|
||||
});
|
||||
|
||||
it('a mark goes when the row no longer lies there', () => {
|
||||
const reader = row('h-14', { left: 700, right: W });
|
||||
markWindowRows(AREA);
|
||||
expect(reader.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
|
||||
reader.getBoundingClientRect = () => ({ left: 500, right: 800, top: 0, bottom: 56, width: 300, height: 56, x: 700, y: 0, toJSON: () => ({}) });
|
||||
markWindowRows(AREA);
|
||||
expect(reader.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
|
||||
});
|
||||
|
||||
it('a pane that finishes changing width, with nothing else changing, is noticed', async () => {
|
||||
const list = row('h-14', { left: 300, right: 700 });
|
||||
const stop = watchWindowRows(AREA);
|
||||
expect(list.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
|
||||
list.getBoundingClientRect = () => ({ left: 300, right: W, top: 0, bottom: 56, width: W - 300, height: 56, x: 300, y: 0, toJSON: () => ({}) });
|
||||
document.body.dispatchEvent(new Event('transitionend', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
expect(list.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
|
||||
stop();
|
||||
});
|
||||
|
||||
it('a row that appears is marked before the next paint', async () => {
|
||||
const stop = watchWindowRows(AREA);
|
||||
const dialogRow = row('h-12', { left: 0, right: W });
|
||||
// A mutation callback, then a microtask: both ahead of any paint.
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(dialogRow.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
|
||||
stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the page roots and the stylesheet', () => {
|
||||
const root = process.cwd();
|
||||
const css = readFileSync(join(root, 'app/globals.css'), 'utf8');
|
||||
|
||||
it('keeps no row: the inset is 0, and the page roots are on the safe area alone', () => {
|
||||
expect(css).toMatch(/--titlebar-inset:\s*0px/);
|
||||
expect(css).toMatch(/--app-top-inset:\s*env\(safe-area-inset-top, 0px\)/);
|
||||
expect(css).not.toMatch(/--titlebar-inset:\s*env/);
|
||||
});
|
||||
|
||||
it('takes the room from env(), so it holds from the first paint', () => {
|
||||
expect(css).toContain('--titlebar-right-inset: calc(100vw - env(titlebar-area-x, 0px) - env(titlebar-area-width, 100vw))');
|
||||
expect(css).toContain('--titlebar-left-inset: env(titlebar-area-x, 0px)');
|
||||
});
|
||||
|
||||
it('does not key on the display-mode media query, which is false in Electron', () => {
|
||||
expect(css).not.toMatch(/display-mode:\s*window-controls-overlay/);
|
||||
});
|
||||
|
||||
it('drags by the app\'s own rows, and leaves what\'s clickable alone', () => {
|
||||
expect(css).toMatch(/:is\(\[data-window-row\], \[data-window-rail\]\) \{\s*app-region: drag;/);
|
||||
expect(css).toMatch(/\[contenteditable="true"\][\s\S]*?app-region: no-drag;/);
|
||||
expect(css).not.toContain('.window-title-bar');
|
||||
});
|
||||
|
||||
function tsxFiles(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name);
|
||||
if (name === '__tests__' || name === 'node_modules') return [];
|
||||
return statSync(path).isDirectory() ? tsxFiles(path) : name.endsWith('.tsx') ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
it('leaves no page root or top bar on the bare safe area, skeletons included', () => {
|
||||
const left = [...tsxFiles(join(root, 'components')), ...tsxFiles(join(root, 'app'))]
|
||||
.filter((path) => /pt-\[(?:calc\([^\]]*)?env\(safe-area-inset-top/.test(readFileSync(path, 'utf8')));
|
||||
expect(left).toEqual([]);
|
||||
});
|
||||
|
||||
it('gives each skeleton the same top inset as the page it stands in for', () => {
|
||||
for (const file of ['components/boot-skeleton/shell.tsx', 'components/boot-skeleton/pro-skeleton.tsx', 'components/boot-skeleton/admin-skeleton.tsx', 'components/mail/mail-app.tsx']) {
|
||||
expect(readFileSync(join(root, file), 'utf8'), file).toContain('pt-[var(--app-top-inset)]');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
import { act, cleanup, render } from '@testing-library/react';
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { WindowTitleBar } from '@/components/layout/window-title-bar';
|
||||
import { TITLEBAR_INSET_VAR, readTitlebarArea } from '@/lib/window-controls-overlay';
|
||||
|
||||
class FakeOverlay extends EventTarget {
|
||||
visible = true;
|
||||
rect = { x: 0, y: 0, width: 867, height: 40 };
|
||||
getTitlebarAreaRect() {
|
||||
return this.rect;
|
||||
}
|
||||
change(next: Partial<FakeOverlay>) {
|
||||
Object.assign(this, next);
|
||||
this.dispatchEvent(new Event('geometrychange'));
|
||||
}
|
||||
}
|
||||
|
||||
function installOverlay(overlay: FakeOverlay | undefined) {
|
||||
Object.defineProperty(navigator, 'windowControlsOverlay', { value: overlay, configurable: true });
|
||||
}
|
||||
|
||||
const inset = () => document.documentElement.style.getPropertyValue(TITLEBAR_INSET_VAR);
|
||||
const strip = () => document.querySelector<HTMLElement>('[data-window-title-bar]');
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
installOverlay(undefined);
|
||||
document.documentElement.style.removeProperty(TITLEBAR_INSET_VAR);
|
||||
});
|
||||
|
||||
describe('the title bar', () => {
|
||||
it('shows nothing and touches nothing without the API', () => {
|
||||
installOverlay(undefined);
|
||||
expect(readTitlebarArea()).toBeNull();
|
||||
render(<WindowTitleBar />);
|
||||
expect(strip()).toBeNull();
|
||||
expect(inset()).toBe('');
|
||||
});
|
||||
|
||||
it('shows nothing when the overlay is there but hidden', () => {
|
||||
const overlay = new FakeOverlay();
|
||||
overlay.visible = false;
|
||||
installOverlay(overlay);
|
||||
render(<WindowTitleBar />);
|
||||
expect(strip()).toBeNull();
|
||||
expect(inset()).toBe('');
|
||||
});
|
||||
|
||||
it('draws the strip next to the controls and pushes the page down', () => {
|
||||
installOverlay(new FakeOverlay());
|
||||
document.title = 'Inbox - Mail';
|
||||
render(<WindowTitleBar />);
|
||||
const bar = strip()!;
|
||||
expect(bar.style.width).toBe('867px');
|
||||
expect(bar.style.height).toBe('40px');
|
||||
expect(bar.textContent).toBe('Inbox - Mail');
|
||||
expect(bar.classList.contains('window-title-bar')).toBe(true);
|
||||
expect(inset()).toBe('40px');
|
||||
});
|
||||
|
||||
it('stays out of the accessibility tree and the tab order', () => {
|
||||
installOverlay(new FakeOverlay());
|
||||
render(<WindowTitleBar />);
|
||||
expect(strip()!.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(strip()!.querySelector('a, button, input, [tabindex]')).toBeNull();
|
||||
});
|
||||
|
||||
it('follows geometrychange, and takes everything back when the overlay goes', () => {
|
||||
const overlay = new FakeOverlay();
|
||||
installOverlay(overlay);
|
||||
render(<WindowTitleBar />);
|
||||
act(() => overlay.change({ rect: { x: 72, y: 0, width: 500, height: 32 } }));
|
||||
expect(strip()!.style.left).toBe('72px');
|
||||
expect(inset()).toBe('32px');
|
||||
act(() => overlay.change({ visible: false }));
|
||||
expect(strip()).toBeNull();
|
||||
expect(inset()).toBe('');
|
||||
});
|
||||
|
||||
it('follows the window title as the surface changes it', async () => {
|
||||
installOverlay(new FakeOverlay());
|
||||
document.title = 'First';
|
||||
render(<WindowTitleBar />);
|
||||
await act(async () => {
|
||||
document.title = 'Second';
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(strip()!.textContent).toBe('Second');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the page roots and the stylesheet', () => {
|
||||
const root = process.cwd();
|
||||
const css = readFileSync(join(root, 'app/globals.css'), 'utf8');
|
||||
|
||||
it('is 0 without an overlay, and exactly the old safe area then', () => {
|
||||
expect(css).toContain('--titlebar-inset: env(titlebar-area-height, 0px);');
|
||||
expect(css).toContain('--app-top-inset: max(env(safe-area-inset-top), var(--titlebar-inset));');
|
||||
});
|
||||
|
||||
it('does not key on the display-mode media query, which is false in Electron', () => {
|
||||
expect(css).not.toMatch(/display-mode:\s*window-controls-overlay/);
|
||||
});
|
||||
|
||||
it('makes the strip the part that drags the window', () => {
|
||||
expect(css).toMatch(/\.window-title-bar\s*\{[^}]*app-region:\s*drag/);
|
||||
});
|
||||
|
||||
function tsxFiles(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name);
|
||||
if (name === '__tests__' || name === 'node_modules') return [];
|
||||
return statSync(path).isDirectory() ? tsxFiles(path) : name.endsWith('.tsx') ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
it('leaves no page root or top bar on the bare safe area, skeletons included', () => {
|
||||
const left = [...tsxFiles(join(root, 'components')), ...tsxFiles(join(root, 'app'))]
|
||||
.filter((path) => /pt-\[(?:calc\([^\]]*)?env\(safe-area-inset-top/.test(readFileSync(path, 'utf8')));
|
||||
expect(left).toEqual([]);
|
||||
});
|
||||
|
||||
it('gives each skeleton the same top inset as the page it stands in for', () => {
|
||||
for (const file of ['components/boot-skeleton/shell.tsx', 'components/boot-skeleton/pro-skeleton.tsx', 'components/boot-skeleton/admin-skeleton.tsx', 'components/mail/mail-app.tsx']) {
|
||||
expect(readFileSync(join(root, file), 'utf8'), file).toContain('pt-[var(--app-top-inset)]');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -106,6 +106,16 @@ export const OUTGOING_HOSTS: readonly OutgoingHost[] = [
|
||||
inMode: 'Avatars show no brand logos; /api/bimi answers 403 before any lookup.',
|
||||
lite: false,
|
||||
},
|
||||
{
|
||||
id: 'spellcheck-dictionaries',
|
||||
host: 'redirector.gvt1.com',
|
||||
files: ['lib/desktop/dictionary-download.ts'],
|
||||
side: 'browser',
|
||||
usedBy: 'Desktop app only: a spellcheck dictionary from Google, for a language the OS has no checker for, after the user said yes when the composer asked. The text being written is not sent.',
|
||||
off: 'Answer no when the composer asks once. Nothing is fetched until a yes.',
|
||||
inMode: 'The composer does not ask, and the app has no address to fetch dictionaries from.',
|
||||
lite: false,
|
||||
},
|
||||
{
|
||||
id: 'translate',
|
||||
host: 'api.mymemory.translated.net',
|
||||
|
||||
@@ -14,6 +14,21 @@ import { getDesktopBridge } from '@/lib/desktop/bridge';
|
||||
|
||||
export type ComposeFlush = () => Promise<void>;
|
||||
|
||||
const openedListeners = new Set<() => void>();
|
||||
|
||||
/** A composer is open in this window. */
|
||||
export function composerIsOpen(): boolean {
|
||||
return flushes.size > 0;
|
||||
}
|
||||
|
||||
/** Calls `listener` as a composer opens. Returns the unsubscribe. */
|
||||
export function onComposerOpened(listener: () => void): () => void {
|
||||
openedListeners.add(listener);
|
||||
return () => {
|
||||
openedListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Less than the desktop shell waits for a closing window. */
|
||||
const DESKTOP_FLUSH_MS = 3000;
|
||||
|
||||
@@ -44,6 +59,7 @@ export function registerComposeFlush(flush: ComposeFlush): () => void {
|
||||
flushes.set(id, flush);
|
||||
for (const waiter of [...waiters]) waiter(flush);
|
||||
syncDesktopFlush();
|
||||
for (const listener of [...openedListeners]) listener();
|
||||
return () => {
|
||||
flushes.delete(id);
|
||||
syncDesktopFlush();
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ContextMenu, ContextMenuHeader, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from '@/components/ui/context-menu';
|
||||
import type { BulwarkDesktopBridge, DesktopNativeMenuItem } from '../types';
|
||||
import { accentColours, applySystemAccent } from '../system-accent';
|
||||
import { mergedDesktopState, registerDesktopSurface, resetDesktopCommands, runDesktopCommand, setDesktopBaseState } from '../commands';
|
||||
import { readMenu } from '../native-menu';
|
||||
import { noteOpenDraft, resetOpenDrafts, restoreUrlFor } from '../window-restore';
|
||||
import { readDesktopPrefs, resetDesktopPrefsCache, setDesktopPref } from '../preferences';
|
||||
import { closePopoutWindow, isPopoutPath } from '@/lib/popout/window-role';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
|
||||
function setBridge(bridge: Partial<BulwarkDesktopBridge> | undefined) {
|
||||
window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setBridge(undefined);
|
||||
resetDesktopCommands();
|
||||
resetOpenDrafts();
|
||||
localStorage.clear();
|
||||
resetDesktopPrefsCache();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function testMenu(extra?: React.ReactNode, handlers: { reply?: () => void; inbox?: () => void } = {}) {
|
||||
return (
|
||||
<>
|
||||
<ContextMenuHeader>Three messages</ContextMenuHeader>
|
||||
<ContextMenuItem label="Reply" onClick={handlers.reply ?? (() => {})} shortcut="R" hint="to the sender" />
|
||||
<ContextMenuItem label="Forward" onClick={() => {}} disabled />
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSubMenu label="Move to">
|
||||
<ContextMenuItem label="Inbox" onClick={handlers.inbox ?? (() => {})} />
|
||||
<ContextMenuItem label="R&D" onClick={() => {}} />
|
||||
</ContextMenuSubMenu>
|
||||
<ContextMenuSubMenu label="Rules" disabled>
|
||||
<ContextMenuItem label="Never shown" onClick={() => {}} />
|
||||
</ContextMenuSubMenu>
|
||||
{extra}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('an HTML menu shown natively', () => {
|
||||
let popup: ReturnType<typeof vi.fn>;
|
||||
let answer: (id: string | null) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
popup = vi.fn(() => new Promise<string | null>((resolve) => { answer = resolve; }));
|
||||
setBridge({ version: 1, platform: 'win32', menu: { setState: () => {}, popup: popup as never } });
|
||||
});
|
||||
|
||||
async function openMenu(children: React.ReactNode, onClose = vi.fn()) {
|
||||
render(<ContextMenu isOpen position={{ x: 10, y: 10 }} onClose={onClose}>{children}</ContextMenu>);
|
||||
await waitFor(() => expect(popup).toHaveBeenCalled());
|
||||
return { items: popup.mock.calls[0][0] as DesktopNativeMenuItem[], at: popup.mock.calls[0][1], onClose };
|
||||
}
|
||||
|
||||
it('reads every kind of row, in order, with submenus', async () => {
|
||||
const { items } = await openMenu(testMenu());
|
||||
expect(items.map((item) => item.label ?? item.type)).toEqual(['Three messages', 'Reply', 'Forward', 'separator', 'Move to', 'Rules']);
|
||||
expect(items[0].enabled).toBe(false);
|
||||
expect(items[2].enabled).toBe(false);
|
||||
expect(items[4].submenu?.map((item) => item.label)).toEqual(['Inbox', 'R&D']);
|
||||
// A disabled submenu shows, and has nothing in it.
|
||||
expect(items[5].enabled).toBe(false);
|
||||
expect(items[5].submenu).toBeUndefined();
|
||||
});
|
||||
|
||||
it('takes the label without the shortcut or the hint', async () => {
|
||||
const { items } = await openMenu(testMenu());
|
||||
expect(items[1].label).toBe('Reply');
|
||||
});
|
||||
|
||||
it('at the pointer from a right-click, under the button from a button', async () => {
|
||||
const { at } = await openMenu(testMenu());
|
||||
expect(at).toBeUndefined();
|
||||
popup.mockClear();
|
||||
render(
|
||||
<ContextMenu isOpen position={{ x: 0, y: 0, anchor: { left: 40, top: 50, right: 90, bottom: 70 } }} onClose={() => {}}>
|
||||
<ContextMenuItem label="One" onClick={() => {}} />
|
||||
</ContextMenu>,
|
||||
);
|
||||
await waitFor(() => expect(popup).toHaveBeenCalled());
|
||||
expect(popup.mock.calls[0][1]).toEqual({ x: 40, y: 74 });
|
||||
});
|
||||
|
||||
it('a pick runs the HTML row, the one a browser would run, and closes the menu', async () => {
|
||||
const reply = vi.fn();
|
||||
const inbox = vi.fn();
|
||||
const { items, onClose } = await openMenu(testMenu(undefined, { reply, inbox }));
|
||||
await act(async () => {
|
||||
answer(items[4].submenu![0].id!);
|
||||
});
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(inbox).toHaveBeenCalledTimes(1);
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dismissing it runs nothing and closes the menu', async () => {
|
||||
const reply = vi.fn();
|
||||
const { onClose } = await openMenu(testMenu(undefined, { reply }));
|
||||
await act(async () => {
|
||||
answer(null);
|
||||
});
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the hidden copy is never visible', async () => {
|
||||
await openMenu(testMenu());
|
||||
const menu = document.body.querySelector<HTMLElement>('[role="menu"]');
|
||||
expect(menu?.style.visibility).toBe('hidden');
|
||||
});
|
||||
|
||||
it('stays HTML when something in it can\'t be shown natively', async () => {
|
||||
render(
|
||||
<ContextMenu isOpen position={{ x: 10, y: 10 }} onClose={() => {}}>
|
||||
{testMenu(<input type="color" aria-label="Colour" />)}
|
||||
</ContextMenu>,
|
||||
);
|
||||
await waitFor(() => expect(document.body.querySelector<HTMLElement>('[role="menu"]')?.style.visibility).toBe('visible'));
|
||||
expect(popup).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('menuitem', { name: /Reply/ })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('a plugin\'s frame keeps the rest native, with a line to the HTML menu', async () => {
|
||||
const frame = (
|
||||
<div data-plugin-slot="context-menu-email"><iframe title="plugin" /></div>
|
||||
);
|
||||
const onClose = vi.fn();
|
||||
const { items } = await openMenu(testMenu(frame), onClose);
|
||||
expect(items.at(-1)).toMatchObject({ label: 'from_plugins' });
|
||||
expect(items.map((item) => item.label)).toContain('Reply');
|
||||
await act(async () => {
|
||||
answer(items.at(-1)!.id!);
|
||||
});
|
||||
await waitFor(() => expect(document.body.querySelector<HTMLElement>('[role="menu"]')?.style.visibility).toBe('visible'));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a frame that is no plugin\'s stays HTML whole', () => {
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = '<button role="menuitem">Reply</button><iframe></iframe>';
|
||||
expect(readMenu(root).unsupported).toBe(true);
|
||||
});
|
||||
|
||||
it('a row that went while the menu was up says so, instead of doing nothing', async () => {
|
||||
const warning = vi.spyOn(toast, 'warning').mockImplementation(() => '' as never);
|
||||
const reply = vi.fn();
|
||||
const { items, onClose } = await openMenu(testMenu(undefined, { reply }));
|
||||
const row = screen.getAllByRole('menuitem', { hidden: true }).find((el) => el.textContent?.includes('Reply'))!;
|
||||
row.parentElement!.remove();
|
||||
await act(async () => {
|
||||
answer(items[1].id!);
|
||||
});
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
expect(warning).toHaveBeenCalledWith('pick_gone');
|
||||
warning.mockRestore();
|
||||
});
|
||||
|
||||
it('a row with no words stays HTML too: a colour swatch says nothing in a native menu', () => {
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = '<button role="menuitem"><svg></svg></button>';
|
||||
expect(readMenu(root).unsupported).toBe(true);
|
||||
});
|
||||
|
||||
it('a search field over a list is left out, and what it searches stays', () => {
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = '<input type="search" /><button role="menuitem">Inbox</button>';
|
||||
const read = readMenu(root);
|
||||
expect(read.unsupported).toBe(false);
|
||||
expect(read.items.map((item) => item.label)).toEqual(['Inbox']);
|
||||
});
|
||||
|
||||
it('reads check and radio rows', () => {
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = '<button role="menuitemcheckbox" aria-checked="true">Show</button><button role="menuitemradio" aria-checked="false">Other</button>';
|
||||
expect(readMenu(root).items).toMatchObject([
|
||||
{ type: 'checkbox', checked: true, label: 'Show' },
|
||||
{ type: 'radio', checked: false, label: 'Other' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the same menu in a browser', () => {
|
||||
it('is the HTML menu, on screen, and no bridge is asked', async () => {
|
||||
render(<ContextMenu isOpen position={{ x: 10, y: 10 }} onClose={() => {}}>{testMenu()}</ContextMenu>);
|
||||
await waitFor(() => expect(document.body.querySelector<HTMLElement>('[role="menu"]')?.style.visibility).toBe('visible'));
|
||||
// A closed submenu's rows aren't in the page.
|
||||
expect(screen.queryByRole('menuitem', { name: 'Inbox' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('menu commands', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('the newest surface answers, and an older one is back when it goes', () => {
|
||||
const older = vi.fn();
|
||||
const newer = vi.fn();
|
||||
registerDesktopSurface({ handlers: { 'message.reply': older }, state: {} });
|
||||
const stop = registerDesktopSurface({ handlers: { 'message.reply': newer }, state: {} });
|
||||
expect(runDesktopCommand('message.reply')).toBe(true);
|
||||
expect(newer).toHaveBeenCalledTimes(1);
|
||||
expect(older).not.toHaveBeenCalled();
|
||||
stop();
|
||||
runDesktopCommand('message.reply');
|
||||
expect(older).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('says when nothing answered, so the host can take it', () => {
|
||||
expect(runDesktopCommand('go.calendar')).toBe(false);
|
||||
});
|
||||
|
||||
it('the shell gets the state, surfaces over the base', () => {
|
||||
const setState = vi.fn();
|
||||
setBridge({ version: 1, platform: 'win32', menu: { setState } });
|
||||
setDesktopBaseState({ 'message.reply': false, 'file.print': true });
|
||||
registerDesktopSurface({ handlers: {}, state: { 'message.reply': true } });
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(setState).toHaveBeenLastCalledWith({ 'message.reply': true, 'file.print': true });
|
||||
expect(mergedDesktopState()['file.print']).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composers that come back', () => {
|
||||
it('tell the shell which draft they hold, and forget it when they go', () => {
|
||||
const setRestore = vi.fn();
|
||||
setBridge({ version: 1, platform: 'win32', windows: { setRestore } });
|
||||
noteOpenDraft('a', { draftId: 'd1', localAccountId: 'login', ownerAccountId: 'group' });
|
||||
expect(setRestore).toHaveBeenLastCalledWith([expect.stringContaining('draft=d1')]);
|
||||
expect(setRestore.mock.lastCall![0][0]).toContain('account=login');
|
||||
expect(setRestore.mock.lastCall![0][0]).toContain('owner=group');
|
||||
noteOpenDraft('a', { draftId: 'd2' });
|
||||
expect(setRestore.mock.lastCall![0]).toHaveLength(1);
|
||||
expect(setRestore.mock.lastCall![0][0]).toContain('draft=d2');
|
||||
noteOpenDraft('a', null);
|
||||
expect(setRestore).toHaveBeenLastCalledWith([]);
|
||||
});
|
||||
|
||||
it('are pop-out addresses the window type check recognises', () => {
|
||||
expect(restoreUrlFor({ draftId: 'x' })).toMatch(/\/popout\/compose\?draft=x$/);
|
||||
});
|
||||
|
||||
it('do nothing in a browser', () => {
|
||||
expect(() => noteOpenDraft('a', { draftId: 'd1' })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a pop-out closing itself', () => {
|
||||
it('goes through the shell in the desktop app, not window.close', () => {
|
||||
const closeSelf = vi.fn();
|
||||
const close = vi.spyOn(window, 'close').mockImplementation(() => {});
|
||||
setBridge({ version: 1, platform: 'win32', windows: { closeSelf } });
|
||||
closePopoutWindow();
|
||||
expect(closeSelf).toHaveBeenCalledTimes(1);
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
close.mockRestore();
|
||||
});
|
||||
|
||||
it('uses window.close in a browser', () => {
|
||||
vi.useFakeTimers();
|
||||
const close = vi.spyOn(window, 'close').mockImplementation(() => {});
|
||||
closePopoutWindow();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
close.mockRestore();
|
||||
});
|
||||
|
||||
it('counts the warm spare as a pop-out, which is a guest of the main window', () => {
|
||||
expect(isPopoutPath('/popout/spare')).toBe(true);
|
||||
expect(isPopoutPath('/de/popout/spare')).toBe(true);
|
||||
expect(isPopoutPath('/popout/spare/x')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('window settings', () => {
|
||||
it('both default to on, and stay on this computer', () => {
|
||||
expect(readDesktopPrefs()).toEqual({ openOnDoubleClick: true, restoreWindows: true, dictionaryDownload: 'ask' });
|
||||
setDesktopPref('restoreWindows', false);
|
||||
resetDesktopPrefsCache();
|
||||
expect(readDesktopPrefs().restoreWindows).toBe(false);
|
||||
expect(readDesktopPrefs().openOnDoubleClick).toBe(true);
|
||||
});
|
||||
|
||||
it('a damaged value is the default', () => {
|
||||
localStorage.setItem('bulwark-desktop-window-prefs', '{nope');
|
||||
expect(readDesktopPrefs().restoreWindows).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the OS accent colour', () => {
|
||||
beforeEach(() => setBridge({ version: 1, platform: 'win32' }));
|
||||
|
||||
const apply = (input: Partial<Parameters<typeof applySystemAccent>[0]>) => {
|
||||
const root = document.createElement('div');
|
||||
applySystemAccent({ accent: '#0078d4', dark: false, followsSystem: true, ...input }, root);
|
||||
return root.style.getPropertyValue('--color-primary');
|
||||
};
|
||||
|
||||
it('is used while the theme follows the system', () => {
|
||||
expect(apply({})).toBe('#0078d4');
|
||||
});
|
||||
|
||||
it('not under a theme the user picked', () => {
|
||||
expect(apply({ followsSystem: false })).toBe('');
|
||||
});
|
||||
|
||||
it('not in a contrast theme', () => {
|
||||
expect(apply({ highContrast: true })).toBe('');
|
||||
});
|
||||
|
||||
it('not where it would vanish against the page', () => {
|
||||
expect(accentColours('#fefefe', false)).toBeNull();
|
||||
expect(accentColours('#050505', true)).toBeNull();
|
||||
});
|
||||
|
||||
it('text on it is what reads', () => {
|
||||
expect(accentColours('#0078d4', false)?.foreground).toBe('#ffffff');
|
||||
expect(accentColours('#ffd400', true)?.foreground).toBe('#000000');
|
||||
});
|
||||
|
||||
it('a removed accent takes its colours off again', () => {
|
||||
const root = document.createElement('div');
|
||||
applySystemAccent({ accent: '#0078d4', dark: false, followsSystem: true }, root);
|
||||
applySystemAccent({ dark: false, followsSystem: true }, root);
|
||||
expect(root.style.getPropertyValue('--color-primary')).toBe('');
|
||||
expect(root.style.getPropertyValue('--color-primary-foreground')).toBe('');
|
||||
});
|
||||
|
||||
it('does nothing in a browser', () => {
|
||||
setBridge(undefined);
|
||||
expect(apply({})).toBe('');
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -4,5 +4,6 @@
|
||||
* frame. Nothing happens in a browser, which has no bridge.
|
||||
*/
|
||||
export function desktopBootScript(): string {
|
||||
return `if (window.bulwarkDesktop) document.documentElement.setAttribute('data-desktop', '');`;
|
||||
return `if (window.bulwarkDesktop) document.documentElement.setAttribute('data-desktop', '');
|
||||
if (navigator.windowControlsOverlay && navigator.windowControlsOverlay.visible) document.documentElement.setAttribute('data-window-controls', '');`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* The desktop app's menu bar, on the webmail's side. The shell shows the menu and
|
||||
* sends an id; what runs is the same handler the shortcut or button runs, from
|
||||
* whichever part of the app registered it. What's on screen decides what's enabled:
|
||||
* a surface reports that here, and the newest surface wins per command.
|
||||
*
|
||||
* Nothing happens in a browser: there's no bridge to publish to, and nobody sends
|
||||
* an id.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { getDesktopBridge } from './bridge';
|
||||
import type { DesktopMenuState } from './types';
|
||||
|
||||
export type DesktopCommandHandlers = Record<string, (() => void) | undefined>;
|
||||
|
||||
interface Surface {
|
||||
handlers: DesktopCommandHandlers;
|
||||
state: DesktopMenuState;
|
||||
}
|
||||
|
||||
const surfaces = new Set<Surface>();
|
||||
let base: DesktopMenuState = {};
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** What the shell should show now: the base, with each surface over it, newest last. */
|
||||
export function mergedDesktopState(): DesktopMenuState {
|
||||
const merged: DesktopMenuState = { ...base };
|
||||
for (const surface of surfaces) Object.assign(merged, surface.state);
|
||||
return merged;
|
||||
}
|
||||
|
||||
function publish(): void {
|
||||
if (timer) return;
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
getDesktopBridge()?.menu?.setState(mergedDesktopState());
|
||||
}, 30);
|
||||
}
|
||||
|
||||
/** What holds whatever is on screen: signed in or not, the device policy. */
|
||||
export function setDesktopBaseState(state: DesktopMenuState): void {
|
||||
base = state;
|
||||
publish();
|
||||
}
|
||||
|
||||
/** Runs a command from the newest surface that has it. False when none does. */
|
||||
export function runDesktopCommand(id: string): boolean {
|
||||
for (const surface of [...surfaces].reverse()) {
|
||||
const handler = surface.handlers[id];
|
||||
if (handler) {
|
||||
handler();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function registerDesktopSurface(surface: Surface): () => void {
|
||||
surfaces.add(surface);
|
||||
publish();
|
||||
return () => {
|
||||
surfaces.delete(surface);
|
||||
publish();
|
||||
};
|
||||
}
|
||||
|
||||
function sameState(a: DesktopMenuState, b: DesktopMenuState): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* This part of the app answers these commands while `enabled`. Handlers can be new
|
||||
* on every render: the newest ones run, and only a changed state goes to the shell.
|
||||
*/
|
||||
export function useDesktopCommands(handlers: DesktopCommandHandlers, state: DesktopMenuState, enabled = true): void {
|
||||
const surfaceRef = useRef<Surface>({ handlers, state });
|
||||
surfaceRef.current.handlers = handlers;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !getDesktopBridge()?.menu) return;
|
||||
return registerDesktopSurface(surfaceRef.current);
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sameState(surfaceRef.current.state, state)) return;
|
||||
surfaceRef.current.state = state;
|
||||
publish();
|
||||
});
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetDesktopCommands(): void {
|
||||
surfaces.clear();
|
||||
base = {};
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Spellcheck dictionaries for languages the OS has no checker for. They'd come from
|
||||
* this Google host, so nothing is fetched until the user says yes in the composer,
|
||||
* and never in no-third-party mode. The shell starts every session with the address
|
||||
* pointing nowhere (src/main/spellcheck.ts in the desktop repo).
|
||||
*/
|
||||
|
||||
import { noThirdPartyTraffic } from '@/lib/third-party-mode';
|
||||
|
||||
export const DICTIONARY_HOST_URL = 'https://redirector.gvt1.com/edgedl/chrome/dict/';
|
||||
|
||||
export function dictionaryDownloadAllowed(): boolean {
|
||||
return !noThirdPartyTraffic();
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* What the shell's menus and spellcheck should speak. The interface language and
|
||||
* its translations of the menus come from the host, and each open composer adds the
|
||||
* language its text is in, so spelling is checked in what's being written.
|
||||
*/
|
||||
|
||||
import { getDesktopBridge } from './bridge';
|
||||
|
||||
let ui = '';
|
||||
let labels: Record<string, string> | undefined;
|
||||
const composers = new Map<string, string>();
|
||||
|
||||
function push(): void {
|
||||
const shell = getDesktopBridge()?.shell;
|
||||
if (!shell?.setLanguages || !ui) return;
|
||||
shell.setLanguages({ ui, composer: [...new Set(composers.values())], labels });
|
||||
}
|
||||
|
||||
export function setDesktopInterfaceLanguage(language: string, translated: Record<string, string>): void {
|
||||
ui = language;
|
||||
labels = translated;
|
||||
push();
|
||||
}
|
||||
|
||||
/** With null, this composer has gone. */
|
||||
export function noteComposerLanguage(key: string, language: string | null): void {
|
||||
if (language) {
|
||||
if (composers.get(key) === language) return;
|
||||
composers.set(key, language);
|
||||
} else if (!composers.delete(key)) {
|
||||
return;
|
||||
}
|
||||
push();
|
||||
}
|
||||
|
||||
/** The shell's text in the interface language, by its keys in src/main/strings.ts. */
|
||||
export function shellLabels(t: (key: string) => string): Record<string, string> {
|
||||
return {
|
||||
menuFile: t('desktopUi.menu.file'),
|
||||
menuEdit: t('desktopUi.menu.edit'),
|
||||
menuView: t('desktopUi.menu.view'),
|
||||
menuMessage: t('desktopUi.menu.message'),
|
||||
menuGo: t('desktopUi.menu.go'),
|
||||
menuWindow: t('desktopUi.menu.window'),
|
||||
menuHelp: t('desktopUi.menu.help'),
|
||||
menuNewMessage: t('email_composer.new_message'),
|
||||
menuNewWindow: t('desktopUi.menu.new_window'),
|
||||
menuPrint: t('desktopUi.menu.print'),
|
||||
menuSavePdf: t('desktopUi.menu.save_pdf'),
|
||||
menuSwitchMode: t('desktopUi.menu.switch_connection'),
|
||||
menuClose: t('desktopUi.menu.close'),
|
||||
menuQuit: t('desktopUi.menu.quit'),
|
||||
menuUndo: t('desktopUi.menu.undo'),
|
||||
menuRedo: t('desktopUi.menu.redo'),
|
||||
menuCut: t('desktopUi.menu.cut'),
|
||||
menuCopy: t('desktopUi.menu.copy'),
|
||||
menuPaste: t('desktopUi.menu.paste'),
|
||||
menuSelectAll: t('desktopUi.menu.select_all'),
|
||||
menuSearch: t('desktopUi.menu.search'),
|
||||
menuRefresh: t('desktopUi.menu.refresh'),
|
||||
menuReadingPane: t('desktopUi.menu.reading_pane'),
|
||||
menuPaneRight: t('desktopUi.menu.pane_right'),
|
||||
menuPaneBelow: t('desktopUi.menu.pane_below'),
|
||||
menuPaneOff: t('desktopUi.menu.pane_off'),
|
||||
menuZoomIn: t('desktopUi.menu.zoom_in'),
|
||||
menuZoomOut: t('desktopUi.menu.zoom_out'),
|
||||
menuZoomReset: t('desktopUi.menu.zoom_reset'),
|
||||
menuFullScreen: t('desktopUi.menu.full_screen'),
|
||||
menuReply: t('context_menu.reply'),
|
||||
menuReplyAll: t('context_menu.reply_all'),
|
||||
menuForward: t('context_menu.forward'),
|
||||
menuMarkRead: t('context_menu.mark_read'),
|
||||
menuMarkUnread: t('context_menu.mark_unread'),
|
||||
menuStar: t('context_menu.star'),
|
||||
menuMove: t('context_menu.move_to_dialog'),
|
||||
menuArchive: t('context_menu.archive'),
|
||||
menuSpam: t('context_menu.mark_as_spam'),
|
||||
menuDelete: t('context_menu.delete'),
|
||||
menuOpenInWindow: t('desktopUi.menu.open_in_window'),
|
||||
menuGoInbox: t('sidebar.mailboxes.inbox'),
|
||||
menuGoSent: t('sidebar.mailboxes.sent'),
|
||||
menuGoDrafts: t('sidebar.mailboxes.drafts'),
|
||||
menuGoNext: t('desktopUi.menu.next_message'),
|
||||
menuGoPrevious: t('desktopUi.menu.previous_message'),
|
||||
menuGoNextUnread: t('desktopUi.menu.next_unread'),
|
||||
menuGoMail: t('desktopUi.menu.mail'),
|
||||
menuGoCalendar: t('sidebar.calendar'),
|
||||
menuGoContacts: t('sidebar.contacts'),
|
||||
menuGoFiles: t('desktopUi.menu.files'),
|
||||
menuGoBack: t('desktopUi.menu.back'),
|
||||
menuGoForward: t('desktopUi.menu.forward'),
|
||||
menuMinimize: t('desktopUi.menu.minimize'),
|
||||
menuCycle: t('desktopUi.menu.next_window'),
|
||||
menuShortcuts: t('desktopUi.menu.shortcuts'),
|
||||
menuDocs: t('desktopUi.menu.docs'),
|
||||
menuSettings: t('desktopUi.menu.settings'),
|
||||
menuAbout: t('desktopUi.menu.about'),
|
||||
contextCut: t('desktopUi.menu.cut'),
|
||||
contextCopy: t('desktopUi.menu.copy'),
|
||||
contextPaste: t('desktopUi.menu.paste'),
|
||||
contextSelectAll: t('desktopUi.menu.select_all'),
|
||||
contextAddToDictionary: t('desktopUi.context.add_to_dictionary'),
|
||||
contextNoSuggestions: t('desktopUi.context.no_suggestions'),
|
||||
contextOpenLink: t('desktopUi.context.open_link'),
|
||||
contextCopyLink: t('desktopUi.context.copy_link'),
|
||||
contextCopyAddress: t('desktopUi.context.copy_address'),
|
||||
contextSaveImage: t('desktopUi.context.save_image'),
|
||||
contextCopyImage: t('desktopUi.context.copy_image'),
|
||||
unsavedTitle: t('desktopUi.closing.unsaved_title'),
|
||||
unsavedDetail: t('desktopUi.closing.unsaved_detail'),
|
||||
closeAnyway: t('desktopUi.closing.close_anyway'),
|
||||
unsentTitle: t('desktopUi.closing.unsent_title'),
|
||||
unsentOne: t('desktopUi.closing.unsent_one'),
|
||||
unsentMany: t('desktopUi.closing.unsent_many'),
|
||||
closeNow: t('desktopUi.closing.quit'),
|
||||
leaveTitle: t('desktopUi.closing.leave_title'),
|
||||
leaveDetail: t('desktopUi.closing.leave_detail'),
|
||||
leave: t('desktopUi.closing.leave'),
|
||||
stay: t('desktopUi.closing.stay'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetDesktopLanguages(): void {
|
||||
ui = '';
|
||||
labels = undefined;
|
||||
composers.clear();
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Turns an HTML menu into a native one. The ContextMenu component renders as it does
|
||||
* in a browser, hidden, with its submenus open. This reads the result and shows that
|
||||
* natively. A pick clicks the row the HTML menu has for it, so a menu has one list of
|
||||
* entries with one set of handlers and the two can't drift.
|
||||
*
|
||||
* A menu with something a native one can't show (a colour picker, a row with no text)
|
||||
* stays HTML. So does anything marked data-native-menu="html". A plugin's frame in the
|
||||
* menu doesn't: the rest shows natively, with one line that opens the HTML menu.
|
||||
*/
|
||||
|
||||
import { getDesktopBridge } from './bridge';
|
||||
import type { DesktopNativeMenuItem } from './types';
|
||||
|
||||
export function nativeMenusAvailable(): boolean {
|
||||
return !!getDesktopBridge()?.menu?.popup;
|
||||
}
|
||||
|
||||
export interface ReadMenu {
|
||||
items: DesktopNativeMenuItem[];
|
||||
/** The HTML row behind each native item's id. */
|
||||
elements: Map<string, HTMLElement>;
|
||||
/** There's something here a native menu can't show. */
|
||||
unsupported: boolean;
|
||||
/** A plugin drew something of its own in the menu. */
|
||||
hasFrames: boolean;
|
||||
}
|
||||
|
||||
const ROW = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], button, a[href]';
|
||||
|
||||
/** The native line that opens the HTML menu instead. */
|
||||
export const HTML_MENU_ITEM = "html-menu";
|
||||
|
||||
function isDisabled(el: HTMLElement): boolean {
|
||||
return (el as HTMLButtonElement).disabled === true || el.getAttribute('aria-disabled') === 'true';
|
||||
}
|
||||
|
||||
function labelOf(el: HTMLElement): string {
|
||||
const named = el.getAttribute('aria-label') ?? el.querySelector<HTMLElement>('[data-menu-label]')?.textContent ?? el.textContent ?? '';
|
||||
return named.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function isHidden(el: Element): boolean {
|
||||
return typeof window.getComputedStyle === 'function' && window.getComputedStyle(el).display === 'none';
|
||||
}
|
||||
|
||||
export function readMenu(root: HTMLElement): ReadMenu {
|
||||
const elements = new Map<string, HTMLElement>();
|
||||
let unsupported = root.querySelector('[data-native-menu="html"], select, textarea, input[type="color"]') !== null;
|
||||
let hasFrames = false;
|
||||
let next = 1;
|
||||
|
||||
const walk = (container: Element, into: DesktopNativeMenuItem[]): void => {
|
||||
for (const child of Array.from(container.children)) {
|
||||
if (!(child instanceof HTMLElement) || isHidden(child)) continue;
|
||||
if (child.getAttribute('role') === 'separator') {
|
||||
into.push({ type: 'separator' });
|
||||
continue;
|
||||
}
|
||||
if (child.tagName === 'IFRAME') {
|
||||
// Only a plugin's own slot has this coming. Anything else stays HTML whole.
|
||||
if (child.closest('[data-plugin-slot]')) hasFrames = true;
|
||||
else unsupported = true;
|
||||
continue;
|
||||
}
|
||||
if (child.dataset.submenuPanel !== undefined) continue;
|
||||
if (child.dataset.contextMenuHeader !== undefined) {
|
||||
const label = labelOf(child);
|
||||
if (label) into.push({ id: `h${next++}`, label, enabled: false });
|
||||
continue;
|
||||
}
|
||||
if (child.dataset.submenuTrigger !== undefined) {
|
||||
const label = labelOf(child);
|
||||
if (isDisabled(child)) {
|
||||
into.push({ id: `s${next++}`, label, enabled: false });
|
||||
continue;
|
||||
}
|
||||
const panel = child.parentElement?.querySelector<HTMLElement>(':scope > [data-submenu-panel]');
|
||||
if (!panel) {
|
||||
unsupported = true;
|
||||
continue;
|
||||
}
|
||||
const submenu: DesktopNativeMenuItem[] = [];
|
||||
walk(panel, submenu);
|
||||
into.push({ label, submenu });
|
||||
continue;
|
||||
}
|
||||
if (child.matches(ROW)) {
|
||||
// A row that opens its own popup: there's nothing to click through to.
|
||||
if (child.hasAttribute('aria-haspopup')) {
|
||||
unsupported = true;
|
||||
continue;
|
||||
}
|
||||
const label = labelOf(child);
|
||||
if (!label) {
|
||||
// A swatch or an icon says nothing in a native menu.
|
||||
unsupported = true;
|
||||
continue;
|
||||
}
|
||||
const id = `i${next++}`;
|
||||
elements.set(id, child);
|
||||
const role = child.getAttribute('role');
|
||||
into.push({
|
||||
id,
|
||||
label,
|
||||
enabled: !isDisabled(child),
|
||||
...(role === 'menuitemcheckbox' || role === 'menuitemradio'
|
||||
? { type: role === 'menuitemcheckbox' ? 'checkbox' as const : 'radio' as const, checked: child.getAttribute('aria-checked') === 'true' }
|
||||
: {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// A search field over a list is no use where you can't type.
|
||||
if (child instanceof HTMLInputElement) continue;
|
||||
walk(child, into);
|
||||
}
|
||||
};
|
||||
|
||||
const items: DesktopNativeMenuItem[] = [];
|
||||
walk(root, items);
|
||||
return { items, elements, unsupported, hasFrames };
|
||||
}
|
||||
|
||||
const SETTLE_MS = 30;
|
||||
const SETTLE_LIMIT_MS = 400;
|
||||
|
||||
/**
|
||||
* The menu once it stopped changing. Entries can render a moment after the menu
|
||||
* (the mail menu waits for its data), and a native menu can't grow once it's up.
|
||||
*/
|
||||
export async function readSettledMenu(root: HTMLElement, cancelled: () => boolean): Promise<ReadMenu> {
|
||||
const started = Date.now();
|
||||
let previous = readMenu(root);
|
||||
while (!cancelled() && Date.now() - started < SETTLE_LIMIT_MS) {
|
||||
await new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
|
||||
const current = readMenu(root);
|
||||
if (current.items.length > 0 && JSON.stringify(current.items) === JSON.stringify(previous.items)) return current;
|
||||
previous = current;
|
||||
}
|
||||
return previous;
|
||||
}
|
||||
|
||||
function labelOfId(items: DesktopNativeMenuItem[], id: string): string | undefined {
|
||||
for (const item of items) {
|
||||
if (item.id === id) return item.label;
|
||||
const below = item.submenu && labelOfId(item.submenu, id);
|
||||
if (below) return below;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type NativeMenuOutcome = 'done' | 'html' | 'gone';
|
||||
|
||||
/**
|
||||
* Shows it and clicks the row picked. Says when the pick found nothing: the page can
|
||||
* re-render under a menu that stays up (the list refreshing), and a row that's gone
|
||||
* would otherwise do nothing with no word about it. If the same line is back at the
|
||||
* same place, that's the one.
|
||||
*/
|
||||
export async function showNativeMenu(read: ReadMenu, root: HTMLElement, at?: { x: number; y: number }, fromPlugins?: string): Promise<NativeMenuOutcome> {
|
||||
const popup = getDesktopBridge()?.menu?.popup;
|
||||
if (!popup) return 'done';
|
||||
const items = read.hasFrames && fromPlugins
|
||||
? [...read.items, { type: 'separator' as const }, { id: HTML_MENU_ITEM, label: fromPlugins }]
|
||||
: read.items;
|
||||
document.documentElement.dataset.nativeMenuOpen = '';
|
||||
let picked: string | null;
|
||||
try {
|
||||
picked = await popup(items, at);
|
||||
} finally {
|
||||
delete document.documentElement.dataset.nativeMenuOpen;
|
||||
}
|
||||
if (!picked) return 'done';
|
||||
if (picked === HTML_MENU_ITEM) return 'html';
|
||||
let row = read.elements.get(picked);
|
||||
if (!row?.isConnected) {
|
||||
const again = root.isConnected ? readMenu(root) : null;
|
||||
row = again && labelOfId(again.items, picked) === labelOfId(read.items, picked) ? again.elements.get(picked) : undefined;
|
||||
}
|
||||
if (!row?.isConnected) return 'gone';
|
||||
row.click();
|
||||
return 'done';
|
||||
}
|
||||
|
||||
/** A native menu is up: the window's blur or a stray event says nothing about it. */
|
||||
export function nativeMenuIsOpen(): boolean {
|
||||
return typeof document !== 'undefined' && document.documentElement.dataset.nativeMenuOpen !== undefined;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* "Open Link" in the desktop app's native menu. It has to go where a click on the
|
||||
* link goes: a mail address to the app's composer, anything else past
|
||||
* resolveExternalLink, where a plugin can stop it or send it elsewhere.
|
||||
*/
|
||||
|
||||
import { isOpenableLinkHref } from '@/lib/email-sanitization';
|
||||
import { resolveExternalLink } from '@/lib/plugin-hook-result';
|
||||
import { requestInternalMailto } from '@/lib/protocol-handlers/mailto';
|
||||
import { getDesktopBridge } from './bridge';
|
||||
|
||||
export async function openLinkFromNativeMenu(href: string, text: string, emailId?: string): Promise<void> {
|
||||
const shell = getDesktopBridge()?.shell;
|
||||
if (!shell) return;
|
||||
if (/^mailto:/i.test(href)) {
|
||||
if (!requestInternalMailto(href)) shell.openExternal(href);
|
||||
return;
|
||||
}
|
||||
if (!isOpenableLinkHref(href)) return;
|
||||
const target = await resolveExternalLink({ href, target: '_blank', emailId, text });
|
||||
if (target) shell.openExternal(target);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* What the desktop app's windows do. These belong to this computer, not the account
|
||||
* (one user can have a laptop and a desktop that differ), so they stay out of the
|
||||
* synced settings. The shell gets its copy of "restore" from the host.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'bulwark-desktop-window-prefs';
|
||||
|
||||
export interface DesktopWindowPrefs {
|
||||
/** Double-clicking a message opens it in its own window. */
|
||||
openOnDoubleClick: boolean;
|
||||
/** The windows open at quit come back at the next start. */
|
||||
restoreWindows: boolean;
|
||||
/** Spellcheck dictionaries from Google, for a language the OS has no checker for. Asked once. */
|
||||
dictionaryDownload: 'ask' | 'yes' | 'no';
|
||||
}
|
||||
|
||||
const DEFAULTS: DesktopWindowPrefs = { openOnDoubleClick: true, restoreWindows: true, dictionaryDownload: 'ask' };
|
||||
|
||||
let cached: DesktopWindowPrefs | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function readDesktopPrefs(): DesktopWindowPrefs {
|
||||
if (cached) return cached;
|
||||
let stored: Partial<DesktopWindowPrefs> = {};
|
||||
try {
|
||||
const raw = typeof localStorage === 'undefined' ? null : localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) stored = JSON.parse(raw) as Partial<DesktopWindowPrefs>;
|
||||
} catch {
|
||||
// Damaged: the defaults.
|
||||
}
|
||||
cached = {
|
||||
openOnDoubleClick: typeof stored.openOnDoubleClick === 'boolean' ? stored.openOnDoubleClick : DEFAULTS.openOnDoubleClick,
|
||||
restoreWindows: typeof stored.restoreWindows === 'boolean' ? stored.restoreWindows : DEFAULTS.restoreWindows,
|
||||
dictionaryDownload: stored.dictionaryDownload === 'yes' || stored.dictionaryDownload === 'no' ? stored.dictionaryDownload : DEFAULTS.dictionaryDownload,
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function setDesktopPref<K extends keyof DesktopWindowPrefs>(key: K, value: DesktopWindowPrefs[K]): void {
|
||||
cached = { ...readDesktopPrefs(), [key]: value };
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(cached));
|
||||
} catch {
|
||||
// Full or unavailable: it holds until the window closes.
|
||||
}
|
||||
for (const listener of [...listeners]) listener();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
// Another window changed it.
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== STORAGE_KEY) return;
|
||||
cached = null;
|
||||
listener();
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
window.removeEventListener('storage', onStorage);
|
||||
};
|
||||
}
|
||||
|
||||
export function useDesktopPrefs(): DesktopWindowPrefs {
|
||||
return useSyncExternalStore(subscribe, readDesktopPrefs, () => DEFAULTS);
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetDesktopPrefsCache(): void {
|
||||
cached = null;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* The OS accent colour, when the user's theme is "system". Buttons and selection take
|
||||
* it the way native controls do. Not under a theme the user chose, and not when the
|
||||
* colour would be hard to see: the app's own stays then.
|
||||
*/
|
||||
|
||||
import { getDesktopBridge } from './bridge';
|
||||
|
||||
const VARIABLES = ['--color-primary', '--color-ring', '--color-primary-foreground'] as const;
|
||||
|
||||
function channels(hex: string): [number, number, number] | null {
|
||||
const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
|
||||
if (!match) return null;
|
||||
return [parseInt(match[1], 16), parseInt(match[2], 16), parseInt(match[3], 16)];
|
||||
}
|
||||
|
||||
function luminance([r, g, b]: [number, number, number]): number {
|
||||
const [lr, lg, lb] = [r, g, b].map((value) => {
|
||||
const c = value / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
|
||||
}
|
||||
|
||||
function contrast(a: number, b: number): number {
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
}
|
||||
|
||||
/** Non-text contrast, WCAG 1.4.11. */
|
||||
const MIN_AGAINST_BACKGROUND = 3;
|
||||
const MIN_TEXT = 4.5;
|
||||
|
||||
/**
|
||||
* The colours to use, or null for the app's own: the accent has to stand out from the
|
||||
* page, and text on it has to be readable in either black or white.
|
||||
*/
|
||||
export function accentColours(accent: string, dark: boolean): { primary: string; foreground: string } | null {
|
||||
const rgb = channels(accent);
|
||||
if (!rgb) return null;
|
||||
const own = luminance(rgb);
|
||||
// The standard theme's page backgrounds.
|
||||
const page = dark ? 0.0091 : 1;
|
||||
if (contrast(own, page) < MIN_AGAINST_BACKGROUND) return null;
|
||||
const onWhite = contrast(own, 1);
|
||||
const onBlack = contrast(own, 0);
|
||||
if (Math.max(onWhite, onBlack) < MIN_TEXT) return null;
|
||||
return { primary: accent, foreground: onWhite >= MIN_TEXT ? '#ffffff' : '#000000' };
|
||||
}
|
||||
|
||||
export interface AccentInput {
|
||||
accent?: string;
|
||||
/** Whether the page is dark now. */
|
||||
dark: boolean;
|
||||
/** The user's theme setting is "system" and no installed theme is active. */
|
||||
followsSystem: boolean;
|
||||
/** A contrast theme has its own colours and they win. */
|
||||
highContrast?: boolean;
|
||||
}
|
||||
|
||||
export function applySystemAccent({ accent, dark, followsSystem, highContrast }: AccentInput, root: HTMLElement = document.documentElement): void {
|
||||
const colours = accent && followsSystem && !highContrast && getDesktopBridge() ? accentColours(accent, dark) : null;
|
||||
if (!colours) {
|
||||
for (const name of VARIABLES) root.style.removeProperty(name);
|
||||
return;
|
||||
}
|
||||
root.style.setProperty('--color-primary', colours.primary);
|
||||
root.style.setProperty('--color-ring', colours.primary);
|
||||
root.style.setProperty('--color-primary-foreground', colours.foreground);
|
||||
}
|
||||
+107
-4
@@ -18,6 +18,8 @@ export interface DesktopWindowInfo {
|
||||
type: DesktopWindowType;
|
||||
title: string;
|
||||
focused: boolean;
|
||||
/** False for a main window hidden while pop-outs are open. */
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
export interface DesktopWindowsBridge {
|
||||
@@ -36,13 +38,72 @@ export interface DesktopWindowsBridge {
|
||||
list?: () => Promise<DesktopWindowInfo[]>;
|
||||
/** Fires when a window opens, closes, retitles or takes the focus. Returns the unsubscribe. */
|
||||
onChanged?: (listener: (windows: DesktopWindowInfo[]) => void) => () => void;
|
||||
/**
|
||||
* This page has saved or discarded and closes its window. Goes through the shell
|
||||
* so bounds get stored and no second save runs. Works however the window opened.
|
||||
*/
|
||||
closeSelf?: () => void;
|
||||
/** What this window's open drafts come back as at the next start: pop-out composer addresses. */
|
||||
setRestore?: (urls: string[]) => void;
|
||||
setRestoreEnabled?: (enabled: boolean) => void;
|
||||
/** Signed in, with a session to lend: windows from last time can open. */
|
||||
sessionReady?: () => void;
|
||||
/** The warm spare's page, see components/popout/popout-spare-app.tsx. */
|
||||
spare?: {
|
||||
ready: () => void;
|
||||
taken: () => void;
|
||||
onActivate: (listener: (url: string) => void) => () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DesktopThemeBridge {
|
||||
/** The page's colours, for title bar buttons and the first frame of new windows. */
|
||||
set: (theme: { dark: boolean; background: string; foreground?: string }) => void;
|
||||
/** Fires when the OS switches between light and dark. Returns the unsubscribe. */
|
||||
onSystemChange?: (listener: (system: { dark: boolean; highContrast: boolean }) => void) => () => void;
|
||||
/** Fires when the OS switches between light and dark, contrast or accent. Returns the unsubscribe. */
|
||||
onSystemChange?: (listener: (system: DesktopSystemTheme) => void) => () => void;
|
||||
/** How the OS looks now. */
|
||||
system?: () => Promise<DesktopSystemTheme>;
|
||||
}
|
||||
|
||||
export interface DesktopSystemTheme {
|
||||
dark: boolean;
|
||||
highContrast: boolean;
|
||||
/** The OS accent colour as #rrggbb, where the OS has one and tells. */
|
||||
accent?: string;
|
||||
}
|
||||
|
||||
export type DesktopMenuState = Record<string, boolean | { enabled?: boolean; checked?: boolean }>;
|
||||
|
||||
/** A native menu built from an HTML one (lib/desktop/native-menu.ts). */
|
||||
export interface DesktopNativeMenuItem {
|
||||
/** Handed back when this item is chosen. Left out of separators and submenus. */
|
||||
id?: string;
|
||||
label?: string;
|
||||
type?: 'normal' | 'separator' | 'checkbox' | 'radio';
|
||||
enabled?: boolean;
|
||||
checked?: boolean;
|
||||
submenu?: DesktopNativeMenuItem[];
|
||||
}
|
||||
|
||||
export interface DesktopMenuBarEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
/** The letter that opens it with Alt, lower case. */
|
||||
mnemonic?: string;
|
||||
}
|
||||
|
||||
export interface DesktopMenuBarEvent {
|
||||
visible: boolean;
|
||||
/** Open this menu right away: Alt plus its letter. */
|
||||
open?: string;
|
||||
}
|
||||
|
||||
/** What a page answers when asked to save before a window goes. */
|
||||
export interface DesktopFlushAnswer {
|
||||
failed?: number;
|
||||
timedOut?: boolean;
|
||||
/** Mail waiting to be sent. */
|
||||
unsent?: number;
|
||||
}
|
||||
|
||||
export interface DesktopMenuBridge {
|
||||
@@ -50,12 +111,51 @@ export interface DesktopMenuBridge {
|
||||
* What the app menu shows as enabled or checked, by command id. The shell
|
||||
* keeps the last state per window and applies it when that window has focus.
|
||||
*/
|
||||
setState: (state: Record<string, boolean | { enabled?: boolean; checked?: boolean }>) => void;
|
||||
setState: (state: DesktopMenuState) => void;
|
||||
/** A menu command to run. Returns the unsubscribe. */
|
||||
onCommand?: (listener: (id: string) => void) => () => void;
|
||||
/** Shows a native menu. At the pointer when `at` is left out. Answers with the id picked, or null. */
|
||||
popup?: (items: DesktopNativeMenuItem[], at?: { x: number; y: number }) => Promise<string | null>;
|
||||
/** The strip that stands in for a menu bar in a window with no native bar. */
|
||||
bar?: {
|
||||
entries: () => Promise<DesktopMenuBarEntry[]>;
|
||||
/** Opens that menu natively, at page coordinates. */
|
||||
open: (id: string, at: { x: number; y: number }) => void;
|
||||
onEvent: (listener: (event: DesktopMenuBarEvent) => void) => () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DesktopShellBridge {
|
||||
/** http, https and mailto only. */
|
||||
openExternal: (url: string) => void;
|
||||
/** The shell's menus follow `ui`, in `labels` (lib/desktop/languages.ts). Spellcheck follows `ui` and `composer`. */
|
||||
setLanguages?: (languages: { ui: string; composer?: string[]; labels?: Record<string, string> }) => void;
|
||||
}
|
||||
|
||||
export interface DesktopSpellcheckBridge {
|
||||
/** Languages with neither an OS checker nor a dictionary here. */
|
||||
missing: () => Promise<string[]>;
|
||||
onMissing: (listener: (language: string) => void) => () => void;
|
||||
/** The user said yes: dictionaries may come from Google. Never with no-third-party mode on. */
|
||||
allowDownload: () => void;
|
||||
}
|
||||
|
||||
export interface DesktopDialogsBridge {
|
||||
/** A native question. Answers with the index of the button chosen. The last button is the safe one. */
|
||||
confirm: (options: {
|
||||
message: string;
|
||||
detail?: string;
|
||||
buttons: string[];
|
||||
defaultId?: number;
|
||||
type?: 'none' | 'info' | 'warning' | 'question';
|
||||
}) => Promise<number>;
|
||||
}
|
||||
|
||||
export interface DesktopClipboardBridge {
|
||||
/** Puts a file on the clipboard, for pasting into a folder. False when that didn't work. */
|
||||
copyFile: (name: string, data: ArrayBuffer) => Promise<boolean>;
|
||||
/** Signed out: nothing copied from that account stays on disk. */
|
||||
clearFiles?: () => void;
|
||||
}
|
||||
|
||||
export type DesktopServerPrepareResult =
|
||||
@@ -85,7 +185,7 @@ export interface DesktopLifecycleBridge {
|
||||
* Before a window closes or the app quits, the shell asks the page to save what
|
||||
* it has and waits a few seconds. A window with no handler closes at once.
|
||||
*/
|
||||
onFlushRequest: (handler: () => Promise<unknown>) => () => void;
|
||||
onFlushRequest: (handler: () => Promise<DesktopFlushAnswer | unknown>) => () => void;
|
||||
}
|
||||
|
||||
export type DesktopBrowserSignInResult =
|
||||
@@ -360,6 +460,9 @@ export interface BulwarkDesktopBridge {
|
||||
theme?: DesktopThemeBridge;
|
||||
menu?: DesktopMenuBridge;
|
||||
shell?: DesktopShellBridge;
|
||||
dialogs?: DesktopDialogsBridge;
|
||||
spellcheck?: DesktopSpellcheckBridge;
|
||||
clipboard?: DesktopClipboardBridge;
|
||||
lifecycle?: DesktopLifecycleBridge;
|
||||
os?: DesktopOsBridge;
|
||||
policy?: DesktopPolicyBridge;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* What a composer comes back as when the app starts again: a pop-out on its draft.
|
||||
* Each open composer says which draft it holds, as soon as it has one. The shell
|
||||
* keeps the list per window. Closing a window drops its list, so a draft the user
|
||||
* put away stays in Drafts and doesn't come back on screen. Quitting keeps it.
|
||||
*/
|
||||
|
||||
import { appPath } from '@/lib/deep-links';
|
||||
import { getDesktopBridge } from './bridge';
|
||||
|
||||
export interface OpenDraft {
|
||||
draftId: string;
|
||||
/** The login it was saved through. */
|
||||
localAccountId?: string | null;
|
||||
/** The JMAP account holding it, when not that login's own. */
|
||||
ownerAccountId?: string | null;
|
||||
}
|
||||
|
||||
const open = new Map<string, OpenDraft>();
|
||||
|
||||
export function restoreUrlFor(draft: OpenDraft): string {
|
||||
const search = new URLSearchParams({ draft: draft.draftId });
|
||||
if (draft.localAccountId) search.set('account', draft.localAccountId);
|
||||
if (draft.ownerAccountId) search.set('owner', draft.ownerAccountId);
|
||||
return `${appPath('/popout/compose')}?${search.toString()}`;
|
||||
}
|
||||
|
||||
/** With null, this composer holds no draft any more: sent, discarded or closed. */
|
||||
export function noteOpenDraft(key: string, draft: OpenDraft | null): void {
|
||||
const windows = getDesktopBridge()?.windows;
|
||||
if (!windows?.setRestore) return;
|
||||
if (draft) open.set(key, draft);
|
||||
else if (!open.delete(key)) return;
|
||||
windows.setRestore([...open.values()].map(restoreUrlFor));
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetOpenDrafts(): void {
|
||||
open.clear();
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ComposerDraftData } from '@/components/email/email-composer';
|
||||
import { findDraftIdentityIdForLogin, type DraftSource } from '@/lib/draft-source';
|
||||
import { getDraftComposerBody } from '@/lib/email-body-selection';
|
||||
import type { Email, Identity, Mailbox } from '@/lib/jmap/types';
|
||||
import { messagePriorityFromHeaders } from '@/lib/message-priority';
|
||||
import { reopenedLabelChoice } from '@/lib/outgoing/classification';
|
||||
|
||||
/**
|
||||
* Whether a message may reopen as a draft from an id kept since last time. Sending
|
||||
* keeps the id, and a composer on it would replace, resend or delete the message. A
|
||||
* draft has `$draft` or sits in Drafts (a send undone comes back without the keyword);
|
||||
* and in Sent or Trash it's nothing to edit, whatever its keywords say.
|
||||
*/
|
||||
export function isRestorableDraft(
|
||||
draft: Pick<Email, 'keywords' | 'mailboxIds'>,
|
||||
mailboxes: Pick<Mailbox, 'id' | 'originalId' | 'role'>[],
|
||||
): boolean {
|
||||
const roles = Object.keys(draft.mailboxIds ?? {}).map((id) => mailboxes.find((m) => m.id === id || m.originalId === id)?.role);
|
||||
if (roles.includes('sent') || roles.includes('trash')) return false;
|
||||
return !!draft.keywords?.$draft || roles.includes('drafts');
|
||||
}
|
||||
|
||||
/**
|
||||
* What a composer starts from when a saved draft is opened: from the list, or from
|
||||
* last time the desktop app was running. `draft` has its full body.
|
||||
*/
|
||||
export function composerDataFromDraft(
|
||||
draft: Email,
|
||||
{ accountId, localAccountId, identities, noSubject }: DraftSource & { identities: Identity[]; noSubject: string },
|
||||
): ComposerDraftData {
|
||||
const { body, plainTextMode } = getDraftComposerBody(draft);
|
||||
return {
|
||||
to: draft.to?.map((a) => a.email).filter(Boolean).join(', ') || '',
|
||||
cc: draft.cc?.map((a) => a.email).filter(Boolean).join(', ') || '',
|
||||
bcc: draft.bcc?.map((a) => a.email).filter(Boolean).join(', ') || '',
|
||||
// Drafts saved before #1189 stored an empty subject as the composer's
|
||||
// "(No Subject)" placeholder; reopen those with an empty field.
|
||||
subject: draft.subject && draft.subject !== noSubject ? draft.subject : '',
|
||||
body,
|
||||
// Re-open in the format the draft was written in (#1022).
|
||||
plainTextMode,
|
||||
showCc: (draft.cc?.length || 0) > 0,
|
||||
showBcc: (draft.bcc?.length || 0) > 0,
|
||||
// Restore the identity the draft was composed with: the saved From (name and
|
||||
// address) against the list the composer renders, so a draft from a non-active
|
||||
// account, or one of two identities sharing an address, isn't reset to the default.
|
||||
selectedIdentityId: findDraftIdentityIdForLogin(identities, draft.from?.[0], localAccountId),
|
||||
subAddressTag: '',
|
||||
mode: 'compose',
|
||||
draftId: draft.id,
|
||||
draftAccountId: accountId,
|
||||
draftLocalAccountId: localAccountId,
|
||||
// A reply draft re-opens in compose mode; its threading headers must
|
||||
// come along or the reply leaves its thread.
|
||||
inReplyTo: draft.inReplyTo ?? undefined,
|
||||
references: draft.references ?? undefined,
|
||||
priority: messagePriorityFromHeaders(draft.headers),
|
||||
// A sent message brought back (Undo, Cancel and edit) has no draft
|
||||
// marker: its SIO-Label says which label it went out with.
|
||||
classification: reopenedLabelChoice(draft.headers, localAccountId),
|
||||
// Existing server-side attachments must ride along, or the composer
|
||||
// starts empty and the next save/send silently rebuilds the draft
|
||||
// without them (#849).
|
||||
attachments: (draft.attachments ?? [])
|
||||
.filter((a) => !!a.blobId)
|
||||
.map((a) => ({
|
||||
blobId: a.blobId,
|
||||
name: a.name,
|
||||
type: a.type,
|
||||
size: a.size,
|
||||
cid: a.cid,
|
||||
disposition: a.disposition,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export const BUILT_IN_HOSTNAMES: readonly string[] = [
|
||||
'www.google.com',
|
||||
'randomuser.me',
|
||||
'img.freepik.com',
|
||||
'redirector.gvt1.com',
|
||||
];
|
||||
|
||||
function hostnameOf(url: string): string | null {
|
||||
|
||||
@@ -75,6 +75,12 @@ export function openMessageWindow(target: MessageWindowTarget): boolean {
|
||||
return focused(openWindow(messagePopoutUrl(target), `bulwark-message-${target.emailId.replace(/\W/g, '_')}`, 'message'));
|
||||
}
|
||||
|
||||
/** A new message in its own window, with nothing to hand over. */
|
||||
export function openNewComposerWindow(localAccountId?: string | null): boolean {
|
||||
const name = `bulwark-compose-new-${Date.now().toString(36)}`;
|
||||
return focused(openWindow(appPath('/popout/compose') + query({ account: localAccountId }), name, 'compose'));
|
||||
}
|
||||
|
||||
export interface ComposerWindow {
|
||||
/** Hands the draft over. True once the composer is on screen there; until then the sender keeps its copy. */
|
||||
handOver(payload: ComposePopoutPayload): Promise<boolean>;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* stores/auth-store.ts and stores/account-store.ts ask here.
|
||||
*/
|
||||
|
||||
import { getDesktopBridge } from '@/lib/desktop/bridge';
|
||||
import { withBasePath } from '@/lib/browser-navigation';
|
||||
import { locales } from '@/i18n/routing';
|
||||
|
||||
@@ -28,7 +29,7 @@ export function isPopoutPath(pathname: string): boolean {
|
||||
if (before.length > 0 && !(locales as readonly string[]).includes(before[before.length - 1])) return false;
|
||||
if (before.some((segment) => OTHER_SECTIONS.has(segment))) return false;
|
||||
const rest = segments.slice(at + 1);
|
||||
return (rest.length === 1 && rest[0] === 'compose') || (rest.length === 2 && rest[0] === 'message');
|
||||
return (rest.length === 1 && (rest[0] === 'compose' || rest[0] === 'spare')) || (rest.length === 2 && rest[0] === 'message');
|
||||
}
|
||||
|
||||
export function isPopoutWindow(): boolean {
|
||||
@@ -61,6 +62,13 @@ export function mainWindowStorage(): Pick<Storage, 'getItem' | 'setItem' | 'remo
|
||||
*/
|
||||
export function closePopoutWindow(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
// The desktop app closes it for us: however the window came to be, and past the
|
||||
// shell's own bookkeeping. Whoever calls this has saved or discarded by now.
|
||||
const closeSelf = getDesktopBridge()?.windows?.closeSelf;
|
||||
if (closeSelf) {
|
||||
closeSelf();
|
||||
return;
|
||||
}
|
||||
window.close();
|
||||
window.setTimeout(() => {
|
||||
if (!window.closed) window.location.replace(withBasePath('/'));
|
||||
|
||||
+114
-12
@@ -3,15 +3,20 @@
|
||||
* titleBarOverlay window) draws the page up to the top of the window and lays
|
||||
* the native minimise, maximise and close buttons over it.
|
||||
*
|
||||
* There's no title bar row. The app's own columns run to the top of the window,
|
||||
* the window's controls sit in the top row of whichever column is there, and that
|
||||
* row leaves them room. Its empty parts are what the window is dragged by, and a
|
||||
* double-click on them maximises, as on a title bar.
|
||||
*
|
||||
* Which row that is depends on the page and the width: the reader's toolbar, the
|
||||
* list's header when there's no reader, a dialog that fills the window. So this
|
||||
* looks: an app row that sits at the top of the window gets marked, and CSS in
|
||||
* app/globals.css does the rest. A row that starts the window's top row is an h-14
|
||||
* (or h-12, h-16) box all over the app; anything else says `data-window-top-row`.
|
||||
*
|
||||
* Asks navigator.windowControlsOverlay, never the media query:
|
||||
* `(display-mode: window-controls-overlay)` is false in Electron with
|
||||
* titleBarOverlay, while the API and env(titlebar-area-*) work there.
|
||||
*
|
||||
* app/globals.css gives --titlebar-inset its first value from
|
||||
* env(titlebar-area-height, 0px), so the page has the right top padding from
|
||||
* the first paint, before any script runs. This module keeps the variable in
|
||||
* step with the API afterwards, which is what a browser or bridge without the
|
||||
* env() values relies on.
|
||||
*/
|
||||
|
||||
export interface TitlebarArea {
|
||||
@@ -26,7 +31,19 @@ interface OverlayLike extends EventTarget {
|
||||
getTitlebarAreaRect?: () => { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
export const TITLEBAR_INSET_VAR = '--titlebar-inset';
|
||||
export const TITLEBAR_RIGHT_VAR = '--titlebar-right-inset';
|
||||
export const TITLEBAR_LEFT_VAR = '--titlebar-left-inset';
|
||||
export const TITLEBAR_ROW_VAR = '--titlebar-row-height';
|
||||
|
||||
/** The window has its controls over the page. */
|
||||
export const CONTROLS_ATTRIBUTE = 'data-window-controls';
|
||||
/** They're at the left (macOS): the rail makes room at its top. */
|
||||
export const CONTROLS_LEFT_ATTRIBUTE = 'data-window-controls-left';
|
||||
/** This row is at the top of the window: its empty parts drag. */
|
||||
export const ROW_ATTRIBUTE = 'data-window-row';
|
||||
/** The window's controls are over this end of it. */
|
||||
export const ROW_RIGHT_ATTRIBUTE = 'data-window-row-right';
|
||||
export const ROW_LEFT_ATTRIBUTE = 'data-window-row-left';
|
||||
|
||||
function overlay(): OverlayLike | null {
|
||||
if (typeof navigator === 'undefined') return null;
|
||||
@@ -43,13 +60,28 @@ export function readTitlebarArea(): TitlebarArea | null {
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
}
|
||||
|
||||
/** What the controls take of each side, from the strip they leave. */
|
||||
export function controlsInsets(area: TitlebarArea, viewportWidth: number): { left: number; right: number } {
|
||||
return { left: Math.max(0, area.x), right: Math.max(0, viewportWidth - area.x - area.width) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets --titlebar-inset for this area. With no area the inline value goes, so
|
||||
* the stylesheet's value (0 without an overlay) is what counts again.
|
||||
* Says where the controls are, for the CSS. With no area the inline values go, so
|
||||
* what the stylesheet has from env() (nothing without an overlay) counts again.
|
||||
*/
|
||||
export function applyTitlebarInset(area: TitlebarArea | null, root: HTMLElement = document.documentElement): void {
|
||||
if (area) root.style.setProperty(TITLEBAR_INSET_VAR, `${area.height}px`);
|
||||
else root.style.removeProperty(TITLEBAR_INSET_VAR);
|
||||
export function applyControlsArea(area: TitlebarArea | null, root: HTMLElement = document.documentElement): void {
|
||||
if (!area) {
|
||||
for (const name of [TITLEBAR_RIGHT_VAR, TITLEBAR_LEFT_VAR, TITLEBAR_ROW_VAR]) root.style.removeProperty(name);
|
||||
root.removeAttribute(CONTROLS_ATTRIBUTE);
|
||||
root.removeAttribute(CONTROLS_LEFT_ATTRIBUTE);
|
||||
return;
|
||||
}
|
||||
const { left, right } = controlsInsets(area, root.ownerDocument.defaultView?.innerWidth ?? 0);
|
||||
root.style.setProperty(TITLEBAR_RIGHT_VAR, `${right}px`);
|
||||
root.style.setProperty(TITLEBAR_LEFT_VAR, `${left}px`);
|
||||
root.style.setProperty(TITLEBAR_ROW_VAR, `${area.height}px`);
|
||||
root.setAttribute(CONTROLS_ATTRIBUTE, '');
|
||||
root.toggleAttribute(CONTROLS_LEFT_ATTRIBUTE, left > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,3 +98,73 @@ export function watchTitlebarArea(listener: (area: TitlebarArea | null) => void)
|
||||
current.addEventListener('geometrychange', onChange);
|
||||
return () => current.removeEventListener('geometrychange', onChange);
|
||||
}
|
||||
|
||||
const ROW_CANDIDATES = '[data-window-top-row], [data-composer-header], header, div.h-14, div.min-h-14, div.h-12, div.h-16, div.min-h-16';
|
||||
/** No toolbar is taller than this: past it, it's a tile or a pane that happens to sit at the top. */
|
||||
const MAX_ROW_HEIGHT = 96;
|
||||
|
||||
/**
|
||||
* Marks the rows at the top of the window, for what lies over them, and returns them.
|
||||
* One pass, cheap: a dozen rects.
|
||||
*/
|
||||
export function markWindowRows(area: TitlebarArea, doc: Document = document): HTMLElement[] {
|
||||
const viewportWidth = doc.defaultView?.innerWidth ?? 0;
|
||||
const { left, right } = controlsInsets(area, viewportWidth);
|
||||
const seen = new Set<HTMLElement>();
|
||||
for (const el of Array.from(doc.querySelectorAll<HTMLElement>(ROW_CANDIDATES))) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const explicit = el.hasAttribute('data-window-top-row');
|
||||
const fits = rect.top <= 1 && rect.height > 0 && rect.width > 0 && (explicit || rect.height <= MAX_ROW_HEIGHT);
|
||||
// The outermost box of a row: an inner one sits inside its padding already.
|
||||
const nested = fits && [...seen].some((outer) => outer.contains(el));
|
||||
if (!fits || nested) {
|
||||
el.removeAttribute(ROW_ATTRIBUTE);
|
||||
el.removeAttribute(ROW_RIGHT_ATTRIBUTE);
|
||||
el.removeAttribute(ROW_LEFT_ATTRIBUTE);
|
||||
continue;
|
||||
}
|
||||
seen.add(el);
|
||||
el.setAttribute(ROW_ATTRIBUTE, '');
|
||||
el.toggleAttribute(ROW_RIGHT_ATTRIBUTE, right > 0 && rect.right > viewportWidth - right);
|
||||
el.toggleAttribute(ROW_LEFT_ATTRIBUTE, left > 0 && rect.left < left);
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps those marks right as the page changes, before the next paint. Mutation and
|
||||
* resize callbacks both run ahead of painting, so a row never shows without its room.
|
||||
* A pane can also change width with no change to the page, in a transition or by
|
||||
* being dragged, so the rows themselves are watched too, and transitions' ends.
|
||||
*/
|
||||
export function watchWindowRows(area: TitlebarArea, doc: Document = document): () => void {
|
||||
let queued = false;
|
||||
let watched = new Set<HTMLElement>();
|
||||
const resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(() => queue());
|
||||
const run = () => {
|
||||
queued = false;
|
||||
const rows = new Set(markWindowRows(area, doc));
|
||||
if (!resize) return;
|
||||
for (const row of watched) if (!rows.has(row)) resize.unobserve(row);
|
||||
for (const row of rows) if (!watched.has(row)) resize.observe(row);
|
||||
watched = rows;
|
||||
};
|
||||
function queue() {
|
||||
if (queued) return;
|
||||
queued = true;
|
||||
queueMicrotask(run);
|
||||
}
|
||||
run();
|
||||
const mutations = new MutationObserver((records) => {
|
||||
// Our own marks changing say nothing new.
|
||||
if (records.some((record) => record.type !== 'attributes' || !record.attributeName?.startsWith('data-window-'))) queue();
|
||||
});
|
||||
mutations.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['class', 'hidden', 'style'] });
|
||||
resize?.observe(doc.documentElement);
|
||||
doc.addEventListener('transitionend', queue, true);
|
||||
return () => {
|
||||
mutations.disconnect();
|
||||
resize?.disconnect();
|
||||
doc.removeEventListener('transitionend', queue, true);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "تم نقل هذه الرسالة إلى {folder}.",
|
||||
"window_not_ready": "لم تفتح النافذة الجديدة في الوقت المناسب. تبقى مسودتك هنا."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "النوافذ",
|
||||
"double_click_label": "فتح الرسائل في نافذة جديدة بالنقر المزدوج",
|
||||
"double_click_description": "النقرة الواحدة تفتح الرسالة بجانب القائمة كالمعتاد.",
|
||||
"restore_label": "إعادة فتح النوافذ عند البدء",
|
||||
"restore_description": "تعود نوافذ الرسائل والمسودات التي كانت مفتوحة عند الخروج."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "فتح",
|
||||
"save_as": "حفظ باسم…",
|
||||
"copy": "نسخ",
|
||||
"copy_failed": "تعذّر نسخ الملف."
|
||||
},
|
||||
"menu": {
|
||||
"file": "ملف",
|
||||
"edit": "تحرير",
|
||||
"view": "عرض",
|
||||
"message": "رسالة",
|
||||
"go": "انتقال",
|
||||
"window": "نافذة",
|
||||
"help": "مساعدة",
|
||||
"new_window": "نافذة جديدة",
|
||||
"print": "طباعة…",
|
||||
"save_pdf": "حفظ بصيغة PDF…",
|
||||
"switch_connection": "تغيير الاتصال…",
|
||||
"close": "إغلاق",
|
||||
"quit": "إنهاء",
|
||||
"undo": "تراجع",
|
||||
"redo": "إعادة",
|
||||
"cut": "قص",
|
||||
"copy": "نسخ",
|
||||
"paste": "لصق",
|
||||
"select_all": "تحديد الكل",
|
||||
"search": "بحث في البريد",
|
||||
"refresh": "التحقق من البريد الجديد",
|
||||
"reading_pane": "جزء القراءة",
|
||||
"pane_right": "على اليمين",
|
||||
"pane_below": "أسفل القائمة",
|
||||
"pane_off": "إيقاف",
|
||||
"zoom_in": "تكبير",
|
||||
"zoom_out": "تصغير",
|
||||
"zoom_reset": "الحجم الفعلي",
|
||||
"full_screen": "ملء الشاشة",
|
||||
"open_in_window": "فتح في نافذة جديدة",
|
||||
"next_message": "الرسالة التالية",
|
||||
"previous_message": "الرسالة السابقة",
|
||||
"next_unread": "الرسالة التالية غير المقروءة",
|
||||
"mail": "البريد",
|
||||
"files": "الملفات",
|
||||
"back": "رجوع",
|
||||
"forward": "للأمام",
|
||||
"minimize": "تصغير",
|
||||
"next_window": "النافذة التالية",
|
||||
"shortcuts": "اختصارات لوحة المفاتيح",
|
||||
"docs": "الوثائق",
|
||||
"settings": "الإعدادات…",
|
||||
"about": "حول Bulwark",
|
||||
"from_plugins": "المزيد من الإضافات…",
|
||||
"pick_gone": "هذه القائمة قديمة. افتحها مرة أخرى وحاول ثانية.",
|
||||
"bar_label": "القائمة"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "إضافة «{word}» إلى القاموس",
|
||||
"no_suggestions": "لا توجد اقتراحات",
|
||||
"open_link": "فتح الرابط",
|
||||
"copy_link": "نسخ الرابط",
|
||||
"copy_address": "نسخ العنوان",
|
||||
"save_image": "حفظ الصورة…",
|
||||
"copy_image": "نسخ الصورة"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "تعذّر حفظ مسودة",
|
||||
"unsaved_detail": "إذا أغلقت الآن، سيضيع ما كتبته منذ آخر حفظ.",
|
||||
"close_anyway": "إغلاق على أي حال",
|
||||
"unsent_title": "توجد رسائل لم تُرسل بعد",
|
||||
"unsent_one": "توجد رسالة واحدة في انتظار الإرسال. ستُرسل في المرة القادمة التي تفتح فيها Bulwark.",
|
||||
"unsent_many": "توجد {count} رسائل في انتظار الإرسال. ستُرسل في المرة القادمة التي تفتح فيها Bulwark.",
|
||||
"quit": "إنهاء",
|
||||
"leave_title": "المغادرة مع تغييرات غير محفوظة؟",
|
||||
"leave_detail": "لم تُحفظ بعد التغييرات التي أجريتها في هذه الصفحة.",
|
||||
"leave": "مغادرة",
|
||||
"stay": "بقاء"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "يحتاج التدقيق الإملائي بلغة {language} إلى قاموس. يمكن لـ Bulwark تنزيله من Google. لا يُرسل نصك.",
|
||||
"download": "تنزيل"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "تعذّر فتح هذه المسودة الآن",
|
||||
"unavailable_body": "إنها محفوظة بأمان على الخادم في المسودات. حاول مرة أخرى عند عودة الاتصال.",
|
||||
"try_again": "إعادة المحاولة",
|
||||
"close": "إغلاق"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "سجّل الدخول باستخدام المتصفح",
|
||||
"browser_waiting": "أكمل تسجيل الدخول في المتصفح",
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "Aquest missatge s’ha mogut a {folder}.",
|
||||
"window_not_ready": "La finestra nova no s’ha obert a temps. L’esborrany es queda aquí."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "Finestres",
|
||||
"double_click_label": "Obre els missatges en una finestra nova amb doble clic",
|
||||
"double_click_description": "Un sol clic continua obrint el missatge al costat de la llista.",
|
||||
"restore_label": "Torna a obrir les finestres en iniciar",
|
||||
"restore_description": "Les finestres de missatges i esborranys obertes en sortir tornen a aparèixer."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "Obre",
|
||||
"save_as": "Desa com a…",
|
||||
"copy": "Copia",
|
||||
"copy_failed": "No s’ha pogut copiar el fitxer."
|
||||
},
|
||||
"menu": {
|
||||
"file": "Fitxer",
|
||||
"edit": "Edita",
|
||||
"view": "Visualitza",
|
||||
"message": "Missatge",
|
||||
"go": "Vés-hi",
|
||||
"window": "Finestra",
|
||||
"help": "Ajuda",
|
||||
"new_window": "Finestra nova",
|
||||
"print": "Imprimeix…",
|
||||
"save_pdf": "Desa com a PDF…",
|
||||
"switch_connection": "Canvia de connexió…",
|
||||
"close": "Tanca",
|
||||
"quit": "Surt",
|
||||
"undo": "Desfés",
|
||||
"redo": "Refés",
|
||||
"cut": "Retalla",
|
||||
"copy": "Copia",
|
||||
"paste": "Enganxa",
|
||||
"select_all": "Selecciona-ho tot",
|
||||
"search": "Cerca al correu",
|
||||
"refresh": "Comprova si hi ha correu nou",
|
||||
"reading_pane": "Tauler de lectura",
|
||||
"pane_right": "A la dreta",
|
||||
"pane_below": "Sota la llista",
|
||||
"pane_off": "Desactivat",
|
||||
"zoom_in": "Amplia",
|
||||
"zoom_out": "Redueix",
|
||||
"zoom_reset": "Mida real",
|
||||
"full_screen": "Pantalla completa",
|
||||
"open_in_window": "Obre en una finestra nova",
|
||||
"next_message": "Missatge següent",
|
||||
"previous_message": "Missatge anterior",
|
||||
"next_unread": "Següent missatge no llegit",
|
||||
"mail": "Correu",
|
||||
"files": "Fitxers",
|
||||
"back": "Enrere",
|
||||
"forward": "Endavant",
|
||||
"minimize": "Minimitza",
|
||||
"next_window": "Finestra següent",
|
||||
"shortcuts": "Dreceres de teclat",
|
||||
"docs": "Documentació",
|
||||
"settings": "Configuració…",
|
||||
"about": "Quant a Bulwark",
|
||||
"from_plugins": "Més dels connectors…",
|
||||
"pick_gone": "Aquest menú estava desfasat. Torneu-lo a obrir i proveu-ho una altra vegada.",
|
||||
"bar_label": "Menú"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Afegeix «{word}» al diccionari",
|
||||
"no_suggestions": "Cap suggeriment",
|
||||
"open_link": "Obre l’enllaç",
|
||||
"copy_link": "Copia l’enllaç",
|
||||
"copy_address": "Copia l’adreça",
|
||||
"save_image": "Desa la imatge…",
|
||||
"copy_image": "Copia la imatge"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "No s’ha pogut desar un esborrany",
|
||||
"unsaved_detail": "Si tanqueu ara, es perdrà el que heu escrit des de l’últim desament.",
|
||||
"close_anyway": "Tanca igualment",
|
||||
"unsent_title": "Hi ha correu sense enviar",
|
||||
"unsent_one": "Hi ha un missatge pendent d’enviar. Se n’anirà la propera vegada que obriu Bulwark.",
|
||||
"unsent_many": "Hi ha {count} missatges pendents d’enviar. Se n’aniran la propera vegada que obriu Bulwark.",
|
||||
"quit": "Surt",
|
||||
"leave_title": "Voleu sortir amb canvis sense desar?",
|
||||
"leave_detail": "Els canvis fets en aquesta pàgina encara no s’han desat.",
|
||||
"leave": "Surt",
|
||||
"stay": "Queda’t"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "La revisió ortogràfica en {language} necessita un diccionari. Bulwark el pot baixar de Google. El vostre text no s’envia.",
|
||||
"download": "Baixa"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "Aquest esborrany no es pot obrir ara",
|
||||
"unavailable_body": "Està segur al servidor, a Esborranys. Torneu-ho a provar quan hi hagi connexió.",
|
||||
"try_again": "Torna-ho a provar",
|
||||
"close": "Tanca"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Inicia la sessió amb el navegador",
|
||||
"browser_waiting": "Acaba d'iniciar la sessió al navegador",
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "Tato zpráva byla přesunuta do {folder}.",
|
||||
"window_not_ready": "Nové okno se neotevřelo včas. Koncept zůstává zde."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "Okna",
|
||||
"double_click_label": "Otevírat zprávy v novém okně dvojitým kliknutím",
|
||||
"double_click_description": "Jedním kliknutím se zpráva dál otevře vedle seznamu.",
|
||||
"restore_label": "Při spuštění znovu otevřít okna",
|
||||
"restore_description": "Okna zpráv a konceptů otevřená při ukončení se znovu otevřou."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "Otevřít",
|
||||
"save_as": "Uložit jako…",
|
||||
"copy": "Kopírovat",
|
||||
"copy_failed": "Soubor se nepodařilo zkopírovat."
|
||||
},
|
||||
"menu": {
|
||||
"file": "Soubor",
|
||||
"edit": "Úpravy",
|
||||
"view": "Zobrazení",
|
||||
"message": "Zpráva",
|
||||
"go": "Přejít",
|
||||
"window": "Okno",
|
||||
"help": "Nápověda",
|
||||
"new_window": "Nové okno",
|
||||
"print": "Tisk…",
|
||||
"save_pdf": "Uložit jako PDF…",
|
||||
"switch_connection": "Změnit připojení…",
|
||||
"close": "Zavřít",
|
||||
"quit": "Ukončit",
|
||||
"undo": "Zpět",
|
||||
"redo": "Znovu",
|
||||
"cut": "Vyjmout",
|
||||
"copy": "Kopírovat",
|
||||
"paste": "Vložit",
|
||||
"select_all": "Vybrat vše",
|
||||
"search": "Hledat v poště",
|
||||
"refresh": "Zkontrolovat novou poštu",
|
||||
"reading_pane": "Podokno čtení",
|
||||
"pane_right": "Vpravo",
|
||||
"pane_below": "Pod seznamem",
|
||||
"pane_off": "Vypnuto",
|
||||
"zoom_in": "Přiblížit",
|
||||
"zoom_out": "Oddálit",
|
||||
"zoom_reset": "Skutečná velikost",
|
||||
"full_screen": "Celá obrazovka",
|
||||
"open_in_window": "Otevřít v novém okně",
|
||||
"next_message": "Další zpráva",
|
||||
"previous_message": "Předchozí zpráva",
|
||||
"next_unread": "Další nepřečtená zpráva",
|
||||
"mail": "Pošta",
|
||||
"files": "Soubory",
|
||||
"back": "Zpět",
|
||||
"forward": "Vpřed",
|
||||
"minimize": "Minimalizovat",
|
||||
"next_window": "Další okno",
|
||||
"shortcuts": "Klávesové zkratky",
|
||||
"docs": "Dokumentace",
|
||||
"settings": "Nastavení…",
|
||||
"about": "O aplikaci Bulwark",
|
||||
"from_plugins": "Více z doplňků…",
|
||||
"pick_gone": "Tato nabídka byla zastaralá. Otevřete ji znovu a zkuste to ještě jednou.",
|
||||
"bar_label": "Nabídka"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Přidat „{word}“ do slovníku",
|
||||
"no_suggestions": "Žádné návrhy",
|
||||
"open_link": "Otevřít odkaz",
|
||||
"copy_link": "Kopírovat odkaz",
|
||||
"copy_address": "Kopírovat adresu",
|
||||
"save_image": "Uložit obrázek…",
|
||||
"copy_image": "Kopírovat obrázek"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "Koncept se nepodařilo uložit",
|
||||
"unsaved_detail": "Pokud nyní okno zavřete, přijdete o to, co jste napsali od posledního uložení.",
|
||||
"close_anyway": "Přesto zavřít",
|
||||
"unsent_title": "Některá pošta ještě nebyla odeslána",
|
||||
"unsent_one": "Jedna zpráva čeká na odeslání. Odejde při dalším spuštění Bulwark.",
|
||||
"unsent_many": "Počet zpráv čekajících na odeslání: {count}. Odejdou při dalším spuštění Bulwark.",
|
||||
"quit": "Ukončit",
|
||||
"leave_title": "Odejít s neuloženými změnami?",
|
||||
"leave_detail": "Změny na této stránce zatím nejsou uloženy.",
|
||||
"leave": "Odejít",
|
||||
"stay": "Zůstat"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "Kontrola pravopisu pro jazyk {language} potřebuje slovník. Bulwark ho může stáhnout od společnosti Google. Váš text se neodesílá.",
|
||||
"download": "Stáhnout"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "Tento koncept se teď nedá otevřít",
|
||||
"unavailable_body": "Je v bezpečí na serveru ve složce Koncepty. Zkuste to znovu, až se obnoví připojení.",
|
||||
"try_again": "Zkusit znovu",
|
||||
"close": "Zavřít"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Přihlásit se v prohlížeči",
|
||||
"browser_waiting": "Dokončete přihlášení v prohlížeči",
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "Denne besked er flyttet til {folder}.",
|
||||
"window_not_ready": "Det nye vindue åbnede ikke i tide. Din kladde bliver her."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "Vinduer",
|
||||
"double_click_label": "Åbn beskeder i et nyt vindue ved dobbeltklik",
|
||||
"double_click_description": "Et enkelt klik åbner stadig beskeden ved siden af listen.",
|
||||
"restore_label": "Åbn vinduer igen ved start",
|
||||
"restore_description": "Besked- og kladdevinduer, der var åbne, da du lukkede, kommer tilbage."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "Åbn",
|
||||
"save_as": "Gem som…",
|
||||
"copy": "Kopiér",
|
||||
"copy_failed": "Filen kunne ikke kopieres."
|
||||
},
|
||||
"menu": {
|
||||
"file": "Filer",
|
||||
"edit": "Rediger",
|
||||
"view": "Vis",
|
||||
"message": "Besked",
|
||||
"go": "Gå",
|
||||
"window": "Vindue",
|
||||
"help": "Hjælp",
|
||||
"new_window": "Nyt vindue",
|
||||
"print": "Udskriv…",
|
||||
"save_pdf": "Gem som PDF…",
|
||||
"switch_connection": "Skift forbindelse…",
|
||||
"close": "Luk",
|
||||
"quit": "Afslut",
|
||||
"undo": "Fortryd",
|
||||
"redo": "Gentag",
|
||||
"cut": "Klip",
|
||||
"copy": "Kopiér",
|
||||
"paste": "Indsæt",
|
||||
"select_all": "Markér alt",
|
||||
"search": "Søg i mail",
|
||||
"refresh": "Hent nye mails",
|
||||
"reading_pane": "Læserude",
|
||||
"pane_right": "Til højre",
|
||||
"pane_below": "Under listen",
|
||||
"pane_off": "Fra",
|
||||
"zoom_in": "Zoom ind",
|
||||
"zoom_out": "Zoom ud",
|
||||
"zoom_reset": "Faktisk størrelse",
|
||||
"full_screen": "Fuld skærm",
|
||||
"open_in_window": "Åbn i nyt vindue",
|
||||
"next_message": "Næste besked",
|
||||
"previous_message": "Forrige besked",
|
||||
"next_unread": "Næste ulæste besked",
|
||||
"mail": "Mail",
|
||||
"files": "Filer",
|
||||
"back": "Tilbage",
|
||||
"forward": "Frem",
|
||||
"minimize": "Minimer",
|
||||
"next_window": "Næste vindue",
|
||||
"shortcuts": "Tastaturgenveje",
|
||||
"docs": "Dokumentation",
|
||||
"settings": "Indstillinger…",
|
||||
"about": "Om Bulwark",
|
||||
"from_plugins": "Mere fra plugins…",
|
||||
"pick_gone": "Den menu var forældet. Åbn den igen, og prøv en gang til.",
|
||||
"bar_label": "Menu"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Føj “{word}” til ordbogen",
|
||||
"no_suggestions": "Ingen forslag",
|
||||
"open_link": "Åbn link",
|
||||
"copy_link": "Kopiér link",
|
||||
"copy_address": "Kopiér adresse",
|
||||
"save_image": "Gem billede…",
|
||||
"copy_image": "Kopiér billede"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "En kladde kunne ikke gemmes",
|
||||
"unsaved_detail": "Hvis du lukker nu, går det tabt, du har skrevet siden sidste gang, du gemte.",
|
||||
"close_anyway": "Luk alligevel",
|
||||
"unsent_title": "Nogle mails er ikke sendt endnu",
|
||||
"unsent_one": "Én besked venter på at blive sendt. Den bliver sendt, næste gang du åbner Bulwark.",
|
||||
"unsent_many": "{count} beskeder venter på at blive sendt. De bliver sendt, næste gang du åbner Bulwark.",
|
||||
"quit": "Afslut",
|
||||
"leave_title": "Forlad siden med ugemte ændringer?",
|
||||
"leave_detail": "Dine ændringer på denne side er ikke gemt endnu.",
|
||||
"leave": "Forlad",
|
||||
"stay": "Bliv"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "Stavekontrol på {language} kræver en ordbog. Bulwark kan hente den fra Google. Din tekst bliver ikke sendt.",
|
||||
"download": "Hent"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "Denne kladde kan ikke åbnes lige nu",
|
||||
"unavailable_body": "Den er sikker på serveren i Kladder. Prøv igen, når forbindelsen er tilbage.",
|
||||
"try_again": "Prøv igen",
|
||||
"close": "Luk"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Log ind med din browser",
|
||||
"browser_waiting": "Gør login færdigt i din browser",
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "Diese Nachricht wurde nach {folder} verschoben.",
|
||||
"window_not_ready": "Das neue Fenster wurde nicht rechtzeitig geöffnet. Ihr Entwurf bleibt hier."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "Fenster",
|
||||
"double_click_label": "Nachrichten per Doppelklick in einem neuen Fenster öffnen",
|
||||
"double_click_description": "Ein einfacher Klick öffnet die Nachricht weiterhin neben der Liste.",
|
||||
"restore_label": "Fenster beim Start wieder öffnen",
|
||||
"restore_description": "Nachrichten- und Entwurfsfenster, die beim Beenden offen waren, werden wieder geöffnet."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "Öffnen",
|
||||
"save_as": "Speichern unter…",
|
||||
"copy": "Kopieren",
|
||||
"copy_failed": "Die Datei konnte nicht kopiert werden."
|
||||
},
|
||||
"menu": {
|
||||
"file": "Datei",
|
||||
"edit": "Bearbeiten",
|
||||
"view": "Ansicht",
|
||||
"message": "Nachricht",
|
||||
"go": "Gehe zu",
|
||||
"window": "Fenster",
|
||||
"help": "Hilfe",
|
||||
"new_window": "Neues Fenster",
|
||||
"print": "Drucken…",
|
||||
"save_pdf": "Als PDF sichern…",
|
||||
"switch_connection": "Verbindung wechseln…",
|
||||
"close": "Schließen",
|
||||
"quit": "Beenden",
|
||||
"undo": "Rückgängig",
|
||||
"redo": "Wiederholen",
|
||||
"cut": "Ausschneiden",
|
||||
"copy": "Kopieren",
|
||||
"paste": "Einfügen",
|
||||
"select_all": "Alles auswählen",
|
||||
"search": "E-Mails durchsuchen",
|
||||
"refresh": "Neue E-Mails abrufen",
|
||||
"reading_pane": "Lesebereich",
|
||||
"pane_right": "Rechts",
|
||||
"pane_below": "Unter der Liste",
|
||||
"pane_off": "Aus",
|
||||
"zoom_in": "Vergrößern",
|
||||
"zoom_out": "Verkleinern",
|
||||
"zoom_reset": "Tatsächliche Größe",
|
||||
"full_screen": "Vollbild",
|
||||
"open_in_window": "In neuem Fenster öffnen",
|
||||
"next_message": "Nächste Nachricht",
|
||||
"previous_message": "Vorherige Nachricht",
|
||||
"next_unread": "Nächste ungelesene Nachricht",
|
||||
"mail": "E-Mail",
|
||||
"files": "Dateien",
|
||||
"back": "Zurück",
|
||||
"forward": "Vorwärts",
|
||||
"minimize": "Minimieren",
|
||||
"next_window": "Nächstes Fenster",
|
||||
"shortcuts": "Tastenkürzel",
|
||||
"docs": "Dokumentation",
|
||||
"settings": "Einstellungen…",
|
||||
"about": "Über Bulwark",
|
||||
"from_plugins": "Mehr von Plugins…",
|
||||
"pick_gone": "Dieses Menü war veraltet. Öffnen Sie es erneut und versuchen Sie es noch einmal.",
|
||||
"bar_label": "Menü"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "„{word}“ zum Wörterbuch hinzufügen",
|
||||
"no_suggestions": "Keine Vorschläge",
|
||||
"open_link": "Link öffnen",
|
||||
"copy_link": "Link kopieren",
|
||||
"copy_address": "Adresse kopieren",
|
||||
"save_image": "Bild sichern…",
|
||||
"copy_image": "Bild kopieren"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "Ein Entwurf konnte nicht gespeichert werden",
|
||||
"unsaved_detail": "Wenn Sie jetzt schließen, geht verloren, was Sie seit dem letzten Speichern geschrieben haben.",
|
||||
"close_anyway": "Trotzdem schließen",
|
||||
"unsent_title": "Einige E-Mails wurden noch nicht gesendet",
|
||||
"unsent_one": "Eine Nachricht wartet auf den Versand. Sie wird beim nächsten Start von Bulwark gesendet.",
|
||||
"unsent_many": "{count} Nachrichten warten auf den Versand. Sie werden beim nächsten Start von Bulwark gesendet.",
|
||||
"quit": "Beenden",
|
||||
"leave_title": "Mit ungespeicherten Änderungen verlassen?",
|
||||
"leave_detail": "Ihre Änderungen auf dieser Seite sind noch nicht gespeichert.",
|
||||
"leave": "Verlassen",
|
||||
"stay": "Bleiben"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "Die Rechtschreibprüfung für {language} benötigt ein Wörterbuch. Bulwark kann es von Google herunterladen. Ihr Text wird nicht gesendet.",
|
||||
"download": "Herunterladen"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "Dieser Entwurf kann gerade nicht geöffnet werden",
|
||||
"unavailable_body": "Er ist auf dem Server im Ordner „Entwürfe“ gesichert. Versuchen Sie es erneut, sobald die Verbindung wieder besteht.",
|
||||
"try_again": "Erneut versuchen",
|
||||
"close": "Schließen"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Mit dem Browser anmelden",
|
||||
"browser_waiting": "Schließen Sie die Anmeldung im Browser ab",
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "This message was moved to {folder}.",
|
||||
"window_not_ready": "The new window did not open in time. Your draft stays here."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "Windows",
|
||||
"double_click_label": "Open messages in a new window on double-click",
|
||||
"double_click_description": "A single click still opens the message next to the list.",
|
||||
"restore_label": "Reopen windows at start",
|
||||
"restore_description": "Message and draft windows that were open when you quit come back."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "Open",
|
||||
"save_as": "Save as…",
|
||||
"copy": "Copy",
|
||||
"copy_failed": "The file could not be copied."
|
||||
},
|
||||
"menu": {
|
||||
"file": "File",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"message": "Message",
|
||||
"go": "Go",
|
||||
"window": "Window",
|
||||
"help": "Help",
|
||||
"new_window": "New window",
|
||||
"print": "Print…",
|
||||
"save_pdf": "Save as PDF…",
|
||||
"switch_connection": "Change connection…",
|
||||
"close": "Close",
|
||||
"quit": "Quit",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"cut": "Cut",
|
||||
"copy": "Copy",
|
||||
"paste": "Paste",
|
||||
"select_all": "Select all",
|
||||
"search": "Search mail",
|
||||
"refresh": "Check for new mail",
|
||||
"reading_pane": "Reading pane",
|
||||
"pane_right": "On the right",
|
||||
"pane_below": "Below the list",
|
||||
"pane_off": "Off",
|
||||
"zoom_in": "Zoom in",
|
||||
"zoom_out": "Zoom out",
|
||||
"zoom_reset": "Actual size",
|
||||
"full_screen": "Full screen",
|
||||
"open_in_window": "Open in new window",
|
||||
"next_message": "Next message",
|
||||
"previous_message": "Previous message",
|
||||
"next_unread": "Next unread message",
|
||||
"mail": "Mail",
|
||||
"files": "Files",
|
||||
"back": "Back",
|
||||
"forward": "Forward",
|
||||
"minimize": "Minimize",
|
||||
"next_window": "Next window",
|
||||
"shortcuts": "Keyboard shortcuts",
|
||||
"docs": "Documentation",
|
||||
"settings": "Settings…",
|
||||
"about": "About Bulwark",
|
||||
"from_plugins": "More from plugins…",
|
||||
"pick_gone": "That menu was out of date. Open it again and try once more.",
|
||||
"bar_label": "Menu"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Add \"{word}\" to dictionary",
|
||||
"no_suggestions": "No suggestions",
|
||||
"open_link": "Open link",
|
||||
"copy_link": "Copy link",
|
||||
"copy_address": "Copy address",
|
||||
"save_image": "Save image…",
|
||||
"copy_image": "Copy image"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "A draft could not be saved",
|
||||
"unsaved_detail": "If you close now, what you wrote since the last save is lost.",
|
||||
"close_anyway": "Close anyway",
|
||||
"unsent_title": "Some mail has not been sent yet",
|
||||
"unsent_one": "One message is waiting to be sent. It goes out the next time you open Bulwark.",
|
||||
"unsent_many": "{count} messages are waiting to be sent. They go out the next time you open Bulwark.",
|
||||
"quit": "Quit",
|
||||
"leave_title": "Leave with unsaved changes?",
|
||||
"leave_detail": "What you changed on this page is not saved yet.",
|
||||
"leave": "Leave",
|
||||
"stay": "Stay"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "Spell checking in {language} needs a dictionary. Bulwark can download it from Google. Your text isn't sent.",
|
||||
"download": "Download"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "This draft can't be opened right now",
|
||||
"unavailable_body": "It's safe on the server, in Drafts. Try again when the connection is back.",
|
||||
"try_again": "Try again",
|
||||
"close": "Close"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Sign in with your browser",
|
||||
"browser_waiting": "Finish signing in in your browser",
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "Este mensaje se movió a {folder}.",
|
||||
"window_not_ready": "La ventana nueva no se abrió a tiempo. Tu borrador se queda aquí."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "Ventanas",
|
||||
"double_click_label": "Abrir mensajes en una ventana nueva con doble clic",
|
||||
"double_click_description": "Un solo clic sigue abriendo el mensaje junto a la lista.",
|
||||
"restore_label": "Volver a abrir las ventanas al iniciar",
|
||||
"restore_description": "Las ventanas de mensajes y borradores abiertas al salir vuelven a aparecer."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "Abrir",
|
||||
"save_as": "Guardar como…",
|
||||
"copy": "Copiar",
|
||||
"copy_failed": "No se pudo copiar el archivo."
|
||||
},
|
||||
"menu": {
|
||||
"file": "Archivo",
|
||||
"edit": "Edición",
|
||||
"view": "Visualización",
|
||||
"message": "Mensaje",
|
||||
"go": "Ir",
|
||||
"window": "Ventana",
|
||||
"help": "Ayuda",
|
||||
"new_window": "Nueva ventana",
|
||||
"print": "Imprimir…",
|
||||
"save_pdf": "Guardar como PDF…",
|
||||
"switch_connection": "Cambiar de conexión…",
|
||||
"close": "Cerrar",
|
||||
"quit": "Salir",
|
||||
"undo": "Deshacer",
|
||||
"redo": "Rehacer",
|
||||
"cut": "Cortar",
|
||||
"copy": "Copiar",
|
||||
"paste": "Pegar",
|
||||
"select_all": "Seleccionar todo",
|
||||
"search": "Buscar en el correo",
|
||||
"refresh": "Comprobar correo nuevo",
|
||||
"reading_pane": "Panel de lectura",
|
||||
"pane_right": "A la derecha",
|
||||
"pane_below": "Debajo de la lista",
|
||||
"pane_off": "Desactivado",
|
||||
"zoom_in": "Acercar",
|
||||
"zoom_out": "Alejar",
|
||||
"zoom_reset": "Tamaño real",
|
||||
"full_screen": "Pantalla completa",
|
||||
"open_in_window": "Abrir en una ventana nueva",
|
||||
"next_message": "Mensaje siguiente",
|
||||
"previous_message": "Mensaje anterior",
|
||||
"next_unread": "Siguiente mensaje no leído",
|
||||
"mail": "Correo",
|
||||
"files": "Archivos",
|
||||
"back": "Atrás",
|
||||
"forward": "Adelante",
|
||||
"minimize": "Minimizar",
|
||||
"next_window": "Ventana siguiente",
|
||||
"shortcuts": "Métodos abreviados de teclado",
|
||||
"docs": "Documentación",
|
||||
"settings": "Configuración…",
|
||||
"about": "Acerca de Bulwark",
|
||||
"from_plugins": "Más de los complementos…",
|
||||
"pick_gone": "Ese menú estaba desactualizado. Ábralo de nuevo e inténtelo otra vez.",
|
||||
"bar_label": "Menú"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Añadir «{word}» al diccionario",
|
||||
"no_suggestions": "Sin sugerencias",
|
||||
"open_link": "Abrir enlace",
|
||||
"copy_link": "Copiar enlace",
|
||||
"copy_address": "Copiar dirección",
|
||||
"save_image": "Guardar imagen…",
|
||||
"copy_image": "Copiar imagen"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "No se pudo guardar un borrador",
|
||||
"unsaved_detail": "Si cierra ahora, se perderá lo que escribió desde el último guardado.",
|
||||
"close_anyway": "Cerrar de todos modos",
|
||||
"unsent_title": "Hay correo sin enviar",
|
||||
"unsent_one": "Hay un mensaje esperando para enviarse. Saldrá la próxima vez que abra Bulwark.",
|
||||
"unsent_many": "Hay {count} mensajes esperando para enviarse. Saldrán la próxima vez que abra Bulwark.",
|
||||
"quit": "Salir",
|
||||
"leave_title": "¿Salir con cambios sin guardar?",
|
||||
"leave_detail": "Los cambios que hizo en esta página aún no se han guardado.",
|
||||
"leave": "Salir",
|
||||
"stay": "Quedarse"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "La revisión ortográfica en {language} necesita un diccionario. Bulwark puede descargarlo de Google. Su texto no se envía.",
|
||||
"download": "Descargar"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "Este borrador no se puede abrir ahora",
|
||||
"unavailable_body": "Está a salvo en el servidor, en Borradores. Inténtelo de nuevo cuando vuelva la conexión.",
|
||||
"try_again": "Reintentar",
|
||||
"close": "Cerrar"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Iniciar sesión con el navegador",
|
||||
"browser_waiting": "Termina de iniciar sesión en el navegador",
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "See kiri teisaldati kausta {folder}.",
|
||||
"window_not_ready": "Uus aken ei avanenud õigeks ajaks. Mustand jääb siia."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "Aknad",
|
||||
"double_click_label": "Ava kirjad topeltklõpsuga uues aknas",
|
||||
"double_click_description": "Üks klõps avab kirja endiselt loendi kõrval.",
|
||||
"restore_label": "Ava aknad käivitamisel uuesti",
|
||||
"restore_description": "Kirja- ja mustandiaknad, mis olid väljumisel avatud, tulevad tagasi."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "Ava",
|
||||
"save_as": "Salvesta nimega…",
|
||||
"copy": "Kopeeri",
|
||||
"copy_failed": "Faili ei saanud kopeerida."
|
||||
},
|
||||
"menu": {
|
||||
"file": "Fail",
|
||||
"edit": "Redigeerimine",
|
||||
"view": "Vaade",
|
||||
"message": "Kiri",
|
||||
"go": "Mine",
|
||||
"window": "Aken",
|
||||
"help": "Spikker",
|
||||
"new_window": "Uus aken",
|
||||
"print": "Prindi…",
|
||||
"save_pdf": "Salvesta PDF-ina…",
|
||||
"switch_connection": "Vaheta ühendust…",
|
||||
"close": "Sulge",
|
||||
"quit": "Välju",
|
||||
"undo": "Võta tagasi",
|
||||
"redo": "Tee uuesti",
|
||||
"cut": "Lõika",
|
||||
"copy": "Kopeeri",
|
||||
"paste": "Kleebi",
|
||||
"select_all": "Vali kõik",
|
||||
"search": "Otsi kirjadest",
|
||||
"refresh": "Kontrolli uusi kirju",
|
||||
"reading_pane": "Lugemisvaade",
|
||||
"pane_right": "Paremal",
|
||||
"pane_below": "Loendi all",
|
||||
"pane_off": "Väljas",
|
||||
"zoom_in": "Suurenda",
|
||||
"zoom_out": "Vähenda",
|
||||
"zoom_reset": "Tegelik suurus",
|
||||
"full_screen": "Täisekraan",
|
||||
"open_in_window": "Ava uues aknas",
|
||||
"next_message": "Järgmine kiri",
|
||||
"previous_message": "Eelmine kiri",
|
||||
"next_unread": "Järgmine lugemata kiri",
|
||||
"mail": "Post",
|
||||
"files": "Failid",
|
||||
"back": "Tagasi",
|
||||
"forward": "Edasi",
|
||||
"minimize": "Minimeeri",
|
||||
"next_window": "Järgmine aken",
|
||||
"shortcuts": "Klaviatuuri otseteed",
|
||||
"docs": "Dokumentatsioon",
|
||||
"settings": "Seaded…",
|
||||
"about": "Teave Bulwarki kohta",
|
||||
"from_plugins": "Veel pistikprogrammidelt…",
|
||||
"pick_gone": "See menüü oli aegunud. Avage see uuesti ja proovige veel kord.",
|
||||
"bar_label": "Menüü"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Lisa „{word}” sõnastikku",
|
||||
"no_suggestions": "Soovitusi pole",
|
||||
"open_link": "Ava link",
|
||||
"copy_link": "Kopeeri link",
|
||||
"copy_address": "Kopeeri aadress",
|
||||
"save_image": "Salvesta pilt…",
|
||||
"copy_image": "Kopeeri pilt"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "Mustandit ei õnnestunud salvestada",
|
||||
"unsaved_detail": "Kui sulgete nüüd, läheb kaduma see, mille olete pärast viimast salvestamist kirjutanud.",
|
||||
"close_anyway": "Sulge siiski",
|
||||
"unsent_title": "Mõni kiri on veel saatmata",
|
||||
"unsent_one": "Üks kiri ootab saatmist. See läheb teele järgmine kord, kui Bulwarki avate.",
|
||||
"unsent_many": "{count} kirja ootab saatmist. Need lähevad teele järgmine kord, kui Bulwarki avate.",
|
||||
"quit": "Välju",
|
||||
"leave_title": "Lahkuda salvestamata muudatustega?",
|
||||
"leave_detail": "Selle lehe muudatused pole veel salvestatud.",
|
||||
"leave": "Lahku",
|
||||
"stay": "Jää"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "Õigekirjakontroll keelele {language} vajab sõnastikku. Bulwark saab selle Google’ilt alla laadida. Teie teksti ei saadeta.",
|
||||
"download": "Laadi alla"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "Seda mustandit ei saa praegu avada",
|
||||
"unavailable_body": "See on serveris kaustas Mustandid turvaliselt alles. Proovige uuesti, kui ühendus on tagasi.",
|
||||
"try_again": "Proovi uuesti",
|
||||
"close": "Sulge"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Logi sisse brauseriga",
|
||||
"browser_waiting": "Lõpeta sisselogimine brauseris",
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "این پیام به {folder} منتقل شد.",
|
||||
"window_not_ready": "پنجرهٔ جدید بهموقع باز نشد. پیشنویس شما همینجا میماند."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "پنجرهها",
|
||||
"double_click_label": "باز کردن پیامها در پنجرهٔ جدید با دوبار کلیک",
|
||||
"double_click_description": "یک کلیک همچنان پیام را کنار فهرست باز میکند.",
|
||||
"restore_label": "باز کردن دوبارهٔ پنجرهها هنگام شروع",
|
||||
"restore_description": "پنجرههای پیام و پیشنویس که هنگام خروج باز بودند برمیگردند."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "باز کردن",
|
||||
"save_as": "ذخیره با نام…",
|
||||
"copy": "کپی",
|
||||
"copy_failed": "فایل کپی نشد."
|
||||
},
|
||||
"menu": {
|
||||
"file": "پرونده",
|
||||
"edit": "ویرایش",
|
||||
"view": "نما",
|
||||
"message": "پیام",
|
||||
"go": "برو به",
|
||||
"window": "پنجره",
|
||||
"help": "راهنما",
|
||||
"new_window": "پنجره جدید",
|
||||
"print": "چاپ…",
|
||||
"save_pdf": "ذخیره بهصورت PDF…",
|
||||
"switch_connection": "تغییر اتصال…",
|
||||
"close": "بستن",
|
||||
"quit": "خروج",
|
||||
"undo": "واگرد",
|
||||
"redo": "ازنو",
|
||||
"cut": "برش",
|
||||
"copy": "رونوشت",
|
||||
"paste": "چسباندن",
|
||||
"select_all": "انتخاب همه",
|
||||
"search": "جستجو در نامهها",
|
||||
"refresh": "بررسی نامههای جدید",
|
||||
"reading_pane": "قاب خواندن",
|
||||
"pane_right": "سمت راست",
|
||||
"pane_below": "زیر فهرست",
|
||||
"pane_off": "خاموش",
|
||||
"zoom_in": "بزرگنمایی",
|
||||
"zoom_out": "کوچکنمایی",
|
||||
"zoom_reset": "اندازه واقعی",
|
||||
"full_screen": "تمامصفحه",
|
||||
"open_in_window": "باز کردن در پنجره جدید",
|
||||
"next_message": "پیام بعدی",
|
||||
"previous_message": "پیام قبلی",
|
||||
"next_unread": "پیام خواندهنشده بعدی",
|
||||
"mail": "نامه",
|
||||
"files": "پروندهها",
|
||||
"back": "بازگشت",
|
||||
"forward": "جلو",
|
||||
"minimize": "کوچک کردن",
|
||||
"next_window": "پنجره بعدی",
|
||||
"shortcuts": "میانبرهای صفحهکلید",
|
||||
"docs": "مستندات",
|
||||
"settings": "تنظیمات…",
|
||||
"about": "درباره Bulwark",
|
||||
"from_plugins": "موارد بیشتر از افزونهها…",
|
||||
"pick_gone": "این منو قدیمی شده بود. دوباره آن را باز کنید و یک بار دیگر امتحان کنید.",
|
||||
"bar_label": "منو"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "افزودن «{word}» به واژهنامه",
|
||||
"no_suggestions": "پیشنهادی نیست",
|
||||
"open_link": "باز کردن پیوند",
|
||||
"copy_link": "رونوشت پیوند",
|
||||
"copy_address": "رونوشت نشانی",
|
||||
"save_image": "ذخیره تصویر…",
|
||||
"copy_image": "رونوشت تصویر"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "یک پیشنویس ذخیره نشد",
|
||||
"unsaved_detail": "اگر اکنون ببندید، آنچه از آخرین ذخیره نوشتهاید از بین میرود.",
|
||||
"close_anyway": "با این حال بسته شود",
|
||||
"unsent_title": "برخی نامهها هنوز ارسال نشدهاند",
|
||||
"unsent_one": "یک پیام در انتظار ارسال است. دفعه بعد که Bulwark را باز کنید ارسال میشود.",
|
||||
"unsent_many": "{count} پیام در انتظار ارسال هستند. دفعه بعد که Bulwark را باز کنید ارسال میشوند.",
|
||||
"quit": "خروج",
|
||||
"leave_title": "با تغییرات ذخیرهنشده خارج میشوید؟",
|
||||
"leave_detail": "تغییراتی که در این صفحه دادهاید هنوز ذخیره نشده است.",
|
||||
"leave": "خروج",
|
||||
"stay": "ماندن"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "بررسی املای {language} به واژهنامه نیاز دارد. Bulwark میتواند آن را از Google دانلود کند. متن شما ارسال نمیشود.",
|
||||
"download": "دانلود"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "اکنون نمیتوان این پیشنویس را باز کرد",
|
||||
"unavailable_body": "این پیشنویس در پوشه پیشنویسها روی سرور سالم است. وقتی اتصال برگشت دوباره امتحان کنید.",
|
||||
"try_again": "تلاش دوباره",
|
||||
"close": "بستن"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "ورود با مرورگر",
|
||||
"browser_waiting": "ورود را در مرورگر کامل کنید",
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "Ce message a été déplacé vers {folder}.",
|
||||
"window_not_ready": "La nouvelle fenêtre ne s’est pas ouverte à temps. Votre brouillon reste ici."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "Fenêtres",
|
||||
"double_click_label": "Ouvrir les messages dans une nouvelle fenêtre par double-clic",
|
||||
"double_click_description": "Un simple clic ouvre toujours le message à côté de la liste.",
|
||||
"restore_label": "Rouvrir les fenêtres au démarrage",
|
||||
"restore_description": "Les fenêtres de messages et de brouillons ouvertes à la fermeture reviennent."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "Ouvrir",
|
||||
"save_as": "Enregistrer sous…",
|
||||
"copy": "Copier",
|
||||
"copy_failed": "Impossible de copier le fichier."
|
||||
},
|
||||
"menu": {
|
||||
"file": "Fichier",
|
||||
"edit": "Édition",
|
||||
"view": "Présentation",
|
||||
"message": "Message",
|
||||
"go": "Aller",
|
||||
"window": "Fenêtre",
|
||||
"help": "Aide",
|
||||
"new_window": "Nouvelle fenêtre",
|
||||
"print": "Imprimer…",
|
||||
"save_pdf": "Enregistrer au format PDF…",
|
||||
"switch_connection": "Changer de connexion…",
|
||||
"close": "Fermer",
|
||||
"quit": "Quitter",
|
||||
"undo": "Annuler",
|
||||
"redo": "Rétablir",
|
||||
"cut": "Couper",
|
||||
"copy": "Copier",
|
||||
"paste": "Coller",
|
||||
"select_all": "Tout sélectionner",
|
||||
"search": "Rechercher dans les messages",
|
||||
"refresh": "Relever le courrier",
|
||||
"reading_pane": "Volet de lecture",
|
||||
"pane_right": "À droite",
|
||||
"pane_below": "Sous la liste",
|
||||
"pane_off": "Désactivé",
|
||||
"zoom_in": "Zoom avant",
|
||||
"zoom_out": "Zoom arrière",
|
||||
"zoom_reset": "Taille réelle",
|
||||
"full_screen": "Plein écran",
|
||||
"open_in_window": "Ouvrir dans une nouvelle fenêtre",
|
||||
"next_message": "Message suivant",
|
||||
"previous_message": "Message précédent",
|
||||
"next_unread": "Prochain message non lu",
|
||||
"mail": "Courrier",
|
||||
"files": "Fichiers",
|
||||
"back": "Précédent",
|
||||
"forward": "Suivant",
|
||||
"minimize": "Réduire",
|
||||
"next_window": "Fenêtre suivante",
|
||||
"shortcuts": "Raccourcis clavier",
|
||||
"docs": "Documentation",
|
||||
"settings": "Paramètres…",
|
||||
"about": "À propos de Bulwark",
|
||||
"from_plugins": "Plus d’options des modules…",
|
||||
"pick_gone": "Ce menu n’était plus à jour. Rouvrez-le et réessayez.",
|
||||
"bar_label": "Menu"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Ajouter « {word} » au dictionnaire",
|
||||
"no_suggestions": "Aucune suggestion",
|
||||
"open_link": "Ouvrir le lien",
|
||||
"copy_link": "Copier le lien",
|
||||
"copy_address": "Copier l’adresse",
|
||||
"save_image": "Enregistrer l’image…",
|
||||
"copy_image": "Copier l’image"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "Un brouillon n’a pas pu être enregistré",
|
||||
"unsaved_detail": "Si vous fermez maintenant, ce que vous avez écrit depuis le dernier enregistrement sera perdu.",
|
||||
"close_anyway": "Fermer quand même",
|
||||
"unsent_title": "Des messages n’ont pas encore été envoyés",
|
||||
"unsent_one": "Un message attend d’être envoyé. Il partira à la prochaine ouverture de Bulwark.",
|
||||
"unsent_many": "{count} messages attendent d’être envoyés. Ils partiront à la prochaine ouverture de Bulwark.",
|
||||
"quit": "Quitter",
|
||||
"leave_title": "Quitter sans enregistrer les modifications ?",
|
||||
"leave_detail": "Vos modifications sur cette page ne sont pas encore enregistrées.",
|
||||
"leave": "Quitter la page",
|
||||
"stay": "Rester"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "La correction orthographique en {language} nécessite un dictionnaire. Bulwark peut le télécharger depuis Google. Votre texte n’est pas envoyé.",
|
||||
"download": "Télécharger"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "Ce brouillon ne peut pas être ouvert pour le moment",
|
||||
"unavailable_body": "Il est en sécurité sur le serveur, dans Brouillons. Réessayez quand la connexion sera rétablie.",
|
||||
"try_again": "Réessayer",
|
||||
"close": "Fermer"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Se connecter avec le navigateur",
|
||||
"browser_waiting": "Terminez la connexion dans votre navigateur",
|
||||
|
||||
@@ -6668,6 +6668,101 @@
|
||||
"message_moved_to": "ההודעה הזו הועברה אל {folder}.",
|
||||
"window_not_ready": "החלון החדש לא נפתח בזמן. הטיוטה נשארת כאן."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "חלונות",
|
||||
"double_click_label": "פתיחת הודעות בחלון חדש בלחיצה כפולה",
|
||||
"double_click_description": "לחיצה אחת עדיין פותחת את ההודעה ליד הרשימה.",
|
||||
"restore_label": "פתיחה מחדש של חלונות בהפעלה",
|
||||
"restore_description": "חלונות הודעות וטיוטות שהיו פתוחים ביציאה חוזרים."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "פתיחה",
|
||||
"save_as": "שמירה בשם…",
|
||||
"copy": "העתקה",
|
||||
"copy_failed": "לא ניתן היה להעתיק את הקובץ."
|
||||
},
|
||||
"menu": {
|
||||
"file": "קובץ",
|
||||
"edit": "עריכה",
|
||||
"view": "תצוגה",
|
||||
"message": "הודעה",
|
||||
"go": "מעבר",
|
||||
"window": "חלון",
|
||||
"help": "עזרה",
|
||||
"new_window": "חלון חדש",
|
||||
"print": "הדפסה…",
|
||||
"save_pdf": "שמירה כ-PDF…",
|
||||
"switch_connection": "שינוי חיבור…",
|
||||
"close": "סגירה",
|
||||
"quit": "יציאה",
|
||||
"undo": "ביטול",
|
||||
"redo": "ביצוע חוזר",
|
||||
"cut": "גזירה",
|
||||
"copy": "העתקה",
|
||||
"paste": "הדבקה",
|
||||
"select_all": "בחירת הכול",
|
||||
"search": "חיפוש בדואר",
|
||||
"refresh": "בדיקת דואר חדש",
|
||||
"reading_pane": "חלונית קריאה",
|
||||
"pane_right": "מימין",
|
||||
"pane_below": "מתחת לרשימה",
|
||||
"pane_off": "כבויה",
|
||||
"zoom_in": "הגדלה",
|
||||
"zoom_out": "הקטנה",
|
||||
"zoom_reset": "גודל בפועל",
|
||||
"full_screen": "מסך מלא",
|
||||
"open_in_window": "פתיחה בחלון חדש",
|
||||
"next_message": "ההודעה הבאה",
|
||||
"previous_message": "ההודעה הקודמת",
|
||||
"next_unread": "ההודעה הבאה שלא נקראה",
|
||||
"mail": "דואר",
|
||||
"files": "קבצים",
|
||||
"back": "חזרה",
|
||||
"forward": "קדימה",
|
||||
"minimize": "מזעור",
|
||||
"next_window": "החלון הבא",
|
||||
"shortcuts": "קיצורי מקלדת",
|
||||
"docs": "תיעוד",
|
||||
"settings": "הגדרות…",
|
||||
"about": "מידע על Bulwark",
|
||||
"from_plugins": "עוד מהתוספים…",
|
||||
"pick_gone": "התפריט הזה היה מיושן. פתחו אותו שוב ונסו פעם נוספת.",
|
||||
"bar_label": "תפריט"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "הוספת “{word}” למילון",
|
||||
"no_suggestions": "אין הצעות",
|
||||
"open_link": "פתיחת קישור",
|
||||
"copy_link": "העתקת קישור",
|
||||
"copy_address": "העתקת כתובת",
|
||||
"save_image": "שמירת תמונה…",
|
||||
"copy_image": "העתקת תמונה"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "לא ניתן היה לשמור טיוטה",
|
||||
"unsaved_detail": "אם תסגרו עכשיו, מה שכתבתם מאז השמירה האחרונה יאבד.",
|
||||
"close_anyway": "לסגור בכל זאת",
|
||||
"unsent_title": "יש הודעות שעדיין לא נשלחו",
|
||||
"unsent_one": "הודעה אחת ממתינה לשליחה. היא תישלח בפעם הבאה שתפתחו את Bulwark.",
|
||||
"unsent_many": "{count} הודעות ממתינות לשליחה. הן יישלחו בפעם הבאה שתפתחו את Bulwark.",
|
||||
"quit": "יציאה",
|
||||
"leave_title": "לעזוב עם שינויים שלא נשמרו?",
|
||||
"leave_detail": "השינויים שביצעתם בדף הזה עדיין לא נשמרו.",
|
||||
"leave": "עזיבה",
|
||||
"stay": "להישאר"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "בדיקת איות ב{language} דורשת מילון. Bulwark יכול להוריד אותו מ-Google. הטקסט שלכם לא נשלח.",
|
||||
"download": "הורדה"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "אי אפשר לפתוח את הטיוטה הזאת כרגע",
|
||||
"unavailable_body": "היא שמורה בשרת, בתיקיית הטיוטות. נסו שוב כשהחיבור יחזור.",
|
||||
"try_again": "ניסיון חוזר",
|
||||
"close": "סגירה"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "כניסה באמצעות הדפדפן",
|
||||
"browser_waiting": "יש להשלים את הכניסה בדפדפן",
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "Ezt az üzenetet ide helyezték át: {folder}.",
|
||||
"window_not_ready": "Az új ablak nem nyílt meg időben. A piszkozat itt marad."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "Ablakok",
|
||||
"double_click_label": "Üzenetek megnyitása új ablakban dupla kattintással",
|
||||
"double_click_description": "Egy kattintás továbbra is a lista mellett nyitja meg az üzenetet.",
|
||||
"restore_label": "Ablakok újranyitása indításkor",
|
||||
"restore_description": "A kilépéskor nyitva lévő üzenet- és piszkozatablakok visszatérnek."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "Megnyitás",
|
||||
"save_as": "Mentés másként…",
|
||||
"copy": "Másolás",
|
||||
"copy_failed": "A fájlt nem sikerült másolni."
|
||||
},
|
||||
"menu": {
|
||||
"file": "Fájl",
|
||||
"edit": "Szerkesztés",
|
||||
"view": "Nézet",
|
||||
"message": "Üzenet",
|
||||
"go": "Ugrás",
|
||||
"window": "Ablak",
|
||||
"help": "Súgó",
|
||||
"new_window": "Új ablak",
|
||||
"print": "Nyomtatás…",
|
||||
"save_pdf": "Mentés PDF-ként…",
|
||||
"switch_connection": "Kapcsolat váltása…",
|
||||
"close": "Bezárás",
|
||||
"quit": "Kilépés",
|
||||
"undo": "Visszavonás",
|
||||
"redo": "Ismétlés",
|
||||
"cut": "Kivágás",
|
||||
"copy": "Másolás",
|
||||
"paste": "Beillesztés",
|
||||
"select_all": "Összes kijelölése",
|
||||
"search": "Keresés a levelek között",
|
||||
"refresh": "Új levelek lekérése",
|
||||
"reading_pane": "Olvasópanel",
|
||||
"pane_right": "Jobbra",
|
||||
"pane_below": "A lista alatt",
|
||||
"pane_off": "Ki",
|
||||
"zoom_in": "Nagyítás",
|
||||
"zoom_out": "Kicsinyítés",
|
||||
"zoom_reset": "Tényleges méret",
|
||||
"full_screen": "Teljes képernyő",
|
||||
"open_in_window": "Megnyitás új ablakban",
|
||||
"next_message": "Következő üzenet",
|
||||
"previous_message": "Előző üzenet",
|
||||
"next_unread": "Következő olvasatlan üzenet",
|
||||
"mail": "Levelek",
|
||||
"files": "Fájlok",
|
||||
"back": "Vissza",
|
||||
"forward": "Előre",
|
||||
"minimize": "Minimalizálás",
|
||||
"next_window": "Következő ablak",
|
||||
"shortcuts": "Billentyűparancsok",
|
||||
"docs": "Dokumentáció",
|
||||
"settings": "Beállítások…",
|
||||
"about": "A Bulwark névjegye",
|
||||
"from_plugins": "Továbbiak a bővítményektől…",
|
||||
"pick_gone": "Ez a menü elavult volt. Nyissa meg újra, és próbálja meg még egyszer.",
|
||||
"bar_label": "Menü"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "„{word}” hozzáadása a szótárhoz",
|
||||
"no_suggestions": "Nincs javaslat",
|
||||
"open_link": "Hivatkozás megnyitása",
|
||||
"copy_link": "Hivatkozás másolása",
|
||||
"copy_address": "Cím másolása",
|
||||
"save_image": "Kép mentése…",
|
||||
"copy_image": "Kép másolása"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "Egy piszkozatot nem sikerült menteni",
|
||||
"unsaved_detail": "Ha most bezárja, elvész, amit az utolsó mentés óta írt.",
|
||||
"close_anyway": "Bezárás mégis",
|
||||
"unsent_title": "Néhány levél még nincs elküldve",
|
||||
"unsent_one": "Egy üzenet küldésre vár. A Bulwark következő megnyitásakor elmegy.",
|
||||
"unsent_many": "{count} üzenet küldésre vár. A Bulwark következő megnyitásakor elmennek.",
|
||||
"quit": "Kilépés",
|
||||
"leave_title": "Elhagyja az oldalt mentetlen módosításokkal?",
|
||||
"leave_detail": "Az oldalon végzett módosítások még nincsenek mentve.",
|
||||
"leave": "Elhagyás",
|
||||
"stay": "Maradás"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "A(z) {language} nyelvű helyesírás-ellenőrzéshez szótár kell. A Bulwark letöltheti a Google-tól. A szövege nem kerül elküldésre.",
|
||||
"download": "Letöltés"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "Ez a piszkozat most nem nyitható meg",
|
||||
"unavailable_body": "Biztonságban van a kiszolgálón, a Piszkozatok mappában. Próbálja újra, ha visszatért a kapcsolat.",
|
||||
"try_again": "Újrapróbálás",
|
||||
"close": "Bezárás"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Bejelentkezés böngészővel",
|
||||
"browser_waiting": "Fejezze be a bejelentkezést a böngészőben",
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "Questo messaggio è stato spostato in {folder}.",
|
||||
"window_not_ready": "La nuova finestra non si è aperta in tempo. La bozza rimane qui."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "Finestre",
|
||||
"double_click_label": "Apri i messaggi in una nuova finestra con il doppio clic",
|
||||
"double_click_description": "Un solo clic apre comunque il messaggio accanto all’elenco.",
|
||||
"restore_label": "Riapri le finestre all’avvio",
|
||||
"restore_description": "Le finestre di messaggi e bozze aperte all’uscita tornano."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "Apri",
|
||||
"save_as": "Salva con nome…",
|
||||
"copy": "Copia",
|
||||
"copy_failed": "Impossibile copiare il file."
|
||||
},
|
||||
"menu": {
|
||||
"file": "File",
|
||||
"edit": "Modifica",
|
||||
"view": "Vista",
|
||||
"message": "Messaggio",
|
||||
"go": "Vai",
|
||||
"window": "Finestra",
|
||||
"help": "Aiuto",
|
||||
"new_window": "Nuova finestra",
|
||||
"print": "Stampa…",
|
||||
"save_pdf": "Salva come PDF…",
|
||||
"switch_connection": "Cambia connessione…",
|
||||
"close": "Chiudi",
|
||||
"quit": "Esci",
|
||||
"undo": "Annulla",
|
||||
"redo": "Ripeti",
|
||||
"cut": "Taglia",
|
||||
"copy": "Copia",
|
||||
"paste": "Incolla",
|
||||
"select_all": "Seleziona tutto",
|
||||
"search": "Cerca nella posta",
|
||||
"refresh": "Controlla nuova posta",
|
||||
"reading_pane": "Pannello di lettura",
|
||||
"pane_right": "A destra",
|
||||
"pane_below": "Sotto l’elenco",
|
||||
"pane_off": "Disattivato",
|
||||
"zoom_in": "Ingrandisci",
|
||||
"zoom_out": "Riduci",
|
||||
"zoom_reset": "Dimensione effettiva",
|
||||
"full_screen": "Schermo intero",
|
||||
"open_in_window": "Apri in una nuova finestra",
|
||||
"next_message": "Messaggio successivo",
|
||||
"previous_message": "Messaggio precedente",
|
||||
"next_unread": "Prossimo messaggio non letto",
|
||||
"mail": "Posta",
|
||||
"files": "File",
|
||||
"back": "Indietro",
|
||||
"forward": "Avanti",
|
||||
"minimize": "Riduci a icona",
|
||||
"next_window": "Finestra successiva",
|
||||
"shortcuts": "Scorciatoie da tastiera",
|
||||
"docs": "Documentazione",
|
||||
"settings": "Impostazioni…",
|
||||
"about": "Informazioni su Bulwark",
|
||||
"from_plugins": "Altro dai plugin…",
|
||||
"pick_gone": "Questo menu non era aggiornato. Riaprilo e riprova.",
|
||||
"bar_label": "Menu"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Aggiungi «{word}» al dizionario",
|
||||
"no_suggestions": "Nessun suggerimento",
|
||||
"open_link": "Apri link",
|
||||
"copy_link": "Copia link",
|
||||
"copy_address": "Copia indirizzo",
|
||||
"save_image": "Salva immagine…",
|
||||
"copy_image": "Copia immagine"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "Impossibile salvare una bozza",
|
||||
"unsaved_detail": "Se chiudi ora, quello che hai scritto dall’ultimo salvataggio andrà perso.",
|
||||
"close_anyway": "Chiudi comunque",
|
||||
"unsent_title": "Alcuni messaggi non sono ancora stati inviati",
|
||||
"unsent_one": "Un messaggio è in attesa di invio. Partirà alla prossima apertura di Bulwark.",
|
||||
"unsent_many": "{count} messaggi sono in attesa di invio. Partiranno alla prossima apertura di Bulwark.",
|
||||
"quit": "Esci",
|
||||
"leave_title": "Uscire con modifiche non salvate?",
|
||||
"leave_detail": "Le modifiche fatte in questa pagina non sono ancora salvate.",
|
||||
"leave": "Esci",
|
||||
"stay": "Rimani"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "Il controllo ortografico in {language} richiede un dizionario. Bulwark può scaricarlo da Google. Il tuo testo non viene inviato.",
|
||||
"download": "Scarica"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "Questa bozza non può essere aperta ora",
|
||||
"unavailable_body": "È al sicuro sul server, in Bozze. Riprova quando la connessione sarà tornata.",
|
||||
"try_again": "Riprova",
|
||||
"close": "Chiudi"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Accedi con il browser",
|
||||
"browser_waiting": "Completa l'accesso nel browser",
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "このメッセージは {folder} に移動されました。",
|
||||
"window_not_ready": "新しいウィンドウが時間内に開きませんでした。下書きはここに残ります。"
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "ウィンドウ",
|
||||
"double_click_label": "ダブルクリックでメッセージを新しいウィンドウで開く",
|
||||
"double_click_description": "シングルクリックでは、これまでどおりリストの横に開きます。",
|
||||
"restore_label": "起動時にウィンドウを再び開く",
|
||||
"restore_description": "終了時に開いていたメッセージと下書きのウィンドウが戻ります。"
|
||||
},
|
||||
"attachment": {
|
||||
"open": "開く",
|
||||
"save_as": "名前を付けて保存…",
|
||||
"copy": "コピー",
|
||||
"copy_failed": "ファイルをコピーできませんでした。"
|
||||
},
|
||||
"menu": {
|
||||
"file": "ファイル",
|
||||
"edit": "編集",
|
||||
"view": "表示",
|
||||
"message": "メッセージ",
|
||||
"go": "移動",
|
||||
"window": "ウインドウ",
|
||||
"help": "ヘルプ",
|
||||
"new_window": "新規ウインドウ",
|
||||
"print": "印刷…",
|
||||
"save_pdf": "PDFとして保存…",
|
||||
"switch_connection": "接続を変更…",
|
||||
"close": "閉じる",
|
||||
"quit": "終了",
|
||||
"undo": "取り消す",
|
||||
"redo": "やり直す",
|
||||
"cut": "切り取り",
|
||||
"copy": "コピー",
|
||||
"paste": "貼り付け",
|
||||
"select_all": "すべてを選択",
|
||||
"search": "メールを検索",
|
||||
"refresh": "新着メールを確認",
|
||||
"reading_pane": "閲覧ウインドウ",
|
||||
"pane_right": "右側",
|
||||
"pane_below": "リストの下",
|
||||
"pane_off": "オフ",
|
||||
"zoom_in": "拡大",
|
||||
"zoom_out": "縮小",
|
||||
"zoom_reset": "実際のサイズ",
|
||||
"full_screen": "フルスクリーン",
|
||||
"open_in_window": "新規ウインドウで開く",
|
||||
"next_message": "次のメッセージ",
|
||||
"previous_message": "前のメッセージ",
|
||||
"next_unread": "次の未読メッセージ",
|
||||
"mail": "メール",
|
||||
"files": "ファイル",
|
||||
"back": "戻る",
|
||||
"forward": "進む",
|
||||
"minimize": "最小化",
|
||||
"next_window": "次のウインドウ",
|
||||
"shortcuts": "キーボードショートカット",
|
||||
"docs": "ドキュメント",
|
||||
"settings": "設定…",
|
||||
"about": "Bulwarkについて",
|
||||
"from_plugins": "プラグインのその他の項目…",
|
||||
"pick_gone": "このメニューは古くなっています。もう一度開いて、やり直してください。",
|
||||
"bar_label": "メニュー"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "「{word}」を辞書に追加",
|
||||
"no_suggestions": "候補なし",
|
||||
"open_link": "リンクを開く",
|
||||
"copy_link": "リンクをコピー",
|
||||
"copy_address": "アドレスをコピー",
|
||||
"save_image": "画像を保存…",
|
||||
"copy_image": "画像をコピー"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "下書きを保存できませんでした",
|
||||
"unsaved_detail": "今閉じると、最後に保存した後に書いた内容は失われます。",
|
||||
"close_anyway": "このまま閉じる",
|
||||
"unsent_title": "未送信のメールがあります",
|
||||
"unsent_one": "1件のメッセージが送信を待っています。次にBulwarkを開いたときに送信されます。",
|
||||
"unsent_many": "{count}件のメッセージが送信を待っています。次にBulwarkを開いたときに送信されます。",
|
||||
"quit": "終了",
|
||||
"leave_title": "保存されていない変更を残して移動しますか?",
|
||||
"leave_detail": "このページで行った変更はまだ保存されていません。",
|
||||
"leave": "移動する",
|
||||
"stay": "とどまる"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "{language}のスペルチェックには辞書が必要です。BulwarkはGoogleから辞書をダウンロードできます。入力した文章は送信されません。",
|
||||
"download": "ダウンロード"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "この下書きは今は開けません",
|
||||
"unavailable_body": "下書きはサーバーの「下書き」に安全に保管されています。接続が戻ってから、もう一度お試しください。",
|
||||
"try_again": "再試行",
|
||||
"close": "閉じる"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "ブラウザでサインイン",
|
||||
"browser_waiting": "ブラウザでサインインを完了してください",
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "이 메시지는 {folder}(으)로 이동되었습니다.",
|
||||
"window_not_ready": "새 창이 제때 열리지 않았습니다. 임시 보관 메일은 여기에 남아 있습니다."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "창",
|
||||
"double_click_label": "두 번 클릭하면 메시지를 새 창에서 열기",
|
||||
"double_click_description": "한 번 클릭하면 지금처럼 목록 옆에 메시지가 열립니다.",
|
||||
"restore_label": "시작할 때 창 다시 열기",
|
||||
"restore_description": "종료할 때 열려 있던 메시지와 임시보관 창이 다시 열립니다."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "열기",
|
||||
"save_as": "다른 이름으로 저장…",
|
||||
"copy": "복사",
|
||||
"copy_failed": "파일을 복사할 수 없습니다."
|
||||
},
|
||||
"menu": {
|
||||
"file": "파일",
|
||||
"edit": "편집",
|
||||
"view": "보기",
|
||||
"message": "메시지",
|
||||
"go": "이동",
|
||||
"window": "창",
|
||||
"help": "도움말",
|
||||
"new_window": "새 창",
|
||||
"print": "인쇄…",
|
||||
"save_pdf": "PDF로 저장…",
|
||||
"switch_connection": "연결 변경…",
|
||||
"close": "닫기",
|
||||
"quit": "종료",
|
||||
"undo": "실행 취소",
|
||||
"redo": "다시 실행",
|
||||
"cut": "잘라내기",
|
||||
"copy": "복사",
|
||||
"paste": "붙여넣기",
|
||||
"select_all": "모두 선택",
|
||||
"search": "메일 검색",
|
||||
"refresh": "새 메일 확인",
|
||||
"reading_pane": "읽기 창",
|
||||
"pane_right": "오른쪽",
|
||||
"pane_below": "목록 아래",
|
||||
"pane_off": "끄기",
|
||||
"zoom_in": "확대",
|
||||
"zoom_out": "축소",
|
||||
"zoom_reset": "실제 크기",
|
||||
"full_screen": "전체 화면",
|
||||
"open_in_window": "새 창에서 열기",
|
||||
"next_message": "다음 메시지",
|
||||
"previous_message": "이전 메시지",
|
||||
"next_unread": "다음 읽지 않은 메시지",
|
||||
"mail": "메일",
|
||||
"files": "파일",
|
||||
"back": "뒤로",
|
||||
"forward": "앞으로",
|
||||
"minimize": "최소화",
|
||||
"next_window": "다음 창",
|
||||
"shortcuts": "키보드 단축키",
|
||||
"docs": "문서",
|
||||
"settings": "설정…",
|
||||
"about": "Bulwark 정보",
|
||||
"from_plugins": "플러그인 더 보기…",
|
||||
"pick_gone": "이 메뉴는 오래된 것입니다. 다시 열어 한 번 더 시도해 주세요.",
|
||||
"bar_label": "메뉴"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "“{word}”을(를) 사전에 추가",
|
||||
"no_suggestions": "추천 없음",
|
||||
"open_link": "링크 열기",
|
||||
"copy_link": "링크 복사",
|
||||
"copy_address": "주소 복사",
|
||||
"save_image": "이미지 저장…",
|
||||
"copy_image": "이미지 복사"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "임시 보관 메일을 저장하지 못했습니다",
|
||||
"unsaved_detail": "지금 닫으면 마지막으로 저장한 이후에 작성한 내용이 사라집니다.",
|
||||
"close_anyway": "그래도 닫기",
|
||||
"unsent_title": "아직 보내지 않은 메일이 있습니다",
|
||||
"unsent_one": "메시지 1개가 전송을 기다리고 있습니다. 다음에 Bulwark를 열 때 전송됩니다.",
|
||||
"unsent_many": "메시지 {count}개가 전송을 기다리고 있습니다. 다음에 Bulwark를 열 때 전송됩니다.",
|
||||
"quit": "종료",
|
||||
"leave_title": "저장하지 않은 변경 사항이 있습니다. 나가시겠습니까?",
|
||||
"leave_detail": "이 페이지에서 변경한 내용이 아직 저장되지 않았습니다.",
|
||||
"leave": "나가기",
|
||||
"stay": "머무르기"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "{language} 맞춤법 검사에는 사전이 필요합니다. Bulwark가 Google에서 내려받을 수 있습니다. 입력한 텍스트는 전송되지 않습니다.",
|
||||
"download": "내려받기"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "지금은 이 임시 보관 메일을 열 수 없습니다",
|
||||
"unavailable_body": "서버의 임시 보관함에 안전하게 있습니다. 연결이 돌아오면 다시 시도해 주세요.",
|
||||
"try_again": "다시 시도",
|
||||
"close": "닫기"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "브라우저로 로그인",
|
||||
"browser_waiting": "브라우저에서 로그인을 마치세요",
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "Šis ziņojums tika pārvietots uz {folder}.",
|
||||
"window_not_ready": "Jaunais logs neatvērās laikus. Melnraksts paliek šeit."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "Logi",
|
||||
"double_click_label": "Atvērt ziņojumus jaunā logā ar dubultklikšķi",
|
||||
"double_click_description": "Viens klikšķis joprojām atver ziņojumu blakus sarakstam.",
|
||||
"restore_label": "Startējot atkal atvērt logus",
|
||||
"restore_description": "Ziņojumu un melnrakstu logi, kas bija atvērti izejot, atgriežas."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "Atvērt",
|
||||
"save_as": "Saglabāt kā…",
|
||||
"copy": "Kopēt",
|
||||
"copy_failed": "Failu neizdevās nokopēt."
|
||||
},
|
||||
"menu": {
|
||||
"file": "Fails",
|
||||
"edit": "Rediģēt",
|
||||
"view": "Skats",
|
||||
"message": "Ziņojums",
|
||||
"go": "Doties uz",
|
||||
"window": "Logs",
|
||||
"help": "Palīdzība",
|
||||
"new_window": "Jauns logs",
|
||||
"print": "Drukāt…",
|
||||
"save_pdf": "Saglabāt kā PDF…",
|
||||
"switch_connection": "Mainīt savienojumu…",
|
||||
"close": "Aizvērt",
|
||||
"quit": "Iziet",
|
||||
"undo": "Atsaukt",
|
||||
"redo": "Atkārtot",
|
||||
"cut": "Izgriezt",
|
||||
"copy": "Kopēt",
|
||||
"paste": "Ielīmēt",
|
||||
"select_all": "Atlasīt visu",
|
||||
"search": "Meklēt pastā",
|
||||
"refresh": "Pārbaudīt jaunu pastu",
|
||||
"reading_pane": "Lasīšanas rūts",
|
||||
"pane_right": "Labajā pusē",
|
||||
"pane_below": "Zem saraksta",
|
||||
"pane_off": "Izslēgta",
|
||||
"zoom_in": "Tuvināt",
|
||||
"zoom_out": "Tālināt",
|
||||
"zoom_reset": "Faktiskais izmērs",
|
||||
"full_screen": "Pilnekrāna režīms",
|
||||
"open_in_window": "Atvērt jaunā logā",
|
||||
"next_message": "Nākamais ziņojums",
|
||||
"previous_message": "Iepriekšējais ziņojums",
|
||||
"next_unread": "Nākamais nelasītais ziņojums",
|
||||
"mail": "Pasts",
|
||||
"files": "Faili",
|
||||
"back": "Atpakaļ",
|
||||
"forward": "Uz priekšu",
|
||||
"minimize": "Minimizēt",
|
||||
"next_window": "Nākamais logs",
|
||||
"shortcuts": "Tastatūras īsinājumtaustiņi",
|
||||
"docs": "Dokumentācija",
|
||||
"settings": "Iestatījumi…",
|
||||
"about": "Par Bulwark",
|
||||
"from_plugins": "Vairāk no spraudņiem…",
|
||||
"pick_gone": "Šī izvēlne bija novecojusi. Atveriet to vēlreiz un mēģiniet vēlreiz.",
|
||||
"bar_label": "Izvēlne"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Pievienot “{word}” vārdnīcai",
|
||||
"no_suggestions": "Nav ieteikumu",
|
||||
"open_link": "Atvērt saiti",
|
||||
"copy_link": "Kopēt saiti",
|
||||
"copy_address": "Kopēt adresi",
|
||||
"save_image": "Saglabāt attēlu…",
|
||||
"copy_image": "Kopēt attēlu"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "Melnrakstu neizdevās saglabāt",
|
||||
"unsaved_detail": "Ja aizvērsiet tagad, kas uzrakstīts kopš pēdējās saglabāšanas, tiks zaudēts.",
|
||||
"close_anyway": "Tomēr aizvērt",
|
||||
"unsent_title": "Daļa pasta vēl nav nosūtīta",
|
||||
"unsent_one": "Viens ziņojums gaida nosūtīšanu. Tas tiks nosūtīts nākamreiz, kad atvērsiet Bulwark.",
|
||||
"unsent_many": "Ziņojumi, kas gaida nosūtīšanu: {count}. Tie tiks nosūtīti nākamreiz, kad atvērsiet Bulwark.",
|
||||
"quit": "Iziet",
|
||||
"leave_title": "Pamest lapu ar nesaglabātām izmaiņām?",
|
||||
"leave_detail": "Šajā lapā veiktās izmaiņas vēl nav saglabātas.",
|
||||
"leave": "Pamest",
|
||||
"stay": "Palikt"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "Pareizrakstības pārbaudei valodā {language} nepieciešama vārdnīca. Bulwark to var lejupielādēt no Google. Jūsu teksts netiek sūtīts.",
|
||||
"download": "Lejupielādēt"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "Šo melnrakstu pašlaik nevar atvērt",
|
||||
"unavailable_body": "Tas ir drošībā serverī, mapē Melnraksti. Mēģiniet vēlreiz, kad savienojums būs atjaunots.",
|
||||
"try_again": "Mēģināt vēlreiz",
|
||||
"close": "Aizvērt"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Pierakstīties ar pārlūku",
|
||||
"browser_waiting": "Pabeidziet pierakstīšanos pārlūkā",
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "Энэ мессежийг {folder} руу зөөсөн.",
|
||||
"window_not_ready": "Шинэ цонх цагтаа нээгдсэнгүй. Таны ноорог энд үлдэнэ."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "Цонх",
|
||||
"double_click_label": "Давхар товшиход мессежийг шинэ цонхонд нээх",
|
||||
"double_click_description": "Нэг товшилт мессежийг жагсаалтын хажууд нээсээр байна.",
|
||||
"restore_label": "Эхлэхэд цонхнуудыг дахин нээх",
|
||||
"restore_description": "Гарах үед нээлттэй байсан мессеж, ноорог цонхнууд буцаж нээгдэнэ."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "Нээх",
|
||||
"save_as": "Өөрөөр хадгалах…",
|
||||
"copy": "Хуулах",
|
||||
"copy_failed": "Файлыг хуулж чадсангүй."
|
||||
},
|
||||
"menu": {
|
||||
"file": "Файл",
|
||||
"edit": "Засах",
|
||||
"view": "Харах",
|
||||
"message": "Зурвас",
|
||||
"go": "Очих",
|
||||
"window": "Цонх",
|
||||
"help": "Тусламж",
|
||||
"new_window": "Шинэ цонх",
|
||||
"print": "Хэвлэх…",
|
||||
"save_pdf": "PDF хэлбэрээр хадгалах…",
|
||||
"switch_connection": "Холболт солих…",
|
||||
"close": "Хаах",
|
||||
"quit": "Гарах",
|
||||
"undo": "Буцаах",
|
||||
"redo": "Дахин хийх",
|
||||
"cut": "Хайчлах",
|
||||
"copy": "Хуулах",
|
||||
"paste": "Буулгах",
|
||||
"select_all": "Бүгдийг сонгох",
|
||||
"search": "Захидлаас хайх",
|
||||
"refresh": "Шинэ захидал шалгах",
|
||||
"reading_pane": "Унших хэсэг",
|
||||
"pane_right": "Баруун талд",
|
||||
"pane_below": "Жагсаалтын доор",
|
||||
"pane_off": "Идэвхгүй",
|
||||
"zoom_in": "Томруулах",
|
||||
"zoom_out": "Жижигрүүлэх",
|
||||
"zoom_reset": "Бодит хэмжээ",
|
||||
"full_screen": "Бүтэн дэлгэц",
|
||||
"open_in_window": "Шинэ цонхонд нээх",
|
||||
"next_message": "Дараагийн зурвас",
|
||||
"previous_message": "Өмнөх зурвас",
|
||||
"next_unread": "Дараагийн уншаагүй зурвас",
|
||||
"mail": "Имэйл",
|
||||
"files": "Файлууд",
|
||||
"back": "Буцах",
|
||||
"forward": "Урагш",
|
||||
"minimize": "Жижигрүүлэх",
|
||||
"next_window": "Дараагийн цонх",
|
||||
"shortcuts": "Гарын товчлол",
|
||||
"docs": "Баримт бичиг",
|
||||
"settings": "Тохиргоо…",
|
||||
"about": "Bulwark-ийн тухай",
|
||||
"from_plugins": "Залгаснуудын бусад зүйл…",
|
||||
"pick_gone": "Энэ цэс хуучирсан байна. Дахин нээгээд нэг удаа оролдоно уу.",
|
||||
"bar_label": "Цэс"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "“{word}”-ийг толь бичигт нэмэх",
|
||||
"no_suggestions": "Санал алга",
|
||||
"open_link": "Холбоос нээх",
|
||||
"copy_link": "Холбоос хуулах",
|
||||
"copy_address": "Хаяг хуулах",
|
||||
"save_image": "Зураг хадгалах…",
|
||||
"copy_image": "Зураг хуулах"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "Ноорогийг хадгалж чадсангүй",
|
||||
"unsaved_detail": "Одоо хаавал сүүлд хадгалснаас хойш бичсэн зүйл тань алдагдана.",
|
||||
"close_anyway": "Ямартай ч хаах",
|
||||
"unsent_title": "Зарим захидал хараахан илгээгдээгүй байна",
|
||||
"unsent_one": "Нэг зурвас илгээгдэхийг хүлээж байна. Bulwark-ийг дараа нээхэд илгээгдэнэ.",
|
||||
"unsent_many": "{count} зурвас илгээгдэхийг хүлээж байна. Bulwark-ийг дараа нээхэд илгээгдэнэ.",
|
||||
"quit": "Гарах",
|
||||
"leave_title": "Хадгалаагүй өөрчлөлттэй гарах уу?",
|
||||
"leave_detail": "Энэ хуудсанд хийсэн өөрчлөлт тань хараахан хадгалагдаагүй байна.",
|
||||
"leave": "Гарах",
|
||||
"stay": "Үлдэх"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "{language} хэлний үсгийн алдаа шалгагчид толь бичиг хэрэгтэй. Bulwark үүнийг Google-ээс татаж авч чадна. Таны бичвэр илгээгдэхгүй.",
|
||||
"download": "Татах"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "Энэ ноорогийг одоо нээх боломжгүй",
|
||||
"unavailable_body": "Ноорог серверийн Ноорог хавтсанд аюулгүй байна. Холболт сэргэхэд дахин оролдоно уу.",
|
||||
"try_again": "Дахин оролдох",
|
||||
"close": "Хаах"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Хөтчөөр нэвтрэх",
|
||||
"browser_waiting": "Нэвтрэлтээ хөтөч дээр дуусгана уу",
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "Denne meldingen ble flyttet til {folder}.",
|
||||
"window_not_ready": "Det nye vinduet åpnet seg ikke i tide. Utkastet blir her."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "Vinduer",
|
||||
"double_click_label": "Åpne meldinger i et nytt vindu ved dobbeltklikk",
|
||||
"double_click_description": "Et enkelt klikk åpner fortsatt meldingen ved siden av listen.",
|
||||
"restore_label": "Åpne vinduer på nytt ved start",
|
||||
"restore_description": "Meldings- og utkastvinduer som var åpne da du avsluttet, kommer tilbake."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "Åpne",
|
||||
"save_as": "Lagre som…",
|
||||
"copy": "Kopier",
|
||||
"copy_failed": "Filen kunne ikke kopieres."
|
||||
},
|
||||
"menu": {
|
||||
"file": "Fil",
|
||||
"edit": "Rediger",
|
||||
"view": "Vis",
|
||||
"message": "Melding",
|
||||
"go": "Gå",
|
||||
"window": "Vindu",
|
||||
"help": "Hjelp",
|
||||
"new_window": "Nytt vindu",
|
||||
"print": "Skriv ut…",
|
||||
"save_pdf": "Lagre som PDF…",
|
||||
"switch_connection": "Bytt tilkobling…",
|
||||
"close": "Lukk",
|
||||
"quit": "Avslutt",
|
||||
"undo": "Angre",
|
||||
"redo": "Gjør om",
|
||||
"cut": "Klipp ut",
|
||||
"copy": "Kopier",
|
||||
"paste": "Lim inn",
|
||||
"select_all": "Velg alt",
|
||||
"search": "Søk i e-post",
|
||||
"refresh": "Se etter ny e-post",
|
||||
"reading_pane": "Leserute",
|
||||
"pane_right": "Til høyre",
|
||||
"pane_below": "Under listen",
|
||||
"pane_off": "Av",
|
||||
"zoom_in": "Zoom inn",
|
||||
"zoom_out": "Zoom ut",
|
||||
"zoom_reset": "Faktisk størrelse",
|
||||
"full_screen": "Fullskjerm",
|
||||
"open_in_window": "Åpne i nytt vindu",
|
||||
"next_message": "Neste melding",
|
||||
"previous_message": "Forrige melding",
|
||||
"next_unread": "Neste uleste melding",
|
||||
"mail": "E-post",
|
||||
"files": "Filer",
|
||||
"back": "Tilbake",
|
||||
"forward": "Fremover",
|
||||
"minimize": "Minimer",
|
||||
"next_window": "Neste vindu",
|
||||
"shortcuts": "Tastatursnarveier",
|
||||
"docs": "Dokumentasjon",
|
||||
"settings": "Innstillinger…",
|
||||
"about": "Om Bulwark",
|
||||
"from_plugins": "Mer fra utvidelser…",
|
||||
"pick_gone": "Den menyen var utdatert. Åpne den på nytt og prøv igjen.",
|
||||
"bar_label": "Meny"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Legg til «{word}» i ordboken",
|
||||
"no_suggestions": "Ingen forslag",
|
||||
"open_link": "Åpne lenke",
|
||||
"copy_link": "Kopier lenke",
|
||||
"copy_address": "Kopier adresse",
|
||||
"save_image": "Lagre bilde…",
|
||||
"copy_image": "Kopier bilde"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "Et utkast kunne ikke lagres",
|
||||
"unsaved_detail": "Hvis du lukker nå, går det du har skrevet siden forrige lagring tapt.",
|
||||
"close_anyway": "Lukk likevel",
|
||||
"unsent_title": "Noe e-post er ikke sendt ennå",
|
||||
"unsent_one": "Én melding venter på å bli sendt. Den sendes neste gang du åpner Bulwark.",
|
||||
"unsent_many": "{count} meldinger venter på å bli sendt. De sendes neste gang du åpner Bulwark.",
|
||||
"quit": "Avslutt",
|
||||
"leave_title": "Forlate siden med ulagrede endringer?",
|
||||
"leave_detail": "Endringene dine på denne siden er ikke lagret ennå.",
|
||||
"leave": "Forlat",
|
||||
"stay": "Bli"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "Stavekontroll for {language} trenger en ordbok. Bulwark kan laste den ned fra Google. Teksten din blir ikke sendt.",
|
||||
"download": "Last ned"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "Dette utkastet kan ikke åpnes akkurat nå",
|
||||
"unavailable_body": "Det er trygt på serveren, i Utkast. Prøv igjen når tilkoblingen er tilbake.",
|
||||
"try_again": "Prøv igjen",
|
||||
"close": "Lukk"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Logg på med nettleseren",
|
||||
"browser_waiting": "Fullfør påloggingen i nettleseren",
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "Dit bericht is verplaatst naar {folder}.",
|
||||
"window_not_ready": "Het nieuwe venster is niet op tijd geopend. Je concept blijft hier."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "Vensters",
|
||||
"double_click_label": "Berichten openen in een nieuw venster met dubbelklikken",
|
||||
"double_click_description": "Eén klik opent het bericht nog steeds naast de lijst.",
|
||||
"restore_label": "Vensters opnieuw openen bij het starten",
|
||||
"restore_description": "Bericht- en conceptvensters die open waren bij het afsluiten, komen terug."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "Openen",
|
||||
"save_as": "Opslaan als…",
|
||||
"copy": "Kopiëren",
|
||||
"copy_failed": "Het bestand kon niet worden gekopieerd."
|
||||
},
|
||||
"menu": {
|
||||
"file": "Bestand",
|
||||
"edit": "Bewerken",
|
||||
"view": "Weergave",
|
||||
"message": "Bericht",
|
||||
"go": "Ga",
|
||||
"window": "Venster",
|
||||
"help": "Help",
|
||||
"new_window": "Nieuw venster",
|
||||
"print": "Afdrukken…",
|
||||
"save_pdf": "Bewaar als pdf…",
|
||||
"switch_connection": "Verbinding wijzigen…",
|
||||
"close": "Sluit",
|
||||
"quit": "Stop",
|
||||
"undo": "Herstel",
|
||||
"redo": "Opnieuw",
|
||||
"cut": "Knip",
|
||||
"copy": "Kopieer",
|
||||
"paste": "Plak",
|
||||
"select_all": "Selecteer alles",
|
||||
"search": "Zoek in e-mail",
|
||||
"refresh": "Controleer op nieuwe e-mail",
|
||||
"reading_pane": "Leesvenster",
|
||||
"pane_right": "Rechts",
|
||||
"pane_below": "Onder de lijst",
|
||||
"pane_off": "Uit",
|
||||
"zoom_in": "Zoom in",
|
||||
"zoom_out": "Zoom uit",
|
||||
"zoom_reset": "Werkelijke grootte",
|
||||
"full_screen": "Volledig scherm",
|
||||
"open_in_window": "Open in nieuw venster",
|
||||
"next_message": "Volgend bericht",
|
||||
"previous_message": "Vorig bericht",
|
||||
"next_unread": "Volgend ongelezen bericht",
|
||||
"mail": "E-mail",
|
||||
"files": "Bestanden",
|
||||
"back": "Terug",
|
||||
"forward": "Vooruit",
|
||||
"minimize": "Minimaliseer",
|
||||
"next_window": "Volgend venster",
|
||||
"shortcuts": "Toetsenbordsneltoetsen",
|
||||
"docs": "Documentatie",
|
||||
"settings": "Instellingen…",
|
||||
"about": "Over Bulwark",
|
||||
"from_plugins": "Meer van plug-ins…",
|
||||
"pick_gone": "Dat menu was verouderd. Open het opnieuw en probeer het nog eens.",
|
||||
"bar_label": "Menu"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Voeg ‘{word}’ toe aan woordenboek",
|
||||
"no_suggestions": "Geen suggesties",
|
||||
"open_link": "Open link",
|
||||
"copy_link": "Kopieer link",
|
||||
"copy_address": "Kopieer adres",
|
||||
"save_image": "Bewaar afbeelding…",
|
||||
"copy_image": "Kopieer afbeelding"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "Een concept kon niet worden bewaard",
|
||||
"unsaved_detail": "Als u nu sluit, gaat verloren wat u sinds de laatste keer bewaren hebt geschreven.",
|
||||
"close_anyway": "Toch sluiten",
|
||||
"unsent_title": "Er is e-mail die nog niet is verzonden",
|
||||
"unsent_one": "Eén bericht wacht om te worden verzonden. Het wordt verzonden de volgende keer dat u Bulwark opent.",
|
||||
"unsent_many": "{count} berichten wachten om te worden verzonden. Ze worden verzonden de volgende keer dat u Bulwark opent.",
|
||||
"quit": "Stop",
|
||||
"leave_title": "Verlaten met niet-bewaarde wijzigingen?",
|
||||
"leave_detail": "Uw wijzigingen op deze pagina zijn nog niet bewaard.",
|
||||
"leave": "Verlaat",
|
||||
"stay": "Blijf"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "Voor spellingcontrole in het {language} is een woordenboek nodig. Bulwark kan dit downloaden van Google. Uw tekst wordt niet verzonden.",
|
||||
"download": "Download"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "Dit concept kan nu niet worden geopend",
|
||||
"unavailable_body": "Het staat veilig op de server, in Concepten. Probeer het opnieuw zodra de verbinding terug is.",
|
||||
"try_again": "Probeer opnieuw",
|
||||
"close": "Sluit"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Inloggen met je browser",
|
||||
"browser_waiting": "Rond het inloggen af in je browser",
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "Ta wiadomość została przeniesiona do {folder}.",
|
||||
"window_not_ready": "Nowe okno nie otworzyło się na czas. Twój szkic zostaje tutaj."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "Okna",
|
||||
"double_click_label": "Otwieraj wiadomości w nowym oknie po dwukrotnym kliknięciu",
|
||||
"double_click_description": "Pojedyncze kliknięcie nadal otwiera wiadomość obok listy.",
|
||||
"restore_label": "Otwieraj okna ponownie przy starcie",
|
||||
"restore_description": "Okna wiadomości i wersji roboczych otwarte przy zamykaniu wrócą."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "Otwórz",
|
||||
"save_as": "Zapisz jako…",
|
||||
"copy": "Kopiuj",
|
||||
"copy_failed": "Nie udało się skopiować pliku."
|
||||
},
|
||||
"menu": {
|
||||
"file": "Plik",
|
||||
"edit": "Edycja",
|
||||
"view": "Widok",
|
||||
"message": "Wiadomość",
|
||||
"go": "Przejdź",
|
||||
"window": "Okno",
|
||||
"help": "Pomoc",
|
||||
"new_window": "Nowe okno",
|
||||
"print": "Drukuj…",
|
||||
"save_pdf": "Zapisz jako PDF…",
|
||||
"switch_connection": "Zmień połączenie…",
|
||||
"close": "Zamknij",
|
||||
"quit": "Zakończ",
|
||||
"undo": "Cofnij",
|
||||
"redo": "Ponów",
|
||||
"cut": "Wytnij",
|
||||
"copy": "Kopiuj",
|
||||
"paste": "Wklej",
|
||||
"select_all": "Zaznacz wszystko",
|
||||
"search": "Szukaj w poczcie",
|
||||
"refresh": "Sprawdź nową pocztę",
|
||||
"reading_pane": "Okienko podglądu",
|
||||
"pane_right": "Po prawej",
|
||||
"pane_below": "Pod listą",
|
||||
"pane_off": "Wyłączone",
|
||||
"zoom_in": "Powiększ",
|
||||
"zoom_out": "Pomniejsz",
|
||||
"zoom_reset": "Rzeczywisty rozmiar",
|
||||
"full_screen": "Pełny ekran",
|
||||
"open_in_window": "Otwórz w nowym oknie",
|
||||
"next_message": "Następna wiadomość",
|
||||
"previous_message": "Poprzednia wiadomość",
|
||||
"next_unread": "Następna nieprzeczytana wiadomość",
|
||||
"mail": "Poczta",
|
||||
"files": "Pliki",
|
||||
"back": "Wstecz",
|
||||
"forward": "Dalej",
|
||||
"minimize": "Minimalizuj",
|
||||
"next_window": "Następne okno",
|
||||
"shortcuts": "Skróty klawiszowe",
|
||||
"docs": "Dokumentacja",
|
||||
"settings": "Ustawienia…",
|
||||
"about": "O programie Bulwark",
|
||||
"from_plugins": "Więcej z wtyczek…",
|
||||
"pick_gone": "To menu było nieaktualne. Otwórz je ponownie i spróbuj jeszcze raz.",
|
||||
"bar_label": "Menu"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Dodaj „{word}” do słownika",
|
||||
"no_suggestions": "Brak podpowiedzi",
|
||||
"open_link": "Otwórz łącze",
|
||||
"copy_link": "Kopiuj łącze",
|
||||
"copy_address": "Kopiuj adres",
|
||||
"save_image": "Zapisz obraz…",
|
||||
"copy_image": "Kopiuj obraz"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "Nie udało się zapisać wersji roboczej",
|
||||
"unsaved_detail": "Jeśli zamkniesz teraz, utracisz wszystko, co zostało napisane od ostatniego zapisu.",
|
||||
"close_anyway": "Zamknij mimo to",
|
||||
"unsent_title": "Część poczty nie została jeszcze wysłana",
|
||||
"unsent_one": "Jedna wiadomość czeka na wysłanie. Zostanie wysłana przy następnym uruchomieniu Bulwark.",
|
||||
"unsent_many": "Wiadomości czekające na wysłanie: {count}. Zostaną wysłane przy następnym uruchomieniu Bulwark.",
|
||||
"quit": "Zakończ",
|
||||
"leave_title": "Opuścić stronę bez zapisania zmian?",
|
||||
"leave_detail": "Zmiany wprowadzone na tej stronie nie zostały jeszcze zapisane.",
|
||||
"leave": "Opuść",
|
||||
"stay": "Zostań"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "Sprawdzanie pisowni dla języka {language} wymaga słownika. Bulwark może go pobrać od Google. Twój tekst nie jest wysyłany.",
|
||||
"download": "Pobierz"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "Nie można teraz otworzyć tej wersji roboczej",
|
||||
"unavailable_body": "Jest bezpieczna na serwerze, w folderze Wersje robocze. Spróbuj ponownie, gdy połączenie wróci.",
|
||||
"try_again": "Spróbuj ponownie",
|
||||
"close": "Zamknij"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Zaloguj się w przeglądarce",
|
||||
"browser_waiting": "Dokończ logowanie w przeglądarce",
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "Esta mensagem foi movida para {folder}.",
|
||||
"window_not_ready": "A nova janela não abriu a tempo. O seu rascunho fica aqui."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "Janelas",
|
||||
"double_click_label": "Abrir mensagens numa nova janela com duplo clique",
|
||||
"double_click_description": "Um único clique continua a abrir a mensagem junto à lista.",
|
||||
"restore_label": "Reabrir janelas ao iniciar",
|
||||
"restore_description": "As janelas de mensagens e rascunhos abertas ao sair voltam a aparecer."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "Abrir",
|
||||
"save_as": "Guardar como…",
|
||||
"copy": "Copiar",
|
||||
"copy_failed": "Não foi possível copiar o ficheiro."
|
||||
},
|
||||
"menu": {
|
||||
"file": "Ficheiro",
|
||||
"edit": "Editar",
|
||||
"view": "Ver",
|
||||
"message": "Mensagem",
|
||||
"go": "Ir",
|
||||
"window": "Janela",
|
||||
"help": "Ajuda",
|
||||
"new_window": "Nova janela",
|
||||
"print": "Imprimir…",
|
||||
"save_pdf": "Guardar como PDF…",
|
||||
"switch_connection": "Mudar de ligação…",
|
||||
"close": "Fechar",
|
||||
"quit": "Sair",
|
||||
"undo": "Anular",
|
||||
"redo": "Refazer",
|
||||
"cut": "Cortar",
|
||||
"copy": "Copiar",
|
||||
"paste": "Colar",
|
||||
"select_all": "Selecionar tudo",
|
||||
"search": "Pesquisar no correio",
|
||||
"refresh": "Verificar novo correio",
|
||||
"reading_pane": "Painel de leitura",
|
||||
"pane_right": "À direita",
|
||||
"pane_below": "Abaixo da lista",
|
||||
"pane_off": "Desativado",
|
||||
"zoom_in": "Ampliar",
|
||||
"zoom_out": "Reduzir",
|
||||
"zoom_reset": "Tamanho real",
|
||||
"full_screen": "Ecrã inteiro",
|
||||
"open_in_window": "Abrir numa nova janela",
|
||||
"next_message": "Mensagem seguinte",
|
||||
"previous_message": "Mensagem anterior",
|
||||
"next_unread": "Próxima mensagem por ler",
|
||||
"mail": "Correio",
|
||||
"files": "Ficheiros",
|
||||
"back": "Anterior",
|
||||
"forward": "Seguinte",
|
||||
"minimize": "Minimizar",
|
||||
"next_window": "Janela seguinte",
|
||||
"shortcuts": "Atalhos de teclado",
|
||||
"docs": "Documentação",
|
||||
"settings": "Definições…",
|
||||
"about": "Acerca do Bulwark",
|
||||
"from_plugins": "Mais dos plugins…",
|
||||
"pick_gone": "Esse menu estava desatualizado. Abra-o de novo e tente outra vez.",
|
||||
"bar_label": "Menu"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Adicionar «{word}» ao dicionário",
|
||||
"no_suggestions": "Sem sugestões",
|
||||
"open_link": "Abrir ligação",
|
||||
"copy_link": "Copiar ligação",
|
||||
"copy_address": "Copiar endereço",
|
||||
"save_image": "Guardar imagem…",
|
||||
"copy_image": "Copiar imagem"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "Não foi possível guardar um rascunho",
|
||||
"unsaved_detail": "Se fechar agora, perde o que escreveu desde a última gravação.",
|
||||
"close_anyway": "Fechar mesmo assim",
|
||||
"unsent_title": "Há mensagens por enviar",
|
||||
"unsent_one": "Uma mensagem aguarda envio. Será enviada na próxima vez que abrir o Bulwark.",
|
||||
"unsent_many": "{count} mensagens aguardam envio. Serão enviadas na próxima vez que abrir o Bulwark.",
|
||||
"quit": "Sair",
|
||||
"leave_title": "Sair com alterações não guardadas?",
|
||||
"leave_detail": "As alterações feitas nesta página ainda não foram guardadas.",
|
||||
"leave": "Sair",
|
||||
"stay": "Ficar"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "A verificação ortográfica em {language} precisa de um dicionário. O Bulwark pode transferi-lo da Google. O seu texto não é enviado.",
|
||||
"download": "Transferir"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "Este rascunho não pode ser aberto agora",
|
||||
"unavailable_body": "Está seguro no servidor, em Rascunhos. Tente novamente quando a ligação voltar.",
|
||||
"try_again": "Tentar novamente",
|
||||
"close": "Fechar"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Entrar com o navegador",
|
||||
"browser_waiting": "Termine de entrar no navegador",
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "Acest mesaj a fost mutat în {folder}.",
|
||||
"window_not_ready": "Fereastra nouă nu s-a deschis la timp. Ciorna rămâne aici."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "Ferestre",
|
||||
"double_click_label": "Deschide mesajele într-o fereastră nouă la dublu clic",
|
||||
"double_click_description": "Un singur clic deschide în continuare mesajul lângă listă.",
|
||||
"restore_label": "Redeschide ferestrele la pornire",
|
||||
"restore_description": "Ferestrele de mesaje și ciorne deschise la ieșire revin."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "Deschide",
|
||||
"save_as": "Salvează ca…",
|
||||
"copy": "Copiază",
|
||||
"copy_failed": "Fișierul nu a putut fi copiat."
|
||||
},
|
||||
"menu": {
|
||||
"file": "Fișier",
|
||||
"edit": "Editare",
|
||||
"view": "Vizualizare",
|
||||
"message": "Mesaj",
|
||||
"go": "Salt la",
|
||||
"window": "Fereastră",
|
||||
"help": "Ajutor",
|
||||
"new_window": "Fereastră nouă",
|
||||
"print": "Imprimare…",
|
||||
"save_pdf": "Salvare ca PDF…",
|
||||
"switch_connection": "Schimbare conexiune…",
|
||||
"close": "Închidere",
|
||||
"quit": "Ieșire",
|
||||
"undo": "Anulare",
|
||||
"redo": "Refacere",
|
||||
"cut": "Decupare",
|
||||
"copy": "Copiere",
|
||||
"paste": "Lipire",
|
||||
"select_all": "Selectare totală",
|
||||
"search": "Căutare în e-mail",
|
||||
"refresh": "Verificare e-mailuri noi",
|
||||
"reading_pane": "Panou de citire",
|
||||
"pane_right": "În dreapta",
|
||||
"pane_below": "Sub listă",
|
||||
"pane_off": "Dezactivat",
|
||||
"zoom_in": "Mărire",
|
||||
"zoom_out": "Micșorare",
|
||||
"zoom_reset": "Dimensiune reală",
|
||||
"full_screen": "Ecran complet",
|
||||
"open_in_window": "Deschidere în fereastră nouă",
|
||||
"next_message": "Mesajul următor",
|
||||
"previous_message": "Mesajul anterior",
|
||||
"next_unread": "Următorul mesaj necitit",
|
||||
"mail": "E-mail",
|
||||
"files": "Fișiere",
|
||||
"back": "Înapoi",
|
||||
"forward": "Înainte",
|
||||
"minimize": "Minimizare",
|
||||
"next_window": "Fereastra următoare",
|
||||
"shortcuts": "Comenzi rapide de la tastatură",
|
||||
"docs": "Documentație",
|
||||
"settings": "Setări…",
|
||||
"about": "Despre Bulwark",
|
||||
"from_plugins": "Mai multe de la pluginuri…",
|
||||
"pick_gone": "Meniul era depășit. Deschideți-l din nou și încercați încă o dată.",
|
||||
"bar_label": "Meniu"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Adăugare „{word}” în dicționar",
|
||||
"no_suggestions": "Fără sugestii",
|
||||
"open_link": "Deschidere link",
|
||||
"copy_link": "Copiere link",
|
||||
"copy_address": "Copiere adresă",
|
||||
"save_image": "Salvare imagine…",
|
||||
"copy_image": "Copiere imagine"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "O ciornă nu a putut fi salvată",
|
||||
"unsaved_detail": "Dacă închideți acum, ce ați scris de la ultima salvare se pierde.",
|
||||
"close_anyway": "Închidere oricum",
|
||||
"unsent_title": "Unele mesaje nu au fost trimise încă",
|
||||
"unsent_one": "Un mesaj așteaptă să fie trimis. Pleacă la următoarea deschidere a Bulwark.",
|
||||
"unsent_many": "{count} mesaje așteaptă să fie trimise. Pleacă la următoarea deschidere a Bulwark.",
|
||||
"quit": "Ieșire",
|
||||
"leave_title": "Părăsiți pagina cu modificări nesalvate?",
|
||||
"leave_detail": "Modificările făcute pe această pagină nu au fost salvate încă.",
|
||||
"leave": "Părăsire",
|
||||
"stay": "Rămâneți"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "Verificarea ortografică pentru {language} are nevoie de un dicționar. Bulwark îl poate descărca de la Google. Textul dvs. nu este trimis.",
|
||||
"download": "Descărcare"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "Această ciornă nu poate fi deschisă acum",
|
||||
"unavailable_body": "Este în siguranță pe server, în Ciorne. Încercați din nou când revine conexiunea.",
|
||||
"try_again": "Încercați din nou",
|
||||
"close": "Închidere"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Conectează-te cu browserul",
|
||||
"browser_waiting": "Finalizează conectarea în browser",
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "Это письмо перемещено в {folder}.",
|
||||
"window_not_ready": "Новое окно не открылось вовремя. Черновик остаётся здесь."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "Окна",
|
||||
"double_click_label": "Открывать письма в новом окне двойным щелчком",
|
||||
"double_click_description": "Одиночный щелчок по-прежнему открывает письмо рядом со списком.",
|
||||
"restore_label": "Снова открывать окна при запуске",
|
||||
"restore_description": "Окна писем и черновиков, открытые при выходе, вернутся."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "Открыть",
|
||||
"save_as": "Сохранить как…",
|
||||
"copy": "Копировать",
|
||||
"copy_failed": "Не удалось скопировать файл."
|
||||
},
|
||||
"menu": {
|
||||
"file": "Файл",
|
||||
"edit": "Правка",
|
||||
"view": "Вид",
|
||||
"message": "Сообщение",
|
||||
"go": "Переход",
|
||||
"window": "Окно",
|
||||
"help": "Справка",
|
||||
"new_window": "Новое окно",
|
||||
"print": "Печать…",
|
||||
"save_pdf": "Сохранить как PDF…",
|
||||
"switch_connection": "Сменить подключение…",
|
||||
"close": "Закрыть",
|
||||
"quit": "Выйти",
|
||||
"undo": "Отменить",
|
||||
"redo": "Повторить",
|
||||
"cut": "Вырезать",
|
||||
"copy": "Копировать",
|
||||
"paste": "Вставить",
|
||||
"select_all": "Выделить всё",
|
||||
"search": "Поиск по почте",
|
||||
"refresh": "Проверить новую почту",
|
||||
"reading_pane": "Область чтения",
|
||||
"pane_right": "Справа",
|
||||
"pane_below": "Под списком",
|
||||
"pane_off": "Выключена",
|
||||
"zoom_in": "Увеличить",
|
||||
"zoom_out": "Уменьшить",
|
||||
"zoom_reset": "Реальный размер",
|
||||
"full_screen": "Полноэкранный режим",
|
||||
"open_in_window": "Открыть в новом окне",
|
||||
"next_message": "Следующее сообщение",
|
||||
"previous_message": "Предыдущее сообщение",
|
||||
"next_unread": "Следующее непрочитанное сообщение",
|
||||
"mail": "Почта",
|
||||
"files": "Файлы",
|
||||
"back": "Назад",
|
||||
"forward": "Вперёд",
|
||||
"minimize": "Свернуть",
|
||||
"next_window": "Следующее окно",
|
||||
"shortcuts": "Сочетания клавиш",
|
||||
"docs": "Документация",
|
||||
"settings": "Настройки…",
|
||||
"about": "О программе Bulwark",
|
||||
"from_plugins": "Ещё из плагинов…",
|
||||
"pick_gone": "Это меню устарело. Откройте его снова и повторите попытку.",
|
||||
"bar_label": "Меню"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Добавить «{word}» в словарь",
|
||||
"no_suggestions": "Нет вариантов",
|
||||
"open_link": "Открыть ссылку",
|
||||
"copy_link": "Копировать ссылку",
|
||||
"copy_address": "Копировать адрес",
|
||||
"save_image": "Сохранить изображение…",
|
||||
"copy_image": "Копировать изображение"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "Не удалось сохранить черновик",
|
||||
"unsaved_detail": "Если закрыть сейчас, всё написанное после последнего сохранения будет потеряно.",
|
||||
"close_anyway": "Всё равно закрыть",
|
||||
"unsent_title": "Часть писем ещё не отправлена",
|
||||
"unsent_one": "Одно сообщение ждёт отправки. Оно уйдёт при следующем запуске Bulwark.",
|
||||
"unsent_many": "Сообщений, ожидающих отправки: {count}. Они уйдут при следующем запуске Bulwark.",
|
||||
"quit": "Выйти",
|
||||
"leave_title": "Уйти с несохранёнными изменениями?",
|
||||
"leave_detail": "Изменения на этой странице ещё не сохранены.",
|
||||
"leave": "Уйти",
|
||||
"stay": "Остаться"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "Для проверки орфографии на языке «{language}» нужен словарь. Bulwark может скачать его у Google. Ваш текст не отправляется.",
|
||||
"download": "Скачать"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "Этот черновик сейчас нельзя открыть",
|
||||
"unavailable_body": "Он в сохранности на сервере, в папке «Черновики». Повторите попытку, когда появится соединение.",
|
||||
"try_again": "Повторить",
|
||||
"close": "Закрыть"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Войти через браузер",
|
||||
"browser_waiting": "Завершите вход в браузере",
|
||||
|
||||
@@ -6667,6 +6667,101 @@
|
||||
"message_moved_to": "Táto správa bola presunutá do {folder}.",
|
||||
"window_not_ready": "Nové okno sa neotvorilo včas. Koncept zostáva tu."
|
||||
},
|
||||
"desktopUi": {
|
||||
"settings": {
|
||||
"title": "Okná",
|
||||
"double_click_label": "Otvárať správy v novom okne dvojitým kliknutím",
|
||||
"double_click_description": "Jedným kliknutím sa správa stále otvorí vedľa zoznamu.",
|
||||
"restore_label": "Pri spustení znova otvoriť okná",
|
||||
"restore_description": "Okná správ a konceptov otvorené pri ukončení sa vrátia."
|
||||
},
|
||||
"attachment": {
|
||||
"open": "Otvoriť",
|
||||
"save_as": "Uložiť ako…",
|
||||
"copy": "Kopírovať",
|
||||
"copy_failed": "Súbor sa nepodarilo skopírovať."
|
||||
},
|
||||
"menu": {
|
||||
"file": "Súbor",
|
||||
"edit": "Upraviť",
|
||||
"view": "Zobrazenie",
|
||||
"message": "Správa",
|
||||
"go": "Prejsť",
|
||||
"window": "Okno",
|
||||
"help": "Pomocník",
|
||||
"new_window": "Nové okno",
|
||||
"print": "Tlačiť…",
|
||||
"save_pdf": "Uložiť ako PDF…",
|
||||
"switch_connection": "Zmeniť pripojenie…",
|
||||
"close": "Zavrieť",
|
||||
"quit": "Ukončiť",
|
||||
"undo": "Späť",
|
||||
"redo": "Znova",
|
||||
"cut": "Vystrihnúť",
|
||||
"copy": "Kopírovať",
|
||||
"paste": "Prilepiť",
|
||||
"select_all": "Vybrať všetko",
|
||||
"search": "Hľadať v pošte",
|
||||
"refresh": "Skontrolovať novú poštu",
|
||||
"reading_pane": "Panel čítania",
|
||||
"pane_right": "Vpravo",
|
||||
"pane_below": "Pod zoznamom",
|
||||
"pane_off": "Vypnuté",
|
||||
"zoom_in": "Priblížiť",
|
||||
"zoom_out": "Oddialiť",
|
||||
"zoom_reset": "Skutočná veľkosť",
|
||||
"full_screen": "Celá obrazovka",
|
||||
"open_in_window": "Otvoriť v novom okne",
|
||||
"next_message": "Ďalšia správa",
|
||||
"previous_message": "Predchádzajúca správa",
|
||||
"next_unread": "Ďalšia neprečítaná správa",
|
||||
"mail": "Pošta",
|
||||
"files": "Súbory",
|
||||
"back": "Späť",
|
||||
"forward": "Dopredu",
|
||||
"minimize": "Minimalizovať",
|
||||
"next_window": "Ďalšie okno",
|
||||
"shortcuts": "Klávesové skratky",
|
||||
"docs": "Dokumentácia",
|
||||
"settings": "Nastavenia…",
|
||||
"about": "O aplikácii Bulwark",
|
||||
"from_plugins": "Viac z doplnkov…",
|
||||
"pick_gone": "Táto ponuka bola zastaraná. Otvorte ju znova a skúste to ešte raz.",
|
||||
"bar_label": "Ponuka"
|
||||
},
|
||||
"context": {
|
||||
"add_to_dictionary": "Pridať „{word}“ do slovníka",
|
||||
"no_suggestions": "Žiadne návrhy",
|
||||
"open_link": "Otvoriť odkaz",
|
||||
"copy_link": "Kopírovať odkaz",
|
||||
"copy_address": "Kopírovať adresu",
|
||||
"save_image": "Uložiť obrázok…",
|
||||
"copy_image": "Kopírovať obrázok"
|
||||
},
|
||||
"closing": {
|
||||
"unsaved_title": "Koncept sa nepodarilo uložiť",
|
||||
"unsaved_detail": "Ak teraz zatvoríte okno, prídete o to, čo ste napísali od posledného uloženia.",
|
||||
"close_anyway": "Napriek tomu zavrieť",
|
||||
"unsent_title": "Niektorá pošta ešte nebola odoslaná",
|
||||
"unsent_one": "Jedna správa čaká na odoslanie. Odíde pri ďalšom spustení Bulwark.",
|
||||
"unsent_many": "Počet správ čakajúcich na odoslanie: {count}. Odídu pri ďalšom spustení Bulwark.",
|
||||
"quit": "Ukončiť",
|
||||
"leave_title": "Odísť s neuloženými zmenami?",
|
||||
"leave_detail": "Zmeny na tejto stránke zatiaľ nie sú uložené.",
|
||||
"leave": "Odísť",
|
||||
"stay": "Zostať"
|
||||
},
|
||||
"spellcheck": {
|
||||
"offer": "Kontrola pravopisu pre jazyk {language} potrebuje slovník. Bulwark ho môže stiahnuť od spoločnosti Google. Váš text sa neodosiela.",
|
||||
"download": "Stiahnuť"
|
||||
},
|
||||
"restore": {
|
||||
"unavailable_title": "Tento koncept sa teraz nedá otvoriť",
|
||||
"unavailable_body": "Je v bezpečí na serveri v priečinku Koncepty. Skúste to znova, keď sa obnoví pripojenie.",
|
||||
"try_again": "Skúsiť znova",
|
||||
"close": "Zavrieť"
|
||||
}
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Prihlásiť sa v prehliadači",
|
||||
"browser_waiting": "Dokončite prihlásenie v prehliadači",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user