,
+ document.body,
+ );
+
+ const preferred = choice ? defaultZone(choice.zones, choice.preferred) : undefined;
+ const names = choice?.list.map((file) => file.name) ?? [];
+
+ return (
+ <>
+ {overlay}
+
+ >
+ );
+}
diff --git a/components/file-drop/file-drop-zones.tsx b/components/file-drop/file-drop-zones.tsx
new file mode 100644
index 000000000..f47b07c7b
--- /dev/null
+++ b/components/file-drop/file-drop-zones.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import { forwardRef, type ComponentType, type ReactNode } from "react";
+import { useTranslations } from "next-intl";
+import { CalendarPlus, FolderInput, 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> = {
+ "attach-here": Paperclip,
+ "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 "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) => void;
+ children?: ReactNode;
+}
+
+/** One big choice: the same look as a drop target and as a button. */
+export const ZoneCard = forwardRef(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 = (
+ <>
+
+ {label}
+ {note && {note}}
+ >
+ );
+ if (as === "button") {
+ return (
+
+ );
+ }
+ return (
+
+ {content}
+
+ );
+});
+
+/** 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";
+}
diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx
index 04fe72301..a4bdf9456 100644
--- a/components/files/files-app.tsx
+++ b/components/files/files-app.tsx
@@ -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 " 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(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 (
-
+
{!isEmbedded && {tShell("skip_to_content")}}
{refreshIndicator}
diff --git a/lib/desktop/__tests__/os-window.test.tsx b/lib/desktop/__tests__/os-window.test.tsx
index b6df496bb..84cbc30ff 100644
--- a/lib/desktop/__tests__/os-window.test.tsx
+++ b/lib/desktop/__tests__/os-window.test.tsx
@@ -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) => (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 = {
diff --git a/lib/desktop/window-files.ts b/lib/desktop/window-files.ts
index 63a67630b..4666b0e40 100644
--- a/lib/desktop/window-files.ts
+++ b/lib/desktop/window-files.ts
@@ -1,13 +1,9 @@
/**
- * 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';
-
interface ComposerTarget {
take: (files: File[]) => unknown;
/** The Pro shell keeps composers mounted on tabs out of sight. */
@@ -18,7 +14,7 @@ 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. */
+/** A composer on screen takes files itself. */
export function setComposerFileTarget(take: ComposerTarget['take'], visible: ComposerTarget['visible']): () => void {
const target = { take, visible };
composerTargets.push(target);
@@ -28,7 +24,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 +33,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 +43,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 | 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;
}
diff --git a/lib/file-drop/__tests__/drag-watch.test.ts b/lib/file-drop/__tests__/drag-watch.test.ts
new file mode 100644
index 000000000..d0358dbba
--- /dev/null
+++ b/lib/file-drop/__tests__/drag-watch.test.ts
@@ -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();
+ });
+});
diff --git a/lib/file-drop/__tests__/zones.test.ts b/lib/file-drop/__tests__/zones.test.ts
new file mode 100644
index 000000000..bc1297903
--- /dev/null
+++ b/lib/file-drop/__tests__/zones.test.ts
@@ -0,0 +1,119 @@
+import { describe, expect, it } from 'vitest';
+import { defaultZone, fileDropZones, filesWithinLimits, kindForType, MAX_ZONES, type DropFiles, type DropPlace } from '../zones';
+
+const place = (patch: Partial = {}): DropPlace => ({
+ composer: 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('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');
+ });
+});
diff --git a/lib/file-drop/actions.ts b/lib/file-drop/actions.ts
new file mode 100644
index 000000000..fed28072a
--- /dev/null
+++ b/lib/file-drop/actions.ts
@@ -0,0 +1,58 @@
+/**
+ * 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;
+ /** Email/import into the folder the zone named. */
+ importMessages: (files: File[]) => void | Promise;
+}
+
+/** `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 {
+ 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 '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;
+ }
+}
diff --git a/lib/file-drop/choice.ts b/lib/file-drop/choice.ts
new file mode 100644
index 000000000..5fd6e2559
--- /dev/null
+++ b/lib/file-drop/choice.ts
@@ -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;
+ };
+}
+
diff --git a/lib/file-drop/drag-watch.ts b/lib/file-drop/drag-watch.ts
new file mode 100644
index 000000000..64286d0c1
--- /dev/null
+++ b/lib/file-drop/drag-watch.ts
@@ -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 | 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);
+ };
+}
diff --git a/lib/file-drop/files-target.ts b/lib/file-drop/files-target.ts
new file mode 100644
index 000000000..418c0c88d
--- /dev/null
+++ b/lib/file-drop/files-target.ts
@@ -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 ". 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);
+ };
+}
diff --git a/lib/file-drop/read.ts b/lib/file-drop/read.ts
new file mode 100644
index 000000000..5c9ebd960
--- /dev/null
+++ b/lib/file-drop/read.ts
@@ -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 {
+ 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 }));
+}
diff --git a/lib/file-drop/zones.ts b/lib/file-drop/zones.ts
new file mode 100644
index 000000000..d0ab21855
--- /dev/null
+++ b/lib/file-drop/zones.ts
@@ -0,0 +1,145 @@
+/**
+ * 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' | 'new-message' | 'upload' | 'calendar' | 'contacts' | 'open-eml' | 'import-eml';
+
+export type DroppedKind = Exclude | '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;
+ /** 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. */
+function dropped(kind: HandoffKind | null): DroppedKind {
+ return kind && kind !== 'compose' ? kind : 'other';
+}
+
+export function kindForType(type: string): DroppedKind {
+ return dropped(kindForFile({ name: '', type }));
+}
+
+export function kindForName(file: { name: string; type?: string }): DroppedKind {
+ 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 {
+ 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[] = [];
+ 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,
+): { 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';
+ 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];
+}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index ed9cbecb0..aa5807286 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -7058,6 +7058,35 @@
"types_character": "تكتب هذه المفاتيح حرفًا على لوحة مفاتيحك، وسيتوقف عن العمل في كل التطبيقات. جرّب مفاتيح أخرى.",
"reserved": "يستخدم النظام أو معظم التطبيقات هذه المفاتيح. جرّب مفاتيح أخرى."
},
+ "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_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} د",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 5e3d4916d..04f546b54 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -7058,6 +7058,35 @@
"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."
},
+ "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_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",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index ace3e4105..9c5721a75 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -7058,6 +7058,35 @@
"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."
},
+ "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_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",
diff --git a/locales/da/common.json b/locales/da/common.json
index b473c8379..ad28302ef 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -7058,6 +7058,35 @@
"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."
},
+ "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_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.",
diff --git a/locales/de/common.json b/locales/de/common.json
index 9cb2097f7..ae9368ddc 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -7058,6 +7058,35 @@
"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."
},
+ "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_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",
diff --git a/locales/en/common.json b/locales/en/common.json
index 84f9389a7..3cba72c6a 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -7058,6 +7058,35 @@
"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."
},
+ "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_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",
diff --git a/locales/es/common.json b/locales/es/common.json
index a44fce868..398480475 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -7058,6 +7058,35 @@
"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."
},
+ "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_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",
diff --git a/locales/et/common.json b/locales/et/common.json
index ace51f3b5..25c3db37e 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -7058,6 +7058,35 @@
"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."
},
+ "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_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",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 9961aab01..ee05eb3dc 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -7058,6 +7058,35 @@
"types_character": "این کلیدها روی صفحهکلید شما نویسهای مینویسند و آن نویسه دیگر در هیچ برنامهای کار نمیکند. کلیدهای دیگری را امتحان کنید.",
"reserved": "سیستم یا بیشتر برنامهها از این کلیدها استفاده میکنند. کلیدهای دیگری را امتحان کنید."
},
+ "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_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} دقیقه",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 750e84417..1c45bde45 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -7058,6 +7058,35 @@
"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."
},
+ "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_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",
diff --git a/locales/he/common.json b/locales/he/common.json
index 23cb11fa8..5066ec4b6 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -7059,6 +7059,35 @@
"types_character": "המקשים האלה מקלידים תו במקלדת שלכם, והוא יפסיק לעבוד בכל האפליקציות. נסו מקשים אחרים.",
"reserved": "המערכת או רוב האפליקציות משתמשות במקשים האלה. נסו מקשים אחרים."
},
+ "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_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} דק'",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 172fa6df6..db74f7bc4 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -7058,6 +7058,35 @@
"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."
},
+ "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_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",
diff --git a/locales/it/common.json b/locales/it/common.json
index 71f13cca0..a6d8ff703 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -7058,6 +7058,35 @@
"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."
},
+ "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_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",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 7d382e843..8b3443527 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -7058,6 +7058,35 @@
"types_character": "このキーはお使いのキーボードで文字を入力します。割り当てると、すべてのアプリでその文字を入力できなくなります。別のキーをお試しください。",
"reserved": "このキーはシステムまたは多くのアプリが使用しています。別のキーをお試しください。"
},
+ "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_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} 分後に再通知",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 9f9643ad0..2afd81a3d 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -7058,6 +7058,35 @@
"types_character": "이 키는 사용 중인 키보드에서 문자를 입력하며, 지정하면 모든 앱에서 그 문자를 입력할 수 없게 됩니다. 다른 키를 사용해 보세요.",
"reserved": "시스템 또는 대부분의 앱이 이 키를 사용합니다. 다른 키를 사용해 보세요."
},
+ "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_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}분 후 다시 알림",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index a4bc8ef83..d17bb9fb0 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -7058,6 +7058,35 @@
"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."
},
+ "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_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",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 152574c4e..2303cb1aa 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -7058,6 +7058,35 @@
"types_character": "Эдгээр товч таны гар дээр тэмдэгт бичдэг тул тэр тэмдэгт бүх аппад ажиллахаа болино. Өөр товч туршина уу.",
"reserved": "Систем эсвэл ихэнх апп эдгээр товчийг ашигладаг. Өөр товч туршина уу."
},
+ "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_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} минут хойшлуулах",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 9a6c14f76..f1e3cfd14 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -7058,6 +7058,35 @@
"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."
},
+ "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_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",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 3c57a4007..b9ddfd6af 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -7058,6 +7058,35 @@
"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."
},
+ "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_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",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 20bd079ae..65b613b33 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -7058,6 +7058,35 @@
"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."
},
+ "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_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",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 2452edf4a..f9a029c78 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -7058,6 +7058,35 @@
"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."
},
+ "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_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",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 03bfef66a..d72dd9ebd 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -7058,6 +7058,35 @@
"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."
},
+ "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_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",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index ebb25a96f..2a34bc36b 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -7058,6 +7058,35 @@
"types_character": "На вашей клавиатуре эти клавиши вводят символ, и он перестал бы работать во всех приложениях. Попробуйте другие клавиши.",
"reserved": "Эти клавиши использует система или большинство приложений. Попробуйте другие клавиши."
},
+ "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_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} мин",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 43343c99e..8acfc3240 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -7058,6 +7058,35 @@
"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."
},
+ "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_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",
diff --git a/locales/th/common.json b/locales/th/common.json
index d4aef2af2..c25f55fdf 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -7066,6 +7066,35 @@
"types_character": "ปุ่มเหล่านี้พิมพ์อักขระบนแป้นพิมพ์ของคุณ และอักขระนั้นจะใช้ไม่ได้ในทุกแอป ลองใช้ปุ่มอื่น",
"reserved": "ระบบหรือแอปส่วนใหญ่ใช้ปุ่มเหล่านี้ ลองใช้ปุ่มอื่น"
},
+ "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_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} นาที",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 03393f7f0..956d66056 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -7058,6 +7058,35 @@
"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."
},
+ "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_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",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index f033cd1ee..58b3f3d2d 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -7058,6 +7058,35 @@
"types_character": "На вашій клавіатурі ці клавіші вводять символ, і він перестав би працювати в усіх застосунках. Спробуйте інші клавіші.",
"reserved": "Ці клавіші використовує система або більшість застосунків. Спробуйте інші клавіші."
},
+ "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_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} хв",
diff --git a/locales/vi/common.json b/locales/vi/common.json
index 9faa09bde..59b4ac37b 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -7070,6 +7070,35 @@
"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."
},
+ "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_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",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index eed67bf21..4a6e8f67a 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -7058,6 +7058,35 @@
"types_character": "這些鍵在您的鍵盤上會輸入一個字元,占用後該字元在所有應用程式中都無法輸入。請試試其他按鍵。",
"reserved": "系統或大多數應用程式正在使用這些鍵。請試試其他按鍵。"
},
+ "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_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} 分鐘後再提醒",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 0edeb8c81..e17e37435 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -7058,6 +7058,35 @@
"types_character": "这些键在您的键盘上会输入一个字符,占用后该字符在所有应用中都无法输入。请试试其他按键。",
"reserved": "系统或大多数应用在使用这些键。请试试其他按键。"
},
+ "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_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} 分钟后再提醒",