feat: offer attach or upload when files are dragged onto any screen

Files dragged into the window from outside now bring up a full-window
overlay with large drop zones: attach to a new message and upload to
Files, with the open composer first, "Upload to <folder>" on the Files
screen, and add to calendar, add to contacts, open or import for .ics,
.vcf and .eml files. At most four zones, the one under the pointer
highlights, Esc or dragging out cancels, a drop beside the zones does
nothing. Drags that start in the app never show it. A file pasted
outside a text field gets the same choices as a keyboard dialog.

The desktop app used to turn any file dropped on the window into a new
draft (lib/desktop/window-files.ts). That handling moved to the
overlay, so the "files on the window" tests in os-window.test.tsx went
with it: the same cases are now in components/file-drop/__tests__.
This commit is contained in:
Linus Rath
2026-11-04 05:37:35 +01:00
parent 0e400dbcf4
commit e211ae905e
46 changed files with 2341 additions and 232 deletions
+2
View File
@@ -31,6 +31,7 @@ import { OfflineHost } from "@/components/offline/offline-host";
import { DeviceWipeHost } from "@/components/offline/device-wipe-host";
import { NotificationActionsHost } from "@/components/providers/notification-actions-host";
import { DesktopOsHost } from "@/components/desktop/desktop-os-host";
import { FileDropHost } from "@/components/file-drop/file-drop-host";
import { EditedFilesHost } from "@/components/desktop/edited-files-host";
import { DevicePolicyHost } from "@/components/device/device-policy-host";
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
@@ -103,6 +104,7 @@ export default async function LocaleLayout({
<SessionTimeoutGuard />
<AccountTemplateSync />
{children}
<FileDropHost />
<PluginDialogHost />
<ExternalRecipientConfirmHost />
<PhishingReportHost />
+3 -5
View File
@@ -8,15 +8,15 @@ import { isAppLocked } from "@/lib/desktop/app-lock-state";
import { contentHiddenFor, startMailNotifications, type MailNotificationLabels, type MailNotifications } from "@/lib/desktop/mail-notifications";
import { startOsSummary } from "@/lib/desktop/os-summary";
import { startPowerWatch } from "@/lib/desktop/power";
import { startWindowFiles } from "@/lib/desktop/window-files";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
/**
* The main window's end of the desktop app's OS integration: new mail as native
* notifications, what the tray and jump list show, waking up after sleep, and files
* dropped or pasted on the window. Renders nothing, and does nothing in a browser.
* notifications, what the tray and jump list show, and waking up after sleep. Files
* dropped or pasted on the window are the file drop overlay's (components/file-drop).
* Renders nothing, and does nothing in a browser.
* Lives inside MainWindowOnly, so pop-outs don't do any of it a second time.
*/
export function DesktopOsHost() {
@@ -55,12 +55,10 @@ export function DesktopOsHost() {
const summary = startOsSummary(() => localeRef.current);
summaryRef.current = summary;
const stopPower = startPowerWatch();
const stopFiles = startWindowFiles();
return () => {
mail.stop();
summary.stop();
stopPower();
stopFiles();
mailRef.current = null;
summaryRef.current = null;
};
@@ -0,0 +1,297 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// The overlay on every screen: what shows for a drag from outside, where a drop goes, and
// the same choices as a dialog for a pasted file or one opened with the desktop app.
const hoisted = vi.hoisted(() => ({
push: vi.fn(),
pathname: { current: '/' },
toastError: vi.fn(),
toastInfo: vi.fn(),
toastSuccess: vi.fn(),
locked: { current: false },
flushComposers: vi.fn(async () => ({ total: 0, settled: 0, failed: 0, timedOut: false })),
}));
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => (values ? `${key}:${JSON.stringify(values)}` : key),
}));
vi.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push: hoisted.push, replace: vi.fn(), back: vi.fn() }),
usePathname: () => hoisted.pathname.current,
}));
vi.mock('@/stores/toast-store', () => ({ toast: { error: hoisted.toastError, info: hoisted.toastInfo, success: hoisted.toastSuccess } }));
vi.mock('@/lib/compose-flush-registry', () => ({ flushComposers: hoisted.flushComposers }));
vi.mock('@/lib/desktop/app-lock-state', () => ({ isAppLocked: () => hoisted.locked.current }));
const { useAuthStore } = await import('@/stores/auth-store');
const { clearHandoffs, takeHandoff } = await import('@/lib/launch-handoff');
const { setComposerFileTarget, setOwnDragActive } = await import('@/lib/desktop/window-files');
const { setFilesDropTarget, takeQueuedFilesUpload } = await import('@/lib/file-drop/files-target');
const { requestFileChoice } = await import('@/lib/file-drop/choice');
const { FileDropHost } = await import('../file-drop-host');
interface FakeClient {
supportsFiles: () => boolean;
supportsContacts: () => boolean;
getMaxSizeUpload: () => number;
getMaxSizeAttachmentsPerEmail: () => number;
}
function signIn(client: Partial<FakeClient> = {}) {
useAuthStore.setState({
isAuthenticated: true,
activeAccountId: null,
client: {
supportsFiles: () => true,
supportsContacts: () => false,
getMaxSizeUpload: () => 0,
getMaxSizeAttachmentsPerEmail: () => 0,
...client,
} as never,
});
}
function transfer(files: File[]) {
return {
types: ['Files'],
files,
items: files.map((file) => ({ kind: 'file', type: file.type, getAsFile: () => file, webkitGetAsEntry: () => ({ isFile: true, isDirectory: false }) })),
dropEffect: 'none',
};
}
/** A drag from outside reaching the window, as the browser sends it. */
function dragIn(files: File[]) {
const event = new Event('dragenter', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'dataTransfer', { value: transfer(files) });
act(() => {
document.body.dispatchEvent(event);
});
}
function zone(id: string): HTMLElement {
const found = document.querySelector<HTMLElement>(`[data-testid="file-drop-overlay"] [data-drop-zone="${id}"]`);
if (!found) throw new Error(`no zone ${id}`);
return found;
}
const flush = () => act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
const file = new File(['hello'], 'report.pdf', { type: 'application/pdf' });
beforeEach(() => {
clearHandoffs();
takeHandoff('compose');
takeQueuedFilesUpload();
hoisted.locked.current = false;
hoisted.pathname.current = '/';
vi.clearAllMocks();
signIn();
});
afterEach(() => {
setOwnDragActive(false);
useAuthStore.setState({ isAuthenticated: false, client: null });
});
describe('a drag from outside', () => {
it('covers the window with the two main choices and says how many files', () => {
render(<FileDropHost />);
dragIn([file, file]);
expect(screen.getByTestId('file-drop-overlay')).toBeTruthy();
expect(zone('new-message').textContent).toContain('zone_new_message');
expect(zone('upload').textContent).toContain('zone_upload_files');
expect(screen.getByTestId('file-drop-count').textContent).toBe('files_summary:{"count":2}');
});
it('a drop on "Attach to a new message" starts a draft with the file', async () => {
render(<FileDropHost />);
dragIn([file]);
fireEvent.drop(zone('new-message'), { dataTransfer: transfer([file]) });
await flush();
expect(takeHandoff('compose')?.files).toEqual([file]);
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
});
it('with a composer on screen, its zone comes first and takes the file', async () => {
const take = vi.fn();
const clear = setComposerFileTarget(take, () => true);
render(<FileDropHost />);
dragIn([file]);
const zones = Array.from(document.querySelectorAll('[data-drop-zone]')).map((element) => element.getAttribute('data-drop-zone'));
expect(zones[0]).toBe('attach-here');
fireEvent.drop(zone('attach-here'), { dataTransfer: transfer([file]) });
await flush();
expect(take).toHaveBeenCalledWith([file]);
expect(takeHandoff('compose')).toBeNull();
clear();
});
it('on the Files screen, "Upload to <folder>" hands the files to that folder', async () => {
const take = vi.fn();
const clear = setFilesDropTarget({ folder: 'Reports', take, visible: () => true });
render(<FileDropHost />);
dragIn([file]);
expect(zone('upload').textContent).toContain('zone_upload_folder:{"folder":"Reports"}');
fireEvent.drop(zone('upload'), { dataTransfer: transfer([file]) });
await flush();
expect(take).toHaveBeenCalledWith({ files: [file], asFolder: false });
clear();
});
it('from another screen, Files gets the files after the app goes there', async () => {
render(<FileDropHost />);
dragIn([file]);
fireEvent.drop(zone('upload'), { dataTransfer: transfer([file]) });
await flush();
expect(hoisted.push).toHaveBeenCalledWith('/files');
expect(takeQueuedFilesUpload()).toEqual({ files: [file], asFolder: false });
});
it('a drop beside the zones does nothing', async () => {
render(<FileDropHost />);
dragIn([file]);
fireEvent.drop(screen.getByTestId('file-drop-overlay'), { dataTransfer: transfer([file]) });
await flush();
expect(takeHandoff('compose')).toBeNull();
expect(takeQueuedFilesUpload()).toBeNull();
});
it('a file over the server limit is named and left out before anything starts', async () => {
signIn({ getMaxSizeUpload: () => 3 });
render(<FileDropHost />);
dragIn([file]);
expect(zone('upload').textContent).toContain('note_limit_file');
fireEvent.drop(zone('upload'), { dataTransfer: transfer([file]) });
await flush();
expect(hoisted.toastError).toHaveBeenCalledWith(expect.stringContaining('too_large_file'));
expect(takeQueuedFilesUpload()).toBeNull();
});
it('the app\'s own drags never bring it up', () => {
render(<FileDropHost />);
act(() => {
document.body.dispatchEvent(new Event('dragstart', { bubbles: true }));
});
dragIn([file]);
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
act(() => {
document.body.dispatchEvent(new Event('pointermove', { bubbles: true }));
});
setOwnDragActive(true);
dragIn([file]);
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
});
it('nothing while the app is locked, signed out, or behind a dialog of its own', () => {
render(<FileDropHost />);
hoisted.locked.current = true;
dragIn([file]);
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
hoisted.locked.current = false;
const dialog = document.body.appendChild(document.createElement('div'));
dialog.setAttribute('aria-modal', 'true');
dragIn([file]);
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
dialog.remove();
useAuthStore.setState({ isAuthenticated: false });
dragIn([file]);
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
});
it('leaves Files out on a server without it', () => {
signIn({ supportsFiles: () => false });
render(<FileDropHost />);
dragIn([file]);
expect(document.querySelector('[data-drop-zone="upload"]')).toBeNull();
expect(zone('new-message')).toBeTruthy();
});
it('dragging out of the window takes it away', () => {
render(<FileDropHost />);
dragIn([file]);
const leave = new Event('dragleave', { bubbles: true });
Object.defineProperty(leave, 'relatedTarget', { value: null });
act(() => {
screen.getByTestId('file-drop-overlay').dispatchEvent(leave);
});
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
});
});
describe('the same choices without a drag', () => {
function paste(target: EventTarget, files: File[], types = ['Files']) {
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: { files, types } });
act(() => {
target.dispatchEvent(event);
});
return event;
}
it('a file pasted outside a text field asks, with the focus on a new message, and Enter takes it', async () => {
render(<FileDropHost />);
expect(paste(document.body, [file]).defaultPrevented).toBe(true);
const dialog = await screen.findByTestId('file-drop-choice');
await waitFor(() => expect(document.activeElement?.getAttribute('data-drop-zone')).toBe('new-message'));
expect(dialog.querySelector('[data-drop-zone="upload"]')).toBeTruthy();
fireEvent.click(document.activeElement as HTMLElement);
await flush();
expect(takeHandoff('compose')?.files).toEqual([file]);
expect(screen.queryByTestId('file-drop-choice')).toBeNull();
});
it('arrow keys move between the choices', async () => {
render(<FileDropHost />);
paste(document.body, [file]);
await screen.findByTestId('file-drop-choice');
await waitFor(() => expect(document.activeElement?.getAttribute('data-drop-zone')).toBe('new-message'));
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowRight' });
expect(document.activeElement?.getAttribute('data-drop-zone')).toBe('upload');
});
it('with only one thing to do, it does it without asking', async () => {
signIn({ supportsFiles: () => false });
render(<FileDropHost />);
paste(document.body, [file]);
await flush();
expect(screen.queryByTestId('file-drop-choice')).toBeNull();
expect(takeHandoff('compose')?.files).toEqual([file]);
});
it('pasted text, or a file in a search field, is left alone', () => {
render(<FileDropHost />);
expect(paste(document.body, [file], ['text/plain', 'Files']).defaultPrevented).toBe(false);
const input = document.body.appendChild(document.createElement('input'));
expect(paste(input, [file]).defaultPrevented).toBe(false);
input.remove();
expect(screen.queryByTestId('file-drop-choice')).toBeNull();
});
it('a bare file pasted into a composer on screen attaches to it', () => {
const take = vi.fn();
const clear = setComposerFileTarget(take, () => true);
render(<FileDropHost />);
const composer = document.body.appendChild(document.createElement('div'));
composer.setAttribute('data-testid', 'email-composer');
const body = composer.appendChild(document.createElement('div'));
body.setAttribute('contenteditable', 'true');
paste(body, [file]);
expect(take).toHaveBeenCalledWith([file]);
composer.remove();
clear();
});
it('a file opened with the desktop app gets the dialog, and waits for a sign-in', async () => {
useAuthStore.setState({ isAuthenticated: false });
render(<FileDropHost />);
act(() => requestFileChoice({ files: [file], preferred: 'new-message' }));
expect(screen.queryByTestId('file-drop-choice')).toBeNull();
act(() => signIn());
expect(await screen.findByTestId('file-drop-choice')).toBeTruthy();
});
});
+334
View File
@@ -0,0 +1,334 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
import { usePathname, useRouter } from "@/i18n/navigation";
import { Dialog } from "@/components/ui/dialog";
import { Upload } from "@/components/icons";
import { announce } from "@/lib/announce";
import { flushComposers } from "@/lib/compose-flush-registry";
import { matchSurface } from "@/lib/deep-links";
import { isAppLocked } from "@/lib/desktop/app-lock-state";
import { pasteTarget, visibleComposer } from "@/lib/desktop/window-files";
import { runFileDropZone, type FileDropEnv } from "@/lib/file-drop/actions";
import { listenForFileChoices, type FileChoiceRequest } from "@/lib/file-drop/choice";
import { startDragWatch } from "@/lib/file-drop/drag-watch";
import { filesDropTarget } from "@/lib/file-drop/files-target";
import { readDroppedFiles, type DroppedFiles } from "@/lib/file-drop/read";
import {
defaultZone,
fileDropZones,
filesWithinLimits,
kindForName,
type DropFiles,
type DropPlace,
type FileDropZone,
type FileDropZoneId,
} from "@/lib/file-drop/zones";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { isOfflineNow, useOfflineStore } from "@/lib/offline/state";
import { isPopoutPath } from "@/lib/popout/window-role";
import { formatFileSize } from "@/lib/utils";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { toast } from "@/stores/toast-store";
import { useZoneText, ZoneCard, zoneGridClass } from "./file-drop-zones";
import { cn } from "@/lib/utils";
/** How long leaving the page may take to save an open composer. */
const FLUSH_BEFORE_LEAVING_MS = 5000;
const NAMES_SHOWN = 3;
/** Where a dropped .eml could go: the real folder in view on the mail screen, if new mail may go in. */
function importFolder(pathname: string, translate: (key: string) => string): { id: string; name: string; accountId?: string } | null {
if (matchSurface(pathname)?.surface !== "mail") return null;
const { selectedMailbox, mailboxes } = useEmailStore.getState();
const mailbox = mailboxes.find((entry) => entry.id === selectedMailbox);
if (!mailbox || mailbox.isShared || mailbox.myRights?.mayAddItems === false) return null;
return { id: mailbox.id, name: localizeMailboxName(mailbox.role, mailbox.name, translate), accountId: mailbox.accountId };
}
/** Whatever would stop a drop now. A dialog of its own (an import, a picker) keeps its own drop zone. */
function overlayBlocked(): boolean {
if (isAppLocked() || !useAuthStore.getState().isAuthenticated) return true;
const modal = document.querySelector('[aria-modal="true"]');
return !!modal && !modal.querySelector('[data-testid="email-composer"]');
}
/** What can be done with files here and now. Read at the moment of the drag, not kept. */
function currentPlace(pathname: string, translate: (key: string) => string): { place: DropPlace; folder: ReturnType<typeof importFolder> } {
const auth = useAuthStore.getState();
const client = auth.client;
const features = usePolicyStore.getState();
const account = auth.activeAccountId ? useAccountStore.getState().accounts.find((entry) => entry.id === auth.activeAccountId) : undefined;
const folder = importFolder(pathname, translate);
const place: DropPlace = {
composer: !!visibleComposer(),
popout: isPopoutPath(window.location.pathname),
files: !!client?.supportsFiles?.() && features.isFeatureEnabled("filesEnabled"),
filesFolder: filesDropTarget()?.folder ?? null,
calendar: useCalendarStore.getState().supportsCalendar && features.isFeatureEnabled("calendarEnabled"),
contacts: !!client?.supportsContacts?.() && features.isFeatureEnabled("contactsEnabled"),
importFolder: folder?.name ?? null,
offline: isOfflineNow(account?.serverUrl),
keepsOffline: !!auth.activeAccountId && useOfflineStore.getState().enabled.includes(auth.activeAccountId),
maxUploadBytes: client?.getMaxSizeUpload?.() ?? 0,
maxMessageBytes: client?.getMaxSizeAttachmentsPerEmail?.() ?? 0,
};
return { place, folder };
}
interface DragState {
files: DropFiles;
zones: FileDropZone[];
place: DropPlace;
folder: ReturnType<typeof importFolder>;
}
interface ChoiceState extends DragState {
list: File[];
preferred?: FileDropZoneId;
}
function asDropped(files: File[]): DroppedFiles {
return { plain: files, all: files, folders: false };
}
function summaryOf(files: readonly File[]): DropFiles {
return { count: files.length, kinds: files.map((file) => kindForName(file)), bytes: files.reduce((sum, file) => sum + file.size, 0) };
}
/**
* The file drop overlay on every screen: files dragged in from outside get big zones to drop
* on, the same zones come up as a dialog for a pasted file or a file opened with the desktop
* app. Mounted once in the locale layout, in pop-outs too, where only their own composer
* takes files.
*/
export function FileDropHost() {
const t = useTranslations("fileDrop");
const tSidebar = useTranslations("sidebar");
const { label, note } = useZoneText();
const router = useRouter();
const pathname = usePathname();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const [drag, setDrag] = useState<DragState | null>(null);
const [active, setActive] = useState<FileDropZoneId | null>(null);
const [choice, setChoice] = useState<ChoiceState | null>(null);
const [pendingChoices, setPendingChoices] = useState<FileChoiceRequest[]>([]);
const defaultRef = useRef<HTMLButtonElement & HTMLDivElement>(null);
const ctx = useRef({ pathname, router, t, tSidebar, label });
ctx.current = { pathname, router, t, tSidebar, label };
const translateMailbox = useCallback((key: string) => ctx.current.tSidebar(`mailboxes.${key}`), []);
const countText = useCallback((files: DropFiles) => {
if (files.count === null) return t("files_unknown");
if (files.bytes !== null) return t("files_summary_size", { count: files.count, size: formatFileSize(files.bytes) });
return t("files_summary", { count: files.count });
}, [t]);
const env = useCallback((folder: ReturnType<typeof importFolder>): FileDropEnv => ({
goTo: async (path) => {
const result = await flushComposers(FLUSH_BEFORE_LEAVING_MS);
if (result.failed > 0 || result.timedOut) {
toast.error(ctx.current.t("save_first_failed"));
return;
}
if (ctx.current.pathname !== path) ctx.current.router.push(path);
},
importMessages: async (files) => {
const client = useAuthStore.getState().client;
if (!client || !folder) return;
let done = 0;
for (const file of files) {
try {
await client.importRawEmail(file, { [folder.id]: true }, { $seen: true }, folder.accountId);
done += 1;
} catch (error) {
console.error("Importing a dropped message failed:", error);
toast.error(ctx.current.t("import_failed", { name: file.name }));
}
}
if (done > 0) toast.success(ctx.current.t("import_done", { count: done, folder: folder.name }));
},
}), []);
/** Checks what only the files in hand can tell, then hands them over. */
const run = useCallback((zone: FileDropZoneId, dropped: DroppedFiles, state: DragState) => {
const upload = zone === "upload";
if (!upload && dropped.folders) toast.info(ctx.current.t("folders_left_out"));
const { fitting, tooLarge, overMessage } = filesWithinLimits(zone, upload ? dropped.all : dropped.plain, state.place);
for (const file of tooLarge) toast.error(ctx.current.t("too_large_file", { name: file.name, max: formatFileSize(state.place.maxUploadBytes) }));
if (overMessage) toast.error(ctx.current.t("too_large_message", { max: formatFileSize(state.place.maxMessageBytes) }));
if (fitting.length === 0) return;
const what = upload ? { files: fitting, asFolder: dropped.folders } : undefined;
void runFileDropZone(zone, fitting, env(state.folder), what).catch((error) => {
console.error("A file drop failed:", error);
});
}, [env]);
// Files dragged in from outside, anywhere.
useEffect(() => {
return startDragWatch({
enter: (files) => {
if (overlayBlocked()) return false;
const { place, folder } = currentPlace(ctx.current.pathname, translateMailbox);
const zones = fileDropZones(files, place);
if (zones.length === 0) return false;
setActive(null);
setDrag({ files, zones, place, folder });
const choices = zones.map((zone) => ctx.current.label(zone)).join(", ");
announce(ctx.current.t("announce", { files: files.count ?? 0, choices }));
return true;
},
leave: () => {
setDrag(null);
setActive(null);
},
});
}, [translateMailbox]);
// A file pasted outside a text field: the same choices, from the keyboard.
useEffect(() => {
const onPaste = (event: ClipboardEvent) => {
const where = pasteTarget(event);
if (!where) return;
const files = Array.from(event.clipboardData?.files ?? []);
if (where === "composer") {
event.preventDefault();
visibleComposer()?.take(files);
return;
}
if (overlayBlocked()) return;
event.preventDefault();
setPendingChoices((list) => [...list, { files, preferred: visibleComposer() ? "attach-here" : "new-message" }]);
};
window.addEventListener("paste", onPaste);
return () => window.removeEventListener("paste", onPaste);
}, []);
// Files opened with the desktop app.
useEffect(() => listenForFileChoices((request) => setPendingChoices((list) => [...list, request])), []);
// One question at a time, and none while signed out or locked.
useEffect(() => {
if (choice || pendingChoices.length === 0 || !isAuthenticated || isAppLocked()) return;
const [next, ...rest] = pendingChoices;
setPendingChoices(rest);
const { place, folder } = currentPlace(ctx.current.pathname, translateMailbox);
const files = summaryOf(next.files);
const zones = fileDropZones(files, place);
const usable = zones.filter((zone) => !zone.disabled);
if (usable.length === 1) {
run(usable[0].id, asDropped(next.files), { files, zones, place, folder });
return;
}
if (zones.length === 0) {
toast.error(ctx.current.t("nothing_here"));
return;
}
setChoice({ files, zones, place, folder, list: next.files, preferred: next.preferred });
}, [choice, pendingChoices, isAuthenticated, run, translateMailbox]);
const pick = (zone: FileDropZone) => {
if (!choice || zone.disabled) return;
const state = choice;
setChoice(null);
run(zone.id, asDropped(state.list), state);
};
const zoneAt = (target: EventTarget | null): FileDropZone | null => {
const element = (target as Element | null)?.closest?.("[data-drop-zone]");
const id = element?.getAttribute("data-drop-zone");
return drag?.zones.find((zone) => zone.id === id) ?? null;
};
const overlay = drag && typeof document !== "undefined" && createPortal(
<div
className="fixed inset-0 z-[70] flex flex-col items-center justify-center gap-6 bg-background/85 p-6 backdrop-blur-sm pt-[calc(1.5rem+var(--titlebar-row-height,0px))] animate-in fade-in duration-100"
data-testid="file-drop-overlay"
role="region"
aria-label={t("overlay_label")}
onDragOver={(event) => {
event.preventDefault();
const zone = zoneAt(event.target);
const usable = zone && !zone.disabled ? zone : null;
event.dataTransfer.dropEffect = usable ? "copy" : "none";
if ((usable?.id ?? null) !== active) setActive(usable?.id ?? null);
}}
onDrop={(event) => {
event.preventDefault();
const zone = zoneAt(event.target);
const state = drag;
setDrag(null);
setActive(null);
if (!zone || zone.disabled) return;
void readDroppedFiles(event.dataTransfer).then((dropped) => run(zone.id, dropped, state));
}}
>
<div className="flex flex-col items-center gap-1 text-center">
<Upload className="h-8 w-8 text-primary" aria-hidden="true" />
<p className="text-xl font-semibold text-foreground">{t("overlay_title")}</p>
<p className="text-sm text-muted-foreground" data-testid="file-drop-count">{countText(drag.files)}</p>
</div>
<div className={cn("grid w-full gap-4", zoneGridClass(drag.zones.length))}>
{drag.zones.map((zone) => (
<ZoneCard key={zone.id} zone={zone} label={label(zone)} note={note(zone.note)} active={active === zone.id} />
))}
</div>
<p className="text-sm text-muted-foreground">{t("overlay_hint")}</p>
</div>,
document.body,
);
const preferred = choice ? defaultZone(choice.zones, choice.preferred) : undefined;
const names = choice?.list.map((file) => file.name) ?? [];
return (
<>
{overlay}
<Dialog
isOpen={!!choice}
onClose={() => setChoice(null)}
title={t("choose_title", { count: choice?.list.length ?? 0 })}
description={choice ? `${names.slice(0, NAMES_SHOWN).join(", ")}${names.length > NAMES_SHOWN ? ", …" : ""} (${countText(choice.files)})` : undefined}
size="lg"
initialFocus={defaultRef}
testId="file-drop-choice"
>
{choice && (
<div
className={cn("mt-4 grid w-full gap-3", zoneGridClass(Math.min(choice.zones.length, 2)))}
role="group"
aria-label={t("choose_title", { count: choice.list.length })}
onKeyDown={(event) => {
if (!["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp"].includes(event.key)) return;
const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>("button[data-drop-zone]"));
const at = buttons.indexOf(document.activeElement as HTMLButtonElement);
if (at === -1) return;
event.preventDefault();
const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1;
buttons[(at + step + buttons.length) % buttons.length].focus();
}}
>
{choice.zones.map((zone) => (
<ZoneCard
key={zone.id}
ref={zone.id === preferred?.id ? defaultRef : undefined}
as="button"
zone={zone}
label={label(zone)}
note={note(zone.note)}
onClick={() => pick(zone)}
/>
))}
</div>
)}
</Dialog>
</>
);
}
+114
View File
@@ -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<FileDropZoneId, ComponentType<{ className?: string }>> = {
"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<HTMLElement>) => void;
children?: ReactNode;
}
/** One big choice: the same look as a drop target and as a button. */
export const ZoneCard = forwardRef<HTMLButtonElement & HTMLDivElement, ZoneCardProps>(function ZoneCard(
{ zone, label, note, active = false, as = "div", onClick, onKeyDown },
ref,
) {
const Icon = ICONS[zone.id];
const className = cn(
"group flex min-h-36 flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed p-6 text-center transition-colors select-none",
zone.disabled
? "border-border bg-muted/40 text-muted-foreground cursor-not-allowed"
: active
? "border-primary bg-primary/10 text-foreground ring-4 ring-primary/20"
: "border-border bg-background/90 text-foreground hover:border-primary/60",
as === "button" && !zone.disabled && "focus-visible:outline-none focus-visible:border-primary focus-visible:ring-4 focus-visible:ring-primary/30",
);
const content = (
<>
<Icon className={cn("h-10 w-10", zone.disabled ? "text-muted-foreground" : active ? "text-primary" : "text-muted-foreground group-hover:text-primary")} />
<span className="text-lg font-semibold leading-snug">{label}</span>
{note && <span className="text-sm text-muted-foreground">{note}</span>}
</>
);
if (as === "button") {
return (
<button
ref={ref}
type="button"
className={className}
data-drop-zone={zone.id}
aria-disabled={zone.disabled || undefined}
onClick={zone.disabled ? undefined : onClick}
onKeyDown={onKeyDown}
>
{content}
</button>
);
}
return (
<div
ref={ref}
className={className}
data-drop-zone={zone.id}
data-active={active || undefined}
data-disabled={zone.disabled || undefined}
role="group"
aria-label={note ? `${label}. ${note}` : label}
>
{content}
</div>
);
});
/** Two zones side by side, three in a row, four as two by two (a row on a wide window). */
export function zoneGridClass(count: number): string {
if (count <= 1) return "grid-cols-1 max-w-md";
if (count === 2) return "grid-cols-1 sm:grid-cols-2 max-w-3xl";
if (count === 3) return "grid-cols-1 sm:grid-cols-3 max-w-5xl";
return "grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 max-w-6xl";
}
+34 -2
View File
@@ -8,7 +8,8 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store";
import { useFileStore, deletesToTrash, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type UploadReport } from "@/stores/file-store";
import { useFileStore, deletesToTrash, isSharedFilesPage, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type UploadReport } from "@/stores/file-store";
import { hasQueuedFilesUpload, setFilesDropTarget, subscribeQueuedFilesUpload, takeQueuedFilesUpload } from "@/lib/file-drop/files-target";
import { isTrashLocation } from "@/lib/files-trash";
import { fileErrorKind, isPartialCopy } from "@/lib/file-errors";
import { toast } from "@/stores/toast-store";
@@ -805,6 +806,37 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
useFileStore.setState({ supportsFiles: null });
}, [getClientForAccount]);
// The file drop overlay: "Upload to <folder>" while this screen is on view in a folder
// that takes files, and what was dropped elsewhere for Files once one is listed.
const dropRootRef = useRef<HTMLDivElement>(null);
const canTakeDrops = supportsFiles === true && !isAccountPicker && !isSharedFilesPage(currentParentId) && !isTrashLocation(currentParentId);
const dropUploadRef = useRef({ files: handleUploadFiles, folder: handleUploadFolder });
dropUploadRef.current = { files: handleUploadFiles, folder: handleUploadFolder };
useEffect(() => {
if (!canTakeDrops) return;
const folder = currentPath === "/" ? null : currentPath.split("/").filter(Boolean).pop() ?? null;
return setFilesDropTarget({
folder,
take: ({ files, asFolder }) => void (asFolder ? dropUploadRef.current.folder(files) : dropUploadRef.current.files(files)),
visible: () => !!dropRootRef.current?.isConnected && dropRootRef.current.getClientRects().length > 0,
});
}, [canTakeDrops, currentPath]);
useEffect(() => {
if (supportsFiles !== true || isLoading || !storeClient || isAccountPicker) return;
const take = () => {
if (!hasQueuedFilesUpload()) return;
// In the trash or the list of shared drives: to the top of Files first.
if (!canTakeDrops) {
void navigate(null);
return;
}
const upload = takeQueuedFilesUpload();
if (upload) void (upload.asFolder ? dropUploadRef.current.folder(upload.files) : dropUploadRef.current.files(upload.files));
};
take();
return subscribeQueuedFilesUpload(take);
}, [supportsFiles, isLoading, storeClient, isAccountPicker, canTakeDrops, navigate]);
// Until the session is restored: the page skeleton the server drew.
if (!isAuthenticated) {
return isEmbedded
@@ -813,7 +845,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
}
return (
<div className={cn("flex flex-col overflow-hidden pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
<div ref={dropRootRef} className={cn("flex flex-col overflow-hidden pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
{!isEmbedded && <SkipLink>{tShell("skip_to_content")}</SkipLink>}
<AppTopBannerSlot />
{refreshIndicator}
+1 -171
View File
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { BulwarkDesktopBridge, DesktopOsPowerEvent, DesktopOsStatus } from '../types';
import type { CalendarEvent } from '@/lib/jmap/types';
/** The window's end: badge, dropped and pasted files, drag-out, waking up, the settings section. */
/** The window's end: badge, drag-out, waking up, the settings section. Dropped and pasted files: components/file-drop. */
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => (values ? `${key}:${JSON.stringify(values)}` : key),
@@ -90,176 +90,6 @@ describe('the policy keeps refreshing', () => {
});
});
function dropEvent(type: string, files: File[]): Event {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(event, 'dataTransfer', { value: { types: files.length ? ['Files'] : ['text/plain'], files, dropEffect: 'none' } });
return event;
}
describe('files on the window', () => {
const file = new File(['x'], 'report.pdf');
beforeEach(async () => {
const { clearHandoffs, takeHandoff } = await import('@/lib/launch-handoff');
clearHandoffs();
takeHandoff('compose');
});
it('nothing happens in a browser', async () => {
const { startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
const stop = startWindowFiles();
const event = dropEvent('drop', [file]);
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
expect(takeHandoff('compose')).toBeNull();
stop();
});
it('a drop on the window starts a new draft with the file', async () => {
const { startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
const over = dropEvent('dragover', [file]);
window.dispatchEvent(over);
expect(over.defaultPrevented).toBe(true);
const drop = dropEvent('drop', [file]);
window.dispatchEvent(drop);
expect(drop.defaultPrevented).toBe(true);
expect(takeHandoff('compose')?.files).toEqual([file]);
stop();
});
it('a drop something else took is left alone', async () => {
const { startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
const event = dropEvent('drop', [file]);
event.preventDefault();
window.dispatchEvent(event);
expect(takeHandoff('compose')).toBeNull();
stop();
});
it('text dragged around is not a file', async () => {
const { startWindowFiles } = await import('../window-files');
setBridge({});
const stop = startWindowFiles();
const event = dropEvent('dragover', []);
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
stop();
});
it('an open composer takes the file itself', async () => {
const { setComposerFileTarget, startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
const target = vi.fn();
const clear = setComposerFileTarget(target, () => true);
window.dispatchEvent(dropEvent('drop', [file]));
expect(target).toHaveBeenCalledWith([file]);
expect(takeHandoff('compose')).toBeNull();
clear();
stop();
});
it('a composer out of sight on a hidden tab takes nothing', async () => {
const { setComposerFileTarget, startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
const hidden = vi.fn();
const clear = setComposerFileTarget(hidden, () => false);
window.dispatchEvent(dropEvent('drop', [file]));
expect(hidden).not.toHaveBeenCalled();
expect(takeHandoff('compose')?.files).toEqual([file]);
clear();
stop();
});
it('a pasted file is taken; pasted text is not', async () => {
const { startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
const paste = (files: File[]) => {
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: { files, types: files.length ? ['Files'] : ['text/plain'] } });
window.dispatchEvent(event);
return event;
};
expect(paste([]).defaultPrevented).toBe(false);
expect(takeHandoff('compose')).toBeNull();
expect(paste([file]).defaultPrevented).toBe(true);
expect(takeHandoff('compose')?.files).toEqual([file]);
stop();
});
describe('a paste that is not for us', () => {
const pasteOn = (target: EventTarget, types: string[]) => {
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: { files: [file], types } });
target.dispatchEvent(event);
return event;
};
it('text with a file, as a spreadsheet copies, stays text', async () => {
const { startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
expect(pasteOn(document.body, ['text/plain', 'Files']).defaultPrevented).toBe(false);
expect(takeHandoff('compose')).toBeNull();
stop();
});
it('in a search field or any other input, a file is not an attachment', async () => {
const { startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
const input = document.body.appendChild(document.createElement('input'));
expect(pasteOn(input, ['Files']).defaultPrevented).toBe(false);
expect(takeHandoff('compose')).toBeNull();
input.remove();
stop();
});
it('in the body of a composer on screen, a bare file attaches', async () => {
const { setComposerFileTarget, startWindowFiles } = await import('../window-files');
setBridge({});
const stop = startWindowFiles();
const target = vi.fn();
const clear = setComposerFileTarget(target, () => true);
const composer = document.body.appendChild(document.createElement('div'));
composer.setAttribute('data-testid', 'email-composer');
const body = composer.appendChild(document.createElement('div'));
body.setAttribute('contenteditable', 'true');
pasteOn(body, ['Files']);
expect(target).toHaveBeenCalledWith([file]);
composer.remove();
clear();
stop();
});
});
it('our own native drag over our own window is not a new file', async () => {
const { setOwnDragActive, startWindowFiles } = await import('../window-files');
const { takeHandoff } = await import('@/lib/launch-handoff');
setBridge({});
const stop = startWindowFiles();
setOwnDragActive(true);
window.dispatchEvent(dropEvent('drop', [file]));
expect(takeHandoff('compose')).toBeNull();
setOwnDragActive(false);
stop();
});
});
describe('dragging an attachment out', () => {
const blobUrl = 'blob:https://app/1';
const source = {
+18 -54
View File
@@ -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<Element> | null)?.closest?.('[data-testid="email-composer"]');
return inComposer && !!visibleComposer();
}
function hasFiles(event: DragEvent): boolean {
return !!event.dataTransfer && [...event.dataTransfer.types].includes('Files');
}
export function startWindowFiles(): () => void {
if (!getDesktopBridge()?.os) return () => {};
const onDragOver = (event: DragEvent) => {
if (event.defaultPrevented || ownDrag || !hasFiles(event)) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
};
const onDrop = (event: DragEvent) => {
if (event.defaultPrevented || ownDrag || !hasFiles(event)) return;
event.preventDefault();
const files = Array.from(event.dataTransfer?.files ?? []);
if (files.length > 0) take(files);
};
const onPaste = (event: ClipboardEvent) => {
if (event.defaultPrevented) return;
const files = Array.from(event.clipboardData?.files ?? []);
if (files.length === 0 || !pasteIsForUs(event)) return;
event.preventDefault();
take(files);
};
window.addEventListener('dragover', onDragOver);
window.addEventListener('drop', onDrop);
window.addEventListener('paste', onPaste);
return () => {
window.removeEventListener('dragover', onDragOver);
window.removeEventListener('drop', onDrop);
window.removeEventListener('paste', onPaste);
};
return inComposer && visibleComposer() ? 'composer' : null;
}
+106
View File
@@ -0,0 +1,106 @@
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { DRAG_STALE_MS, startDragWatch } from '../drag-watch';
import type { DropFiles } from '../zones';
import { setOwnDragActive } from '@/lib/desktop/window-files';
/** jsdom has no DragEvent: an Event with the parts the watch reads. */
function drag(type: string, options: { files?: number; types?: string[]; itemTypes?: string[]; relatedTarget?: EventTarget | null; target?: EventTarget } = {}): Event {
const event = new Event(type, { bubbles: true, cancelable: true });
const count = options.files ?? 1;
const types = options.types ?? (count > 0 ? ['Files'] : ['text/plain']);
const items = (options.itemTypes ?? Array.from({ length: count }, () => '')).map((itemType) => ({ kind: 'file', type: itemType }));
Object.defineProperty(event, 'dataTransfer', { value: { types, items, files: [], dropEffect: 'none' } });
if ('relatedTarget' in options) Object.defineProperty(event, 'relatedTarget', { value: options.relatedTarget });
(options.target ?? document.body).dispatchEvent(event);
return event;
}
describe('noticing files dragged in from outside', () => {
let enter: Mock<(files: DropFiles) => boolean>;
let leave: Mock<() => void>;
let stop: () => void;
beforeEach(() => {
vi.useFakeTimers();
enter = vi.fn((_files: DropFiles) => true);
leave = vi.fn(() => {});
stop = startDragWatch({ enter, leave });
});
afterEach(() => {
stop();
setOwnDragActive(false);
vi.useRealTimers();
});
it('tells how many files and what kind, as far as the drag shows', () => {
drag('dragenter', { files: 2, itemTypes: ['text/calendar', 'application/pdf'] });
expect(enter).toHaveBeenCalledWith({ count: 2, kinds: ['calendar', 'other'], bytes: null });
});
it('a drag that started in the page is the app\'s own, not files from outside', () => {
document.body.dispatchEvent(new Event('dragstart', { bubbles: true }));
drag('dragenter');
expect(enter).not.toHaveBeenCalled();
// The first pointer event after a drag means it's over, even if dragend went nowhere.
document.body.dispatchEvent(new Event('pointermove', { bubbles: true }));
drag('dragenter');
expect(enter).toHaveBeenCalledTimes(1);
});
it('the app\'s own native drag of an attachment is not a new file either', () => {
setOwnDragActive(true);
drag('dragenter');
expect(enter).not.toHaveBeenCalled();
});
it('text dragged around is no file', () => {
drag('dragenter', { files: 0 });
expect(enter).not.toHaveBeenCalled();
});
it('a component under the pointer does not get the first enter once the overlay takes it', () => {
const inner = document.body.appendChild(document.createElement('div'));
const seen = vi.fn();
inner.addEventListener('dragenter', seen);
drag('dragenter', { target: inner });
expect(seen).not.toHaveBeenCalled();
inner.remove();
});
it('leaving the window ends it; moving between elements does not', () => {
drag('dragenter');
drag('dragleave', { relatedTarget: document.body });
expect(leave).not.toHaveBeenCalled();
drag('dragleave', { relatedTarget: null });
expect(leave).toHaveBeenCalledTimes(1);
});
it('ends by itself when the drag goes quiet, as after Esc in some browsers', () => {
drag('dragenter');
vi.advanceTimersByTime(DRAG_STALE_MS / 2);
drag('dragover');
vi.advanceTimersByTime(DRAG_STALE_MS / 2);
expect(leave).not.toHaveBeenCalled();
vi.advanceTimersByTime(DRAG_STALE_MS);
expect(leave).toHaveBeenCalledTimes(1);
});
it('a drop anywhere ends it, and never lets the browser open the file instead of the app', async () => {
drag('dragenter');
const over = drag('dragover');
expect(over.defaultPrevented).toBe(true);
const dropped = drag('drop');
expect(dropped.defaultPrevented).toBe(true);
await Promise.resolve();
expect(leave).toHaveBeenCalledTimes(1);
});
it('a drag the host turns down (locked, a dialog open) still cannot open the file in place of the app', () => {
enter.mockReturnValue(false);
drag('dragenter');
expect(drag('dragover').defaultPrevented).toBe(true);
expect(drag('drop').defaultPrevented).toBe(true);
expect(leave).not.toHaveBeenCalled();
});
});
+119
View File
@@ -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> = {}): 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');
});
});
+58
View File
@@ -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<void>;
/** Email/import into the folder the zone named. */
importMessages: (files: File[]) => void | Promise<void>;
}
/** `files` without folders for every zone; `upload` also gets what a dropped folder held. */
export async function runFileDropZone(zone: FileDropZoneId, files: File[], env: FileDropEnv, upload?: FilesUpload): Promise<void> {
if (zone === 'upload') {
const what = upload ?? { files, asFolder: false };
if (what.files.length === 0) return;
const target = filesDropTarget();
if (target) {
target.take(what);
return;
}
queueFilesUpload(what);
await env.goTo('/files');
return;
}
if (files.length === 0) return;
switch (zone) {
case 'attach-here': {
const composer = visibleComposer();
// Gone between the drag and the drop: a new message is the next best thing.
if (composer) composer.take(files);
else handOffDraft({ files });
return;
}
case 'new-message':
handOffDraft({ files });
return;
case 'calendar':
handOffFiles(files, 'calendar');
return;
case 'contacts':
handOffFiles(files, 'contacts');
return;
case 'open-eml':
handOffFiles(files.slice(0, 1), 'eml');
return;
case 'import-eml':
await env.importMessages(files);
return;
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* The overlay's choices without a drag: for files pasted outside a text field, and for a
* file opened with the desktop app ("Open with" in Explorer, Finder or a file manager).
* The host shows them as a dialog that works from the keyboard. Requests wait for the
* host to mount, and the host waits for a sign-in.
*/
import type { FileDropZoneId } from './zones';
export interface FileChoiceRequest {
files: File[];
/** Where the focus starts, if that choice is there. */
preferred?: FileDropZoneId;
}
type Listener = (request: FileChoiceRequest) => void;
let listener: Listener | null = null;
const waiting: FileChoiceRequest[] = [];
export function requestFileChoice(request: FileChoiceRequest): void {
if (request.files.length === 0) return;
if (listener) listener(request);
else waiting.push(request);
}
/** One host at a time. What came before it mounted is handed over at once. */
export function listenForFileChoices(next: Listener): () => void {
listener = next;
for (const request of waiting.splice(0)) next(request);
return () => {
if (listener === next) listener = null;
};
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Notices files dragged into the window from outside, and only those. A drag that started
* in this page (a message to a folder, an attachment to the composer, a tab) fires
* `dragstart` here first, and the app's own native drag of an attachment says so through
* setOwnDragActive. Neither shows the overlay.
*
* Listens in the capture phase, so a component's own drop zone doesn't hide the drag from
* it. Once the overlay is up it's on top of everything and takes the drag's events itself.
* A file dropped anywhere else does nothing: the browser's own default would leave the app
* to show the file.
*/
import { isOwnDragActive } from '@/lib/desktop/window-files';
import { kindForType, type DropFiles } from './zones';
/** Chromium sends dragover every 50 ms and Firefox about every 350 ms while the pointer is still. */
export const DRAG_STALE_MS = 1000;
export interface DragWatchHandlers {
/** Files came in from outside. Return false to leave the drag alone (locked, a dialog open). */
enter: (files: DropFiles) => boolean;
/** Out of the window, Esc, a drop, or no word for a while. */
leave: () => void;
}
export function draggedFiles(transfer: DataTransfer | null | undefined): DropFiles | null {
if (!transfer || !Array.from(transfer.types ?? []).includes('Files')) return null;
const items = Array.from(transfer.items ?? []).filter((item) => item.kind === 'file');
if (items.length === 0) return { count: null, kinds: null, bytes: null };
return { count: items.length, kinds: items.map((item) => kindForType(item.type)), bytes: null };
}
export function startDragWatch(handlers: DragWatchHandlers): () => void {
let internal = false;
let active = false;
let staleTimer: ReturnType<typeof setTimeout> | null = null;
const stop = () => {
if (staleTimer) clearTimeout(staleTimer);
staleTimer = null;
if (!active) return;
active = false;
handlers.leave();
};
const keepAlive = () => {
if (staleTimer) clearTimeout(staleTimer);
staleTimer = setTimeout(stop, DRAG_STALE_MS);
};
const fromOutside = (event: DragEvent) => !internal && !isOwnDragActive() && draggedFiles(event.dataTransfer) !== null;
const onDragStart = () => {
internal = true;
};
// dragend goes to the source, which may have left the page by then (a message that moved).
// No pointer events reach a page during a drag, so the first one after says it's over.
const onDragEnd = () => {
internal = false;
};
const onPointer = () => {
internal = false;
};
const onDragEnter = (event: DragEvent) => {
if (!fromOutside(event)) return;
event.preventDefault();
if (active) {
keepAlive();
return;
}
if (handlers.enter(draggedFiles(event.dataTransfer)!)) {
active = true;
// The component under the pointer would start its own highlight for a moment.
event.stopPropagation();
keepAlive();
}
};
const onDragOver = (event: DragEvent) => {
if (!fromOutside(event)) return;
// Without this a drop anywhere opens the file in place of the app.
event.preventDefault();
if (active) keepAlive();
};
const onDragLeave = (event: DragEvent) => {
// Leaving the window, or the drag cancelled with Esc: no element is next.
if (active && event.relatedTarget === null) stop();
};
const onDrop = (event: DragEvent) => {
internal = false;
if (draggedFiles(event.dataTransfer) === null) return;
// The overlay's zones handle their own drop before this runs (bubble, after capture),
// so here it only stops the browser's default and ends the overlay.
event.preventDefault();
queueMicrotask(stop);
};
window.addEventListener('dragstart', onDragStart, true);
window.addEventListener('dragend', onDragEnd, true);
window.addEventListener('pointermove', onPointer, true);
window.addEventListener('pointerdown', onPointer, true);
window.addEventListener('dragenter', onDragEnter, true);
window.addEventListener('dragover', onDragOver, true);
window.addEventListener('dragleave', onDragLeave, true);
window.addEventListener('drop', onDrop);
return () => {
stop();
window.removeEventListener('dragstart', onDragStart, true);
window.removeEventListener('dragend', onDragEnd, true);
window.removeEventListener('pointermove', onPointer, true);
window.removeEventListener('pointerdown', onPointer, true);
window.removeEventListener('dragenter', onDragEnter, true);
window.removeEventListener('dragover', onDragOver, true);
window.removeEventListener('dragleave', onDragLeave, true);
window.removeEventListener('drop', onDrop);
};
}
+59
View File
@@ -0,0 +1,59 @@
/**
* The Files screen's end of the file drop overlay. While it's open on a folder that takes
* uploads it says so here, and the overlay offers "Upload to <folder>". From anywhere else
* the files wait here while the app goes to Files, which takes them once it has listed a
* folder they can go in.
*/
export interface FilesUpload {
files: File[];
/** A dropped folder: the files carry their paths, and the upload keeps them. */
asFolder: boolean;
}
interface FilesTarget {
/** The folder in view, as the user sees its name. Null at the top of Files. */
folder: string | null;
take: (upload: FilesUpload) => unknown;
/** The Pro shell keeps Files mounted on a tab out of sight. */
visible: () => boolean;
}
let target: FilesTarget | null = null;
let waiting: FilesUpload | null = null;
const listeners = new Set<() => void>();
export function setFilesDropTarget(next: FilesTarget | null): () => void {
target = next;
return () => {
if (target === next) target = null;
};
}
/** The Files screen on view, if it can take files where it is. */
export function filesDropTarget(): FilesTarget | null {
return target?.visible() ? target : null;
}
/** Files for the Files screen to upload when it's ready. A newer drop replaces an older one nobody took. */
export function queueFilesUpload(upload: FilesUpload): void {
waiting = upload;
for (const listener of listeners) listener();
}
export function takeQueuedFilesUpload(): FilesUpload | null {
const upload = waiting;
waiting = null;
return upload;
}
export function hasQueuedFilesUpload(): boolean {
return waiting !== null;
}
export function subscribeQueuedFilesUpload(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
+30
View File
@@ -0,0 +1,30 @@
/**
* The files of a drop, read while the drop event lasts. A dropped folder only shows up
* through the entry API, and only Files can take one: its upload keeps the folder's
* layout. Everywhere else a folder is left out.
*/
import { getDroppedFilesAndFolders } from '@/lib/webdav/drop-utils';
export interface DroppedFiles {
/** Files at the top of the drop, no folders. */
plain: File[];
/** Everything, folders walked, with their paths: for Files. */
all: File[];
folders: boolean;
}
/** Call from the drop handler itself: the browser empties the transfer once it returns. */
export function readDroppedFiles(transfer: DataTransfer): Promise<DroppedFiles> {
const items = Array.from(transfer.items ?? []);
const entries = items.map((item) => (typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null));
const plainNow = items.flatMap((item, index) => {
if (item.kind !== 'file' || entries[index]?.isDirectory) return [];
const file = item.getAsFile();
return file ? [file] : [];
});
const plain = items.length > 0 ? plainNow : Array.from(transfer.files ?? []);
const folders = entries.some((entry) => entry?.isDirectory);
if (!folders) return Promise.resolve({ plain, all: plain, folders: false });
return getDroppedFilesAndFolders(transfer).then(({ files }) => ({ plain, all: files, folders: true }));
}
+145
View File
@@ -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<HandoffKind, 'compose'> | '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<FileDropZone, 'note' | 'disabled'> {
if (place.offline && !place.keepsOffline) return { note: { key: 'note_needs_connection' }, disabled: true };
if (place.offline) return { note: { key: 'note_kept_offline' } };
return place.maxMessageBytes > 0 ? { note: { key: 'note_limit_message', size: place.maxMessageBytes } } : {};
}
function serverOnly(place: DropPlace, zone: FileDropZone): FileDropZone {
return place.offline ? { ...zone, note: { key: 'note_needs_connection' }, disabled: true } : zone;
}
/** The zones for these files here, in the order they're shown. */
export function fileDropZones(files: DropFiles, place: DropPlace): FileDropZone[] {
const kind = sharedKind(files);
const one = files.count === 1;
const main: FileDropZone[] = [];
if (place.composer) main.push({ id: 'attach-here', ...mailNote(place) });
if (!place.popout) main.push({ id: 'new-message', ...mailNote(place) });
if (!place.popout && place.files) {
const note: FileDropNote | undefined = place.maxUploadBytes > 0 ? { key: 'note_limit_file', size: place.maxUploadBytes } : undefined;
main.push(serverOnly(place, { id: 'upload', ...(place.filesFolder ? { folder: place.filesFolder } : {}), ...(note ? { note } : {}) }));
}
// What this kind of file is for, best first. Not in a pop-out: each opens on its own page.
const special: FileDropZone[] = [];
if (!place.popout) {
if (kind === 'eml' && one) special.push({ id: 'open-eml' });
if (kind === 'eml' && place.importFolder) special.push(serverOnly(place, { id: 'import-eml', folder: place.importFolder }));
if (kind === 'calendar' && one && place.calendar) special.push({ id: 'calendar' });
if (kind === 'contacts' && one && place.contacts) special.push({ id: 'contacts' });
}
const room = Math.max(0, MAX_ZONES - main.length);
const kept = special.slice(0, room);
const attachHere = main.filter((zone) => zone.id === 'attach-here');
const rest = main.filter((zone) => zone.id !== 'attach-here');
return [...attachHere, ...kept, ...rest];
}
/** What the files may still do, once they're in hand: those too big for the zone stay out. */
export function filesWithinLimits(
zone: FileDropZoneId,
files: readonly File[],
place: Pick<DropPlace, 'maxUploadBytes' | 'maxMessageBytes'>,
): { fitting: File[]; tooLarge: File[]; overMessage: boolean } {
const perFile = place.maxUploadBytes;
const fitting = perFile > 0 ? files.filter((file) => file.size <= perFile) : [...files];
const tooLarge = files.filter((file) => !fitting.includes(file));
const mail = zone === 'attach-here' || zone === 'new-message';
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];
}
+29
View File
@@ -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} د",
+29
View File
@@ -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",
+29
View File
@@ -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",
+29
View File
@@ -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.",
+29
View File
@@ -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",
+29
View File
@@ -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",
+29
View File
@@ -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",
+29
View File
@@ -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",
+29
View File
@@ -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} دقیقه",
+29
View File
@@ -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",
+29
View File
@@ -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} דק'",
+29
View File
@@ -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",
+29
View File
@@ -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",
+29
View File
@@ -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} 分後に再通知",
+29
View File
@@ -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}분 후 다시 알림",
+29
View File
@@ -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",
+29
View File
@@ -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} минут хойшлуулах",
+29
View File
@@ -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",
+29
View File
@@ -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",
+29
View File
@@ -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",
+29
View File
@@ -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",
+29
View File
@@ -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",
+29
View File
@@ -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} мин",
+29
View File
@@ -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",
+29
View File
@@ -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} นาที",
+29
View File
@@ -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",
+29
View File
@@ -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} хв",
+29
View File
@@ -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",
+29
View File
@@ -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} 分鐘後再提醒",
+29
View File
@@ -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} 分钟后再提醒",