Merge branch 'feat/do-drop-overlay' into feat/desktop-offline

# Conflicts:
#	desktop-policies/de-DE/Bulwark.adml
#	desktop-policies/en-US/Bulwark.adml
This commit is contained in:
Linus Rath
2026-11-04 08:37:26 +01:00
61 changed files with 2930 additions and 236 deletions
+2
View File
@@ -31,6 +31,7 @@ import { OfflineHost } from "@/components/offline/offline-host";
import { DeviceWipeHost } from "@/components/offline/device-wipe-host";
import { NotificationActionsHost } from "@/components/providers/notification-actions-host";
import { DesktopOsHost } from "@/components/desktop/desktop-os-host";
import { FileDropHost } from "@/components/file-drop/file-drop-host";
import { EditedFilesHost } from "@/components/desktop/edited-files-host";
import { DevicePolicyHost } from "@/components/device/device-policy-host";
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
@@ -103,6 +104,7 @@ export default async function LocaleLayout({
<SessionTimeoutGuard />
<AccountTemplateSync />
{children}
<FileDropHost />
<PluginDialogHost />
<ExternalRecipientConfirmHost />
<PhishingReportHost />
+3 -5
View File
@@ -8,15 +8,15 @@ import { isAppLocked } from "@/lib/desktop/app-lock-state";
import { contentHiddenFor, startMailNotifications, type MailNotificationLabels, type MailNotifications } from "@/lib/desktop/mail-notifications";
import { startOsSummary } from "@/lib/desktop/os-summary";
import { startPowerWatch } from "@/lib/desktop/power";
import { startWindowFiles } from "@/lib/desktop/window-files";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
/**
* The main window's end of the desktop app's OS integration: new mail as native
* notifications, what the tray and jump list show, waking up after sleep, and files
* dropped or pasted on the window. Renders nothing, and does nothing in a browser.
* notifications, what the tray and jump list show, and waking up after sleep. Files
* dropped or pasted on the window are the file drop overlay's (components/file-drop).
* Renders nothing, and does nothing in a browser.
* Lives inside MainWindowOnly, so pop-outs don't do any of it a second time.
*/
export function DesktopOsHost() {
@@ -55,12 +55,10 @@ export function DesktopOsHost() {
const summary = startOsSummary(() => localeRef.current);
summaryRef.current = summary;
const stopPower = startPowerWatch();
const stopFiles = startWindowFiles();
return () => {
mail.stop();
summary.stop();
stopPower();
stopFiles();
mailRef.current = null;
summaryRef.current = null;
};
+13
View File
@@ -2796,6 +2796,19 @@ export function EmailComposer({
useEffect(() => setComposerFileTarget(
(files) => void addFiles(files),
() => !!composerRootEl?.isConnected && composerRootEl.getClientRects().length > 0,
// The file drop overlay's "Insert into the message": the editor's own paste, so the
// upload, the place and the question for a description are the same as pasting.
{
available: () => !!editorRef.current && !editorRef.current.isDestroyed,
insert: (files) => {
const editor = editorRef.current;
if (!editor || editor.isDestroyed) return;
const transfer = new DataTransfer();
for (const file of files) transfer.items.add(file);
editor.commands.focus();
editor.view.dom.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, cancelable: true }));
},
},
), [addFiles, composerRootEl]);
// The id plugins know this composer by (registered below, with the handle).
const [pluginComposerId, setPluginComposerId] = useState<string | null>(null);
@@ -0,0 +1,312 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// The overlay on every screen: what shows for a drag from outside, where a drop goes, and
// the same choices as a dialog for a pasted file or one opened with the desktop app.
const hoisted = vi.hoisted(() => ({
push: vi.fn(),
pathname: { current: '/' },
toastError: vi.fn(),
toastInfo: vi.fn(),
toastSuccess: vi.fn(),
locked: { current: false },
flushComposers: vi.fn(async () => ({ total: 0, settled: 0, failed: 0, timedOut: false })),
}));
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => (values ? `${key}:${JSON.stringify(values)}` : key),
}));
vi.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push: hoisted.push, replace: vi.fn(), back: vi.fn() }),
usePathname: () => hoisted.pathname.current,
}));
vi.mock('@/stores/toast-store', () => ({ toast: { error: hoisted.toastError, info: hoisted.toastInfo, success: hoisted.toastSuccess } }));
vi.mock('@/lib/compose-flush-registry', () => ({ flushComposers: hoisted.flushComposers }));
vi.mock('@/lib/desktop/app-lock-state', () => ({ isAppLocked: () => hoisted.locked.current }));
const { useAuthStore } = await import('@/stores/auth-store');
const { clearHandoffs, takeHandoff } = await import('@/lib/launch-handoff');
const { setComposerFileTarget, setOwnDragActive } = await import('@/lib/desktop/window-files');
const { setFilesDropTarget, takeQueuedFilesUpload } = await import('@/lib/file-drop/files-target');
const { requestFileChoice } = await import('@/lib/file-drop/choice');
const { FileDropHost } = await import('../file-drop-host');
interface FakeClient {
supportsFiles: () => boolean;
supportsContacts: () => boolean;
getMaxSizeUpload: () => number;
getMaxSizeAttachmentsPerEmail: () => number;
}
function signIn(client: Partial<FakeClient> = {}) {
useAuthStore.setState({
isAuthenticated: true,
activeAccountId: null,
client: {
supportsFiles: () => true,
supportsContacts: () => false,
getMaxSizeUpload: () => 0,
getMaxSizeAttachmentsPerEmail: () => 0,
...client,
} as never,
});
}
function transfer(files: File[]) {
return {
types: ['Files'],
files,
items: files.map((file) => ({ kind: 'file', type: file.type, getAsFile: () => file, webkitGetAsEntry: () => ({ isFile: true, isDirectory: false }) })),
dropEffect: 'none',
};
}
/** A drag from outside reaching the window, as the browser sends it. */
function dragIn(files: File[]) {
const event = new Event('dragenter', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'dataTransfer', { value: transfer(files) });
act(() => {
document.body.dispatchEvent(event);
});
}
function zone(id: string): HTMLElement {
const found = document.querySelector<HTMLElement>(`[data-testid="file-drop-overlay"] [data-drop-zone="${id}"]`);
if (!found) throw new Error(`no zone ${id}`);
return found;
}
const flush = () => act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
const file = new File(['hello'], 'report.pdf', { type: 'application/pdf' });
beforeEach(() => {
clearHandoffs();
takeHandoff('compose');
takeQueuedFilesUpload();
hoisted.locked.current = false;
hoisted.pathname.current = '/';
vi.clearAllMocks();
signIn();
});
afterEach(() => {
setOwnDragActive(false);
useAuthStore.setState({ isAuthenticated: false, client: null });
});
describe('a drag from outside', () => {
it('covers the window with the two main choices and says how many files', () => {
render(<FileDropHost />);
dragIn([file, file]);
expect(screen.getByTestId('file-drop-overlay')).toBeTruthy();
expect(zone('new-message').textContent).toContain('zone_new_message');
expect(zone('upload').textContent).toContain('zone_upload_files');
expect(screen.getByTestId('file-drop-count').textContent).toBe('files_summary:{"count":2}');
});
it('a drop on "Attach to a new message" starts a draft with the file', async () => {
render(<FileDropHost />);
dragIn([file]);
fireEvent.drop(zone('new-message'), { dataTransfer: transfer([file]) });
await flush();
expect(takeHandoff('compose')?.files).toEqual([file]);
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
});
it('with a composer on screen, its zone comes first and takes the file', async () => {
const take = vi.fn();
const clear = setComposerFileTarget(take, () => true);
render(<FileDropHost />);
dragIn([file]);
const zones = Array.from(document.querySelectorAll('[data-drop-zone]')).map((element) => element.getAttribute('data-drop-zone'));
expect(zones[0]).toBe('attach-here');
fireEvent.drop(zone('attach-here'), { dataTransfer: transfer([file]) });
await flush();
expect(take).toHaveBeenCalledWith([file]);
expect(takeHandoff('compose')).toBeNull();
clear();
});
it('a picture dropped on "Insert into the message" goes into the text, as a paste would', async () => {
const take = vi.fn();
const insert = vi.fn();
const clear = setComposerFileTarget(take, () => true, { available: () => true, insert });
const picture = new File(['png'], 'chart.png', { type: 'image/png' });
render(<FileDropHost />);
dragIn([picture]);
expect(zone('insert-inline').textContent).toContain('zone_insert_inline');
fireEvent.drop(zone('insert-inline'), { dataTransfer: transfer([picture]) });
await flush();
expect(insert).toHaveBeenCalledWith([picture]);
expect(take).not.toHaveBeenCalled();
clear();
});
it('on the Files screen, "Upload to <folder>" hands the files to that folder', async () => {
const take = vi.fn();
const clear = setFilesDropTarget({ folder: 'Reports', take, visible: () => true });
render(<FileDropHost />);
dragIn([file]);
expect(zone('upload').textContent).toContain('zone_upload_folder:{"folder":"Reports"}');
fireEvent.drop(zone('upload'), { dataTransfer: transfer([file]) });
await flush();
expect(take).toHaveBeenCalledWith({ files: [file], asFolder: false });
clear();
});
it('from another screen, Files gets the files after the app goes there', async () => {
render(<FileDropHost />);
dragIn([file]);
fireEvent.drop(zone('upload'), { dataTransfer: transfer([file]) });
await flush();
expect(hoisted.push).toHaveBeenCalledWith('/files');
expect(takeQueuedFilesUpload()).toEqual({ files: [file], asFolder: false });
});
it('a drop beside the zones does nothing', async () => {
render(<FileDropHost />);
dragIn([file]);
fireEvent.drop(screen.getByTestId('file-drop-overlay'), { dataTransfer: transfer([file]) });
await flush();
expect(takeHandoff('compose')).toBeNull();
expect(takeQueuedFilesUpload()).toBeNull();
});
it('a file over the server limit is named and left out before anything starts', async () => {
signIn({ getMaxSizeUpload: () => 3 });
render(<FileDropHost />);
dragIn([file]);
expect(zone('upload').textContent).toContain('note_limit_file');
fireEvent.drop(zone('upload'), { dataTransfer: transfer([file]) });
await flush();
expect(hoisted.toastError).toHaveBeenCalledWith(expect.stringContaining('too_large_file'));
expect(takeQueuedFilesUpload()).toBeNull();
});
it('the app\'s own drags never bring it up', () => {
render(<FileDropHost />);
act(() => {
document.body.dispatchEvent(new Event('dragstart', { bubbles: true }));
});
dragIn([file]);
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
act(() => {
document.body.dispatchEvent(new Event('pointermove', { bubbles: true }));
});
setOwnDragActive(true);
dragIn([file]);
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
});
it('nothing while the app is locked, signed out, or behind a dialog of its own', () => {
render(<FileDropHost />);
hoisted.locked.current = true;
dragIn([file]);
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
hoisted.locked.current = false;
const dialog = document.body.appendChild(document.createElement('div'));
dialog.setAttribute('aria-modal', 'true');
dragIn([file]);
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
dialog.remove();
useAuthStore.setState({ isAuthenticated: false });
dragIn([file]);
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
});
it('leaves Files out on a server without it', () => {
signIn({ supportsFiles: () => false });
render(<FileDropHost />);
dragIn([file]);
expect(document.querySelector('[data-drop-zone="upload"]')).toBeNull();
expect(zone('new-message')).toBeTruthy();
});
it('dragging out of the window takes it away', () => {
render(<FileDropHost />);
dragIn([file]);
const leave = new Event('dragleave', { bubbles: true });
Object.defineProperty(leave, 'relatedTarget', { value: null });
act(() => {
screen.getByTestId('file-drop-overlay').dispatchEvent(leave);
});
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
});
});
describe('the same choices without a drag', () => {
function paste(target: EventTarget, files: File[], types = ['Files']) {
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: { files, types } });
act(() => {
target.dispatchEvent(event);
});
return event;
}
it('a file pasted outside a text field asks, with the focus on a new message, and Enter takes it', async () => {
render(<FileDropHost />);
expect(paste(document.body, [file]).defaultPrevented).toBe(true);
const dialog = await screen.findByTestId('file-drop-choice');
await waitFor(() => expect(document.activeElement?.getAttribute('data-drop-zone')).toBe('new-message'));
expect(dialog.querySelector('[data-drop-zone="upload"]')).toBeTruthy();
fireEvent.click(document.activeElement as HTMLElement);
await flush();
expect(takeHandoff('compose')?.files).toEqual([file]);
expect(screen.queryByTestId('file-drop-choice')).toBeNull();
});
it('arrow keys move between the choices', async () => {
render(<FileDropHost />);
paste(document.body, [file]);
await screen.findByTestId('file-drop-choice');
await waitFor(() => expect(document.activeElement?.getAttribute('data-drop-zone')).toBe('new-message'));
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowRight' });
expect(document.activeElement?.getAttribute('data-drop-zone')).toBe('upload');
});
it('with only one thing to do, it does it without asking', async () => {
signIn({ supportsFiles: () => false });
render(<FileDropHost />);
paste(document.body, [file]);
await flush();
expect(screen.queryByTestId('file-drop-choice')).toBeNull();
expect(takeHandoff('compose')?.files).toEqual([file]);
});
it('pasted text, or a file in a search field, is left alone', () => {
render(<FileDropHost />);
expect(paste(document.body, [file], ['text/plain', 'Files']).defaultPrevented).toBe(false);
const input = document.body.appendChild(document.createElement('input'));
expect(paste(input, [file]).defaultPrevented).toBe(false);
input.remove();
expect(screen.queryByTestId('file-drop-choice')).toBeNull();
});
it('a bare file pasted into a composer on screen attaches to it', () => {
const take = vi.fn();
const clear = setComposerFileTarget(take, () => true);
render(<FileDropHost />);
const composer = document.body.appendChild(document.createElement('div'));
composer.setAttribute('data-testid', 'email-composer');
const body = composer.appendChild(document.createElement('div'));
body.setAttribute('contenteditable', 'true');
paste(body, [file]);
expect(take).toHaveBeenCalledWith([file]);
composer.remove();
clear();
});
it('a file opened with the desktop app gets the dialog, and waits for a sign-in', async () => {
useAuthStore.setState({ isAuthenticated: false });
render(<FileDropHost />);
act(() => requestFileChoice({ files: [file], preferred: 'new-message' }));
expect(screen.queryByTestId('file-drop-choice')).toBeNull();
act(() => signIn());
expect(await screen.findByTestId('file-drop-choice')).toBeTruthy();
});
});
+335
View File
@@ -0,0 +1,335 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
import { usePathname, useRouter } from "@/i18n/navigation";
import { Dialog } from "@/components/ui/dialog";
import { Upload } from "@/components/icons";
import { announce } from "@/lib/announce";
import { flushComposers } from "@/lib/compose-flush-registry";
import { matchSurface } from "@/lib/deep-links";
import { isAppLocked } from "@/lib/desktop/app-lock-state";
import { pasteTarget, visibleComposer } from "@/lib/desktop/window-files";
import { runFileDropZone, type FileDropEnv } from "@/lib/file-drop/actions";
import { listenForFileChoices, type FileChoiceRequest } from "@/lib/file-drop/choice";
import { startDragWatch } from "@/lib/file-drop/drag-watch";
import { filesDropTarget } from "@/lib/file-drop/files-target";
import { readDroppedFiles, type DroppedFiles } from "@/lib/file-drop/read";
import {
defaultZone,
fileDropZones,
filesWithinLimits,
kindForName,
type DropFiles,
type DropPlace,
type FileDropZone,
type FileDropZoneId,
} from "@/lib/file-drop/zones";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { isOfflineNow, useOfflineStore } from "@/lib/offline/state";
import { isPopoutPath } from "@/lib/popout/window-role";
import { formatFileSize } from "@/lib/utils";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { toast } from "@/stores/toast-store";
import { useZoneText, ZoneCard, zoneGridClass } from "./file-drop-zones";
import { cn } from "@/lib/utils";
/** How long leaving the page may take to save an open composer. */
const FLUSH_BEFORE_LEAVING_MS = 5000;
const NAMES_SHOWN = 3;
/** Where a dropped .eml could go: the real folder in view on the mail screen, if new mail may go in. */
function importFolder(pathname: string, translate: (key: string) => string): { id: string; name: string; accountId?: string } | null {
if (matchSurface(pathname)?.surface !== "mail") return null;
const { selectedMailbox, mailboxes } = useEmailStore.getState();
const mailbox = mailboxes.find((entry) => entry.id === selectedMailbox);
if (!mailbox || mailbox.isShared || mailbox.myRights?.mayAddItems === false) return null;
return { id: mailbox.id, name: localizeMailboxName(mailbox.role, mailbox.name, translate), accountId: mailbox.accountId };
}
/** Whatever would stop a drop now. A dialog of its own (an import, a picker) keeps its own drop zone. */
function overlayBlocked(): boolean {
if (isAppLocked() || !useAuthStore.getState().isAuthenticated) return true;
const modal = document.querySelector('[aria-modal="true"]');
return !!modal && !modal.querySelector('[data-testid="email-composer"]');
}
/** What can be done with files here and now. Read at the moment of the drag, not kept. */
function currentPlace(pathname: string, translate: (key: string) => string): { place: DropPlace; folder: ReturnType<typeof importFolder> } {
const auth = useAuthStore.getState();
const client = auth.client;
const features = usePolicyStore.getState();
const account = auth.activeAccountId ? useAccountStore.getState().accounts.find((entry) => entry.id === auth.activeAccountId) : undefined;
const folder = importFolder(pathname, translate);
const place: DropPlace = {
composer: !!visibleComposer(),
inlineImages: !!visibleComposer()?.images?.available(),
popout: isPopoutPath(window.location.pathname),
files: !!client?.supportsFiles?.() && features.isFeatureEnabled("filesEnabled"),
filesFolder: filesDropTarget()?.folder ?? null,
calendar: useCalendarStore.getState().supportsCalendar && features.isFeatureEnabled("calendarEnabled"),
contacts: !!client?.supportsContacts?.() && features.isFeatureEnabled("contactsEnabled"),
importFolder: folder?.name ?? null,
offline: isOfflineNow(account?.serverUrl),
keepsOffline: !!auth.activeAccountId && useOfflineStore.getState().enabled.includes(auth.activeAccountId),
maxUploadBytes: client?.getMaxSizeUpload?.() ?? 0,
maxMessageBytes: client?.getMaxSizeAttachmentsPerEmail?.() ?? 0,
};
return { place, folder };
}
interface DragState {
files: DropFiles;
zones: FileDropZone[];
place: DropPlace;
folder: ReturnType<typeof importFolder>;
}
interface ChoiceState extends DragState {
list: File[];
preferred?: FileDropZoneId;
}
function asDropped(files: File[]): DroppedFiles {
return { plain: files, all: files, folders: false };
}
function summaryOf(files: readonly File[]): DropFiles {
return { count: files.length, kinds: files.map((file) => kindForName(file)), bytes: files.reduce((sum, file) => sum + file.size, 0) };
}
/**
* The file drop overlay on every screen: files dragged in from outside get big zones to drop
* on, the same zones come up as a dialog for a pasted file or a file opened with the desktop
* app. Mounted once in the locale layout, in pop-outs too, where only their own composer
* takes files.
*/
export function FileDropHost() {
const t = useTranslations("fileDrop");
const tSidebar = useTranslations("sidebar");
const { label, note } = useZoneText();
const router = useRouter();
const pathname = usePathname();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const [drag, setDrag] = useState<DragState | null>(null);
const [active, setActive] = useState<FileDropZoneId | null>(null);
const [choice, setChoice] = useState<ChoiceState | null>(null);
const [pendingChoices, setPendingChoices] = useState<FileChoiceRequest[]>([]);
const defaultRef = useRef<HTMLButtonElement & HTMLDivElement>(null);
const ctx = useRef({ pathname, router, t, tSidebar, label });
ctx.current = { pathname, router, t, tSidebar, label };
const translateMailbox = useCallback((key: string) => ctx.current.tSidebar(`mailboxes.${key}`), []);
const countText = useCallback((files: DropFiles) => {
if (files.count === null) return t("files_unknown");
if (files.bytes !== null) return t("files_summary_size", { count: files.count, size: formatFileSize(files.bytes) });
return t("files_summary", { count: files.count });
}, [t]);
const env = useCallback((folder: ReturnType<typeof importFolder>): FileDropEnv => ({
goTo: async (path) => {
const result = await flushComposers(FLUSH_BEFORE_LEAVING_MS);
if (result.failed > 0 || result.timedOut) {
toast.error(ctx.current.t("save_first_failed"));
return;
}
if (ctx.current.pathname !== path) ctx.current.router.push(path);
},
importMessages: async (files) => {
const client = useAuthStore.getState().client;
if (!client || !folder) return;
let done = 0;
for (const file of files) {
try {
await client.importRawEmail(file, { [folder.id]: true }, { $seen: true }, folder.accountId);
done += 1;
} catch (error) {
console.error("Importing a dropped message failed:", error);
toast.error(ctx.current.t("import_failed", { name: file.name }));
}
}
if (done > 0) toast.success(ctx.current.t("import_done", { count: done, folder: folder.name }));
},
}), []);
/** Checks what only the files in hand can tell, then hands them over. */
const run = useCallback((zone: FileDropZoneId, dropped: DroppedFiles, state: DragState) => {
const upload = zone === "upload";
if (!upload && dropped.folders) toast.info(ctx.current.t("folders_left_out"));
const { fitting, tooLarge, overMessage } = filesWithinLimits(zone, upload ? dropped.all : dropped.plain, state.place);
for (const file of tooLarge) toast.error(ctx.current.t("too_large_file", { name: file.name, max: formatFileSize(state.place.maxUploadBytes) }));
if (overMessage) toast.error(ctx.current.t("too_large_message", { max: formatFileSize(state.place.maxMessageBytes) }));
if (fitting.length === 0) return;
const what = upload ? { files: fitting, asFolder: dropped.folders } : undefined;
void runFileDropZone(zone, fitting, env(state.folder), what).catch((error) => {
console.error("A file drop failed:", error);
});
}, [env]);
// Files dragged in from outside, anywhere.
useEffect(() => {
return startDragWatch({
enter: (files) => {
if (overlayBlocked()) return false;
const { place, folder } = currentPlace(ctx.current.pathname, translateMailbox);
const zones = fileDropZones(files, place);
if (zones.length === 0) return false;
setActive(null);
setDrag({ files, zones, place, folder });
const choices = zones.map((zone) => ctx.current.label(zone)).join(", ");
announce(ctx.current.t("announce", { files: files.count ?? 0, choices }));
return true;
},
leave: () => {
setDrag(null);
setActive(null);
},
});
}, [translateMailbox]);
// A file pasted outside a text field: the same choices, from the keyboard.
useEffect(() => {
const onPaste = (event: ClipboardEvent) => {
const where = pasteTarget(event);
if (!where) return;
const files = Array.from(event.clipboardData?.files ?? []);
if (where === "composer") {
event.preventDefault();
visibleComposer()?.take(files);
return;
}
if (overlayBlocked()) return;
event.preventDefault();
setPendingChoices((list) => [...list, { files, preferred: visibleComposer() ? "attach-here" : "new-message" }]);
};
window.addEventListener("paste", onPaste);
return () => window.removeEventListener("paste", onPaste);
}, []);
// Files opened with the desktop app.
useEffect(() => listenForFileChoices((request) => setPendingChoices((list) => [...list, request])), []);
// One question at a time, and none while signed out or locked.
useEffect(() => {
if (choice || pendingChoices.length === 0 || !isAuthenticated || isAppLocked()) return;
const [next, ...rest] = pendingChoices;
setPendingChoices(rest);
const { place, folder } = currentPlace(ctx.current.pathname, translateMailbox);
const files = summaryOf(next.files);
const zones = fileDropZones(files, place);
const usable = zones.filter((zone) => !zone.disabled);
if (usable.length === 1) {
run(usable[0].id, asDropped(next.files), { files, zones, place, folder });
return;
}
if (zones.length === 0) {
toast.error(ctx.current.t("nothing_here"));
return;
}
setChoice({ files, zones, place, folder, list: next.files, preferred: next.preferred });
}, [choice, pendingChoices, isAuthenticated, run, translateMailbox]);
const pick = (zone: FileDropZone) => {
if (!choice || zone.disabled) return;
const state = choice;
setChoice(null);
run(zone.id, asDropped(state.list), state);
};
const zoneAt = (target: EventTarget | null): FileDropZone | null => {
const element = (target as Element | null)?.closest?.("[data-drop-zone]");
const id = element?.getAttribute("data-drop-zone");
return drag?.zones.find((zone) => zone.id === id) ?? null;
};
const overlay = drag && typeof document !== "undefined" && createPortal(
<div
className="fixed inset-0 z-[70] flex flex-col items-center justify-center gap-6 bg-background/85 p-6 backdrop-blur-sm pt-[calc(1.5rem+var(--titlebar-row-height,0px))] animate-in fade-in duration-100"
data-testid="file-drop-overlay"
role="region"
aria-label={t("overlay_label")}
onDragOver={(event) => {
event.preventDefault();
const zone = zoneAt(event.target);
const usable = zone && !zone.disabled ? zone : null;
event.dataTransfer.dropEffect = usable ? "copy" : "none";
if ((usable?.id ?? null) !== active) setActive(usable?.id ?? null);
}}
onDrop={(event) => {
event.preventDefault();
const zone = zoneAt(event.target);
const state = drag;
setDrag(null);
setActive(null);
if (!zone || zone.disabled) return;
void readDroppedFiles(event.dataTransfer).then((dropped) => run(zone.id, dropped, state));
}}
>
<div className="flex flex-col items-center gap-1 text-center">
<Upload className="h-8 w-8 text-primary" aria-hidden="true" />
<p className="text-xl font-semibold text-foreground">{t("overlay_title")}</p>
<p className="text-sm text-muted-foreground" data-testid="file-drop-count">{countText(drag.files)}</p>
</div>
<div className={cn("grid w-full gap-4", zoneGridClass(drag.zones.length))}>
{drag.zones.map((zone) => (
<ZoneCard key={zone.id} zone={zone} label={label(zone)} note={note(zone.note)} active={active === zone.id} />
))}
</div>
<p className="text-sm text-muted-foreground">{t("overlay_hint")}</p>
</div>,
document.body,
);
const preferred = choice ? defaultZone(choice.zones, choice.preferred) : undefined;
const names = choice?.list.map((file) => file.name) ?? [];
return (
<>
{overlay}
<Dialog
isOpen={!!choice}
onClose={() => setChoice(null)}
title={t("choose_title", { count: choice?.list.length ?? 0 })}
description={choice ? `${names.slice(0, NAMES_SHOWN).join(", ")}${names.length > NAMES_SHOWN ? ", …" : ""} (${countText(choice.files)})` : undefined}
size="lg"
initialFocus={defaultRef}
testId="file-drop-choice"
>
{choice && (
<div
className={cn("mt-4 grid w-full gap-3", zoneGridClass(Math.min(choice.zones.length, 2)))}
role="group"
aria-label={t("choose_title", { count: choice.list.length })}
onKeyDown={(event) => {
if (!["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp"].includes(event.key)) return;
const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>("button[data-drop-zone]"));
const at = buttons.indexOf(document.activeElement as HTMLButtonElement);
if (at === -1) return;
event.preventDefault();
const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1;
buttons[(at + step + buttons.length) % buttons.length].focus();
}}
>
{choice.zones.map((zone) => (
<ZoneCard
key={zone.id}
ref={zone.id === preferred?.id ? defaultRef : undefined}
as="button"
zone={zone}
label={label(zone)}
note={note(zone.note)}
onClick={() => pick(zone)}
/>
))}
</div>
)}
</Dialog>
</>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { forwardRef, type ComponentType, type ReactNode } from "react";
import { useTranslations } from "next-intl";
import { CalendarPlus, FolderInput, ImageIcon, MailOpen, Paperclip, SquarePen, Upload, UserPlus } from "@/components/icons";
import type { FileDropNote, FileDropZone, FileDropZoneId } from "@/lib/file-drop/zones";
import { cn, formatFileSize } from "@/lib/utils";
const ICONS: Record<FileDropZoneId, ComponentType<{ className?: string }>> = {
"attach-here": Paperclip,
"insert-inline": ImageIcon,
"new-message": SquarePen,
upload: Upload,
calendar: CalendarPlus,
contacts: UserPlus,
"open-eml": MailOpen,
"import-eml": FolderInput,
};
export function useZoneText() {
const t = useTranslations("fileDrop");
const label = (zone: FileDropZone): string => {
switch (zone.id) {
case "attach-here": return t("zone_attach_here");
case "insert-inline": return t("zone_insert_inline");
case "new-message": return t("zone_new_message");
case "upload": return zone.folder ? t("zone_upload_folder", { folder: zone.folder }) : t("zone_upload_files");
case "calendar": return t("zone_calendar");
case "contacts": return t("zone_contacts");
case "open-eml": return t("zone_open_eml");
case "import-eml": return t("zone_import_eml", { folder: zone.folder ?? "" });
}
};
const note = (value: FileDropNote | undefined): string | null => {
if (!value) return null;
switch (value.key) {
case "note_limit_message": return t("note_limit_message", { size: formatFileSize(value.size) });
case "note_limit_file": return t("note_limit_file", { size: formatFileSize(value.size) });
case "note_kept_offline": return t("note_kept_offline");
case "note_needs_connection": return t("note_needs_connection");
}
};
return { label, note };
}
interface ZoneCardProps {
zone: FileDropZone;
label: string;
note: string | null;
active?: boolean;
/** "button" in the dialog; a plain region during a drag, which nothing can focus. */
as?: "button" | "div";
onClick?: () => void;
onKeyDown?: (event: React.KeyboardEvent<HTMLElement>) => void;
children?: ReactNode;
}
/** One big choice: the same look as a drop target and as a button. */
export const ZoneCard = forwardRef<HTMLButtonElement & HTMLDivElement, ZoneCardProps>(function ZoneCard(
{ zone, label, note, active = false, as = "div", onClick, onKeyDown },
ref,
) {
const Icon = ICONS[zone.id];
const className = cn(
"group flex min-h-36 flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed p-6 text-center transition-colors select-none",
zone.disabled
? "border-border bg-muted/40 text-muted-foreground cursor-not-allowed"
: active
? "border-primary bg-primary/10 text-foreground ring-4 ring-primary/20"
: "border-border bg-background/90 text-foreground hover:border-primary/60",
as === "button" && !zone.disabled && "focus-visible:outline-none focus-visible:border-primary focus-visible:ring-4 focus-visible:ring-primary/30",
);
const content = (
<>
<Icon className={cn("h-10 w-10", zone.disabled ? "text-muted-foreground" : active ? "text-primary" : "text-muted-foreground group-hover:text-primary")} />
<span className="text-lg font-semibold leading-snug">{label}</span>
{note && <span className="text-sm text-muted-foreground">{note}</span>}
</>
);
if (as === "button") {
return (
<button
ref={ref}
type="button"
className={className}
data-drop-zone={zone.id}
aria-disabled={zone.disabled || undefined}
onClick={zone.disabled ? undefined : onClick}
onKeyDown={onKeyDown}
>
{content}
</button>
);
}
return (
<div
ref={ref}
className={className}
data-drop-zone={zone.id}
data-active={active || undefined}
data-disabled={zone.disabled || undefined}
role="group"
aria-label={note ? `${label}. ${note}` : label}
>
{content}
</div>
);
});
/** Two zones side by side, three in a row, four as two by two (a row on a wide window). */
export function zoneGridClass(count: number): string {
if (count <= 1) return "grid-cols-1 max-w-md";
if (count === 2) return "grid-cols-1 sm:grid-cols-2 max-w-3xl";
if (count === 3) return "grid-cols-1 sm:grid-cols-3 max-w-5xl";
return "grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 max-w-6xl";
}
+34 -2
View File
@@ -8,7 +8,8 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store";
import { useFileStore, deletesToTrash, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type UploadReport } from "@/stores/file-store";
import { useFileStore, deletesToTrash, isSharedFilesPage, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type UploadReport } from "@/stores/file-store";
import { hasQueuedFilesUpload, setFilesDropTarget, subscribeQueuedFilesUpload, takeQueuedFilesUpload } from "@/lib/file-drop/files-target";
import { isTrashLocation } from "@/lib/files-trash";
import { fileErrorKind, isPartialCopy } from "@/lib/file-errors";
import { toast } from "@/stores/toast-store";
@@ -805,6 +806,37 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
useFileStore.setState({ supportsFiles: null });
}, [getClientForAccount]);
// The file drop overlay: "Upload to <folder>" while this screen is on view in a folder
// that takes files, and what was dropped elsewhere for Files once one is listed.
const dropRootRef = useRef<HTMLDivElement>(null);
const canTakeDrops = supportsFiles === true && !isAccountPicker && !isSharedFilesPage(currentParentId) && !isTrashLocation(currentParentId);
const dropUploadRef = useRef({ files: handleUploadFiles, folder: handleUploadFolder });
dropUploadRef.current = { files: handleUploadFiles, folder: handleUploadFolder };
useEffect(() => {
if (!canTakeDrops) return;
const folder = currentPath === "/" ? null : currentPath.split("/").filter(Boolean).pop() ?? null;
return setFilesDropTarget({
folder,
take: ({ files, asFolder }) => void (asFolder ? dropUploadRef.current.folder(files) : dropUploadRef.current.files(files)),
visible: () => !!dropRootRef.current?.isConnected && dropRootRef.current.getClientRects().length > 0,
});
}, [canTakeDrops, currentPath]);
useEffect(() => {
if (supportsFiles !== true || isLoading || !storeClient || isAccountPicker) return;
const take = () => {
if (!hasQueuedFilesUpload()) return;
// In the trash or the list of shared drives: to the top of Files first.
if (!canTakeDrops) {
void navigate(null);
return;
}
const upload = takeQueuedFilesUpload();
if (upload) void (upload.asFolder ? dropUploadRef.current.folder(upload.files) : dropUploadRef.current.files(upload.files));
};
take();
return subscribeQueuedFilesUpload(take);
}, [supportsFiles, isLoading, storeClient, isAccountPicker, canTakeDrops, navigate]);
// Until the session is restored: the page skeleton the server drew.
if (!isAuthenticated) {
return isEmbedded
@@ -813,7 +845,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
}
return (
<div className={cn("flex flex-col overflow-hidden pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
<div ref={dropRootRef} className={cn("flex flex-col overflow-hidden pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
{!isEmbedded && <SkipLink>{tShell("skip_to_content")}</SkipLink>}
<AppTopBannerSlot />
{refreshIndicator}
@@ -0,0 +1,70 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { BulwarkDesktopBridge, DesktopDevToolsState } from '@/lib/desktop/types';
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => (values ? `${key}:${JSON.stringify(values)}` : key),
}));
const { DesktopDevToolsSettings } = await import('../desktop-devtools-settings');
function setBridge(state: DesktopDevToolsState | null, platform: 'win32' | 'darwin' = 'win32') {
const listeners = new Set<(next: DesktopDevToolsState) => void>();
const set = vi.fn(async (on: boolean) => ({ ...state!, on }));
window.bulwarkDesktop = (state
? {
version: 1,
platform,
devTools: {
get: async () => state,
set,
onChange: (listener: (next: DesktopDevToolsState) => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
}
: { version: 1, platform }) as unknown as BulwarkDesktopBridge;
return { set, emit: (next: DesktopDevToolsState) => listeners.forEach((listener) => listener(next)) };
}
afterEach(() => {
window.bulwarkDesktop = undefined;
});
describe('the developer tools setting', () => {
it('is not there in a browser or on a shell without it', () => {
const { container } = render(<DesktopDevToolsSettings />);
expect(container.innerHTML).toBe('');
setBridge(null);
const second = render(<DesktopDevToolsSettings />);
expect(second.container.innerHTML).toBe('');
});
it('is off by default, names the shortcut, and asks main to turn it on', async () => {
const { set } = setBridge({ on: false, forcedOff: false, locked: false });
render(<DesktopDevToolsSettings />);
const toggle = await screen.findByTestId('desktop-devtools-toggle');
expect(toggle.getAttribute('aria-checked')).toBe('false');
expect(screen.getByText('label:{"shortcut":"Ctrl+Shift+I"}')).toBeTruthy();
fireEvent.click(toggle);
expect(set).toHaveBeenCalledWith(true);
});
it('a Mac names its own keys', async () => {
setBridge({ on: false, forcedOff: false, locked: false }, 'darwin');
render(<DesktopDevToolsSettings />);
expect(await screen.findByText('label:{"shortcut":"Cmd+Option+I"}')).toBeTruthy();
});
it('a policy locks it off, and the app lock stops changes', async () => {
const { emit } = setBridge({ on: false, forcedOff: true, locked: false });
render(<DesktopDevToolsSettings />);
const toggle = await screen.findByTestId('desktop-devtools-toggle');
expect(toggle.hasAttribute('disabled')).toBe(true);
expect(screen.getByText('forced_off')).toBeTruthy();
act(() => emit({ on: false, forcedOff: false, locked: true }));
expect(screen.getByText('locked')).toBeTruthy();
expect(screen.getByTestId('desktop-devtools-toggle').hasAttribute('disabled')).toBe(true);
});
});
@@ -0,0 +1,51 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import type { DesktopDevToolsState } from "@/lib/desktop/types";
import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section";
/**
* Troubleshooting in the desktop app: Chromium's developer tools on a key, off until the
* user turns them on. Main keeps the switch and asks with a native warning, so a page or
* plugin can't turn it on by itself. A browser never shows this.
*/
export function DesktopDevToolsSettings() {
const t = useTranslations("desktopDevTools");
const bridge = getDesktopBridge();
const devTools = bridge?.devTools;
const [state, setState] = useState<DesktopDevToolsState | null>(null);
useEffect(() => {
if (!devTools) return;
let stale = false;
void devTools.get().then((next) => {
if (!stale) setState(next);
}, () => {});
const stop = devTools.onChange((next) => {
if (!stale) setState(next);
});
return () => {
stale = true;
stop();
};
}, [devTools]);
if (!devTools || !state) return null;
const shortcut = bridge?.platform === "darwin" ? "Cmd+Option+I" : "Ctrl+Shift+I";
const description = state.forcedOff ? t("forced_off") : state.locked ? t("locked") : t("description", { shortcut });
return (
<SettingsSection id="desktop-troubleshooting" title={t("section_title")}>
<SettingItem label={t("label", { shortcut })} description={description} locked={state.forcedOff}>
<ToggleSwitch
checked={state.on}
disabled={state.forcedOff || state.locked}
testId="desktop-devtools-toggle"
onChange={(on) => void devTools.set(on).then(setState, () => {})}
/>
</SettingItem>
</SettingsSection>
);
}
+2
View File
@@ -15,6 +15,7 @@ import { PlayCircle } from '@/components/icons';
import { IS_LITE } from '@/lib/lite';
import { DesktopUpdateSettings } from './desktop-update-settings';
import { DesktopWindowSettings } from './desktop-window-settings';
import { DesktopDevToolsSettings } from './desktop-devtools-settings';
/**
* The General page: language and time, which links the browser hands to
@@ -61,6 +62,7 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea
</SettingsSection>
{!IS_LITE && <DesktopUpdateSettings />}
<DesktopDevToolsSettings />
</div>
);
}
+14
View File
@@ -20,6 +20,7 @@
<category name="Bulwark_Desktop_security" displayName="$(string.Category_security)"><parentCategory ref="Bulwark_Desktop"/></category>
<category name="Bulwark_Desktop_privacy" displayName="$(string.Category_privacy)"><parentCategory ref="Bulwark_Desktop"/></category>
<category name="Bulwark_Desktop_signIn" displayName="$(string.Category_signIn)"><parentCategory ref="Bulwark_Desktop"/></category>
<category name="Bulwark_Desktop_system" displayName="$(string.Category_system)"><parentCategory ref="Bulwark_Desktop"/></category>
</categories>
<policies>
<policy name="InstanceUrl" class="Both" displayName="$(string.InstanceUrl)" explainText="$(string.InstanceUrl_Explain)" presentation="$(presentation.InstanceUrl)" key="Software\Policies\Bulwark\Desktop">
@@ -210,5 +211,18 @@
<text id="WipeOfflineData_Value" valueName="WipeOfflineData" required="true" maxLength="128"/>
</elements>
</policy>
<policy name="OpenWithEnabled" class="Both" displayName="$(string.OpenWithEnabled)" explainText="$(string.OpenWithEnabled_Explain)" key="Software\Policies\Bulwark\Desktop" valueName="OpenWithEnabled">
<parentCategory ref="Bulwark_Desktop_system"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<enabledValue><decimal value="1"/></enabledValue>
<disabledValue><decimal value="0"/></disabledValue>
</policy>
<policy name="DeveloperToolsAvailability" class="Both" displayName="$(string.DeveloperToolsAvailability)" explainText="$(string.DeveloperToolsAvailability_Explain)" presentation="$(presentation.DeveloperToolsAvailability)" key="Software\Policies\Bulwark\Desktop">
<parentCategory ref="Bulwark_Desktop_system"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<elements>
<decimal id="DeveloperToolsAvailability_Value" valueName="DeveloperToolsAvailability" required="true" minValue="0" maxValue="2"/>
</elements>
</policy>
</policies>
</policyDefinitions>
+6
View File
@@ -15,6 +15,7 @@
<string id="Category_security">Offline-E-Mails und App-Sperre</string>
<string id="Category_privacy">Datenschutz</string>
<string id="Category_signIn">Anmeldung und Zertifikate</string>
<string id="Category_system">Systemintegration und Fehlerbehebung</string>
<string id="InstanceUrl">Bulwark-Server</string>
<string id="InstanceUrl_Explain">Die Adresse Ihres Bulwark-Servers, beginnend mit https://. Wenn erzwungen, öffnet sich die App auf diesem Server, die Serverauswahl ist ausgeblendet und das Fenster bleibt auf diesem Server. Eine ungültige Adresse zeigt eine Fehlerseite mit dem Namen dieser Richtlinie, und die App weicht auf nichts anderes aus. Als Standardwert öffnet sich die App auf diesem Server, bis der Benutzer etwas anderes wählt.</string>
<string id="AllowStandalone">Direkte Verbindungen zu Mailservern zulassen</string>
@@ -62,6 +63,10 @@
<string id="BrowserSignIn_Explain">Wenn aktiviert oder nicht konfiguriert, bietet die Anmeldeseite „Mit Ihrem Browser anmelden“ an, damit Benutzer dort ihren Passwort-Manager, Passkeys und Ihr Single Sign-on verwenden können. Wenn deaktiviert, fehlt die Schaltfläche und die App lehnt den Vorgang ab. Das Formular in der App bleibt, sofern der Server nicht nur OAuth erlaubt.</string>
<string id="WipeOfflineData">Auf dem Gerät behaltene E-Mails löschen</string>
<string id="WipeOfflineData_Explain">Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen. Diese Liste liegt im Profil des Benutzers. Wer sie bearbeitet, kann also eine Löschung umgehen, von der er weiß: Dies räumt ein Gerät auf, es nimmt der Person am Gerät keine Daten weg.</string>
<string id="OpenWithEnabled">App unter „Öffnen mit“ und „Senden an“ anbieten</string>
<string id="OpenWithEnabled_Explain">Wenn aktiviert oder nicht konfiguriert, trägt sich die App unter Windows in die Liste „Öffnen mit“ für gängige Dokumente und Bilder und in das Menü „Senden an“ ein, damit Benutzer eine Datei aus dem Dateimanager an eine neue Nachricht anhängen können. Sie wird nie zum Standardprogramm für diese Dateien. Wenn deaktiviert, entfernt die App diese Einträge für den Benutzer. Mail-Links und .eml-, .ics- und .vcf-Dateien sind nicht betroffen.</string>
<string id="DeveloperToolsAvailability">Entwicklertools</string>
<string id="DeveloperToolsAvailability_Explain">Dieselben Werte wie die gleichnamige Richtlinie von Chrome. 2: Die Entwicklertools sind aus und die Einstellung ist gesperrt. 0 oder 1 oder nicht konfiguriert: Benutzer können sie nach einer Warnung in den Einstellungen einschalten. Mit den Entwicklertools kann jeder an der Tastatur alles lesen, was die App enthält, auch E-Mails und Passwörter im Speicher.</string>
</stringTable>
<presentationTable>
<presentation id="InstanceUrl"><textBox refId="InstanceUrl_Value"><label>Adresse des Bulwark-Servers</label></textBox></presentation>
@@ -80,6 +85,7 @@
<presentation id="AutoSelectCertificateForUrls"><listBox refId="AutoSelectCertificateForUrls_Value">Regeln</listBox></presentation>
<presentation id="KeystoreAllowedOrigins"><listBox refId="KeystoreAllowedOrigins_Value">Adressen</listBox></presentation>
<presentation id="WipeOfflineData"><textBox refId="WipeOfflineData_Value"><label>Löschcode</label></textBox></presentation>
<presentation id="DeveloperToolsAvailability"><decimalTextBox refId="DeveloperToolsAvailability_Value" defaultValue="2">Wert (0, 1 oder 2)</decimalTextBox></presentation>
</presentationTable>
</resources>
</policyDefinitionResources>
+6
View File
@@ -15,6 +15,7 @@
<string id="Category_security">Offline mail and app lock</string>
<string id="Category_privacy">Privacy</string>
<string id="Category_signIn">Sign-in and certificates</string>
<string id="Category_system">System integration and troubleshooting</string>
<string id="InstanceUrl">Bulwark server</string>
<string id="InstanceUrl_Explain">The address of your Bulwark server, starting with https://. When forced, the app opens on this server, the choice of server is hidden, and the window stays on it. An invalid address shows an error page that names this policy, and the app does not fall back to anything else. As a default, the app opens on this server until the user picks something else.</string>
<string id="AllowStandalone">Allow direct connections to mail servers</string>
@@ -62,6 +63,10 @@
<string id="BrowserSignIn_Explain">When enabled or not configured, the login page offers &quot;Sign in with your browser&quot;, so people can use their password manager, passkeys and your single sign-on there. When disabled, the button is gone and the app refuses the flow. The form in the app stays, unless the server only allows OAuth.</string>
<string id="WipeOfflineData">Delete mail kept on the device</string>
<string id="WipeOfflineData_Explain">Set this to a new value, such as today&apos;s date, to delete everything the app keeps on the device for every server and account: mail, keys, and sign-ins, so people sign in again. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again. That memory is in the user&apos;s own profile, so someone who edits it can skip a wipe they know is coming: this cleans up a device, it doesn&apos;t take data from the person using it.</string>
<string id="OpenWithEnabled">Offer the app in &quot;Open with&quot; and &quot;Send to&quot;</string>
<string id="OpenWithEnabled_Explain">When enabled or not configured, the app adds itself to the &quot;Open with&quot; list for common documents and pictures and to the &quot;Send to&quot; menu on Windows, so people can attach a file to a new message from the file manager. It never becomes the default program for those files. When disabled, the app removes these entries for the user. Mail links and .eml, .ics and .vcf files are not affected.</string>
<string id="DeveloperToolsAvailability">Developer tools</string>
<string id="DeveloperToolsAvailability_Explain">The same values as Chrome&apos;s policy of this name. 2: developer tools are off and the setting is locked. 0 or 1, or not configured: people can turn them on under Settings, after a warning. Developer tools let anyone at the keyboard read everything the app holds, mail and passwords in memory included.</string>
</stringTable>
<presentationTable>
<presentation id="InstanceUrl"><textBox refId="InstanceUrl_Value"><label>Address of the Bulwark server</label></textBox></presentation>
@@ -80,6 +85,7 @@
<presentation id="AutoSelectCertificateForUrls"><listBox refId="AutoSelectCertificateForUrls_Value">Rules</listBox></presentation>
<presentation id="KeystoreAllowedOrigins"><listBox refId="KeystoreAllowedOrigins_Value">Addresses</listBox></presentation>
<presentation id="WipeOfflineData"><textBox refId="WipeOfflineData_Value"><label>Wipe code</label></textBox></presentation>
<presentation id="DeveloperToolsAvailability"><decimalTextBox refId="DeveloperToolsAvailability_Value" defaultValue="2">Value (0, 1 or 2)</decimalTextBox></presentation>
</presentationTable>
</resources>
</policyDefinitionResources>
@@ -65,6 +65,10 @@
</array>
<key>BrowserSignIn</key>
<true/>
<key>OpenWithEnabled</key>
<true/>
<key>DeveloperToolsAvailability</key>
<integer>2</integer>
<key>Recommended</key>
<dict>
<key>UpdateChannel</key>
+2
View File
@@ -27,6 +27,8 @@
"https://mail.example.com"
],
"BrowserSignIn": true,
"OpenWithEnabled": true,
"DeveloperToolsAvailability": 2,
"Recommended": {
"UpdateChannel": "stable"
}
+17
View File
@@ -126,6 +126,23 @@ describe('launches', () => {
expect(draft?.files.map((file) => file.name)).toEqual(['report.pdf']);
});
it('several files sent to the app go on one draft, without a question', async () => {
const { takeHandoff } = await import('@/lib/launch-handoff');
await run({ type: 'files', reason: 'attach', failed: [], files: [{ name: 'a.pdf', data: text('a') }, { name: 'b.png', data: text('b') }] });
expect(takeHandoff('compose')?.files.map((file) => file.name)).toEqual(['a.pdf', 'b.png']);
});
it('a file opened with the app asks: new message or Files, a new message first', async () => {
const { takeHandoff } = await import('@/lib/launch-handoff');
const { listenForFileChoices } = await import('@/lib/file-drop/choice');
const asked = vi.fn();
const stop = listenForFileChoices(asked);
await run({ type: 'files', reason: 'attach', ask: true, failed: [], files: [{ name: 'report.pdf', data: text('pdf') }] });
expect(asked).toHaveBeenCalledWith({ files: [expect.objectContaining({ name: 'report.pdf' })], preferred: 'new-message' });
expect(takeHandoff('compose')).toBeNull();
stop();
});
it('says so when a file could not be read', async () => {
await run({ type: 'files', reason: 'open', failed: ['gone.eml'], files: [] });
expect(notifyFailed).toHaveBeenCalledWith(['gone.eml']);
+1 -171
View File
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { BulwarkDesktopBridge, DesktopOsPowerEvent, DesktopOsStatus } from '../types';
import type { CalendarEvent } from '@/lib/jmap/types';
/** The window's end: badge, dropped and pasted files, drag-out, waking up, the settings section. */
/** The window's end: badge, drag-out, waking up, the settings section. Dropped and pasted files: components/file-drop. */
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => (values ? `${key}:${JSON.stringify(values)}` : key),
@@ -90,176 +90,6 @@ describe('the policy keeps refreshing', () => {
});
});
function dropEvent(type: string, files: File[]): Event {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(event, 'dataTransfer', { value: { types: files.length ? ['Files'] : ['text/plain'], files, dropEffect: 'none' } });
return event;
}
describe('files on the window', () => {
const file = new File(['x'], 'report.pdf');
beforeEach(async () => {
const { clearHandoffs, takeHandoff } = await import('@/lib/launch-handoff');
clearHandoffs();
takeHandoff('compose');
});
it('nothing happens in a browser', async () => {
const { startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
const stop = startWindowFiles();
const event = dropEvent('drop', [file]);
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
expect(takeHandoff('compose')).toBeNull();
stop();
});
it('a drop on the window starts a new draft with the file', async () => {
const { startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
const over = dropEvent('dragover', [file]);
window.dispatchEvent(over);
expect(over.defaultPrevented).toBe(true);
const drop = dropEvent('drop', [file]);
window.dispatchEvent(drop);
expect(drop.defaultPrevented).toBe(true);
expect(takeHandoff('compose')?.files).toEqual([file]);
stop();
});
it('a drop something else took is left alone', async () => {
const { startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
const event = dropEvent('drop', [file]);
event.preventDefault();
window.dispatchEvent(event);
expect(takeHandoff('compose')).toBeNull();
stop();
});
it('text dragged around is not a file', async () => {
const { startWindowFiles } = await import('../window-files');
setBridge({});
const stop = startWindowFiles();
const event = dropEvent('dragover', []);
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
stop();
});
it('an open composer takes the file itself', async () => {
const { setComposerFileTarget, startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
const target = vi.fn();
const clear = setComposerFileTarget(target, () => true);
window.dispatchEvent(dropEvent('drop', [file]));
expect(target).toHaveBeenCalledWith([file]);
expect(takeHandoff('compose')).toBeNull();
clear();
stop();
});
it('a composer out of sight on a hidden tab takes nothing', async () => {
const { setComposerFileTarget, startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
const hidden = vi.fn();
const clear = setComposerFileTarget(hidden, () => false);
window.dispatchEvent(dropEvent('drop', [file]));
expect(hidden).not.toHaveBeenCalled();
expect(takeHandoff('compose')?.files).toEqual([file]);
clear();
stop();
});
it('a pasted file is taken; pasted text is not', async () => {
const { startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
const paste = (files: File[]) => {
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: { files, types: files.length ? ['Files'] : ['text/plain'] } });
window.dispatchEvent(event);
return event;
};
expect(paste([]).defaultPrevented).toBe(false);
expect(takeHandoff('compose')).toBeNull();
expect(paste([file]).defaultPrevented).toBe(true);
expect(takeHandoff('compose')?.files).toEqual([file]);
stop();
});
describe('a paste that is not for us', () => {
const pasteOn = (target: EventTarget, types: string[]) => {
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: { files: [file], types } });
target.dispatchEvent(event);
return event;
};
it('text with a file, as a spreadsheet copies, stays text', async () => {
const { startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
expect(pasteOn(document.body, ['text/plain', 'Files']).defaultPrevented).toBe(false);
expect(takeHandoff('compose')).toBeNull();
stop();
});
it('in a search field or any other input, a file is not an attachment', async () => {
const { startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
const input = document.body.appendChild(document.createElement('input'));
expect(pasteOn(input, ['Files']).defaultPrevented).toBe(false);
expect(takeHandoff('compose')).toBeNull();
input.remove();
stop();
});
it('in the body of a composer on screen, a bare file attaches', async () => {
const { setComposerFileTarget, startWindowFiles } = await import('../window-files');
setBridge({});
const stop = startWindowFiles();
const target = vi.fn();
const clear = setComposerFileTarget(target, () => true);
const composer = document.body.appendChild(document.createElement('div'));
composer.setAttribute('data-testid', 'email-composer');
const body = composer.appendChild(document.createElement('div'));
body.setAttribute('contenteditable', 'true');
pasteOn(body, ['Files']);
expect(target).toHaveBeenCalledWith([file]);
composer.remove();
clear();
stop();
});
});
it('our own native drag over our own window is not a new file', async () => {
const { setOwnDragActive, startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
setOwnDragActive(true);
window.dispatchEvent(dropEvent('drop', [file]));
expect(takeHandoff('compose')).toBeNull();
setOwnDragActive(false);
stop();
});
});
describe('dragging an attachment out', () => {
const blobUrl = 'blob:https://app/1';
const source = {
@@ -32,6 +32,7 @@ describe('the schema', () => {
'PluginsMode', 'PluginAllowlist', 'OfflineEnabled', 'AppLockRequired', 'AppLockTimeoutMinutes', 'TelemetryAllowed',
'CrashReportsAllowed', 'QuickComposeShortcut', 'AuthServerAllowlist', 'AuthNegotiateDelegateAllowlist',
'AutoSelectCertificateForUrls', 'KeystoreEnabled', 'KeystoreAllowedOrigins', 'BrowserSignIn', 'WipeOfflineData',
'OpenWithEnabled', 'DeveloperToolsAvailability',
]);
for (const key of POLICY_KEYS) {
const { title, description } = POLICY_SCHEMA[key];
@@ -41,6 +42,22 @@ describe('the schema', () => {
});
});
describe('developer tools and Open with', () => {
it("takes Chrome values for developer tools, forced only, and a value nobody can read locks them off", () => {
expect(resolvePolicies(layers({ forcedMachine: { DeveloperToolsAvailability: 2 } })).DeveloperToolsAvailability).toMatchObject({ value: 2, locked: true });
expect(resolvePolicies(layers({ recommendedMachine: { DeveloperToolsAvailability: 2 } })).DeveloperToolsAvailability).toMatchObject({ value: null, locked: false });
// Not 0 or 1: the shell reads a locked entry that isn't an explicit yes as off.
expect(resolvePolicies(layers({ forcedUser: { DeveloperToolsAvailability: 7 } })).DeveloperToolsAvailability).toMatchObject({ value: null, locked: true });
});
it('turn Open with off only by force, and a value nobody can read leaves it off', () => {
expect(resolvePolicies(layers({})).OpenWithEnabled).toMatchObject({ value: true, locked: false });
expect(resolvePolicies(layers({ recommendedUser: { OpenWithEnabled: 0 } })).OpenWithEnabled).toMatchObject({ value: true, locked: false });
expect(resolvePolicies(layers({ forcedMachine: { OpenWithEnabled: 0 } })).OpenWithEnabled).toMatchObject({ value: false, locked: true });
expect(resolvePolicies(layers({ forcedMachine: { OpenWithEnabled: 'maybe' } })).OpenWithEnabled).toMatchObject({ value: false, locked: true });
});
});
describe('precedence', () => {
it('forced machine > forced user > recommended machine > recommended user > default', () => {
const all = {
+1
View File
@@ -99,6 +99,7 @@ export function shellLabels(t: (key: string, values?: Record<string, string>) =>
menuZoomOut: t('desktopUi.menu.zoom_out'),
menuZoomReset: t('desktopUi.menu.zoom_reset'),
menuFullScreen: t('desktopUi.menu.full_screen'),
menuToggleDevTools: t('desktopDevTools.menu_toggle'),
menuReply: t('context_menu.reply'),
menuReplyAll: t('context_menu.reply_all'),
menuForward: t('context_menu.forward'),
+5 -1
View File
@@ -9,6 +9,7 @@ import { isAppLocked } from '@/lib/desktop/app-lock-state';
import { getDesktopBridge } from '@/lib/desktop/bridge';
import type { DesktopOsCommand } from '@/lib/desktop/types';
import { openMailLink } from '@/lib/desktop/mail-links';
import { requestFileChoice } from '@/lib/file-drop/choice';
import { handleReminderCommand } from '@/lib/desktop/reminder-notifications';
import type { MailNotificationContext } from '@/lib/desktop/mail-notifications';
import { handOffDraft, handOffFiles, handOffNewMessage, kindForFile } from '@/lib/launch-handoff';
@@ -131,7 +132,10 @@ async function handleNotification(action: string, rawContext: string, reply: str
function handleFiles(command: Extract<DesktopOsCommand, { type: 'files' }>, env: OsCommandEnv): void {
const made = command.files.map(({ name, data }) => new File([new Uint8Array(data)], name));
const failed = [...command.failed];
if (command.reason === 'attach') {
if (command.reason === 'attach' && command.ask) {
// Opened with the app ("Open with"): the same choice as dropping it on the window.
requestFileChoice({ files: made, preferred: 'new-message' });
} else if (command.reason === 'attach') {
if (made.length > 0) handOffDraft({ files: made });
} else {
for (const file of made) {
+34 -1
View File
@@ -29,7 +29,7 @@ export const POLICY_MAX_FILE_BYTES = 1024 * 1024;
export type PolicyText = { en: string; de: string };
export type PolicyCategory = 'connection' | 'updates' | 'plugins' | 'security' | 'privacy' | 'signIn';
export type PolicyCategory = 'connection' | 'updates' | 'plugins' | 'security' | 'privacy' | 'signIn' | 'system';
export const POLICY_CATEGORIES: Record<PolicyCategory, PolicyText> = {
connection: { en: 'Connection', de: 'Verbindung' },
@@ -38,6 +38,7 @@ export const POLICY_CATEGORIES: Record<PolicyCategory, PolicyText> = {
security: { en: 'Offline mail and app lock', de: 'Offline-E-Mails und App-Sperre' },
privacy: { en: 'Privacy', de: 'Datenschutz' },
signIn: { en: 'Sign-in and certificates', de: 'Anmeldung und Zertifikate' },
system: { en: 'System integration and troubleshooting', de: 'Systemintegration und Fehlerbehebung' },
};
export interface CertificateName {
@@ -79,6 +80,8 @@ export interface PolicyValues {
KeystoreAllowedOrigins: string[];
BrowserSignIn: boolean;
WipeOfflineData: string | null;
OpenWithEnabled: boolean;
DeveloperToolsAvailability: number | null;
}
export type PolicyKey = keyof PolicyValues;
@@ -431,6 +434,34 @@ export const POLICY_SCHEMA: Record<PolicyKey, PolicyDefinition> = {
de: 'Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen. Diese Liste liegt im Profil des Benutzers. Wer sie bearbeitet, kann also eine Löschung umgehen, von der er weiß: Dies räumt ein Gerät auf, es nimmt der Person am Gerät keine Daten weg.',
},
},
OpenWithEnabled: {
category: 'system',
type: 'boolean',
default: true,
onInvalid: false,
forcedOnly: true,
sample: true,
title: { en: 'Offer the app in "Open with" and "Send to"', de: 'App unter „Öffnen mit“ und „Senden an“ anbieten' },
description: {
en: 'When enabled or not configured, the app adds itself to the "Open with" list for common documents and pictures and to the "Send to" menu on Windows, so people can attach a file to a new message from the file manager. It never becomes the default program for those files. When disabled, the app removes these entries for the user. Mail links and .eml, .ics and .vcf files are not affected.',
de: 'Wenn aktiviert oder nicht konfiguriert, trägt sich die App unter Windows in die Liste „Öffnen mit“ für gängige Dokumente und Bilder und in das Menü „Senden an“ ein, damit Benutzer eine Datei aus dem Dateimanager an eine neue Nachricht anhängen können. Sie wird nie zum Standardprogramm für diese Dateien. Wenn deaktiviert, entfernt die App diese Einträge für den Benutzer. Mail-Links und .eml-, .ics- und .vcf-Dateien sind nicht betroffen.',
},
},
DeveloperToolsAvailability: {
category: 'system',
type: 'integer',
default: null,
min: 0,
max: 2,
forcedOnly: true,
unit: { en: 'Value (0, 1 or 2)', de: 'Wert (0, 1 oder 2)' },
sample: 2,
title: { en: 'Developer tools', de: 'Entwicklertools' },
description: {
en: 'The same values as Chrome\'s policy of this name. 2: developer tools are off and the setting is locked. 0 or 1, or not configured: people can turn them on under Settings, after a warning. Developer tools let anyone at the keyboard read everything the app holds, mail and passwords in memory included.',
de: 'Dieselben Werte wie die gleichnamige Richtlinie von Chrome. 2: Die Entwicklertools sind aus und die Einstellung ist gesperrt. 0 oder 1 oder nicht konfiguriert: Benutzer können sie nach einer Warnung in den Einstellungen einschalten. Mit den Entwicklertools kann jeder an der Tastatur alles lesen, was die App enthält, auch E-Mails und Passwörter im Speicher.',
},
},
};
export const POLICY_KEYS = Object.keys(POLICY_SCHEMA) as PolicyKey[];
@@ -928,6 +959,8 @@ export const POLICY_CONSUMERS: Record<PolicyKey, PolicyConsumer> = {
KeystoreEnabled: { repo: 'shell', file: 'src/main/features/policies.ts' },
KeystoreAllowedOrigins: { repo: 'shell', file: 'src/main/features/policies.ts' },
BrowserSignIn: { repo: 'shell', file: 'src/main/features/browser-sign-in.ts' },
OpenWithEnabled: { repo: 'shell', file: 'src/main/features/os-default-app.ts' },
DeveloperToolsAvailability: { repo: 'shell', file: 'src/main/features/dev-tools.ts' },
};
/** The part the embedded server enforces. Sent to it by message. */
+21 -1
View File
@@ -604,7 +604,8 @@ export type DesktopOsCommand =
| { type: 'open-mailbox'; mailboxId: string }
| { type: 'mailto'; url: string }
| { type: 'webcal'; url: string }
| { type: 'files'; reason: 'open' | 'attach'; files: DesktopOsFile[]; failed: string[] }
/** `ask`: opened with the app, so the user picks between a new message and Files. Without it, attach. */
| { type: 'files'; reason: 'open' | 'attach'; files: DesktopOsFile[]; failed: string[]; ask?: boolean }
| { type: 'notification'; action: string; context: string; reply?: string };
export interface DesktopOsNotification {
@@ -730,6 +731,23 @@ export interface DesktopPrivacyBridge {
onChange: (listener: (state: DesktopPrivacyState) => void) => () => void;
}
/** `forcedOff`: the DeveloperToolsAvailability policy says no. `locked`: the app lock is on, so nothing changes. */
export interface DesktopDevToolsState {
on: boolean;
forcedOff: boolean;
locked: boolean;
}
/**
* Chromium's developer tools on Ctrl+Shift+I and F12, off by default. Main keeps the switch.
* Turning it on takes a real click and a native warning; the app lock turns it off again.
*/
export interface DesktopDevToolsBridge {
get: () => Promise<DesktopDevToolsState>;
set: (on: boolean) => Promise<DesktopDevToolsState>;
onChange: (listener: (state: DesktopDevToolsState) => void) => () => void;
}
/** An attachment copied to a private folder and open in its default program. */
export interface DesktopEditSession {
id: string;
@@ -850,6 +868,8 @@ export interface BulwarkDesktopBridge {
localImport?: DesktopLocalImportBridge;
privacy?: DesktopPrivacyBridge;
edit?: DesktopEditBridge;
/** Absent on older shells. */
devTools?: DesktopDevToolsBridge;
}
declare global {
+26 -55
View File
@@ -1,26 +1,29 @@
/**
* Files dropped on a window or pasted from the clipboard. Whatever already handles a
* drop keeps doing so, which it shows by preventDefault: the composer, the file page,
* calendar import. The rest becomes an attachment on a new draft, or on the composer
* that's open.
* Who takes files on this window: the composer on screen, for files pasted into it or
* dropped on its zone of the file drop overlay (lib/file-drop). And whether a drag going
* on is the app's own native one, which is no new file.
*/
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { handOffDraft } from '@/lib/launch-handoff';
/** Pictures put in the text where the cursor is, as pasting them does. Only while the rich text editor is there. */
export interface ComposerImages {
available: () => boolean;
insert: (files: File[]) => void;
}
interface ComposerTarget {
take: (files: File[]) => unknown;
/** The Pro shell keeps composers mounted on tabs out of sight. */
visible: () => boolean;
images?: ComposerImages;
}
const composerTargets: ComposerTarget[] = [];
/** Our own native drag passing over our own window isn't a new file. */
let ownDrag = false;
/** A composer on screen takes files itself. No effect outside the desktop app: only this file reads it. */
export function setComposerFileTarget(take: ComposerTarget['take'], visible: ComposerTarget['visible']): () => void {
const target = { take, visible };
/** A composer on screen takes files itself. */
export function setComposerFileTarget(take: ComposerTarget['take'], visible: ComposerTarget['visible'], images?: ComposerImages): () => void {
const target = { take, visible, images };
composerTargets.push(target);
return () => {
const at = composerTargets.indexOf(target);
@@ -28,7 +31,8 @@ export function setComposerFileTarget(take: ComposerTarget['take'], visible: Com
};
}
function visibleComposer(): ComposerTarget | undefined {
/** The newest composer on screen, if any. */
export function visibleComposer(): ComposerTarget | undefined {
return [...composerTargets].reverse().find((target) => target.visible());
}
@@ -36,10 +40,8 @@ export function setOwnDragActive(active: boolean): void {
ownDrag = active;
}
function take(files: File[]): void {
const composer = visibleComposer();
if (composer) composer.take(files);
else handOffDraft({ files });
export function isOwnDragActive(): boolean {
return ownDrag;
}
function isEditable(target: EventTarget | null): boolean {
@@ -48,48 +50,17 @@ function isEditable(target: EventTarget | null): boolean {
}
/**
* Pasting a file is only meant where text isn't. A spreadsheet's cells also come as a
* picture of them, and a field that takes text wants the text. The one editable place a
* bare file belongs in is the composer it's being pasted into.
* Where a pasted file goes, if anywhere. Pasting a file is only meant where text isn't. A
* spreadsheet's cells also come as a picture of them, and a field that takes text wants
* the text. The one editable place a bare file belongs in is the composer it's being
* pasted into.
*/
function pasteIsForUs(event: ClipboardEvent): boolean {
export function pasteTarget(event: ClipboardEvent): 'composer' | 'choose' | null {
if (event.defaultPrevented) return null;
if ((event.clipboardData?.files?.length ?? 0) === 0) return null;
const types = [...(event.clipboardData?.types ?? [])];
if (types.includes('text/plain') || types.includes('text/html')) return false;
if (!isEditable(event.target)) return true;
if (types.includes('text/plain') || types.includes('text/html')) return null;
if (!isEditable(event.target)) return 'choose';
const inComposer = !!(event.target as Partial<Element> | null)?.closest?.('[data-testid="email-composer"]');
return inComposer && !!visibleComposer();
}
function hasFiles(event: DragEvent): boolean {
return !!event.dataTransfer && [...event.dataTransfer.types].includes('Files');
}
export function startWindowFiles(): () => void {
if (!getDesktopBridge()?.os) return () => {};
const onDragOver = (event: DragEvent) => {
if (event.defaultPrevented || ownDrag || !hasFiles(event)) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
};
const onDrop = (event: DragEvent) => {
if (event.defaultPrevented || ownDrag || !hasFiles(event)) return;
event.preventDefault();
const files = Array.from(event.dataTransfer?.files ?? []);
if (files.length > 0) take(files);
};
const onPaste = (event: ClipboardEvent) => {
if (event.defaultPrevented) return;
const files = Array.from(event.clipboardData?.files ?? []);
if (files.length === 0 || !pasteIsForUs(event)) return;
event.preventDefault();
take(files);
};
window.addEventListener('dragover', onDragOver);
window.addEventListener('drop', onDrop);
window.addEventListener('paste', onPaste);
return () => {
window.removeEventListener('dragover', onDragOver);
window.removeEventListener('drop', onDrop);
window.removeEventListener('paste', onPaste);
};
return inComposer && visibleComposer() ? 'composer' : null;
}
+106
View File
@@ -0,0 +1,106 @@
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { DRAG_STALE_MS, startDragWatch } from '../drag-watch';
import type { DropFiles } from '../zones';
import { setOwnDragActive } from '@/lib/desktop/window-files';
/** jsdom has no DragEvent: an Event with the parts the watch reads. */
function drag(type: string, options: { files?: number; types?: string[]; itemTypes?: string[]; relatedTarget?: EventTarget | null; target?: EventTarget } = {}): Event {
const event = new Event(type, { bubbles: true, cancelable: true });
const count = options.files ?? 1;
const types = options.types ?? (count > 0 ? ['Files'] : ['text/plain']);
const items = (options.itemTypes ?? Array.from({ length: count }, () => '')).map((itemType) => ({ kind: 'file', type: itemType }));
Object.defineProperty(event, 'dataTransfer', { value: { types, items, files: [], dropEffect: 'none' } });
if ('relatedTarget' in options) Object.defineProperty(event, 'relatedTarget', { value: options.relatedTarget });
(options.target ?? document.body).dispatchEvent(event);
return event;
}
describe('noticing files dragged in from outside', () => {
let enter: Mock<(files: DropFiles) => boolean>;
let leave: Mock<() => void>;
let stop: () => void;
beforeEach(() => {
vi.useFakeTimers();
enter = vi.fn((_files: DropFiles) => true);
leave = vi.fn(() => {});
stop = startDragWatch({ enter, leave });
});
afterEach(() => {
stop();
setOwnDragActive(false);
vi.useRealTimers();
});
it('tells how many files and what kind, as far as the drag shows', () => {
drag('dragenter', { files: 2, itemTypes: ['text/calendar', 'application/pdf'] });
expect(enter).toHaveBeenCalledWith({ count: 2, kinds: ['calendar', 'other'], bytes: null });
});
it('a drag that started in the page is the app\'s own, not files from outside', () => {
document.body.dispatchEvent(new Event('dragstart', { bubbles: true }));
drag('dragenter');
expect(enter).not.toHaveBeenCalled();
// The first pointer event after a drag means it's over, even if dragend went nowhere.
document.body.dispatchEvent(new Event('pointermove', { bubbles: true }));
drag('dragenter');
expect(enter).toHaveBeenCalledTimes(1);
});
it('the app\'s own native drag of an attachment is not a new file either', () => {
setOwnDragActive(true);
drag('dragenter');
expect(enter).not.toHaveBeenCalled();
});
it('text dragged around is no file', () => {
drag('dragenter', { files: 0 });
expect(enter).not.toHaveBeenCalled();
});
it('a component under the pointer does not get the first enter once the overlay takes it', () => {
const inner = document.body.appendChild(document.createElement('div'));
const seen = vi.fn();
inner.addEventListener('dragenter', seen);
drag('dragenter', { target: inner });
expect(seen).not.toHaveBeenCalled();
inner.remove();
});
it('leaving the window ends it; moving between elements does not', () => {
drag('dragenter');
drag('dragleave', { relatedTarget: document.body });
expect(leave).not.toHaveBeenCalled();
drag('dragleave', { relatedTarget: null });
expect(leave).toHaveBeenCalledTimes(1);
});
it('ends by itself when the drag goes quiet, as after Esc in some browsers', () => {
drag('dragenter');
vi.advanceTimersByTime(DRAG_STALE_MS / 2);
drag('dragover');
vi.advanceTimersByTime(DRAG_STALE_MS / 2);
expect(leave).not.toHaveBeenCalled();
vi.advanceTimersByTime(DRAG_STALE_MS);
expect(leave).toHaveBeenCalledTimes(1);
});
it('a drop anywhere ends it, and never lets the browser open the file instead of the app', async () => {
drag('dragenter');
const over = drag('dragover');
expect(over.defaultPrevented).toBe(true);
const dropped = drag('drop');
expect(dropped.defaultPrevented).toBe(true);
await Promise.resolve();
expect(leave).toHaveBeenCalledTimes(1);
});
it('a drag the host turns down (locked, a dialog open) still cannot open the file in place of the app', () => {
enter.mockReturnValue(false);
drag('dragenter');
expect(drag('dragover').defaultPrevented).toBe(true);
expect(drag('drop').defaultPrevented).toBe(true);
expect(leave).not.toHaveBeenCalled();
});
});
+130
View File
@@ -0,0 +1,130 @@
import { describe, expect, it } from 'vitest';
import { defaultZone, fileDropZones, filesWithinLimits, kindForType, MAX_ZONES, type DropFiles, type DropPlace } from '../zones';
const place = (patch: Partial<DropPlace> = {}): DropPlace => ({
composer: false,
inlineImages: false,
popout: false,
files: true,
filesFolder: null,
calendar: true,
contacts: true,
importFolder: null,
offline: false,
keepsOffline: false,
maxUploadBytes: 0,
maxMessageBytes: 0,
...patch,
});
const drag = (kinds: DropFiles['kinds']): DropFiles => ({ count: kinds?.length ?? null, kinds, bytes: null });
const ids = (files: DropFiles, where: DropPlace) => fileDropZones(files, where).map((zone) => zone.id);
describe('the file drop zones', () => {
it('offer a new message and Files for any file', () => {
expect(ids(drag(['other']), place())).toEqual(['new-message', 'upload']);
});
it('leave Files out where the server or the organisation has none', () => {
expect(ids(drag(['other']), place({ files: false }))).toEqual(['new-message']);
});
it('put an open composer first', () => {
expect(ids(drag(['other', 'other']), place({ composer: true }))).toEqual(['attach-here', 'new-message', 'upload']);
});
it('name the folder on the Files screen', () => {
const upload = fileDropZones(drag(['other']), place({ filesFolder: 'Reports' })).find((zone) => zone.id === 'upload');
expect(upload?.folder).toBe('Reports');
});
it('add the calendar for one .ics, contacts for one .vcf, open and import for an .eml', () => {
expect(ids(drag(['calendar']), place())).toEqual(['calendar', 'new-message', 'upload']);
expect(ids(drag(['contacts']), place())).toEqual(['contacts', 'new-message', 'upload']);
expect(ids(drag(['eml']), place({ importFolder: 'Inbox' }))).toEqual(['open-eml', 'import-eml', 'new-message', 'upload']);
});
it('offer nothing special for a mix or for a calendar the account does not have', () => {
expect(ids(drag(['calendar', 'other']), place())).toEqual(['new-message', 'upload']);
expect(ids(drag(['calendar']), place({ calendar: false }))).toEqual(['new-message', 'upload']);
expect(ids({ count: null, kinds: null, bytes: null }, place())).toEqual(['new-message', 'upload']);
});
it('import several messages, but open only one', () => {
expect(ids(drag(['eml', 'eml']), place({ importFolder: 'Inbox' }))).toEqual(['import-eml', 'new-message', 'upload']);
});
it('never show more than four, keeping both main ones and the composer', () => {
const zones = ids(drag(['eml']), place({ composer: true, importFolder: 'Inbox' }));
expect(zones).toHaveLength(MAX_ZONES);
expect(zones).toEqual(['attach-here', 'open-eml', 'new-message', 'upload']);
});
it('pictures go into the text of an open rich text composer, beside attaching them', () => {
expect(ids(drag(['image', 'image']), place({ composer: true, inlineImages: true }))).toEqual(['attach-here', 'insert-inline', 'new-message', 'upload']);
// Plain text, a mix, or no composer: no such choice.
expect(ids(drag(['image']), place({ composer: true }))).toEqual(['attach-here', 'new-message', 'upload']);
expect(ids(drag(['image', 'other']), place({ composer: true, inlineImages: true }))).toEqual(['attach-here', 'new-message', 'upload']);
expect(ids(drag(['image']), place({ inlineImages: true }))).toEqual(['new-message', 'upload']);
expect(kindForType('image/png')).toBe('image');
expect(kindForType('image/svg+xml')).toBe('other');
});
it('in a pop-out, only its own composer', () => {
expect(ids(drag(['calendar']), place({ popout: true, composer: true }))).toEqual(['attach-here']);
expect(ids(drag(['other']), place({ popout: true }))).toEqual([]);
});
it('offline: Files and the import say they need a connection, a draft without offline mode too', () => {
const zones = fileDropZones(drag(['eml']), place({ offline: true, importFolder: 'Inbox' }));
const byId = Object.fromEntries(zones.map((zone) => [zone.id, zone]));
expect(byId.upload).toMatchObject({ disabled: true, note: { key: 'note_needs_connection' } });
expect(byId['import-eml']).toMatchObject({ disabled: true });
expect(byId['new-message']).toMatchObject({ disabled: true, note: { key: 'note_needs_connection' } });
expect(byId['open-eml'].disabled).toBeUndefined();
});
it('offline with offline mode: a draft keeps the files until the connection is back', () => {
const zones = fileDropZones(drag(['other']), place({ offline: true, keepsOffline: true }));
expect(zones.find((zone) => zone.id === 'new-message')).toEqual({ id: 'new-message', note: { key: 'note_kept_offline' } });
});
it('show the server limits while the sizes are still unknown', () => {
const zones = fileDropZones(drag(['other']), place({ maxUploadBytes: 50, maxMessageBytes: 100 }));
expect(zones.find((zone) => zone.id === 'new-message')?.note).toEqual({ key: 'note_limit_message', size: 100 });
expect(zones.find((zone) => zone.id === 'upload')?.note).toEqual({ key: 'note_limit_file', size: 50 });
});
it('read the kind from the MIME type a drag shows', () => {
expect(kindForType('text/calendar')).toBe('calendar');
expect(kindForType('message/rfc822')).toBe('eml');
expect(kindForType('text/x-vcard')).toBe('contacts');
expect(kindForType('')).toBe('other');
});
});
describe('limits once the files are in hand', () => {
const sized = (name: string, size: number) => new File([new Uint8Array(size)], name);
it('leave out a file over the per-file limit and keep the rest', () => {
const big = sized('big.zip', 60);
const small = sized('a.txt', 10);
expect(filesWithinLimits('upload', [big, small], { maxUploadBytes: 50, maxMessageBytes: 0 })).toEqual({ fitting: [small], tooLarge: [big], overMessage: false });
});
it('refuse a message whose files are too large together', () => {
const files = [sized('a', 30), sized('b', 30)];
expect(filesWithinLimits('new-message', files, { maxUploadBytes: 0, maxMessageBytes: 50 })).toMatchObject({ fitting: [], overMessage: true });
expect(filesWithinLimits('upload', files, { maxUploadBytes: 0, maxMessageBytes: 50 }).fitting).toHaveLength(2);
});
});
describe('the keyboard default', () => {
it('is the asked-for zone when usable, else the first usable', () => {
const zones = fileDropZones(drag(['other']), place({ composer: true }));
expect(defaultZone(zones, 'new-message')?.id).toBe('new-message');
expect(defaultZone(zones)?.id).toBe('attach-here');
const offline = fileDropZones(drag(['other']), place({ offline: true, keepsOffline: true }));
expect(defaultZone(offline, 'upload')?.id).toBe('new-message');
});
});
+65
View File
@@ -0,0 +1,65 @@
/**
* What each zone of the file drop overlay does with the files, through the paths the app
* already has: a composer's own attach (its size checks and plugin hooks included), the
* launch handoff for a new draft and for .ics, .vcf and .eml, the Files screen's upload.
*/
import { visibleComposer } from '@/lib/desktop/window-files';
import { handOffDraft, handOffFiles } from '@/lib/launch-handoff';
import { filesDropTarget, queueFilesUpload, type FilesUpload } from './files-target';
import type { FileDropZoneId } from './zones';
export interface FileDropEnv {
/** Goes to a page, saving an open composer first. */
goTo: (path: '/files') => void | Promise<void>;
/** Email/import into the folder the zone named. */
importMessages: (files: File[]) => void | Promise<void>;
}
/** `files` without folders for every zone; `upload` also gets what a dropped folder held. */
export async function runFileDropZone(zone: FileDropZoneId, files: File[], env: FileDropEnv, upload?: FilesUpload): Promise<void> {
if (zone === 'upload') {
const what = upload ?? { files, asFolder: false };
if (what.files.length === 0) return;
const target = filesDropTarget();
if (target) {
target.take(what);
return;
}
queueFilesUpload(what);
await env.goTo('/files');
return;
}
if (files.length === 0) return;
switch (zone) {
case 'attach-here': {
const composer = visibleComposer();
// Gone between the drag and the drop: a new message is the next best thing.
if (composer) composer.take(files);
else handOffDraft({ files });
return;
}
case 'insert-inline': {
const composer = visibleComposer();
if (composer?.images?.available()) composer.images.insert(files);
else if (composer) composer.take(files);
else handOffDraft({ files });
return;
}
case 'new-message':
handOffDraft({ files });
return;
case 'calendar':
handOffFiles(files, 'calendar');
return;
case 'contacts':
handOffFiles(files, 'contacts');
return;
case 'open-eml':
handOffFiles(files.slice(0, 1), 'eml');
return;
case 'import-eml':
await env.importMessages(files);
return;
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* The overlay's choices without a drag: for files pasted outside a text field, and for a
* file opened with the desktop app ("Open with" in Explorer, Finder or a file manager).
* The host shows them as a dialog that works from the keyboard. Requests wait for the
* host to mount, and the host waits for a sign-in.
*/
import type { FileDropZoneId } from './zones';
export interface FileChoiceRequest {
files: File[];
/** Where the focus starts, if that choice is there. */
preferred?: FileDropZoneId;
}
type Listener = (request: FileChoiceRequest) => void;
let listener: Listener | null = null;
const waiting: FileChoiceRequest[] = [];
export function requestFileChoice(request: FileChoiceRequest): void {
if (request.files.length === 0) return;
if (listener) listener(request);
else waiting.push(request);
}
/** One host at a time. What came before it mounted is handed over at once. */
export function listenForFileChoices(next: Listener): () => void {
listener = next;
for (const request of waiting.splice(0)) next(request);
return () => {
if (listener === next) listener = null;
};
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Notices files dragged into the window from outside, and only those. A drag that started
* in this page (a message to a folder, an attachment to the composer, a tab) fires
* `dragstart` here first, and the app's own native drag of an attachment says so through
* setOwnDragActive. Neither shows the overlay.
*
* Listens in the capture phase, so a component's own drop zone doesn't hide the drag from
* it. Once the overlay is up it's on top of everything and takes the drag's events itself.
* A file dropped anywhere else does nothing: the browser's own default would leave the app
* to show the file.
*/
import { isOwnDragActive } from '@/lib/desktop/window-files';
import { kindForType, type DropFiles } from './zones';
/** Chromium sends dragover every 50 ms and Firefox about every 350 ms while the pointer is still. */
export const DRAG_STALE_MS = 1000;
export interface DragWatchHandlers {
/** Files came in from outside. Return false to leave the drag alone (locked, a dialog open). */
enter: (files: DropFiles) => boolean;
/** Out of the window, Esc, a drop, or no word for a while. */
leave: () => void;
}
export function draggedFiles(transfer: DataTransfer | null | undefined): DropFiles | null {
if (!transfer || !Array.from(transfer.types ?? []).includes('Files')) return null;
const items = Array.from(transfer.items ?? []).filter((item) => item.kind === 'file');
if (items.length === 0) return { count: null, kinds: null, bytes: null };
return { count: items.length, kinds: items.map((item) => kindForType(item.type)), bytes: null };
}
export function startDragWatch(handlers: DragWatchHandlers): () => void {
let internal = false;
let active = false;
let staleTimer: ReturnType<typeof setTimeout> | null = null;
const stop = () => {
if (staleTimer) clearTimeout(staleTimer);
staleTimer = null;
if (!active) return;
active = false;
handlers.leave();
};
const keepAlive = () => {
if (staleTimer) clearTimeout(staleTimer);
staleTimer = setTimeout(stop, DRAG_STALE_MS);
};
const fromOutside = (event: DragEvent) => !internal && !isOwnDragActive() && draggedFiles(event.dataTransfer) !== null;
const onDragStart = () => {
internal = true;
};
// dragend goes to the source, which may have left the page by then (a message that moved).
// No pointer events reach a page during a drag, so the first one after says it's over.
const onDragEnd = () => {
internal = false;
};
const onPointer = () => {
internal = false;
};
const onDragEnter = (event: DragEvent) => {
if (!fromOutside(event)) return;
event.preventDefault();
if (active) {
keepAlive();
return;
}
if (handlers.enter(draggedFiles(event.dataTransfer)!)) {
active = true;
// The component under the pointer would start its own highlight for a moment.
event.stopPropagation();
keepAlive();
}
};
const onDragOver = (event: DragEvent) => {
if (!fromOutside(event)) return;
// Without this a drop anywhere opens the file in place of the app.
event.preventDefault();
if (active) keepAlive();
};
const onDragLeave = (event: DragEvent) => {
// Leaving the window, or the drag cancelled with Esc: no element is next.
if (active && event.relatedTarget === null) stop();
};
const onDrop = (event: DragEvent) => {
internal = false;
if (draggedFiles(event.dataTransfer) === null) return;
// The overlay's zones handle their own drop before this runs (bubble, after capture),
// so here it only stops the browser's default and ends the overlay.
event.preventDefault();
queueMicrotask(stop);
};
window.addEventListener('dragstart', onDragStart, true);
window.addEventListener('dragend', onDragEnd, true);
window.addEventListener('pointermove', onPointer, true);
window.addEventListener('pointerdown', onPointer, true);
window.addEventListener('dragenter', onDragEnter, true);
window.addEventListener('dragover', onDragOver, true);
window.addEventListener('dragleave', onDragLeave, true);
window.addEventListener('drop', onDrop);
return () => {
stop();
window.removeEventListener('dragstart', onDragStart, true);
window.removeEventListener('dragend', onDragEnd, true);
window.removeEventListener('pointermove', onPointer, true);
window.removeEventListener('pointerdown', onPointer, true);
window.removeEventListener('dragenter', onDragEnter, true);
window.removeEventListener('dragover', onDragOver, true);
window.removeEventListener('dragleave', onDragLeave, true);
window.removeEventListener('drop', onDrop);
};
}
+59
View File
@@ -0,0 +1,59 @@
/**
* The Files screen's end of the file drop overlay. While it's open on a folder that takes
* uploads it says so here, and the overlay offers "Upload to <folder>". From anywhere else
* the files wait here while the app goes to Files, which takes them once it has listed a
* folder they can go in.
*/
export interface FilesUpload {
files: File[];
/** A dropped folder: the files carry their paths, and the upload keeps them. */
asFolder: boolean;
}
interface FilesTarget {
/** The folder in view, as the user sees its name. Null at the top of Files. */
folder: string | null;
take: (upload: FilesUpload) => unknown;
/** The Pro shell keeps Files mounted on a tab out of sight. */
visible: () => boolean;
}
let target: FilesTarget | null = null;
let waiting: FilesUpload | null = null;
const listeners = new Set<() => void>();
export function setFilesDropTarget(next: FilesTarget | null): () => void {
target = next;
return () => {
if (target === next) target = null;
};
}
/** The Files screen on view, if it can take files where it is. */
export function filesDropTarget(): FilesTarget | null {
return target?.visible() ? target : null;
}
/** Files for the Files screen to upload when it's ready. A newer drop replaces an older one nobody took. */
export function queueFilesUpload(upload: FilesUpload): void {
waiting = upload;
for (const listener of listeners) listener();
}
export function takeQueuedFilesUpload(): FilesUpload | null {
const upload = waiting;
waiting = null;
return upload;
}
export function hasQueuedFilesUpload(): boolean {
return waiting !== null;
}
export function subscribeQueuedFilesUpload(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
+30
View File
@@ -0,0 +1,30 @@
/**
* The files of a drop, read while the drop event lasts. A dropped folder only shows up
* through the entry API, and only Files can take one: its upload keeps the folder's
* layout. Everywhere else a folder is left out.
*/
import { getDroppedFilesAndFolders } from '@/lib/webdav/drop-utils';
export interface DroppedFiles {
/** Files at the top of the drop, no folders. */
plain: File[];
/** Everything, folders walked, with their paths: for Files. */
all: File[];
folders: boolean;
}
/** Call from the drop handler itself: the browser empties the transfer once it returns. */
export function readDroppedFiles(transfer: DataTransfer): Promise<DroppedFiles> {
const items = Array.from(transfer.items ?? []);
const entries = items.map((item) => (typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null));
const plainNow = items.flatMap((item, index) => {
if (item.kind !== 'file' || entries[index]?.isDirectory) return [];
const file = item.getAsFile();
return file ? [file] : [];
});
const plain = items.length > 0 ? plainNow : Array.from(transfer.files ?? []);
const folders = entries.some((entry) => entry?.isDirectory);
if (!folders) return Promise.resolve({ plain, all: plain, folders: false });
return getDroppedFilesAndFolders(transfer).then(({ files }) => ({ plain, all: files, folders: true }));
}
+157
View File
@@ -0,0 +1,157 @@
/**
* Which choices the file drop overlay offers, with nothing of React or the DOM in it.
*
* Two main choices everywhere: attach to a new message, upload to Files. An open composer
* comes first, and a file the app knows (.ics, .vcf, .eml) adds its own. Never more than
* four, so the zones stay big enough to hit with a drag.
*
* During a drag the browser shows only how many files there are and their MIME types,
* never names or sizes (Chromium's protected drag data). So kinds come from the type
* then, from the name once dropped, and a size limit is shown on the zone rather than
* checked against files nobody can measure yet.
*/
import { kindForFile, type HandoffKind } from '@/lib/launch-handoff';
export type FileDropZoneId = 'attach-here' | 'insert-inline' | 'new-message' | 'upload' | 'calendar' | 'contacts' | 'open-eml' | 'import-eml';
export type DroppedKind = Exclude<HandoffKind, 'compose'> | 'image' | 'other';
export interface DropFiles {
/** Unknown during a drag in some browsers (Safari gives no items). */
count: number | null;
/** One per file, when known. */
kinds: DroppedKind[] | null;
/** Known only once the files are in hand. */
bytes: number | null;
}
export interface DropPlace {
/** A composer is on screen and takes files. */
composer: boolean;
/** Its rich text editor can take pictures into the text. */
inlineImages: boolean;
/** A pop-out window: only its own composer, nothing that needs the main window. */
popout: boolean;
/** Files is on for this account and its server has it. */
files: boolean;
/** On the Files screen, the folder in view, when files can go there. */
filesFolder: string | null;
calendar: boolean;
contacts: boolean;
/** On the mail screen, a real folder in view that takes new messages. */
importFolder: string | null;
/** No connection to the mail server. */
offline: boolean;
/** Offline mode keeps a draft and its files until the connection is back. */
keepsOffline: boolean;
/** 0: the server says nothing. */
maxUploadBytes: number;
maxMessageBytes: number;
}
export type FileDropNote =
| { key: 'note_limit_message'; size: number }
| { key: 'note_limit_file'; size: number }
| { key: 'note_kept_offline' }
| { key: 'note_needs_connection' };
export interface FileDropZone {
id: FileDropZoneId;
/** Names the folder for `upload` on the Files screen and for `import-eml`. */
folder?: string;
note?: FileDropNote;
/** Shown, with the note saying why, and takes no drop. */
disabled?: boolean;
}
export const MAX_ZONES = 4;
/** What a dragged item's MIME type says. Empty or anything else: no special choice. */
/** What a mail can show in its text. Not SVG: it can carry script, and mail clients drop it. */
const INLINE_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
const INLINE_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']);
function dropped(kind: HandoffKind | null): DroppedKind {
return kind && kind !== 'compose' ? kind : 'other';
}
export function kindForType(type: string): DroppedKind {
if (INLINE_IMAGE_TYPES.has(type.toLowerCase())) return 'image';
return dropped(kindForFile({ name: '', type }));
}
export function kindForName(file: { name: string; type?: string }): DroppedKind {
const dot = file.name.lastIndexOf('.');
const extension = dot >= 0 ? file.name.slice(dot).toLowerCase() : '';
if (INLINE_IMAGE_TYPES.has((file.type ?? '').toLowerCase()) || INLINE_IMAGE_EXTENSIONS.has(extension)) return 'image';
return dropped(kindForFile(file));
}
/** The one kind every file shares, or null for a mix or for none known. */
export function sharedKind(files: DropFiles): DroppedKind | null {
if (!files.kinds || files.kinds.length === 0) return null;
const [first] = files.kinds;
return files.kinds.every((kind) => kind === first) ? first : null;
}
function mailNote(place: DropPlace): Pick<FileDropZone, 'note' | 'disabled'> {
if (place.offline && !place.keepsOffline) return { note: { key: 'note_needs_connection' }, disabled: true };
if (place.offline) return { note: { key: 'note_kept_offline' } };
return place.maxMessageBytes > 0 ? { note: { key: 'note_limit_message', size: place.maxMessageBytes } } : {};
}
function serverOnly(place: DropPlace, zone: FileDropZone): FileDropZone {
return place.offline ? { ...zone, note: { key: 'note_needs_connection' }, disabled: true } : zone;
}
/** The zones for these files here, in the order they're shown. */
export function fileDropZones(files: DropFiles, place: DropPlace): FileDropZone[] {
const kind = sharedKind(files);
const one = files.count === 1;
const main: FileDropZone[] = [];
if (place.composer) main.push({ id: 'attach-here', ...mailNote(place) });
if (!place.popout) main.push({ id: 'new-message', ...mailNote(place) });
if (!place.popout && place.files) {
const note: FileDropNote | undefined = place.maxUploadBytes > 0 ? { key: 'note_limit_file', size: place.maxUploadBytes } : undefined;
main.push(serverOnly(place, { id: 'upload', ...(place.filesFolder ? { folder: place.filesFolder } : {}), ...(note ? { note } : {}) }));
}
// What this kind of file is for, best first. Not in a pop-out: each opens on its own page.
const special: FileDropZone[] = [];
// Pictures into the text, as pasting them does: also in a pop-out, it's that composer's own.
if (kind === 'image' && place.composer && place.inlineImages) special.push({ id: 'insert-inline' });
if (!place.popout) {
if (kind === 'eml' && one) special.push({ id: 'open-eml' });
if (kind === 'eml' && place.importFolder) special.push(serverOnly(place, { id: 'import-eml', folder: place.importFolder }));
if (kind === 'calendar' && one && place.calendar) special.push({ id: 'calendar' });
if (kind === 'contacts' && one && place.contacts) special.push({ id: 'contacts' });
}
const room = Math.max(0, MAX_ZONES - main.length);
const kept = special.slice(0, room);
const attachHere = main.filter((zone) => zone.id === 'attach-here');
const rest = main.filter((zone) => zone.id !== 'attach-here');
return [...attachHere, ...kept, ...rest];
}
/** What the files may still do, once they're in hand: those too big for the zone stay out. */
export function filesWithinLimits(
zone: FileDropZoneId,
files: readonly File[],
place: Pick<DropPlace, 'maxUploadBytes' | 'maxMessageBytes'>,
): { fitting: File[]; tooLarge: File[]; overMessage: boolean } {
const perFile = place.maxUploadBytes;
const fitting = perFile > 0 ? files.filter((file) => file.size <= perFile) : [...files];
const tooLarge = files.filter((file) => !fitting.includes(file));
const mail = zone === 'attach-here' || zone === 'new-message' || zone === 'insert-inline';
const total = fitting.reduce((sum, file) => sum + file.size, 0);
const overMessage = mail && place.maxMessageBytes > 0 && total > place.maxMessageBytes;
return { fitting: overMessage ? [] : fitting, tooLarge, overMessage };
}
/** The zone a keyboard user lands on: the asked-for one if it's there and usable, else the first usable. */
export function defaultZone(zones: readonly FileDropZone[], preferred?: FileDropZoneId): FileDropZone | undefined {
const usable = zones.filter((zone) => !zone.disabled);
return usable.find((zone) => zone.id === preferred) ?? usable[0];
}
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "تكتب هذه المفاتيح حرفًا على لوحة مفاتيحك، وسيتوقف عن العمل في كل التطبيقات. جرّب مفاتيح أخرى.",
"reserved": "يستخدم النظام أو معظم التطبيقات هذه المفاتيح. جرّب مفاتيح أخرى."
},
"desktopDevTools": {
"section_title": "استكشاف الأخطاء وإصلاحها",
"label": "أدوات المطورين ({shortcut})",
"description": "يتيح لـ {shortcut} وF12 فتح أدوات المطورين للنافذة التي أنت فيها. يتوقف مرة أخرى عند قفل التطبيق.",
"forced_off": "أوقفته مؤسستك.",
"locked": "ألغِ قفل التطبيق لتغيير هذا.",
"menu_toggle": "إظهار أدوات المطورين أو إخفاؤها"
},
"fileDrop": {
"overlay_label": "أفلت الملفات",
"overlay_title": "إلى أين تذهب الملفات؟",
"overlay_hint": "أفلت خارج الخيارات أو اضغط Esc للإلغاء.",
"files_unknown": "ملفات",
"files_summary": "{count, plural, zero {لا ملفات} one {ملف واحد} two {ملفان} few {# ملفات} many {# ملفًا} other {# ملف}}",
"files_summary_size": "{count, plural, zero {لا ملفات} one {ملف واحد} two {ملفان} few {# ملفات} many {# ملفًا} other {# ملف}}, {size}",
"zone_attach_here": "إرفاق بهذه الرسالة",
"zone_insert_inline": "إدراج في نص الرسالة",
"zone_new_message": "إرفاق برسالة جديدة",
"zone_upload_files": "رفع إلى الملفات",
"zone_upload_folder": "رفع إلى {folder}",
"zone_calendar": "إضافة إلى التقويم",
"zone_contacts": "إضافة إلى جهات الاتصال",
"zone_open_eml": "فتح الرسالة",
"zone_import_eml": "استيراد إلى {folder}",
"note_limit_message": "حتى {size} لكل رسالة",
"note_limit_file": "حتى {size} لكل ملف",
"note_kept_offline": "تبقى على هذا الجهاز حتى تعود متصلًا",
"note_needs_connection": "يحتاج إلى اتصال",
"announce": "{files, plural, =0 {ملفات مسحوبة} zero {لا ملفات مسحوبة} one {ملف واحد مسحوب} two {ملفان مسحوبان} few {# ملفات مسحوبة} many {# ملفًا مسحوبًا} other {# ملف مسحوب}}. الخيارات: {choices}.",
"choose_title": "{count, plural, zero {إلى أين تذهب هذه الملفات؟} one {إلى أين يذهب هذا الملف؟} two {إلى أين يذهب هذان الملفان؟} few {إلى أين تذهب هذه الملفات؟} many {إلى أين تذهب هذه الملفات؟} other {إلى أين تذهب هذه الملفات؟}}",
"save_first_failed": "تعذّر حفظ الرسالة المفتوحة، لذا بقي التطبيق حيث كان.",
"import_done": "{count, plural, zero {لم تُستورد أي رسالة إلى {folder}.} one {استُوردت رسالة واحدة إلى {folder}.} two {استُوردت رسالتان إلى {folder}.} few {استُوردت # رسائل إلى {folder}.} many {استُوردت # رسالة إلى {folder}.} other {استُوردت # رسالة إلى {folder}.}}",
"import_failed": "تعذّر استيراد {name}.",
"too_large_file": "{name} كبير جدًا. الحد هو {max} لكل ملف.",
"too_large_message": "هذه الملفات كبيرة جدًا لرسالة واحدة. الحد هو {max}.",
"folders_left_out": "يمكن أن تذهب المجلدات إلى الملفات فقط، لذا تُركت.",
"nothing_here": "لا يمكن استخدام هذه الملفات هنا."
},
"desktopReminders": {
"generic_title": "تذكير",
"snooze": "تأجيل {count} د",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "Aquestes tecles escriuen un caràcter al teu teclat, i deixaria de funcionar a totes les aplicacions. Prova altres tecles.",
"reserved": "El sistema o la majoria d'aplicacions fan servir aquestes tecles. Prova altres tecles."
},
"desktopDevTools": {
"section_title": "Resolució de problemes",
"label": "Eines per a desenvolupadors ({shortcut})",
"description": "Permet que {shortcut} i F12 obrin les eines per a desenvolupadors a la finestra on ets. Es desactiva quan l'app es bloqueja.",
"forced_off": "Desactivat per la teva organització.",
"locked": "Desbloqueja l'app per canviar-ho.",
"menu_toggle": "Mostra o amaga les eines per a desenvolupadors"
},
"fileDrop": {
"overlay_label": "Deixa anar fitxers",
"overlay_title": "On han d'anar els fitxers?",
"overlay_hint": "Deixa anar fora de les opcions o prem Esc per cancel·lar.",
"files_unknown": "Fitxers",
"files_summary": "{count, plural, one {# fitxer} other {# fitxers}}",
"files_summary_size": "{count, plural, one {# fitxer} other {# fitxers}}, {size}",
"zone_attach_here": "Adjunta a aquest missatge",
"zone_insert_inline": "Insereix al missatge",
"zone_new_message": "Adjunta a un missatge nou",
"zone_upload_files": "Puja a Fitxers",
"zone_upload_folder": "Puja a {folder}",
"zone_calendar": "Afegeix al calendari",
"zone_contacts": "Afegeix als contactes",
"zone_open_eml": "Obre el missatge",
"zone_import_eml": "Importa a {folder}",
"note_limit_message": "Fins a {size} per missatge",
"note_limit_file": "Fins a {size} per fitxer",
"note_kept_offline": "Es queda en aquest dispositiu fins que tornis a tenir connexió",
"note_needs_connection": "Necessita connexió",
"announce": "{files, plural, =0 {Fitxers arrossegats} one {# fitxer arrossegat} other {# fitxers arrossegats}}. Opcions: {choices}.",
"choose_title": "{count, plural, one {On ha d'anar aquest fitxer?} other {On han d'anar aquests fitxers?}}",
"save_first_failed": "No s'ha pogut desar el missatge obert, així que l'app s'ha quedat on era.",
"import_done": "{count, plural, one {# missatge importat a {folder}.} other {# missatges importats a {folder}.}}",
"import_failed": "No s'ha pogut importar {name}.",
"too_large_file": "{name} és massa gran. El límit és {max} per fitxer.",
"too_large_message": "Aquests fitxers són massa grans per a un missatge. El límit és {max}.",
"folders_left_out": "Les carpetes només poden anar a Fitxers, així que s'han deixat fora.",
"nothing_here": "Aquests fitxers no es poden fer servir aquí."
},
"desktopReminders": {
"generic_title": "Recordatori",
"snooze": "Posposa {count} min",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "Tyto klávesy na vaší klávesnici píší znak, který by pak nefungoval v žádné aplikaci. Zkuste jiné klávesy.",
"reserved": "Tyto klávesy používá systém nebo většina aplikací. Zkuste jiné klávesy."
},
"desktopDevTools": {
"section_title": "Řešení potíží",
"label": "Nástroje pro vývojáře ({shortcut})",
"description": "Umožní, aby {shortcut} a F12 otevřely nástroje pro vývojáře v okně, kde jste. Vypne se, když se aplikace zamkne.",
"forced_off": "Vypnuto vaší organizací.",
"locked": "Pro změnu aplikaci odemkněte.",
"menu_toggle": "Zobrazit nebo skrýt nástroje pro vývojáře"
},
"fileDrop": {
"overlay_label": "Pustit soubory",
"overlay_title": "Kam mají soubory jít?",
"overlay_hint": "Pro zrušení pusťte mimo volby nebo stiskněte Esc.",
"files_unknown": "Soubory",
"files_summary": "{count, plural, one {# soubor} few {# soubory} many {# souboru} other {# souborů}}",
"files_summary_size": "{count, plural, one {# soubor} few {# soubory} many {# souboru} other {# souborů}}, {size}",
"zone_attach_here": "Připojit k této zprávě",
"zone_insert_inline": "Vložit do zprávy",
"zone_new_message": "Připojit k nové zprávě",
"zone_upload_files": "Nahrát do Souborů",
"zone_upload_folder": "Nahrát do {folder}",
"zone_calendar": "Přidat do kalendáře",
"zone_contacts": "Přidat do kontaktů",
"zone_open_eml": "Otevřít zprávu",
"zone_import_eml": "Importovat do {folder}",
"note_limit_message": "Až {size} na zprávu",
"note_limit_file": "Až {size} na soubor",
"note_kept_offline": "Zůstane v tomto zařízení, dokud nebudete online",
"note_needs_connection": "Vyžaduje připojení",
"announce": "{files, plural, =0 {Přetaženy soubory} one {Přetažen # soubor} few {Přetaženy # soubory} many {Přetaženo # souboru} other {Přetaženo # souborů}}. Volby: {choices}.",
"choose_title": "{count, plural, one {Kam má tento soubor jít?} few {Kam mají tyto soubory jít?} many {Kam mají tyto soubory jít?} other {Kam mají tyto soubory jít?}}",
"save_first_failed": "Otevřenou zprávu se nepodařilo uložit, proto aplikace zůstala, kde byla.",
"import_done": "{count, plural, one {# zpráva importována do {folder}.} few {# zprávy importovány do {folder}.} many {# zprávy importováno do {folder}.} other {# zpráv importováno do {folder}.}}",
"import_failed": "{name} se nepodařilo importovat.",
"too_large_file": "{name} je příliš velký. Limit je {max} na soubor.",
"too_large_message": "Tyto soubory jsou na jednu zprávu příliš velké. Limit je {max}.",
"folders_left_out": "Složky mohou jít jen do Souborů, proto byly vynechány.",
"nothing_here": "Tyto soubory zde nelze použít."
},
"desktopReminders": {
"generic_title": "Připomenutí",
"snooze": "Odložit o {count} min",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "Disse taster skriver et tegn på dit tastatur, og det ville holde op med at virke i alle apps. Prøv andre taster.",
"reserved": "Dit system eller de fleste apps bruger disse taster. Prøv andre taster."
},
"desktopDevTools": {
"section_title": "Fejlfinding",
"label": "Udviklerværktøjer ({shortcut})",
"description": "Lader {shortcut} og F12 åbne udviklerværktøjerne for det vindue, du er i. Slås fra igen, når appen låses.",
"forced_off": "Slået fra af din organisation.",
"locked": "Lås appen op for at ændre dette.",
"menu_toggle": "Vis eller skjul udviklerværktøjer"
},
"fileDrop": {
"overlay_label": "Slip filer",
"overlay_title": "Hvor skal filerne hen?",
"overlay_hint": "Slip uden for valgene, eller tryk på Esc for at annullere.",
"files_unknown": "Filer",
"files_summary": "{count, plural, one {# fil} other {# filer}}",
"files_summary_size": "{count, plural, one {# fil} other {# filer}}, {size}",
"zone_attach_here": "Vedhæft til denne besked",
"zone_insert_inline": "Indsæt i beskeden",
"zone_new_message": "Vedhæft til en ny besked",
"zone_upload_files": "Upload til Filer",
"zone_upload_folder": "Upload til {folder}",
"zone_calendar": "Føj til kalender",
"zone_contacts": "Føj til kontakter",
"zone_open_eml": "Åbn besked",
"zone_import_eml": "Importér til {folder}",
"note_limit_message": "Op til {size} pr. besked",
"note_limit_file": "Op til {size} pr. fil",
"note_kept_offline": "Bliver på denne enhed, til du er online igen",
"note_needs_connection": "Kræver forbindelse",
"announce": "{files, plural, =0 {Filer} one {# fil} other {# filer}} trukket ind. Valg: {choices}.",
"choose_title": "{count, plural, one {Hvor skal denne fil hen?} other {Hvor skal disse filer hen?}}",
"save_first_failed": "Den åbne besked kunne ikke gemmes, så appen blev, hvor den var.",
"import_done": "{count, plural, one {# besked importeret til {folder}.} other {# beskeder importeret til {folder}.}}",
"import_failed": "{name} kunne ikke importeres.",
"too_large_file": "{name} er for stor. Grænsen er {max} pr. fil.",
"too_large_message": "Disse filer er for store til én besked. Grænsen er {max}.",
"folders_left_out": "Mapper kan kun komme i Filer, så de blev udeladt.",
"nothing_here": "Disse filer kan ikke bruges her."
},
"desktopReminders": {
"generic_title": "Påmindelse",
"snooze": "Udsæt {count} min.",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "Diese Tasten erzeugen auf Ihrer Tastatur ein Zeichen, das dann in keiner App mehr funktionieren würde. Versuchen Sie andere Tasten.",
"reserved": "Ihr System oder die meisten Apps verwenden diese Tasten. Versuchen Sie andere Tasten."
},
"desktopDevTools": {
"section_title": "Fehlerbehebung",
"label": "Entwicklertools ({shortcut})",
"description": "Mit {shortcut} und F12 öffnen sich die Entwicklertools für das Fenster, in dem Sie sind. Schaltet sich wieder aus, wenn die App gesperrt wird.",
"forced_off": "Von Ihrer Organisation ausgeschaltet.",
"locked": "Entsperren Sie die App, um das zu ändern.",
"menu_toggle": "Entwicklertools ein- oder ausblenden"
},
"fileDrop": {
"overlay_label": "Dateien ablegen",
"overlay_title": "Wohin sollen die Dateien?",
"overlay_hint": "Zum Abbrechen außerhalb der Felder ablegen oder Esc drücken.",
"files_unknown": "Dateien",
"files_summary": "{count, plural, one {# Datei} other {# Dateien}}",
"files_summary_size": "{count, plural, one {# Datei} other {# Dateien}}, {size}",
"zone_attach_here": "An diese Nachricht anhängen",
"zone_insert_inline": "In die Nachricht einfügen",
"zone_new_message": "An neue Nachricht anhängen",
"zone_upload_files": "In Dateien hochladen",
"zone_upload_folder": "In {folder} hochladen",
"zone_calendar": "Zum Kalender hinzufügen",
"zone_contacts": "Zu Kontakten hinzufügen",
"zone_open_eml": "Nachricht öffnen",
"zone_import_eml": "In {folder} importieren",
"note_limit_message": "Bis zu {size} pro Nachricht",
"note_limit_file": "Bis zu {size} pro Datei",
"note_kept_offline": "Bleibt auf diesem Gerät, bis Sie wieder online sind",
"note_needs_connection": "Braucht eine Verbindung",
"announce": "{files, plural, =0 {Dateien} one {# Datei} other {# Dateien}} hereingezogen. Möglichkeiten: {choices}.",
"choose_title": "{count, plural, one {Wohin soll diese Datei?} other {Wohin sollen diese Dateien?}}",
"save_first_failed": "Die offene Nachricht konnte nicht gespeichert werden, daher bleibt die App, wo sie war.",
"import_done": "{count, plural, one {# Nachricht in {folder} importiert.} other {# Nachrichten in {folder} importiert.}}",
"import_failed": "{name} konnte nicht importiert werden.",
"too_large_file": "{name} ist zu groß. Die Grenze liegt bei {max} pro Datei.",
"too_large_message": "Diese Dateien sind zusammen zu groß für eine Nachricht. Die Grenze liegt bei {max}.",
"folders_left_out": "Ordner können nur in Dateien, daher wurden sie weggelassen.",
"nothing_here": "Diese Dateien lassen sich hier nicht verwenden."
},
"desktopReminders": {
"generic_title": "Erinnerung",
"snooze": "{count} Min. schlummern",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "These keys type a character on your keyboard, and it would stop working in every app. Try other keys.",
"reserved": "Your system or most apps use these keys. Try other keys."
},
"desktopDevTools": {
"section_title": "Troubleshooting",
"label": "Developer tools ({shortcut})",
"description": "Lets {shortcut} and F12 open developer tools for the window you are in. Turns off again when the app locks.",
"forced_off": "Turned off by your organisation.",
"locked": "Unlock the app to change this.",
"menu_toggle": "Toggle developer tools"
},
"fileDrop": {
"overlay_label": "Drop files",
"overlay_title": "Where should the files go?",
"overlay_hint": "Drop outside the choices or press Esc to cancel.",
"files_unknown": "Files",
"files_summary": "{count, plural, one {# file} other {# files}}",
"files_summary_size": "{count, plural, one {# file} other {# files}}, {size}",
"zone_attach_here": "Attach to this message",
"zone_insert_inline": "Insert into the message",
"zone_new_message": "Attach to a new message",
"zone_upload_files": "Upload to Files",
"zone_upload_folder": "Upload to {folder}",
"zone_calendar": "Add to calendar",
"zone_contacts": "Add to contacts",
"zone_open_eml": "Open message",
"zone_import_eml": "Import into {folder}",
"note_limit_message": "Up to {size} per message",
"note_limit_file": "Up to {size} per file",
"note_kept_offline": "Kept on this device until you're online",
"note_needs_connection": "Needs a connection",
"announce": "{files, plural, =0 {Files} one {# file} other {# files}} dragged in. Choices: {choices}.",
"choose_title": "{count, plural, one {Where should this file go?} other {Where should these files go?}}",
"save_first_failed": "The open message could not be saved, so the app stayed where it was.",
"import_done": "{count, plural, one {# message imported into {folder}.} other {# messages imported into {folder}.}}",
"import_failed": "{name} could not be imported.",
"too_large_file": "{name} is too large. The limit is {max} per file.",
"too_large_message": "These files are too large for one message. The limit is {max}.",
"folders_left_out": "Folders can only go to Files, so they were left out.",
"nothing_here": "These files can't be used here."
},
"desktopReminders": {
"generic_title": "Reminder",
"snooze": "Snooze {count} min",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "Estas teclas escriben un carácter en tu teclado, y dejaría de funcionar en todas las aplicaciones. Prueba otras teclas.",
"reserved": "Tu sistema o la mayoría de las aplicaciones usan estas teclas. Prueba otras teclas."
},
"desktopDevTools": {
"section_title": "Solución de problemas",
"label": "Herramientas para desarrolladores ({shortcut})",
"description": "Permite que {shortcut} y F12 abran las herramientas para desarrolladores en la ventana en la que estás. Se desactiva al bloquearse la app.",
"forced_off": "Desactivado por tu organización.",
"locked": "Desbloquea la app para cambiar esto.",
"menu_toggle": "Mostrar u ocultar herramientas para desarrolladores"
},
"fileDrop": {
"overlay_label": "Soltar archivos",
"overlay_title": "¿Dónde van los archivos?",
"overlay_hint": "Suelta fuera de las opciones o pulsa Esc para cancelar.",
"files_unknown": "Archivos",
"files_summary": "{count, plural, one {# archivo} other {# archivos}}",
"files_summary_size": "{count, plural, one {# archivo} other {# archivos}}, {size}",
"zone_attach_here": "Adjuntar a este mensaje",
"zone_insert_inline": "Insertar en el mensaje",
"zone_new_message": "Adjuntar a un mensaje nuevo",
"zone_upload_files": "Subir a Archivos",
"zone_upload_folder": "Subir a {folder}",
"zone_calendar": "Añadir al calendario",
"zone_contacts": "Añadir a contactos",
"zone_open_eml": "Abrir mensaje",
"zone_import_eml": "Importar en {folder}",
"note_limit_message": "Hasta {size} por mensaje",
"note_limit_file": "Hasta {size} por archivo",
"note_kept_offline": "Se guarda en este dispositivo hasta que vuelvas a tener conexión",
"note_needs_connection": "Necesita conexión",
"announce": "{files, plural, =0 {Archivos arrastrados} one {# archivo arrastrado} other {# archivos arrastrados}}. Opciones: {choices}.",
"choose_title": "{count, plural, one {¿Dónde va este archivo?} other {¿Dónde van estos archivos?}}",
"save_first_failed": "No se pudo guardar el mensaje abierto, así que la app se quedó donde estaba.",
"import_done": "{count, plural, one {# mensaje importado en {folder}.} other {# mensajes importados en {folder}.}}",
"import_failed": "No se pudo importar {name}.",
"too_large_file": "{name} es demasiado grande. El límite es {max} por archivo.",
"too_large_message": "Estos archivos son demasiado grandes para un mensaje. El límite es {max}.",
"folders_left_out": "Las carpetas solo pueden ir a Archivos, así que se han dejado fuera.",
"nothing_here": "Estos archivos no se pueden usar aquí."
},
"desktopReminders": {
"generic_title": "Recordatorio",
"snooze": "Posponer {count} min",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "Need klahvid kirjutavad teie klaviatuuril märgi, mis lakkaks kõigis rakendustes töötamast. Proovige teisi klahve.",
"reserved": "Neid klahve kasutab süsteem või enamik rakendusi. Proovige teisi klahve."
},
"desktopDevTools": {
"section_title": "Tõrkeotsing",
"label": "Arendaja tööriistad ({shortcut})",
"description": "Laseb klahvidel {shortcut} ja F12 avada arendaja tööriistad aknas, kus sa oled. Lülitub välja, kui rakendus lukustub.",
"forced_off": "Teie organisatsioon on selle välja lülitanud.",
"locked": "Selle muutmiseks ava rakenduse lukk.",
"menu_toggle": "Näita või peida arendaja tööriistad"
},
"fileDrop": {
"overlay_label": "Lohista failid siia",
"overlay_title": "Kuhu failid lähevad?",
"overlay_hint": "Tühistamiseks lase lahti väljaspool valikuid või vajuta Esc.",
"files_unknown": "Failid",
"files_summary": "{count, plural, one {# fail} other {# faili}}",
"files_summary_size": "{count, plural, one {# fail} other {# faili}}, {size}",
"zone_attach_here": "Lisa sellele kirjale",
"zone_insert_inline": "Lisa kirja sisse",
"zone_new_message": "Lisa uuele kirjale",
"zone_upload_files": "Laadi üles kausta Failid",
"zone_upload_folder": "Laadi üles kausta {folder}",
"zone_calendar": "Lisa kalendrisse",
"zone_contacts": "Lisa kontaktidesse",
"zone_open_eml": "Ava kiri",
"zone_import_eml": "Impordi kausta {folder}",
"note_limit_message": "Kuni {size} kirja kohta",
"note_limit_file": "Kuni {size} faili kohta",
"note_kept_offline": "Jääb sellesse seadmesse, kuni oled taas võrgus",
"note_needs_connection": "Vajab ühendust",
"announce": "{files, plural, =0 {Failid} one {# fail} other {# faili}} lohistati sisse. Valikud: {choices}.",
"choose_title": "{count, plural, one {Kuhu see fail läheb?} other {Kuhu need failid lähevad?}}",
"save_first_failed": "Avatud kirja ei saanud salvestada, seega jäi rakendus sinna, kus ta oli.",
"import_done": "{count, plural, one {# kiri imporditi kausta {folder}.} other {# kirja imporditi kausta {folder}.}}",
"import_failed": "Faili {name} ei saanud importida.",
"too_large_file": "{name} on liiga suur. Piir on {max} faili kohta.",
"too_large_message": "Need failid on ühe kirja jaoks liiga suured. Piir on {max}.",
"folders_left_out": "Kaustu saab panna ainult kausta Failid, seega jäeti need välja.",
"nothing_here": "Neid faile ei saa siin kasutada."
},
"desktopReminders": {
"generic_title": "Meeldetuletus",
"snooze": "Lükka {count} min edasi",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "این کلیدها روی صفحه‌کلید شما نویسه‌ای می‌نویسند و آن نویسه دیگر در هیچ برنامه‌ای کار نمی‌کند. کلیدهای دیگری را امتحان کنید.",
"reserved": "سیستم یا بیشتر برنامه‌ها از این کلیدها استفاده می‌کنند. کلیدهای دیگری را امتحان کنید."
},
"desktopDevTools": {
"section_title": "عیب‌یابی",
"label": "ابزارهای توسعه‌دهنده ({shortcut})",
"description": "با {shortcut} و F12 ابزارهای توسعه‌دهنده برای پنجره‌ای که در آن هستید باز می‌شود. وقتی برنامه قفل شود دوباره خاموش می‌شود.",
"forced_off": "سازمان شما این را خاموش کرده است.",
"locked": "برای تغییر این، قفل برنامه را باز کنید.",
"menu_toggle": "نمایش یا پنهان کردن ابزارهای توسعه‌دهنده"
},
"fileDrop": {
"overlay_label": "رها کردن فایل‌ها",
"overlay_title": "فایل‌ها کجا بروند؟",
"overlay_hint": "برای لغو، بیرون از گزینه‌ها رها کنید یا Esc را بزنید.",
"files_unknown": "فایل‌ها",
"files_summary": "{count, plural, one {# فایل} other {# فایل}}",
"files_summary_size": "{count, plural, one {# فایل} other {# فایل}}, {size}",
"zone_attach_here": "پیوست به این پیام",
"zone_insert_inline": "درج در متن پیام",
"zone_new_message": "پیوست به پیام جدید",
"zone_upload_files": "بارگذاری در فایل‌ها",
"zone_upload_folder": "بارگذاری در {folder}",
"zone_calendar": "افزودن به تقویم",
"zone_contacts": "افزودن به مخاطبان",
"zone_open_eml": "باز کردن پیام",
"zone_import_eml": "وارد کردن به {folder}",
"note_limit_message": "تا {size} برای هر پیام",
"note_limit_file": "تا {size} برای هر فایل",
"note_kept_offline": "تا وقتی دوباره آنلاین شوید روی این دستگاه می‌ماند",
"note_needs_connection": "به اتصال نیاز دارد",
"announce": "{files, plural, =0 {فایل‌ها} one {# فایل} other {# فایل}} کشیده شد. گزینه‌ها: {choices}.",
"choose_title": "{count, plural, one {این فایل کجا برود؟} other {این فایل‌ها کجا بروند؟}}",
"save_first_failed": "پیام باز ذخیره نشد، پس برنامه همان‌جا ماند.",
"import_done": "{count, plural, one {# پیام به {folder} وارد شد.} other {# پیام به {folder} وارد شد.}}",
"import_failed": "{name} وارد نشد.",
"too_large_file": "{name} خیلی بزرگ است. حد آن {max} برای هر فایل است.",
"too_large_message": "این فایل‌ها برای یک پیام خیلی بزرگ‌اند. حد آن {max} است.",
"folders_left_out": "پوشه‌ها فقط می‌توانند به فایل‌ها بروند، پس کنار گذاشته شدند.",
"nothing_here": "این فایل‌ها اینجا قابل استفاده نیستند."
},
"desktopReminders": {
"generic_title": "یادآوری",
"snooze": "تعویق {count} دقیقه",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "Ces touches produisent un caractère sur votre clavier, et il ne fonctionnerait plus dans aucune application. Essayez d'autres touches.",
"reserved": "Votre système ou la plupart des applications utilisent ces touches. Essayez d'autres touches."
},
"desktopDevTools": {
"section_title": "Dépannage",
"label": "Outils de développement ({shortcut})",
"description": "{shortcut} et F12 ouvrent les outils de développement pour la fenêtre où vous êtes. Se désactive quand l'app se verrouille.",
"forced_off": "Désactivé par votre organisation.",
"locked": "Déverrouillez l'app pour modifier ce réglage.",
"menu_toggle": "Afficher ou masquer les outils de développement"
},
"fileDrop": {
"overlay_label": "Déposer des fichiers",
"overlay_title": "Où mettre les fichiers ?",
"overlay_hint": "Déposez hors des choix ou appuyez sur Échap pour annuler.",
"files_unknown": "Fichiers",
"files_summary": "{count, plural, one {# fichier} other {# fichiers}}",
"files_summary_size": "{count, plural, one {# fichier} other {# fichiers}}, {size}",
"zone_attach_here": "Joindre à ce message",
"zone_insert_inline": "Insérer dans le message",
"zone_new_message": "Joindre à un nouveau message",
"zone_upload_files": "Envoyer dans Fichiers",
"zone_upload_folder": "Envoyer dans {folder}",
"zone_calendar": "Ajouter au calendrier",
"zone_contacts": "Ajouter aux contacts",
"zone_open_eml": "Ouvrir le message",
"zone_import_eml": "Importer dans {folder}",
"note_limit_message": "Jusqu'à {size} par message",
"note_limit_file": "Jusqu'à {size} par fichier",
"note_kept_offline": "Gardé sur cet appareil jusqu'au retour de la connexion",
"note_needs_connection": "Nécessite une connexion",
"announce": "{files, plural, =0 {Fichiers glissés} one {# fichier glissé} other {# fichiers glissés}}. Choix : {choices}.",
"choose_title": "{count, plural, one {Où mettre ce fichier ?} other {Où mettre ces fichiers ?}}",
"save_first_failed": "Le message ouvert n'a pas pu être enregistré, l'app est donc restée où elle était.",
"import_done": "{count, plural, one {# message importé dans {folder}.} other {# messages importés dans {folder}.}}",
"import_failed": "{name} n'a pas pu être importé.",
"too_large_file": "{name} est trop volumineux. La limite est de {max} par fichier.",
"too_large_message": "Ces fichiers sont trop volumineux pour un seul message. La limite est de {max}.",
"folders_left_out": "Les dossiers ne peuvent aller que dans Fichiers, ils ont donc été laissés de côté.",
"nothing_here": "Ces fichiers ne peuvent pas être utilisés ici."
},
"desktopReminders": {
"generic_title": "Rappel",
"snooze": "Reporter de {count} min",
+38
View File
@@ -7107,6 +7107,44 @@
"types_character": "המקשים האלה מקלידים תו במקלדת שלכם, והוא יפסיק לעבוד בכל האפליקציות. נסו מקשים אחרים.",
"reserved": "המערכת או רוב האפליקציות משתמשות במקשים האלה. נסו מקשים אחרים."
},
"desktopDevTools": {
"section_title": "פתרון בעיות",
"label": "כלים למפתחים ({shortcut})",
"description": "מאפשר ל־{shortcut} ול־F12 לפתוח את הכלים למפתחים בחלון שבו אתם נמצאים. נכבה שוב כשהאפליקציה ננעלת.",
"forced_off": "הארגון שלכם כיבה את זה.",
"locked": "בטלו את נעילת האפליקציה כדי לשנות זאת.",
"menu_toggle": "הצגה או הסתרה של הכלים למפתחים"
},
"fileDrop": {
"overlay_label": "שחררו קבצים",
"overlay_title": "לאן הקבצים ילכו?",
"overlay_hint": "כדי לבטל, שחררו מחוץ לאפשרויות או הקישו Esc.",
"files_unknown": "קבצים",
"files_summary": "{count, plural, one {קובץ אחד} two {שני קבצים} other {# קבצים}}",
"files_summary_size": "{count, plural, one {קובץ אחד} two {שני קבצים} other {# קבצים}}, {size}",
"zone_attach_here": "צירוף להודעה הזו",
"zone_insert_inline": "הוספה לגוף ההודעה",
"zone_new_message": "צירוף להודעה חדשה",
"zone_upload_files": "העלאה לקבצים",
"zone_upload_folder": "העלאה אל {folder}",
"zone_calendar": "הוספה ללוח השנה",
"zone_contacts": "הוספה לאנשי הקשר",
"zone_open_eml": "פתיחת ההודעה",
"zone_import_eml": "ייבוא אל {folder}",
"note_limit_message": "עד {size} להודעה",
"note_limit_file": "עד {size} לקובץ",
"note_kept_offline": "נשמר במכשיר הזה עד שתחזרו לרשת",
"note_needs_connection": "נדרש חיבור",
"announce": "{files, plural, =0 {קבצים נגררו} one {קובץ אחד נגרר} two {שני קבצים נגררו} other {# קבצים נגררו}}. אפשרויות: {choices}.",
"choose_title": "{count, plural, one {לאן הקובץ הזה ילך?} two {לאן הקבצים האלה ילכו?} other {לאן הקבצים האלה ילכו?}}",
"save_first_failed": "לא ניתן היה לשמור את ההודעה הפתוחה, לכן האפליקציה נשארה במקומה.",
"import_done": "{count, plural, one {הודעה אחת יובאה אל {folder}.} two {שתי הודעות יובאו אל {folder}.} other {# הודעות יובאו אל {folder}.}}",
"import_failed": "לא ניתן היה לייבא את {name}.",
"too_large_file": "{name} גדול מדי. המגבלה היא {max} לקובץ.",
"too_large_message": "הקבצים האלה גדולים מדי להודעה אחת. המגבלה היא {max}.",
"folders_left_out": "תיקיות יכולות ללכת רק לקבצים, לכן הן הושמטו.",
"nothing_here": "אי אפשר להשתמש בקבצים האלה כאן."
},
"desktopReminders": {
"generic_title": "תזכורת",
"snooze": "דחייה ב-{count} דק'",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "Ezek a billentyűk karaktert írnak az Ön billentyűzetén, amely többé egyik alkalmazásban sem működne. Próbáljon más billentyűket.",
"reserved": "Ezeket a billentyűket a rendszer vagy a legtöbb alkalmazás használja. Próbáljon más billentyűket."
},
"desktopDevTools": {
"section_title": "Hibaelhárítás",
"label": "Fejlesztői eszközök ({shortcut})",
"description": "A(z) {shortcut} és az F12 megnyitja a fejlesztői eszközöket abban az ablakban, ahol éppen van. Kikapcsol, amikor az alkalmazás zárolódik.",
"forced_off": "A szervezete kikapcsolta.",
"locked": "A módosításhoz oldja fel az alkalmazást.",
"menu_toggle": "Fejlesztői eszközök megjelenítése vagy elrejtése"
},
"fileDrop": {
"overlay_label": "Fájlok ejtése",
"overlay_title": "Hová kerüljenek a fájlok?",
"overlay_hint": "A megszakításhoz engedje el a lehetőségeken kívül, vagy nyomja meg az Esc billentyűt.",
"files_unknown": "Fájlok",
"files_summary": "{count, plural, one {# fájl} other {# fájl}}",
"files_summary_size": "{count, plural, one {# fájl} other {# fájl}}, {size}",
"zone_attach_here": "Csatolás ehhez az üzenethez",
"zone_insert_inline": "Beszúrás az üzenetbe",
"zone_new_message": "Csatolás új üzenethez",
"zone_upload_files": "Feltöltés a Fájlokba",
"zone_upload_folder": "Feltöltés ide: {folder}",
"zone_calendar": "Hozzáadás a naptárhoz",
"zone_contacts": "Hozzáadás a névjegyekhez",
"zone_open_eml": "Üzenet megnyitása",
"zone_import_eml": "Importálás ide: {folder}",
"note_limit_message": "Legfeljebb {size} üzenetenként",
"note_limit_file": "Legfeljebb {size} fájlonként",
"note_kept_offline": "Ezen az eszközön marad, amíg újra online nem lesz",
"note_needs_connection": "Kapcsolat szükséges",
"announce": "{files, plural, =0 {Fájlok} one {# fájl} other {# fájl}} behúzva. Lehetőségek: {choices}.",
"choose_title": "{count, plural, one {Hová kerüljön ez a fájl?} other {Hová kerüljenek ezek a fájlok?}}",
"save_first_failed": "A megnyitott üzenetet nem sikerült menteni, ezért az alkalmazás ott maradt, ahol volt.",
"import_done": "{count, plural, one {# üzenet importálva ide: {folder}.} other {# üzenet importálva ide: {folder}.}}",
"import_failed": "{name} nem importálható.",
"too_large_file": "{name} túl nagy. A korlát {max} fájlonként.",
"too_large_message": "Ezek a fájlok együtt túl nagyok egy üzenethez. A korlát {max}.",
"folders_left_out": "Mappák csak a Fájlokba kerülhetnek, ezért kimaradtak.",
"nothing_here": "Ezek a fájlok itt nem használhatók."
},
"desktopReminders": {
"generic_title": "Emlékeztető",
"snooze": "Halasztás {count} perccel",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "Questi tasti scrivono un carattere sulla tua tastiera, che smetterebbe di funzionare in tutte le app. Prova altri tasti.",
"reserved": "Il sistema o la maggior parte delle app usano questi tasti. Prova altri tasti."
},
"desktopDevTools": {
"section_title": "Risoluzione dei problemi",
"label": "Strumenti per sviluppatori ({shortcut})",
"description": "{shortcut} e F12 aprono gli strumenti per sviluppatori nella finestra in cui ti trovi. Si disattiva quando l'app si blocca.",
"forced_off": "Disattivato dalla tua organizzazione.",
"locked": "Sblocca l'app per modificare questa opzione.",
"menu_toggle": "Mostra o nascondi strumenti per sviluppatori"
},
"fileDrop": {
"overlay_label": "Rilascia file",
"overlay_title": "Dove vanno i file?",
"overlay_hint": "Rilascia fuori dalle opzioni o premi Esc per annullare.",
"files_unknown": "File",
"files_summary": "{count, plural, one {# file} other {# file}}",
"files_summary_size": "{count, plural, one {# file} other {# file}}, {size}",
"zone_attach_here": "Allega a questo messaggio",
"zone_insert_inline": "Inserisci nel messaggio",
"zone_new_message": "Allega a un nuovo messaggio",
"zone_upload_files": "Carica in File",
"zone_upload_folder": "Carica in {folder}",
"zone_calendar": "Aggiungi al calendario",
"zone_contacts": "Aggiungi ai contatti",
"zone_open_eml": "Apri messaggio",
"zone_import_eml": "Importa in {folder}",
"note_limit_message": "Fino a {size} per messaggio",
"note_limit_file": "Fino a {size} per file",
"note_kept_offline": "Resta su questo dispositivo finché non torni online",
"note_needs_connection": "Serve una connessione",
"announce": "{files, plural, =0 {File trascinati} one {# file trascinato} other {# file trascinati}}. Opzioni: {choices}.",
"choose_title": "{count, plural, one {Dove va questo file?} other {Dove vanno questi file?}}",
"save_first_failed": "Non è stato possibile salvare il messaggio aperto, quindi l'app è rimasta dov'era.",
"import_done": "{count, plural, one {# messaggio importato in {folder}.} other {# messaggi importati in {folder}.}}",
"import_failed": "Non è stato possibile importare {name}.",
"too_large_file": "{name} è troppo grande. Il limite è {max} per file.",
"too_large_message": "Questi file sono troppo grandi per un solo messaggio. Il limite è {max}.",
"folders_left_out": "Le cartelle possono andare solo in File, quindi sono state escluse.",
"nothing_here": "Questi file non si possono usare qui."
},
"desktopReminders": {
"generic_title": "Promemoria",
"snooze": "Rimanda di {count} min",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "このキーはお使いのキーボードで文字を入力します。割り当てると、すべてのアプリでその文字を入力できなくなります。別のキーをお試しください。",
"reserved": "このキーはシステムまたは多くのアプリが使用しています。別のキーをお試しください。"
},
"desktopDevTools": {
"section_title": "トラブルシューティング",
"label": "デベロッパー ツール ({shortcut})",
"description": "{shortcut} と F12 で、今いるウィンドウのデベロッパー ツールを開けるようにします。アプリがロックされるとオフに戻ります。",
"forced_off": "組織によってオフにされています。",
"locked": "変更するにはアプリのロックを解除してください。",
"menu_toggle": "デベロッパー ツールの表示/非表示"
},
"fileDrop": {
"overlay_label": "ファイルをドロップ",
"overlay_title": "ファイルをどこに入れますか?",
"overlay_hint": "キャンセルするには選択肢の外でドロップするか、Esc キーを押します。",
"files_unknown": "ファイル",
"files_summary": "{count, plural, other {# 個のファイル}}",
"files_summary_size": "{count, plural, other {# 個のファイル}}, {size}",
"zone_attach_here": "このメッセージに添付",
"zone_insert_inline": "メッセージ本文に挿入",
"zone_new_message": "新規メッセージに添付",
"zone_upload_files": "ファイルにアップロード",
"zone_upload_folder": "{folder} にアップロード",
"zone_calendar": "カレンダーに追加",
"zone_contacts": "連絡先に追加",
"zone_open_eml": "メッセージを開く",
"zone_import_eml": "{folder} にインポート",
"note_limit_message": "1 通あたり {size} まで",
"note_limit_file": "1 ファイルあたり {size} まで",
"note_kept_offline": "オンラインに戻るまでこのデバイスに保存されます",
"note_needs_connection": "接続が必要です",
"announce": "{files, plural, =0 {ファイル} other {# 個のファイル}}がドラッグされました。選択肢: {choices}。",
"choose_title": "{count, plural, other {このファイルをどこに入れますか?}}",
"save_first_failed": "開いているメッセージを保存できなかったため、アプリはそのままです。",
"import_done": "{count, plural, other {# 通のメッセージを {folder} にインポートしました。}}",
"import_failed": "{name} をインポートできませんでした。",
"too_large_file": "{name} は大きすぎます。上限は 1 ファイルあたり {max} です。",
"too_large_message": "これらのファイルは 1 通のメッセージには大きすぎます。上限は {max} です。",
"folders_left_out": "フォルダーはファイルにしか入れられないため、除外しました。",
"nothing_here": "これらのファイルはここでは使えません。"
},
"desktopReminders": {
"generic_title": "リマインダー",
"snooze": "{count} 分後に再通知",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "이 키는 사용 중인 키보드에서 문자를 입력하며, 지정하면 모든 앱에서 그 문자를 입력할 수 없게 됩니다. 다른 키를 사용해 보세요.",
"reserved": "시스템 또는 대부분의 앱이 이 키를 사용합니다. 다른 키를 사용해 보세요."
},
"desktopDevTools": {
"section_title": "문제 해결",
"label": "개발자 도구 ({shortcut})",
"description": "{shortcut} 및 F12로 현재 창의 개발자 도구를 열 수 있게 합니다. 앱이 잠기면 다시 꺼집니다.",
"forced_off": "조직에서 이 기능을 껐습니다.",
"locked": "변경하려면 앱 잠금을 해제하세요.",
"menu_toggle": "개발자 도구 표시/숨기기"
},
"fileDrop": {
"overlay_label": "파일 놓기",
"overlay_title": "파일을 어디에 둘까요?",
"overlay_hint": "취소하려면 선택 항목 밖에 놓거나 Esc 키를 누르세요.",
"files_unknown": "파일",
"files_summary": "{count, plural, other {파일 #개}}",
"files_summary_size": "{count, plural, other {파일 #개}}, {size}",
"zone_attach_here": "이 메시지에 첨부",
"zone_insert_inline": "메시지 본문에 삽입",
"zone_new_message": "새 메시지에 첨부",
"zone_upload_files": "파일에 업로드",
"zone_upload_folder": "{folder}에 업로드",
"zone_calendar": "캘린더에 추가",
"zone_contacts": "연락처에 추가",
"zone_open_eml": "메시지 열기",
"zone_import_eml": "{folder}(으)로 가져오기",
"note_limit_message": "메시지당 최대 {size}",
"note_limit_file": "파일당 최대 {size}",
"note_kept_offline": "다시 온라인이 될 때까지 이 기기에 보관됩니다",
"note_needs_connection": "연결이 필요합니다",
"announce": "{files, plural, =0 {파일을} other {파일 #개를}} 끌어왔습니다. 선택 항목: {choices}.",
"choose_title": "{count, plural, other {이 파일을 어디에 둘까요?}}",
"save_first_failed": "열린 메시지를 저장하지 못해 앱이 그대로 있습니다.",
"import_done": "{count, plural, other {메시지 #개를 {folder}(으)로 가져왔습니다.}}",
"import_failed": "{name}을(를) 가져오지 못했습니다.",
"too_large_file": "{name}이(가) 너무 큽니다. 한도는 파일당 {max}입니다.",
"too_large_message": "이 파일들은 한 메시지에 넣기에 너무 큽니다. 한도는 {max}입니다.",
"folders_left_out": "폴더는 파일에만 넣을 수 있어 제외했습니다.",
"nothing_here": "이 파일은 여기에서 사용할 수 없습니다."
},
"desktopReminders": {
"generic_title": "알림",
"snooze": "{count}분 후 다시 알림",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "Šie taustiņi jūsu tastatūrā raksta rakstzīmi, kas pārstātu darboties visās lietotnēs. Izmēģiniet citus taustiņus.",
"reserved": "Šos taustiņus izmanto sistēma vai lielākā daļa lietotņu. Izmēģiniet citus taustiņus."
},
"desktopDevTools": {
"section_title": "Problēmu novēršana",
"label": "Izstrādātāju rīki ({shortcut})",
"description": "Ļauj {shortcut} un F12 atvērt izstrādātāju rīkus logā, kurā esat. Izslēdzas, kad lietotne tiek bloķēta.",
"forced_off": "Jūsu organizācija to ir izslēgusi.",
"locked": "Atbloķējiet lietotni, lai to mainītu.",
"menu_toggle": "Rādīt vai paslēpt izstrādātāju rīkus"
},
"fileDrop": {
"overlay_label": "Nometiet failus",
"overlay_title": "Kur likt failus?",
"overlay_hint": "Lai atceltu, nometiet ārpus izvēlēm vai nospiediet Esc.",
"files_unknown": "Faili",
"files_summary": "{count, plural, zero {# failu} one {# fails} other {# faili}}",
"files_summary_size": "{count, plural, zero {# failu} one {# fails} other {# faili}}, {size}",
"zone_attach_here": "Pievienot šim ziņojumam",
"zone_insert_inline": "Ievietot ziņojumā",
"zone_new_message": "Pievienot jaunam ziņojumam",
"zone_upload_files": "Augšupielādēt failos",
"zone_upload_folder": "Augšupielādēt mapē {folder}",
"zone_calendar": "Pievienot kalendāram",
"zone_contacts": "Pievienot kontaktiem",
"zone_open_eml": "Atvērt ziņojumu",
"zone_import_eml": "Importēt mapē {folder}",
"note_limit_message": "Līdz {size} vienam ziņojumam",
"note_limit_file": "Līdz {size} vienam failam",
"note_kept_offline": "Paliek šajā ierīcē, līdz atkal būsit tiešsaistē",
"note_needs_connection": "Vajadzīgs savienojums",
"announce": "{files, plural, =0 {Ievilkti faili} zero {Ievilkts # failu} one {Ievilkts # fails} other {Ievilkti # faili}}. Izvēles: {choices}.",
"choose_title": "{count, plural, zero {Kur likt šos failus?} one {Kur likt šo failu?} other {Kur likt šos failus?}}",
"save_first_failed": "Atvērto ziņojumu neizdevās saglabāt, tāpēc lietotne palika, kur bija.",
"import_done": "{count, plural, zero {# ziņojumu importēts mapē {folder}.} one {# ziņojums importēts mapē {folder}.} other {# ziņojumi importēti mapē {folder}.}}",
"import_failed": "{name} neizdevās importēt.",
"too_large_file": "{name} ir pārāk liels. Ierobežojums ir {max} vienam failam.",
"too_large_message": "Šie faili ir pārāk lieli vienam ziņojumam. Ierobežojums ir {max}.",
"folders_left_out": "Mapes var likt tikai failos, tāpēc tās tika izlaistas.",
"nothing_here": "Šos failus šeit nevar izmantot."
},
"desktopReminders": {
"generic_title": "Atgādinājums",
"snooze": "Atlikt par {count} min",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "Эдгээр товч таны гар дээр тэмдэгт бичдэг тул тэр тэмдэгт бүх аппад ажиллахаа болино. Өөр товч туршина уу.",
"reserved": "Систем эсвэл ихэнх апп эдгээр товчийг ашигладаг. Өөр товч туршина уу."
},
"desktopDevTools": {
"section_title": "Алдаа засах",
"label": "Хөгжүүлэгчийн хэрэгсэл ({shortcut})",
"description": "{shortcut} болон F12 дарахад таны байгаа цонхонд хөгжүүлэгчийн хэрэгсэл нээгдэнэ. Апп түгжигдэхэд дахин унтарна.",
"forced_off": "Танай байгууллага унтраасан.",
"locked": "Үүнийг өөрчлөхийн тулд аппын түгжээг тайлна уу.",
"menu_toggle": "Хөгжүүлэгчийн хэрэгслийг харуулах эсвэл нуух"
},
"fileDrop": {
"overlay_label": "Файл буулгах",
"overlay_title": "Файлуудыг хаана хийх вэ?",
"overlay_hint": "Цуцлахын тулд сонголтуудын гадна буулгах эсвэл Esc дарна уу.",
"files_unknown": "Файлууд",
"files_summary": "{count, plural, one {# файл} other {# файл}}",
"files_summary_size": "{count, plural, one {# файл} other {# файл}}, {size}",
"zone_attach_here": "Энэ мессежид хавсаргах",
"zone_insert_inline": "Мессежийн дотор оруулах",
"zone_new_message": "Шинэ мессежид хавсаргах",
"zone_upload_files": "Файл руу байршуулах",
"zone_upload_folder": "{folder} руу байршуулах",
"zone_calendar": "Хуанлид нэмэх",
"zone_contacts": "Харилцагчид нэмэх",
"zone_open_eml": "Мессеж нээх",
"zone_import_eml": "{folder} руу импортлох",
"note_limit_message": "Нэг мессежид {size} хүртэл",
"note_limit_file": "Нэг файлд {size} хүртэл",
"note_kept_offline": "Дахин онлайн болох хүртэл энэ төхөөрөмжид хадгалагдана",
"note_needs_connection": "Холболт шаардлагатай",
"announce": "{files, plural, =0 {Файлууд} one {# файл} other {# файл}} чирэгдлээ. Сонголтууд: {choices}.",
"choose_title": "{count, plural, one {Энэ файлыг хаана хийх вэ?} other {Эдгээр файлыг хаана хийх вэ?}}",
"save_first_failed": "Нээлттэй мессежийг хадгалж чадсангүй, тиймээс апп байсан газартаа үлдлээ.",
"import_done": "{count, plural, one {# мессежийг {folder} руу импортоллоо.} other {# мессежийг {folder} руу импортоллоо.}}",
"import_failed": "{name}-г импортлож чадсангүй.",
"too_large_file": "{name} хэт том байна. Хязгаар нь нэг файлд {max}.",
"too_large_message": "Эдгээр файл нэг мессежид хэт том байна. Хязгаар нь {max}.",
"folders_left_out": "Хавтас зөвхөн Файл руу орох боломжтой тул орхигдлоо.",
"nothing_here": "Эдгээр файлыг энд ашиглах боломжгүй."
},
"desktopReminders": {
"generic_title": "Сануулга",
"snooze": "{count} минут хойшлуулах",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "Disse tastene skriver et tegn på tastaturet ditt, og det ville slutte å virke i alle apper. Prøv andre taster.",
"reserved": "Systemet ditt eller de fleste apper bruker disse tastene. Prøv andre taster."
},
"desktopDevTools": {
"section_title": "Feilsøking",
"label": "Utviklerverktøy ({shortcut})",
"description": "Lar {shortcut} og F12 åpne utviklerverktøyene for vinduet du er i. Slås av igjen når appen låses.",
"forced_off": "Slått av av organisasjonen din.",
"locked": "Lås opp appen for å endre dette.",
"menu_toggle": "Vis eller skjul utviklerverktøy"
},
"fileDrop": {
"overlay_label": "Slipp filer",
"overlay_title": "Hvor skal filene?",
"overlay_hint": "Slipp utenfor valgene eller trykk på Esc for å avbryte.",
"files_unknown": "Filer",
"files_summary": "{count, plural, one {# fil} other {# filer}}",
"files_summary_size": "{count, plural, one {# fil} other {# filer}}, {size}",
"zone_attach_here": "Legg ved denne meldingen",
"zone_insert_inline": "Sett inn i meldingen",
"zone_new_message": "Legg ved en ny melding",
"zone_upload_files": "Last opp til Filer",
"zone_upload_folder": "Last opp til {folder}",
"zone_calendar": "Legg til i kalenderen",
"zone_contacts": "Legg til i kontakter",
"zone_open_eml": "Åpne melding",
"zone_import_eml": "Importer til {folder}",
"note_limit_message": "Opptil {size} per melding",
"note_limit_file": "Opptil {size} per fil",
"note_kept_offline": "Blir på denne enheten til du er på nett igjen",
"note_needs_connection": "Trenger tilkobling",
"announce": "{files, plural, =0 {Filer} one {# fil} other {# filer}} dratt inn. Valg: {choices}.",
"choose_title": "{count, plural, one {Hvor skal denne filen?} other {Hvor skal disse filene?}}",
"save_first_failed": "Den åpne meldingen kunne ikke lagres, så appen ble der den var.",
"import_done": "{count, plural, one {# melding importert til {folder}.} other {# meldinger importert til {folder}.}}",
"import_failed": "{name} kunne ikke importeres.",
"too_large_file": "{name} er for stor. Grensen er {max} per fil.",
"too_large_message": "Disse filene er for store for én melding. Grensen er {max}.",
"folders_left_out": "Mapper kan bare gå til Filer, så de ble utelatt.",
"nothing_here": "Disse filene kan ikke brukes her."
},
"desktopReminders": {
"generic_title": "Påminnelse",
"snooze": "Utsett {count} min",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "Deze toetsen typen een teken op je toetsenbord, en dat zou in geen enkele app meer werken. Probeer andere toetsen.",
"reserved": "Je systeem of de meeste apps gebruiken deze toetsen. Probeer andere toetsen."
},
"desktopDevTools": {
"section_title": "Probleemoplossing",
"label": "Ontwikkelaarstools ({shortcut})",
"description": "Met {shortcut} en F12 openen de ontwikkelaarstools voor het venster waarin je bent. Gaat weer uit als de app vergrendelt.",
"forced_off": "Uitgezet door je organisatie.",
"locked": "Ontgrendel de app om dit te wijzigen.",
"menu_toggle": "Ontwikkelaarstools tonen of verbergen"
},
"fileDrop": {
"overlay_label": "Bestanden neerzetten",
"overlay_title": "Waar moeten de bestanden heen?",
"overlay_hint": "Zet buiten de keuzes neer of druk op Esc om te annuleren.",
"files_unknown": "Bestanden",
"files_summary": "{count, plural, one {# bestand} other {# bestanden}}",
"files_summary_size": "{count, plural, one {# bestand} other {# bestanden}}, {size}",
"zone_attach_here": "Bij dit bericht voegen",
"zone_insert_inline": "In het bericht invoegen",
"zone_new_message": "Bij een nieuw bericht voegen",
"zone_upload_files": "Uploaden naar Bestanden",
"zone_upload_folder": "Uploaden naar {folder}",
"zone_calendar": "Aan agenda toevoegen",
"zone_contacts": "Aan contacten toevoegen",
"zone_open_eml": "Bericht openen",
"zone_import_eml": "Importeren in {folder}",
"note_limit_message": "Tot {size} per bericht",
"note_limit_file": "Tot {size} per bestand",
"note_kept_offline": "Blijft op dit apparaat tot je weer online bent",
"note_needs_connection": "Heeft een verbinding nodig",
"announce": "{files, plural, =0 {Bestanden} one {# bestand} other {# bestanden}} binnengesleept. Keuzes: {choices}.",
"choose_title": "{count, plural, one {Waar moet dit bestand heen?} other {Waar moeten deze bestanden heen?}}",
"save_first_failed": "Het open bericht kon niet worden opgeslagen, dus de app bleef waar hij was.",
"import_done": "{count, plural, one {# bericht geïmporteerd in {folder}.} other {# berichten geïmporteerd in {folder}.}}",
"import_failed": "{name} kon niet worden geïmporteerd.",
"too_large_file": "{name} is te groot. De grens is {max} per bestand.",
"too_large_message": "Deze bestanden zijn samen te groot voor één bericht. De grens is {max}.",
"folders_left_out": "Mappen kunnen alleen naar Bestanden, dus ze zijn weggelaten.",
"nothing_here": "Deze bestanden kunnen hier niet worden gebruikt."
},
"desktopReminders": {
"generic_title": "Herinnering",
"snooze": "{count} min uitstellen",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "Te klawisze wpisują znak na Twojej klawiaturze i przestałby on działać we wszystkich aplikacjach. Wypróbuj inne klawisze.",
"reserved": "Te klawisze są używane przez system lub większość aplikacji. Wypróbuj inne klawisze."
},
"desktopDevTools": {
"section_title": "Rozwiązywanie problemów",
"label": "Narzędzia dla deweloperów ({shortcut})",
"description": "Pozwala, by {shortcut} i F12 otwierały narzędzia dla deweloperów w oknie, w którym jesteś. Wyłącza się, gdy aplikacja się zablokuje.",
"forced_off": "Wyłączone przez Twoją organizację.",
"locked": "Odblokuj aplikację, aby to zmienić.",
"menu_toggle": "Pokaż lub ukryj narzędzia dla deweloperów"
},
"fileDrop": {
"overlay_label": "Upuść pliki",
"overlay_title": "Gdzie mają trafić pliki?",
"overlay_hint": "Upuść poza opcjami lub naciśnij Esc, aby anulować.",
"files_unknown": "Pliki",
"files_summary": "{count, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}}",
"files_summary_size": "{count, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}}, {size}",
"zone_attach_here": "Załącz do tej wiadomości",
"zone_insert_inline": "Wstaw do wiadomości",
"zone_new_message": "Załącz do nowej wiadomości",
"zone_upload_files": "Prześlij do Plików",
"zone_upload_folder": "Prześlij do {folder}",
"zone_calendar": "Dodaj do kalendarza",
"zone_contacts": "Dodaj do kontaktów",
"zone_open_eml": "Otwórz wiadomość",
"zone_import_eml": "Importuj do {folder}",
"note_limit_message": "Do {size} na wiadomość",
"note_limit_file": "Do {size} na plik",
"note_kept_offline": "Zostaje na tym urządzeniu, aż znów będziesz online",
"note_needs_connection": "Wymaga połączenia",
"announce": "{files, plural, =0 {Przeciągnięto pliki} one {Przeciągnięto # plik} few {Przeciągnięto # pliki} many {Przeciągnięto # plików} other {Przeciągnięto # pliku}}. Opcje: {choices}.",
"choose_title": "{count, plural, one {Gdzie ma trafić ten plik?} few {Gdzie mają trafić te pliki?} many {Gdzie mają trafić te pliki?} other {Gdzie mają trafić te pliki?}}",
"save_first_failed": "Nie udało się zapisać otwartej wiadomości, więc aplikacja została tam, gdzie była.",
"import_done": "{count, plural, one {Zaimportowano # wiadomość do {folder}.} few {Zaimportowano # wiadomości do {folder}.} many {Zaimportowano # wiadomości do {folder}.} other {Zaimportowano # wiadomości do {folder}.}}",
"import_failed": "Nie udało się zaimportować {name}.",
"too_large_file": "{name} jest za duży. Limit to {max} na plik.",
"too_large_message": "Te pliki są za duże na jedną wiadomość. Limit to {max}.",
"folders_left_out": "Foldery mogą trafić tylko do Plików, więc zostały pominięte.",
"nothing_here": "Tych plików nie można tu użyć."
},
"desktopReminders": {
"generic_title": "Przypomnienie",
"snooze": "Odłóż o {count} min",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "Estas teclas escrevem um carácter no seu teclado, e ele deixaria de funcionar em todas as aplicações. Experimente outras teclas.",
"reserved": "O seu sistema ou a maioria das aplicações usam estas teclas. Experimente outras teclas."
},
"desktopDevTools": {
"section_title": "Resolução de problemas",
"label": "Ferramentas de programador ({shortcut})",
"description": "Permite que {shortcut} e F12 abram as ferramentas de programador na janela em que está. Desliga-se quando a app bloqueia.",
"forced_off": "Desligado pela sua organização.",
"locked": "Desbloqueie a app para alterar isto.",
"menu_toggle": "Mostrar ou ocultar ferramentas de programador"
},
"fileDrop": {
"overlay_label": "Soltar arquivos",
"overlay_title": "Para onde vão os arquivos?",
"overlay_hint": "Solte fora das opções ou prima Esc para cancelar.",
"files_unknown": "Arquivos",
"files_summary": "{count, plural, one {# arquivo} other {# arquivos}}",
"files_summary_size": "{count, plural, one {# arquivo} other {# arquivos}}, {size}",
"zone_attach_here": "Anexar a esta mensagem",
"zone_insert_inline": "Inserir na mensagem",
"zone_new_message": "Anexar a uma nova mensagem",
"zone_upload_files": "Enviar para Arquivos",
"zone_upload_folder": "Enviar para {folder}",
"zone_calendar": "Adicionar ao calendário",
"zone_contacts": "Adicionar aos contactos",
"zone_open_eml": "Abrir mensagem",
"zone_import_eml": "Importar para {folder}",
"note_limit_message": "Até {size} por mensagem",
"note_limit_file": "Até {size} por arquivo",
"note_kept_offline": "Fica neste dispositivo até voltar a estar online",
"note_needs_connection": "Precisa de ligação",
"announce": "{files, plural, =0 {Arquivos arrastados} one {# arquivo arrastado} other {# arquivos arrastados}}. Opções: {choices}.",
"choose_title": "{count, plural, one {Para onde vai este arquivo?} other {Para onde vão estes arquivos?}}",
"save_first_failed": "Não foi possível guardar a mensagem aberta, por isso a app ficou onde estava.",
"import_done": "{count, plural, one {# mensagem importada para {folder}.} other {# mensagens importadas para {folder}.}}",
"import_failed": "Não foi possível importar {name}.",
"too_large_file": "{name} é demasiado grande. O limite é {max} por arquivo.",
"too_large_message": "Estes arquivos são demasiado grandes para uma mensagem. O limite é {max}.",
"folders_left_out": "As pastas só podem ir para Arquivos, por isso ficaram de fora.",
"nothing_here": "Estes arquivos não podem ser usados aqui."
},
"desktopReminders": {
"generic_title": "Lembrete",
"snooze": "Adiar {count} min",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "Aceste taste scriu un caracter pe tastatura ta, iar el n-ar mai funcționa în nicio aplicație. Încearcă alte taste.",
"reserved": "Sistemul sau majoritatea aplicațiilor folosesc aceste taste. Încearcă alte taste."
},
"desktopDevTools": {
"section_title": "Depanare",
"label": "Instrumente pentru dezvoltatori ({shortcut})",
"description": "Permite ca {shortcut} și F12 să deschidă instrumentele pentru dezvoltatori în fereastra în care sunteți. Se oprește când aplicația se blochează.",
"forced_off": "Oprit de organizația dvs.",
"locked": "Deblocați aplicația pentru a schimba asta.",
"menu_toggle": "Afișați sau ascundeți instrumentele pentru dezvoltatori"
},
"fileDrop": {
"overlay_label": "Plasați fișiere",
"overlay_title": "Unde merg fișierele?",
"overlay_hint": "Plasați în afara opțiunilor sau apăsați Esc pentru a anula.",
"files_unknown": "Fișiere",
"files_summary": "{count, plural, one {# fișier} few {# fișiere} other {# de fișiere}}",
"files_summary_size": "{count, plural, one {# fișier} few {# fișiere} other {# de fișiere}}, {size}",
"zone_attach_here": "Atașați la acest mesaj",
"zone_insert_inline": "Inserați în mesaj",
"zone_new_message": "Atașați la un mesaj nou",
"zone_upload_files": "Încărcați în Fișiere",
"zone_upload_folder": "Încărcați în {folder}",
"zone_calendar": "Adăugați în calendar",
"zone_contacts": "Adăugați la contacte",
"zone_open_eml": "Deschideți mesajul",
"zone_import_eml": "Importați în {folder}",
"note_limit_message": "Până la {size} pe mesaj",
"note_limit_file": "Până la {size} pe fișier",
"note_kept_offline": "Rămâne pe acest dispozitiv până reveniți online",
"note_needs_connection": "Necesită conexiune",
"announce": "{files, plural, =0 {Fișiere trase} one {# fișier tras} few {# fișiere trase} other {# de fișiere trase}}. Opțiuni: {choices}.",
"choose_title": "{count, plural, one {Unde merge acest fișier?} few {Unde merg aceste fișiere?} other {Unde merg aceste fișiere?}}",
"save_first_failed": "Mesajul deschis nu a putut fi salvat, așa că aplicația a rămas unde era.",
"import_done": "{count, plural, one {# mesaj importat în {folder}.} few {# mesaje importate în {folder}.} other {# de mesaje importate în {folder}.}}",
"import_failed": "{name} nu a putut fi importat.",
"too_large_file": "{name} este prea mare. Limita este {max} pe fișier.",
"too_large_message": "Aceste fișiere sunt prea mari pentru un mesaj. Limita este {max}.",
"folders_left_out": "Dosarele pot merge doar în Fișiere, așa că au fost lăsate deoparte.",
"nothing_here": "Aceste fișiere nu pot fi folosite aici."
},
"desktopReminders": {
"generic_title": "Memento",
"snooze": "Amână {count} min",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "На вашей клавиатуре эти клавиши вводят символ, и он перестал бы работать во всех приложениях. Попробуйте другие клавиши.",
"reserved": "Эти клавиши использует система или большинство приложений. Попробуйте другие клавиши."
},
"desktopDevTools": {
"section_title": "Устранение неполадок",
"label": "Инструменты разработчика ({shortcut})",
"description": "{shortcut} и F12 открывают инструменты разработчика для окна, в котором вы находитесь. Выключается при блокировке приложения.",
"forced_off": "Отключено вашей организацией.",
"locked": "Разблокируйте приложение, чтобы изменить это.",
"menu_toggle": "Показать или скрыть инструменты разработчика"
},
"fileDrop": {
"overlay_label": "Перетащите файлы",
"overlay_title": "Куда отправить файлы?",
"overlay_hint": "Чтобы отменить, отпустите вне вариантов или нажмите Esc.",
"files_unknown": "Файлы",
"files_summary": "{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}",
"files_summary_size": "{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}, {size}",
"zone_attach_here": "Прикрепить к этому письму",
"zone_insert_inline": "Вставить в текст письма",
"zone_new_message": "Прикрепить к новому письму",
"zone_upload_files": "Загрузить в Файлы",
"zone_upload_folder": "Загрузить в {folder}",
"zone_calendar": "Добавить в календарь",
"zone_contacts": "Добавить в контакты",
"zone_open_eml": "Открыть письмо",
"zone_import_eml": "Импортировать в {folder}",
"note_limit_message": "До {size} на письмо",
"note_limit_file": "До {size} на файл",
"note_kept_offline": "Хранится на этом устройстве, пока вы снова не будете в сети",
"note_needs_connection": "Нужно подключение",
"announce": "{files, plural, =0 {Перетащены файлы} one {Перетащен # файл} few {Перетащено # файла} many {Перетащено # файлов} other {Перетащено # файла}}. Варианты: {choices}.",
"choose_title": "{count, plural, one {Куда отправить этот файл?} few {Куда отправить эти файлы?} many {Куда отправить эти файлы?} other {Куда отправить эти файлы?}}",
"save_first_failed": "Открытое письмо не удалось сохранить, поэтому приложение осталось на месте.",
"import_done": "{count, plural, one {# письмо импортировано в {folder}.} few {# письма импортировано в {folder}.} many {# писем импортировано в {folder}.} other {# письма импортировано в {folder}.}}",
"import_failed": "Не удалось импортировать {name}.",
"too_large_file": "{name} слишком большой. Ограничение: {max} на файл.",
"too_large_message": "Эти файлы слишком большие для одного письма. Ограничение: {max}.",
"folders_left_out": "Папки можно отправить только в Файлы, поэтому они пропущены.",
"nothing_here": "Эти файлы здесь нельзя использовать."
},
"desktopReminders": {
"generic_title": "Напоминание",
"snooze": "Отложить на {count} мин",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "Tieto klávesy na vašej klávesnici píšu znak, ktorý by potom nefungoval v žiadnej aplikácii. Skúste iné klávesy.",
"reserved": "Tieto klávesy používa systém alebo väčšina aplikácií. Skúste iné klávesy."
},
"desktopDevTools": {
"section_title": "Riešenie problémov",
"label": "Nástroje pre vývojárov ({shortcut})",
"description": "Umožní, aby {shortcut} a F12 otvorili nástroje pre vývojárov v okne, kde ste. Vypne sa, keď sa aplikácia zamkne.",
"forced_off": "Vypnuté vašou organizáciou.",
"locked": "Na zmenu aplikáciu odomknite.",
"menu_toggle": "Zobraziť alebo skryť nástroje pre vývojárov"
},
"fileDrop": {
"overlay_label": "Pustiť súbory",
"overlay_title": "Kam majú súbory ísť?",
"overlay_hint": "Na zrušenie pustite mimo volieb alebo stlačte Esc.",
"files_unknown": "Súbory",
"files_summary": "{count, plural, one {# súbor} few {# súbory} many {# súboru} other {# súborov}}",
"files_summary_size": "{count, plural, one {# súbor} few {# súbory} many {# súboru} other {# súborov}}, {size}",
"zone_attach_here": "Priložiť k tejto správe",
"zone_insert_inline": "Vložiť do správy",
"zone_new_message": "Priložiť k novej správe",
"zone_upload_files": "Nahrať do Súborov",
"zone_upload_folder": "Nahrať do {folder}",
"zone_calendar": "Pridať do kalendára",
"zone_contacts": "Pridať do kontaktov",
"zone_open_eml": "Otvoriť správu",
"zone_import_eml": "Importovať do {folder}",
"note_limit_message": "Až {size} na správu",
"note_limit_file": "Až {size} na súbor",
"note_kept_offline": "Zostane v tomto zariadení, kým nebudete online",
"note_needs_connection": "Vyžaduje pripojenie",
"announce": "{files, plural, =0 {Pretiahnuté súbory} one {Pretiahnutý # súbor} few {Pretiahnuté # súbory} many {Pretiahnutého # súboru} other {Pretiahnutých # súborov}}. Voľby: {choices}.",
"choose_title": "{count, plural, one {Kam má tento súbor ísť?} few {Kam majú tieto súbory ísť?} many {Kam majú tieto súbory ísť?} other {Kam majú tieto súbory ísť?}}",
"save_first_failed": "Otvorenú správu sa nepodarilo uložiť, preto aplikácia zostala, kde bola.",
"import_done": "{count, plural, one {# správa importovaná do {folder}.} few {# správy importované do {folder}.} many {# správy importovanej do {folder}.} other {# správ importovaných do {folder}.}}",
"import_failed": "{name} sa nepodarilo importovať.",
"too_large_file": "{name} je príliš veľký. Limit je {max} na súbor.",
"too_large_message": "Tieto súbory sú na jednu správu príliš veľké. Limit je {max}.",
"folders_left_out": "Priečinky môžu ísť len do Súborov, preto boli vynechané.",
"nothing_here": "Tieto súbory sa tu nedajú použiť."
},
"desktopReminders": {
"generic_title": "Pripomienka",
"snooze": "Odložiť o {count} min",
+38
View File
@@ -7114,6 +7114,44 @@
"types_character": "ปุ่มเหล่านี้พิมพ์อักขระบนแป้นพิมพ์ของคุณ และอักขระนั้นจะใช้ไม่ได้ในทุกแอป ลองใช้ปุ่มอื่น",
"reserved": "ระบบหรือแอปส่วนใหญ่ใช้ปุ่มเหล่านี้ ลองใช้ปุ่มอื่น"
},
"desktopDevTools": {
"section_title": "การแก้ไขปัญหา",
"label": "เครื่องมือสำหรับนักพัฒนาซอฟต์แวร์ ({shortcut})",
"description": "ให้ {shortcut} และ F12 เปิดเครื่องมือสำหรับนักพัฒนาซอฟต์แวร์ของหน้าต่างที่คุณอยู่ จะปิดอีกครั้งเมื่อแอปล็อก",
"forced_off": "องค์กรของคุณปิดสิ่งนี้แล้ว",
"locked": "ปลดล็อกแอปเพื่อเปลี่ยนการตั้งค่านี้",
"menu_toggle": "แสดงหรือซ่อนเครื่องมือสำหรับนักพัฒนาซอฟต์แวร์"
},
"fileDrop": {
"overlay_label": "วางไฟล์",
"overlay_title": "จะให้ไฟล์ไปที่ไหน",
"overlay_hint": "วางนอกตัวเลือกหรือกด Esc เพื่อยกเลิก",
"files_unknown": "ไฟล์",
"files_summary": "{count, plural, other {# ไฟล์}}",
"files_summary_size": "{count, plural, other {# ไฟล์}}, {size}",
"zone_attach_here": "แนบกับข้อความนี้",
"zone_insert_inline": "แทรกลงในข้อความ",
"zone_new_message": "แนบกับข้อความใหม่",
"zone_upload_files": "อัปโหลดไปยังไฟล์",
"zone_upload_folder": "อัปโหลดไปยัง {folder}",
"zone_calendar": "เพิ่มในปฏิทิน",
"zone_contacts": "เพิ่มในรายชื่อติดต่อ",
"zone_open_eml": "เปิดข้อความ",
"zone_import_eml": "นำเข้าไปยัง {folder}",
"note_limit_message": "สูงสุด {size} ต่อข้อความ",
"note_limit_file": "สูงสุด {size} ต่อไฟล์",
"note_kept_offline": "เก็บไว้ในอุปกรณ์นี้จนกว่าคุณจะออนไลน์อีกครั้ง",
"note_needs_connection": "ต้องใช้การเชื่อมต่อ",
"announce": "{files, plural, =0 {ลากไฟล์เข้ามาแล้ว} other {ลาก # ไฟล์เข้ามาแล้ว}} ตัวเลือก: {choices}",
"choose_title": "{count, plural, other {จะให้ไฟล์นี้ไปที่ไหน}}",
"save_first_failed": "บันทึกข้อความที่เปิดอยู่ไม่ได้ แอปจึงอยู่ที่เดิม",
"import_done": "{count, plural, other {นำเข้า # ข้อความไปยัง {folder} แล้ว}}",
"import_failed": "นำเข้า {name} ไม่ได้",
"too_large_file": "{name} ใหญ่เกินไป ขีดจำกัดคือ {max} ต่อไฟล์",
"too_large_message": "ไฟล์เหล่านี้ใหญ่เกินไปสำหรับข้อความเดียว ขีดจำกัดคือ {max}",
"folders_left_out": "โฟลเดอร์ไปได้เฉพาะที่ไฟล์ จึงไม่ได้รวมไว้",
"nothing_here": "ใช้ไฟล์เหล่านี้ที่นี่ไม่ได้"
},
"desktopReminders": {
"generic_title": "การแจ้งเตือน",
"snooze": "เลื่อน {count} นาที",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "Bu tuşlar klavyenizde bir karakter yazar ve bu karakter hiçbir uygulamada çalışmaz olur. Başka tuşlar deneyin.",
"reserved": "Bu tuşları sisteminiz veya çoğu uygulama kullanıyor. Başka tuşlar deneyin."
},
"desktopDevTools": {
"section_title": "Sorun giderme",
"label": "Geliştirici araçları ({shortcut})",
"description": "{shortcut} ve F12 ile bulunduğunuz pencere için geliştirici araçları açılır. Uygulama kilitlenince yeniden kapanır.",
"forced_off": "Kuruluşunuz tarafından kapatıldı.",
"locked": "Bunu değiştirmek için uygulamanın kilidini açın.",
"menu_toggle": "Geliştirici araçlarını göster veya gizle"
},
"fileDrop": {
"overlay_label": "Dosyaları bırakın",
"overlay_title": "Dosyalar nereye gitsin?",
"overlay_hint": "İptal etmek için seçeneklerin dışına bırakın veya Esc tuşuna basın.",
"files_unknown": "Dosyalar",
"files_summary": "{count, plural, one {# dosya} other {# dosya}}",
"files_summary_size": "{count, plural, one {# dosya} other {# dosya}}, {size}",
"zone_attach_here": "Bu iletiye ekle",
"zone_insert_inline": "İleti metnine ekle",
"zone_new_message": "Yeni bir iletiye ekle",
"zone_upload_files": "Dosyalar'a yükle",
"zone_upload_folder": "{folder} klasörüne yükle",
"zone_calendar": "Takvime ekle",
"zone_contacts": "Kişilere ekle",
"zone_open_eml": "İletiyi aç",
"zone_import_eml": "{folder} klasörüne içe aktar",
"note_limit_message": "İleti başına en fazla {size}",
"note_limit_file": "Dosya başına en fazla {size}",
"note_kept_offline": "Tekrar çevrimiçi olana kadar bu cihazda kalır",
"note_needs_connection": "Bağlantı gerekir",
"announce": "{files, plural, =0 {Dosyalar} one {# dosya} other {# dosya}} sürüklendi. Seçenekler: {choices}.",
"choose_title": "{count, plural, one {Bu dosya nereye gitsin?} other {Bu dosyalar nereye gitsin?}}",
"save_first_failed": "Açık ileti kaydedilemedi, bu yüzden uygulama olduğu yerde kaldı.",
"import_done": "{count, plural, one {# ileti {folder} klasörüne aktarıldı.} other {# ileti {folder} klasörüne aktarıldı.}}",
"import_failed": "{name} içe aktarılamadı.",
"too_large_file": "{name} çok büyük. Sınır dosya başına {max}.",
"too_large_message": "Bu dosyalar tek bir ileti için çok büyük. Sınır {max}.",
"folders_left_out": "Klasörler yalnızca Dosyalar'a gidebilir, bu yüzden dışarıda bırakıldı.",
"nothing_here": "Bu dosyalar burada kullanılamaz."
},
"desktopReminders": {
"generic_title": "Anımsatıcı",
"snooze": "{count} dk ertele",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "На вашій клавіатурі ці клавіші вводять символ, і він перестав би працювати в усіх застосунках. Спробуйте інші клавіші.",
"reserved": "Ці клавіші використовує система або більшість застосунків. Спробуйте інші клавіші."
},
"desktopDevTools": {
"section_title": "Усунення несправностей",
"label": "Інструменти розробника ({shortcut})",
"description": "{shortcut} і F12 відкривають інструменти розробника для вікна, у якому ви є. Вимикається, коли застосунок блокується.",
"forced_off": "Вимкнено вашою організацією.",
"locked": "Розблокуйте застосунок, щоб змінити це.",
"menu_toggle": "Показати або сховати інструменти розробника"
},
"fileDrop": {
"overlay_label": "Перетягніть файли",
"overlay_title": "Куди надіслати файли?",
"overlay_hint": "Щоб скасувати, відпустіть поза варіантами або натисніть Esc.",
"files_unknown": "Файли",
"files_summary": "{count, plural, one {# файл} few {# файли} many {# файлів} other {# файлу}}",
"files_summary_size": "{count, plural, one {# файл} few {# файли} many {# файлів} other {# файлу}}, {size}",
"zone_attach_here": "Прикріпити до цього повідомлення",
"zone_insert_inline": "Вставити в текст повідомлення",
"zone_new_message": "Прикріпити до нового повідомлення",
"zone_upload_files": "Завантажити у Файли",
"zone_upload_folder": "Завантажити в {folder}",
"zone_calendar": "Додати до календаря",
"zone_contacts": "Додати до контактів",
"zone_open_eml": "Відкрити повідомлення",
"zone_import_eml": "Імпортувати в {folder}",
"note_limit_message": "До {size} на повідомлення",
"note_limit_file": "До {size} на файл",
"note_kept_offline": "Зберігається на цьому пристрої, доки ви знову не будете в мережі",
"note_needs_connection": "Потрібне з’єднання",
"announce": "{files, plural, =0 {Перетягнуто файли} one {Перетягнуто # файл} few {Перетягнуто # файли} many {Перетягнуто # файлів} other {Перетягнуто # файлу}}. Варіанти: {choices}.",
"choose_title": "{count, plural, one {Куди надіслати цей файл?} few {Куди надіслати ці файли?} many {Куди надіслати ці файли?} other {Куди надіслати ці файли?}}",
"save_first_failed": "Відкрите повідомлення не вдалося зберегти, тому застосунок лишився на місці.",
"import_done": "{count, plural, one {# повідомлення імпортовано в {folder}.} few {# повідомлення імпортовано в {folder}.} many {# повідомлень імпортовано в {folder}.} other {# повідомлення імпортовано в {folder}.}}",
"import_failed": "Не вдалося імпортувати {name}.",
"too_large_file": "{name} завеликий. Обмеження: {max} на файл.",
"too_large_message": "Ці файли завеликі для одного повідомлення. Обмеження: {max}.",
"folders_left_out": "Теки можна надіслати лише у Файли, тому їх пропущено.",
"nothing_here": "Ці файли тут не можна використати."
},
"desktopReminders": {
"generic_title": "Нагадування",
"snooze": "Відкласти на {count} хв",
+38
View File
@@ -7118,6 +7118,44 @@
"types_character": "Các phím này gõ một ký tự trên bàn phím của bạn, và ký tự đó sẽ không dùng được trong mọi ứng dụng. Hãy thử phím khác.",
"reserved": "Hệ thống hoặc hầu hết ứng dụng dùng các phím này. Hãy thử phím khác."
},
"desktopDevTools": {
"section_title": "Khắc phục sự cố",
"label": "Công cụ cho nhà phát triển ({shortcut})",
"description": "Cho phép {shortcut} và F12 mở công cụ cho nhà phát triển của cửa sổ bạn đang dùng. Tự tắt lại khi ứng dụng khóa.",
"forced_off": "Tổ chức của bạn đã tắt mục này.",
"locked": "Hãy mở khóa ứng dụng để thay đổi.",
"menu_toggle": "Hiện hoặc ẩn công cụ cho nhà phát triển"
},
"fileDrop": {
"overlay_label": "Thả tệp",
"overlay_title": "Đưa tệp đi đâu?",
"overlay_hint": "Thả bên ngoài các lựa chọn hoặc nhấn Esc để hủy.",
"files_unknown": "Tệp",
"files_summary": "{count, plural, other {# tệp}}",
"files_summary_size": "{count, plural, other {# tệp}}, {size}",
"zone_attach_here": "Đính kèm vào thư này",
"zone_insert_inline": "Chèn vào nội dung thư",
"zone_new_message": "Đính kèm vào thư mới",
"zone_upload_files": "Tải lên Tệp",
"zone_upload_folder": "Tải lên {folder}",
"zone_calendar": "Thêm vào lịch",
"zone_contacts": "Thêm vào danh bạ",
"zone_open_eml": "Mở thư",
"zone_import_eml": "Nhập vào {folder}",
"note_limit_message": "Tối đa {size} mỗi thư",
"note_limit_file": "Tối đa {size} mỗi tệp",
"note_kept_offline": "Được giữ trên thiết bị này đến khi bạn trực tuyến lại",
"note_needs_connection": "Cần kết nối",
"announce": "{files, plural, =0 {Đã kéo tệp vào} other {Đã kéo vào # tệp}}. Lựa chọn: {choices}.",
"choose_title": "{count, plural, other {Đưa các tệp này đi đâu?}}",
"save_first_failed": "Không lưu được thư đang mở, nên ứng dụng vẫn ở chỗ cũ.",
"import_done": "{count, plural, other {Đã nhập # thư vào {folder}.}}",
"import_failed": "Không nhập được {name}.",
"too_large_file": "{name} quá lớn. Giới hạn là {max} mỗi tệp.",
"too_large_message": "Các tệp này quá lớn cho một thư. Giới hạn là {max}.",
"folders_left_out": "Thư mục chỉ có thể đưa vào Tệp, nên đã bị bỏ qua.",
"nothing_here": "Không dùng được các tệp này ở đây."
},
"desktopReminders": {
"generic_title": "Lời nhắc",
"snooze": "Báo lại sau {count} phút",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "這些鍵在您的鍵盤上會輸入一個字元,占用後該字元在所有應用程式中都無法輸入。請試試其他按鍵。",
"reserved": "系統或大多數應用程式正在使用這些鍵。請試試其他按鍵。"
},
"desktopDevTools": {
"section_title": "疑難排解",
"label": "開發人員工具({shortcut})",
"description": "允許 {shortcut} 和 F12 為您所在的視窗開啟開發人員工具。應用程式鎖定時會再次關閉。",
"forced_off": "您的組織已關閉此功能。",
"locked": "請解除鎖定應用程式以變更此設定。",
"menu_toggle": "顯示或隱藏開發人員工具"
},
"fileDrop": {
"overlay_label": "放開檔案",
"overlay_title": "要把檔案放到哪裡?",
"overlay_hint": "在選項外放開或按 Esc 取消。",
"files_unknown": "檔案",
"files_summary": "{count, plural, other {# 個檔案}}",
"files_summary_size": "{count, plural, other {# 個檔案}}, {size}",
"zone_attach_here": "附加到此郵件",
"zone_insert_inline": "插入到郵件內文",
"zone_new_message": "附加到新郵件",
"zone_upload_files": "上傳到檔案",
"zone_upload_folder": "上傳到 {folder}",
"zone_calendar": "加入行事曆",
"zone_contacts": "加入聯絡人",
"zone_open_eml": "開啟郵件",
"zone_import_eml": "匯入到 {folder}",
"note_limit_message": "每封郵件最多 {size}",
"note_limit_file": "每個檔案最多 {size}",
"note_kept_offline": "在您重新連線前保留在此裝置上",
"note_needs_connection": "需要連線",
"announce": "{files, plural, =0 {已拖入檔案} other {已拖入 # 個檔案}}。選項:{choices}。",
"choose_title": "{count, plural, other {要把這些檔案放到哪裡?}}",
"save_first_failed": "無法儲存開啟的郵件,因此應用程式停留在原處。",
"import_done": "{count, plural, other {已將 # 封郵件匯入到 {folder}。}}",
"import_failed": "無法匯入 {name}。",
"too_large_file": "{name} 太大。每個檔案的上限為 {max}。",
"too_large_message": "這些檔案對一封郵件來說太大。上限為 {max}。",
"folders_left_out": "資料夾只能放到檔案中,因此已略過。",
"nothing_here": "這些檔案無法在此使用。"
},
"desktopReminders": {
"generic_title": "提醒",
"snooze": "{count} 分鐘後再提醒",
+38
View File
@@ -7106,6 +7106,44 @@
"types_character": "这些键在您的键盘上会输入一个字符,占用后该字符在所有应用中都无法输入。请试试其他按键。",
"reserved": "系统或大多数应用在使用这些键。请试试其他按键。"
},
"desktopDevTools": {
"section_title": "故障排除",
"label": "开发者工具({shortcut})",
"description": "允许 {shortcut} 和 F12 为您所在的窗口打开开发者工具。应用锁定时会再次关闭。",
"forced_off": "您的组织已关闭此功能。",
"locked": "请解锁应用以更改此设置。",
"menu_toggle": "显示或隐藏开发者工具"
},
"fileDrop": {
"overlay_label": "放下文件",
"overlay_title": "要把文件放到哪里?",
"overlay_hint": "在选项外放下或按 Esc 取消。",
"files_unknown": "文件",
"files_summary": "{count, plural, other {# 个文件}}",
"files_summary_size": "{count, plural, other {# 个文件}}, {size}",
"zone_attach_here": "附加到此邮件",
"zone_insert_inline": "插入到邮件正文",
"zone_new_message": "附加到新邮件",
"zone_upload_files": "上传到文件",
"zone_upload_folder": "上传到 {folder}",
"zone_calendar": "添加到日历",
"zone_contacts": "添加到联系人",
"zone_open_eml": "打开邮件",
"zone_import_eml": "导入到 {folder}",
"note_limit_message": "每封邮件最多 {size}",
"note_limit_file": "每个文件最多 {size}",
"note_kept_offline": "在您重新联网前保存在此设备上",
"note_needs_connection": "需要连接",
"announce": "{files, plural, =0 {已拖入文件} other {已拖入 # 个文件}}。选项:{choices}。",
"choose_title": "{count, plural, other {要把这些文件放到哪里?}}",
"save_first_failed": "无法保存打开的邮件,因此应用停留在原处。",
"import_done": "{count, plural, other {已将 # 封邮件导入到 {folder}。}}",
"import_failed": "无法导入 {name}。",
"too_large_file": "{name} 太大。每个文件的上限为 {max}。",
"too_large_message": "这些文件对一封邮件来说太大。上限为 {max}。",
"folders_left_out": "文件夹只能放到文件中,因此已略过。",
"nothing_here": "这些文件无法在此使用。"
},
"desktopReminders": {
"generic_title": "提醒",
"snooze": "{count} 分钟后再提醒",