diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index ebd3dc38a..7c3a7151b 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -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({ {children} + diff --git a/components/desktop/desktop-os-host.tsx b/components/desktop/desktop-os-host.tsx index a6e522136..b462bc255 100644 --- a/components/desktop/desktop-os-host.tsx +++ b/components/desktop/desktop-os-host.tsx @@ -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; }; diff --git a/components/file-drop/__tests__/file-drop-host.test.tsx b/components/file-drop/__tests__/file-drop-host.test.tsx new file mode 100644 index 000000000..3000d3f7d --- /dev/null +++ b/components/file-drop/__tests__/file-drop-host.test.tsx @@ -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) => (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 = {}) { + 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(`[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(); + 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(); + 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(); + 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 " hands the files to that folder', async () => { + const take = vi.fn(); + const clear = setFilesDropTarget({ folder: 'Reports', take, visible: () => true }); + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + act(() => requestFileChoice({ files: [file], preferred: 'new-message' })); + expect(screen.queryByTestId('file-drop-choice')).toBeNull(); + act(() => signIn()); + expect(await screen.findByTestId('file-drop-choice')).toBeTruthy(); + }); +}); diff --git a/components/file-drop/file-drop-host.tsx b/components/file-drop/file-drop-host.tsx new file mode 100644 index 000000000..4e4071bc5 --- /dev/null +++ b/components/file-drop/file-drop-host.tsx @@ -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 } { + 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; +} + +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(null); + const [active, setActive] = useState(null); + const [choice, setChoice] = useState(null); + const [pendingChoices, setPendingChoices] = useState([]); + const defaultRef = useRef(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): 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( +
{ + 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)); + }} + > +
+
+
+ {drag.zones.map((zone) => ( + + ))} +
+

{t("overlay_hint")}

+
, + document.body, + ); + + const preferred = choice ? defaultZone(choice.zones, choice.preferred) : undefined; + const names = choice?.list.map((file) => file.name) ?? []; + + return ( + <> + {overlay} + 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 && ( +
{ + if (!["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp"].includes(event.key)) return; + const buttons = Array.from(event.currentTarget.querySelectorAll("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) => ( + pick(zone)} + /> + ))} +
+ )} +
+ + ); +} diff --git a/components/file-drop/file-drop-zones.tsx b/components/file-drop/file-drop-zones.tsx new file mode 100644 index 000000000..f47b07c7b --- /dev/null +++ b/components/file-drop/file-drop-zones.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { forwardRef, type ComponentType, type ReactNode } from "react"; +import { useTranslations } from "next-intl"; +import { CalendarPlus, FolderInput, MailOpen, Paperclip, SquarePen, Upload, UserPlus } from "@/components/icons"; +import type { FileDropNote, FileDropZone, FileDropZoneId } from "@/lib/file-drop/zones"; +import { cn, formatFileSize } from "@/lib/utils"; + +const ICONS: Record> = { + "attach-here": Paperclip, + "new-message": SquarePen, + upload: Upload, + calendar: CalendarPlus, + contacts: UserPlus, + "open-eml": MailOpen, + "import-eml": FolderInput, +}; + +export function useZoneText() { + const t = useTranslations("fileDrop"); + const label = (zone: FileDropZone): string => { + switch (zone.id) { + case "attach-here": return t("zone_attach_here"); + case "new-message": return t("zone_new_message"); + case "upload": return zone.folder ? t("zone_upload_folder", { folder: zone.folder }) : t("zone_upload_files"); + case "calendar": return t("zone_calendar"); + case "contacts": return t("zone_contacts"); + case "open-eml": return t("zone_open_eml"); + case "import-eml": return t("zone_import_eml", { folder: zone.folder ?? "" }); + } + }; + const note = (value: FileDropNote | undefined): string | null => { + if (!value) return null; + switch (value.key) { + case "note_limit_message": return t("note_limit_message", { size: formatFileSize(value.size) }); + case "note_limit_file": return t("note_limit_file", { size: formatFileSize(value.size) }); + case "note_kept_offline": return t("note_kept_offline"); + case "note_needs_connection": return t("note_needs_connection"); + } + }; + return { label, note }; +} + +interface ZoneCardProps { + zone: FileDropZone; + label: string; + note: string | null; + active?: boolean; + /** "button" in the dialog; a plain region during a drag, which nothing can focus. */ + as?: "button" | "div"; + onClick?: () => void; + onKeyDown?: (event: React.KeyboardEvent) => void; + children?: ReactNode; +} + +/** One big choice: the same look as a drop target and as a button. */ +export const ZoneCard = forwardRef(function ZoneCard( + { zone, label, note, active = false, as = "div", onClick, onKeyDown }, + ref, +) { + const Icon = ICONS[zone.id]; + const className = cn( + "group flex min-h-36 flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed p-6 text-center transition-colors select-none", + zone.disabled + ? "border-border bg-muted/40 text-muted-foreground cursor-not-allowed" + : active + ? "border-primary bg-primary/10 text-foreground ring-4 ring-primary/20" + : "border-border bg-background/90 text-foreground hover:border-primary/60", + as === "button" && !zone.disabled && "focus-visible:outline-none focus-visible:border-primary focus-visible:ring-4 focus-visible:ring-primary/30", + ); + const content = ( + <> + + {label} + {note && {note}} + + ); + if (as === "button") { + return ( + + ); + } + return ( +
+ {content} +
+ ); +}); + +/** Two zones side by side, three in a row, four as two by two (a row on a wide window). */ +export function zoneGridClass(count: number): string { + if (count <= 1) return "grid-cols-1 max-w-md"; + if (count === 2) return "grid-cols-1 sm:grid-cols-2 max-w-3xl"; + if (count === 3) return "grid-cols-1 sm:grid-cols-3 max-w-5xl"; + return "grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 max-w-6xl"; +} diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index 04fe72301..a4bdf9456 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -8,7 +8,8 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store'; import { useAccountStore } from "@/stores/account-store"; import { useEmailStore } from "@/stores/email-store"; -import { useFileStore, deletesToTrash, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type UploadReport } from "@/stores/file-store"; +import { useFileStore, deletesToTrash, isSharedFilesPage, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type UploadReport } from "@/stores/file-store"; +import { hasQueuedFilesUpload, setFilesDropTarget, subscribeQueuedFilesUpload, takeQueuedFilesUpload } from "@/lib/file-drop/files-target"; import { isTrashLocation } from "@/lib/files-trash"; import { fileErrorKind, isPartialCopy } from "@/lib/file-errors"; import { toast } from "@/stores/toast-store"; @@ -805,6 +806,37 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { useFileStore.setState({ supportsFiles: null }); }, [getClientForAccount]); + // The file drop overlay: "Upload to " while this screen is on view in a folder + // that takes files, and what was dropped elsewhere for Files once one is listed. + const dropRootRef = useRef(null); + const canTakeDrops = supportsFiles === true && !isAccountPicker && !isSharedFilesPage(currentParentId) && !isTrashLocation(currentParentId); + const dropUploadRef = useRef({ files: handleUploadFiles, folder: handleUploadFolder }); + dropUploadRef.current = { files: handleUploadFiles, folder: handleUploadFolder }; + useEffect(() => { + if (!canTakeDrops) return; + const folder = currentPath === "/" ? null : currentPath.split("/").filter(Boolean).pop() ?? null; + return setFilesDropTarget({ + folder, + take: ({ files, asFolder }) => void (asFolder ? dropUploadRef.current.folder(files) : dropUploadRef.current.files(files)), + visible: () => !!dropRootRef.current?.isConnected && dropRootRef.current.getClientRects().length > 0, + }); + }, [canTakeDrops, currentPath]); + useEffect(() => { + if (supportsFiles !== true || isLoading || !storeClient || isAccountPicker) return; + const take = () => { + if (!hasQueuedFilesUpload()) return; + // In the trash or the list of shared drives: to the top of Files first. + if (!canTakeDrops) { + void navigate(null); + return; + } + const upload = takeQueuedFilesUpload(); + if (upload) void (upload.asFolder ? dropUploadRef.current.folder(upload.files) : dropUploadRef.current.files(upload.files)); + }; + take(); + return subscribeQueuedFilesUpload(take); + }, [supportsFiles, isLoading, storeClient, isAccountPicker, canTakeDrops, navigate]); + // Until the session is restored: the page skeleton the server drew. if (!isAuthenticated) { return isEmbedded @@ -813,7 +845,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { } return ( -
+
{!isEmbedded && {tShell("skip_to_content")}} {refreshIndicator} diff --git a/lib/desktop/__tests__/os-window.test.tsx b/lib/desktop/__tests__/os-window.test.tsx index b6df496bb..84cbc30ff 100644 --- a/lib/desktop/__tests__/os-window.test.tsx +++ b/lib/desktop/__tests__/os-window.test.tsx @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { BulwarkDesktopBridge, DesktopOsPowerEvent, DesktopOsStatus } from '../types'; import type { CalendarEvent } from '@/lib/jmap/types'; -/** The window's end: badge, dropped and pasted files, drag-out, waking up, the settings section. */ +/** The window's end: badge, drag-out, waking up, the settings section. Dropped and pasted files: components/file-drop. */ vi.mock('next-intl', () => ({ useTranslations: () => (key: string, values?: Record) => (values ? `${key}:${JSON.stringify(values)}` : key), @@ -90,176 +90,6 @@ describe('the policy keeps refreshing', () => { }); }); -function dropEvent(type: string, files: File[]): Event { - const event = new Event(type, { bubbles: true, cancelable: true }); - Object.defineProperty(event, 'dataTransfer', { value: { types: files.length ? ['Files'] : ['text/plain'], files, dropEffect: 'none' } }); - return event; -} - -describe('files on the window', () => { - const file = new File(['x'], 'report.pdf'); - - beforeEach(async () => { - const { clearHandoffs, takeHandoff } = await import('@/lib/launch-handoff'); - clearHandoffs(); - takeHandoff('compose'); - }); - - it('nothing happens in a browser', async () => { - const { startWindowFiles } = await import('../window-files'); - const { takeHandoff } = await import('@/lib/launch-handoff'); - const stop = startWindowFiles(); - const event = dropEvent('drop', [file]); - window.dispatchEvent(event); - expect(event.defaultPrevented).toBe(false); - expect(takeHandoff('compose')).toBeNull(); - stop(); - }); - - it('a drop on the window starts a new draft with the file', async () => { - const { startWindowFiles } = await import('../window-files'); - const { takeHandoff } = await import('@/lib/launch-handoff'); - setBridge({}); - const stop = startWindowFiles(); - const over = dropEvent('dragover', [file]); - window.dispatchEvent(over); - expect(over.defaultPrevented).toBe(true); - const drop = dropEvent('drop', [file]); - window.dispatchEvent(drop); - expect(drop.defaultPrevented).toBe(true); - expect(takeHandoff('compose')?.files).toEqual([file]); - stop(); - }); - - it('a drop something else took is left alone', async () => { - const { startWindowFiles } = await import('../window-files'); - const { takeHandoff } = await import('@/lib/launch-handoff'); - setBridge({}); - const stop = startWindowFiles(); - const event = dropEvent('drop', [file]); - event.preventDefault(); - window.dispatchEvent(event); - expect(takeHandoff('compose')).toBeNull(); - stop(); - }); - - it('text dragged around is not a file', async () => { - const { startWindowFiles } = await import('../window-files'); - setBridge({}); - const stop = startWindowFiles(); - const event = dropEvent('dragover', []); - window.dispatchEvent(event); - expect(event.defaultPrevented).toBe(false); - stop(); - }); - - it('an open composer takes the file itself', async () => { - const { setComposerFileTarget, startWindowFiles } = await import('../window-files'); - const { takeHandoff } = await import('@/lib/launch-handoff'); - setBridge({}); - const stop = startWindowFiles(); - const target = vi.fn(); - const clear = setComposerFileTarget(target, () => true); - window.dispatchEvent(dropEvent('drop', [file])); - expect(target).toHaveBeenCalledWith([file]); - expect(takeHandoff('compose')).toBeNull(); - clear(); - stop(); - }); - - it('a composer out of sight on a hidden tab takes nothing', async () => { - const { setComposerFileTarget, startWindowFiles } = await import('../window-files'); - const { takeHandoff } = await import('@/lib/launch-handoff'); - setBridge({}); - const stop = startWindowFiles(); - const hidden = vi.fn(); - const clear = setComposerFileTarget(hidden, () => false); - window.dispatchEvent(dropEvent('drop', [file])); - expect(hidden).not.toHaveBeenCalled(); - expect(takeHandoff('compose')?.files).toEqual([file]); - clear(); - stop(); - }); - - it('a pasted file is taken; pasted text is not', async () => { - const { startWindowFiles } = await import('../window-files'); - const { takeHandoff } = await import('@/lib/launch-handoff'); - setBridge({}); - const stop = startWindowFiles(); - const paste = (files: File[]) => { - const event = new Event('paste', { bubbles: true, cancelable: true }); - Object.defineProperty(event, 'clipboardData', { value: { files, types: files.length ? ['Files'] : ['text/plain'] } }); - window.dispatchEvent(event); - return event; - }; - expect(paste([]).defaultPrevented).toBe(false); - expect(takeHandoff('compose')).toBeNull(); - expect(paste([file]).defaultPrevented).toBe(true); - expect(takeHandoff('compose')?.files).toEqual([file]); - stop(); - }); - - describe('a paste that is not for us', () => { - const pasteOn = (target: EventTarget, types: string[]) => { - const event = new Event('paste', { bubbles: true, cancelable: true }); - Object.defineProperty(event, 'clipboardData', { value: { files: [file], types } }); - target.dispatchEvent(event); - return event; - }; - - it('text with a file, as a spreadsheet copies, stays text', async () => { - const { startWindowFiles } = await import('../window-files'); - const { takeHandoff } = await import('@/lib/launch-handoff'); - setBridge({}); - const stop = startWindowFiles(); - expect(pasteOn(document.body, ['text/plain', 'Files']).defaultPrevented).toBe(false); - expect(takeHandoff('compose')).toBeNull(); - stop(); - }); - - it('in a search field or any other input, a file is not an attachment', async () => { - const { startWindowFiles } = await import('../window-files'); - const { takeHandoff } = await import('@/lib/launch-handoff'); - setBridge({}); - const stop = startWindowFiles(); - const input = document.body.appendChild(document.createElement('input')); - expect(pasteOn(input, ['Files']).defaultPrevented).toBe(false); - expect(takeHandoff('compose')).toBeNull(); - input.remove(); - stop(); - }); - - it('in the body of a composer on screen, a bare file attaches', async () => { - const { setComposerFileTarget, startWindowFiles } = await import('../window-files'); - setBridge({}); - const stop = startWindowFiles(); - const target = vi.fn(); - const clear = setComposerFileTarget(target, () => true); - const composer = document.body.appendChild(document.createElement('div')); - composer.setAttribute('data-testid', 'email-composer'); - const body = composer.appendChild(document.createElement('div')); - body.setAttribute('contenteditable', 'true'); - pasteOn(body, ['Files']); - expect(target).toHaveBeenCalledWith([file]); - composer.remove(); - clear(); - stop(); - }); - }); - - it('our own native drag over our own window is not a new file', async () => { - const { setOwnDragActive, startWindowFiles } = await import('../window-files'); - const { takeHandoff } = await import('@/lib/launch-handoff'); - setBridge({}); - const stop = startWindowFiles(); - setOwnDragActive(true); - window.dispatchEvent(dropEvent('drop', [file])); - expect(takeHandoff('compose')).toBeNull(); - setOwnDragActive(false); - stop(); - }); -}); - describe('dragging an attachment out', () => { const blobUrl = 'blob:https://app/1'; const source = { diff --git a/lib/desktop/window-files.ts b/lib/desktop/window-files.ts index 63a67630b..4666b0e40 100644 --- a/lib/desktop/window-files.ts +++ b/lib/desktop/window-files.ts @@ -1,13 +1,9 @@ /** - * Files dropped on a window or pasted from the clipboard. Whatever already handles a - * drop keeps doing so, which it shows by preventDefault: the composer, the file page, - * calendar import. The rest becomes an attachment on a new draft, or on the composer - * that's open. + * Who takes files on this window: the composer on screen, for files pasted into it or + * dropped on its zone of the file drop overlay (lib/file-drop). And whether a drag going + * on is the app's own native one, which is no new file. */ -import { getDesktopBridge } from '@/lib/desktop/bridge'; -import { handOffDraft } from '@/lib/launch-handoff'; - interface ComposerTarget { take: (files: File[]) => unknown; /** The Pro shell keeps composers mounted on tabs out of sight. */ @@ -18,7 +14,7 @@ const composerTargets: ComposerTarget[] = []; /** Our own native drag passing over our own window isn't a new file. */ let ownDrag = false; -/** A composer on screen takes files itself. No effect outside the desktop app: only this file reads it. */ +/** A composer on screen takes files itself. */ export function setComposerFileTarget(take: ComposerTarget['take'], visible: ComposerTarget['visible']): () => void { const target = { take, visible }; composerTargets.push(target); @@ -28,7 +24,8 @@ export function setComposerFileTarget(take: ComposerTarget['take'], visible: Com }; } -function visibleComposer(): ComposerTarget | undefined { +/** The newest composer on screen, if any. */ +export function visibleComposer(): ComposerTarget | undefined { return [...composerTargets].reverse().find((target) => target.visible()); } @@ -36,10 +33,8 @@ export function setOwnDragActive(active: boolean): void { ownDrag = active; } -function take(files: File[]): void { - const composer = visibleComposer(); - if (composer) composer.take(files); - else handOffDraft({ files }); +export function isOwnDragActive(): boolean { + return ownDrag; } function isEditable(target: EventTarget | null): boolean { @@ -48,48 +43,17 @@ function isEditable(target: EventTarget | null): boolean { } /** - * Pasting a file is only meant where text isn't. A spreadsheet's cells also come as a - * picture of them, and a field that takes text wants the text. The one editable place a - * bare file belongs in is the composer it's being pasted into. + * Where a pasted file goes, if anywhere. Pasting a file is only meant where text isn't. A + * spreadsheet's cells also come as a picture of them, and a field that takes text wants + * the text. The one editable place a bare file belongs in is the composer it's being + * pasted into. */ -function pasteIsForUs(event: ClipboardEvent): boolean { +export function pasteTarget(event: ClipboardEvent): 'composer' | 'choose' | null { + if (event.defaultPrevented) return null; + if ((event.clipboardData?.files?.length ?? 0) === 0) return null; const types = [...(event.clipboardData?.types ?? [])]; - if (types.includes('text/plain') || types.includes('text/html')) return false; - if (!isEditable(event.target)) return true; + if (types.includes('text/plain') || types.includes('text/html')) return null; + if (!isEditable(event.target)) return 'choose'; const inComposer = !!(event.target as Partial | null)?.closest?.('[data-testid="email-composer"]'); - return inComposer && !!visibleComposer(); -} - -function hasFiles(event: DragEvent): boolean { - return !!event.dataTransfer && [...event.dataTransfer.types].includes('Files'); -} - -export function startWindowFiles(): () => void { - if (!getDesktopBridge()?.os) return () => {}; - const onDragOver = (event: DragEvent) => { - if (event.defaultPrevented || ownDrag || !hasFiles(event)) return; - event.preventDefault(); - if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; - }; - const onDrop = (event: DragEvent) => { - if (event.defaultPrevented || ownDrag || !hasFiles(event)) return; - event.preventDefault(); - const files = Array.from(event.dataTransfer?.files ?? []); - if (files.length > 0) take(files); - }; - const onPaste = (event: ClipboardEvent) => { - if (event.defaultPrevented) return; - const files = Array.from(event.clipboardData?.files ?? []); - if (files.length === 0 || !pasteIsForUs(event)) return; - event.preventDefault(); - take(files); - }; - window.addEventListener('dragover', onDragOver); - window.addEventListener('drop', onDrop); - window.addEventListener('paste', onPaste); - return () => { - window.removeEventListener('dragover', onDragOver); - window.removeEventListener('drop', onDrop); - window.removeEventListener('paste', onPaste); - }; + return inComposer && visibleComposer() ? 'composer' : null; } diff --git a/lib/file-drop/__tests__/drag-watch.test.ts b/lib/file-drop/__tests__/drag-watch.test.ts new file mode 100644 index 000000000..d0358dbba --- /dev/null +++ b/lib/file-drop/__tests__/drag-watch.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { DRAG_STALE_MS, startDragWatch } from '../drag-watch'; +import type { DropFiles } from '../zones'; +import { setOwnDragActive } from '@/lib/desktop/window-files'; + +/** jsdom has no DragEvent: an Event with the parts the watch reads. */ +function drag(type: string, options: { files?: number; types?: string[]; itemTypes?: string[]; relatedTarget?: EventTarget | null; target?: EventTarget } = {}): Event { + const event = new Event(type, { bubbles: true, cancelable: true }); + const count = options.files ?? 1; + const types = options.types ?? (count > 0 ? ['Files'] : ['text/plain']); + const items = (options.itemTypes ?? Array.from({ length: count }, () => '')).map((itemType) => ({ kind: 'file', type: itemType })); + Object.defineProperty(event, 'dataTransfer', { value: { types, items, files: [], dropEffect: 'none' } }); + if ('relatedTarget' in options) Object.defineProperty(event, 'relatedTarget', { value: options.relatedTarget }); + (options.target ?? document.body).dispatchEvent(event); + return event; +} + +describe('noticing files dragged in from outside', () => { + let enter: Mock<(files: DropFiles) => boolean>; + let leave: Mock<() => void>; + let stop: () => void; + + beforeEach(() => { + vi.useFakeTimers(); + enter = vi.fn((_files: DropFiles) => true); + leave = vi.fn(() => {}); + stop = startDragWatch({ enter, leave }); + }); + + afterEach(() => { + stop(); + setOwnDragActive(false); + vi.useRealTimers(); + }); + + it('tells how many files and what kind, as far as the drag shows', () => { + drag('dragenter', { files: 2, itemTypes: ['text/calendar', 'application/pdf'] }); + expect(enter).toHaveBeenCalledWith({ count: 2, kinds: ['calendar', 'other'], bytes: null }); + }); + + it('a drag that started in the page is the app\'s own, not files from outside', () => { + document.body.dispatchEvent(new Event('dragstart', { bubbles: true })); + drag('dragenter'); + expect(enter).not.toHaveBeenCalled(); + // The first pointer event after a drag means it's over, even if dragend went nowhere. + document.body.dispatchEvent(new Event('pointermove', { bubbles: true })); + drag('dragenter'); + expect(enter).toHaveBeenCalledTimes(1); + }); + + it('the app\'s own native drag of an attachment is not a new file either', () => { + setOwnDragActive(true); + drag('dragenter'); + expect(enter).not.toHaveBeenCalled(); + }); + + it('text dragged around is no file', () => { + drag('dragenter', { files: 0 }); + expect(enter).not.toHaveBeenCalled(); + }); + + it('a component under the pointer does not get the first enter once the overlay takes it', () => { + const inner = document.body.appendChild(document.createElement('div')); + const seen = vi.fn(); + inner.addEventListener('dragenter', seen); + drag('dragenter', { target: inner }); + expect(seen).not.toHaveBeenCalled(); + inner.remove(); + }); + + it('leaving the window ends it; moving between elements does not', () => { + drag('dragenter'); + drag('dragleave', { relatedTarget: document.body }); + expect(leave).not.toHaveBeenCalled(); + drag('dragleave', { relatedTarget: null }); + expect(leave).toHaveBeenCalledTimes(1); + }); + + it('ends by itself when the drag goes quiet, as after Esc in some browsers', () => { + drag('dragenter'); + vi.advanceTimersByTime(DRAG_STALE_MS / 2); + drag('dragover'); + vi.advanceTimersByTime(DRAG_STALE_MS / 2); + expect(leave).not.toHaveBeenCalled(); + vi.advanceTimersByTime(DRAG_STALE_MS); + expect(leave).toHaveBeenCalledTimes(1); + }); + + it('a drop anywhere ends it, and never lets the browser open the file instead of the app', async () => { + drag('dragenter'); + const over = drag('dragover'); + expect(over.defaultPrevented).toBe(true); + const dropped = drag('drop'); + expect(dropped.defaultPrevented).toBe(true); + await Promise.resolve(); + expect(leave).toHaveBeenCalledTimes(1); + }); + + it('a drag the host turns down (locked, a dialog open) still cannot open the file in place of the app', () => { + enter.mockReturnValue(false); + drag('dragenter'); + expect(drag('dragover').defaultPrevented).toBe(true); + expect(drag('drop').defaultPrevented).toBe(true); + expect(leave).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/file-drop/__tests__/zones.test.ts b/lib/file-drop/__tests__/zones.test.ts new file mode 100644 index 000000000..bc1297903 --- /dev/null +++ b/lib/file-drop/__tests__/zones.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import { defaultZone, fileDropZones, filesWithinLimits, kindForType, MAX_ZONES, type DropFiles, type DropPlace } from '../zones'; + +const place = (patch: Partial = {}): DropPlace => ({ + composer: false, + popout: false, + files: true, + filesFolder: null, + calendar: true, + contacts: true, + importFolder: null, + offline: false, + keepsOffline: false, + maxUploadBytes: 0, + maxMessageBytes: 0, + ...patch, +}); + +const drag = (kinds: DropFiles['kinds']): DropFiles => ({ count: kinds?.length ?? null, kinds, bytes: null }); +const ids = (files: DropFiles, where: DropPlace) => fileDropZones(files, where).map((zone) => zone.id); + +describe('the file drop zones', () => { + it('offer a new message and Files for any file', () => { + expect(ids(drag(['other']), place())).toEqual(['new-message', 'upload']); + }); + + it('leave Files out where the server or the organisation has none', () => { + expect(ids(drag(['other']), place({ files: false }))).toEqual(['new-message']); + }); + + it('put an open composer first', () => { + expect(ids(drag(['other', 'other']), place({ composer: true }))).toEqual(['attach-here', 'new-message', 'upload']); + }); + + it('name the folder on the Files screen', () => { + const upload = fileDropZones(drag(['other']), place({ filesFolder: 'Reports' })).find((zone) => zone.id === 'upload'); + expect(upload?.folder).toBe('Reports'); + }); + + it('add the calendar for one .ics, contacts for one .vcf, open and import for an .eml', () => { + expect(ids(drag(['calendar']), place())).toEqual(['calendar', 'new-message', 'upload']); + expect(ids(drag(['contacts']), place())).toEqual(['contacts', 'new-message', 'upload']); + expect(ids(drag(['eml']), place({ importFolder: 'Inbox' }))).toEqual(['open-eml', 'import-eml', 'new-message', 'upload']); + }); + + it('offer nothing special for a mix or for a calendar the account does not have', () => { + expect(ids(drag(['calendar', 'other']), place())).toEqual(['new-message', 'upload']); + expect(ids(drag(['calendar']), place({ calendar: false }))).toEqual(['new-message', 'upload']); + expect(ids({ count: null, kinds: null, bytes: null }, place())).toEqual(['new-message', 'upload']); + }); + + it('import several messages, but open only one', () => { + expect(ids(drag(['eml', 'eml']), place({ importFolder: 'Inbox' }))).toEqual(['import-eml', 'new-message', 'upload']); + }); + + it('never show more than four, keeping both main ones and the composer', () => { + const zones = ids(drag(['eml']), place({ composer: true, importFolder: 'Inbox' })); + expect(zones).toHaveLength(MAX_ZONES); + expect(zones).toEqual(['attach-here', 'open-eml', 'new-message', 'upload']); + }); + + it('in a pop-out, only its own composer', () => { + expect(ids(drag(['calendar']), place({ popout: true, composer: true }))).toEqual(['attach-here']); + expect(ids(drag(['other']), place({ popout: true }))).toEqual([]); + }); + + it('offline: Files and the import say they need a connection, a draft without offline mode too', () => { + const zones = fileDropZones(drag(['eml']), place({ offline: true, importFolder: 'Inbox' })); + const byId = Object.fromEntries(zones.map((zone) => [zone.id, zone])); + expect(byId.upload).toMatchObject({ disabled: true, note: { key: 'note_needs_connection' } }); + expect(byId['import-eml']).toMatchObject({ disabled: true }); + expect(byId['new-message']).toMatchObject({ disabled: true, note: { key: 'note_needs_connection' } }); + expect(byId['open-eml'].disabled).toBeUndefined(); + }); + + it('offline with offline mode: a draft keeps the files until the connection is back', () => { + const zones = fileDropZones(drag(['other']), place({ offline: true, keepsOffline: true })); + expect(zones.find((zone) => zone.id === 'new-message')).toEqual({ id: 'new-message', note: { key: 'note_kept_offline' } }); + }); + + it('show the server limits while the sizes are still unknown', () => { + const zones = fileDropZones(drag(['other']), place({ maxUploadBytes: 50, maxMessageBytes: 100 })); + expect(zones.find((zone) => zone.id === 'new-message')?.note).toEqual({ key: 'note_limit_message', size: 100 }); + expect(zones.find((zone) => zone.id === 'upload')?.note).toEqual({ key: 'note_limit_file', size: 50 }); + }); + + it('read the kind from the MIME type a drag shows', () => { + expect(kindForType('text/calendar')).toBe('calendar'); + expect(kindForType('message/rfc822')).toBe('eml'); + expect(kindForType('text/x-vcard')).toBe('contacts'); + expect(kindForType('')).toBe('other'); + }); +}); + +describe('limits once the files are in hand', () => { + const sized = (name: string, size: number) => new File([new Uint8Array(size)], name); + + it('leave out a file over the per-file limit and keep the rest', () => { + const big = sized('big.zip', 60); + const small = sized('a.txt', 10); + expect(filesWithinLimits('upload', [big, small], { maxUploadBytes: 50, maxMessageBytes: 0 })).toEqual({ fitting: [small], tooLarge: [big], overMessage: false }); + }); + + it('refuse a message whose files are too large together', () => { + const files = [sized('a', 30), sized('b', 30)]; + expect(filesWithinLimits('new-message', files, { maxUploadBytes: 0, maxMessageBytes: 50 })).toMatchObject({ fitting: [], overMessage: true }); + expect(filesWithinLimits('upload', files, { maxUploadBytes: 0, maxMessageBytes: 50 }).fitting).toHaveLength(2); + }); +}); + +describe('the keyboard default', () => { + it('is the asked-for zone when usable, else the first usable', () => { + const zones = fileDropZones(drag(['other']), place({ composer: true })); + expect(defaultZone(zones, 'new-message')?.id).toBe('new-message'); + expect(defaultZone(zones)?.id).toBe('attach-here'); + const offline = fileDropZones(drag(['other']), place({ offline: true, keepsOffline: true })); + expect(defaultZone(offline, 'upload')?.id).toBe('new-message'); + }); +}); diff --git a/lib/file-drop/actions.ts b/lib/file-drop/actions.ts new file mode 100644 index 000000000..fed28072a --- /dev/null +++ b/lib/file-drop/actions.ts @@ -0,0 +1,58 @@ +/** + * What each zone of the file drop overlay does with the files, through the paths the app + * already has: a composer's own attach (its size checks and plugin hooks included), the + * launch handoff for a new draft and for .ics, .vcf and .eml, the Files screen's upload. + */ + +import { visibleComposer } from '@/lib/desktop/window-files'; +import { handOffDraft, handOffFiles } from '@/lib/launch-handoff'; +import { filesDropTarget, queueFilesUpload, type FilesUpload } from './files-target'; +import type { FileDropZoneId } from './zones'; + +export interface FileDropEnv { + /** Goes to a page, saving an open composer first. */ + goTo: (path: '/files') => void | Promise; + /** Email/import into the folder the zone named. */ + importMessages: (files: File[]) => void | Promise; +} + +/** `files` without folders for every zone; `upload` also gets what a dropped folder held. */ +export async function runFileDropZone(zone: FileDropZoneId, files: File[], env: FileDropEnv, upload?: FilesUpload): Promise { + if (zone === 'upload') { + const what = upload ?? { files, asFolder: false }; + if (what.files.length === 0) return; + const target = filesDropTarget(); + if (target) { + target.take(what); + return; + } + queueFilesUpload(what); + await env.goTo('/files'); + return; + } + if (files.length === 0) return; + switch (zone) { + case 'attach-here': { + const composer = visibleComposer(); + // Gone between the drag and the drop: a new message is the next best thing. + if (composer) composer.take(files); + else handOffDraft({ files }); + return; + } + case 'new-message': + handOffDraft({ files }); + return; + case 'calendar': + handOffFiles(files, 'calendar'); + return; + case 'contacts': + handOffFiles(files, 'contacts'); + return; + case 'open-eml': + handOffFiles(files.slice(0, 1), 'eml'); + return; + case 'import-eml': + await env.importMessages(files); + return; + } +} diff --git a/lib/file-drop/choice.ts b/lib/file-drop/choice.ts new file mode 100644 index 000000000..5fd6e2559 --- /dev/null +++ b/lib/file-drop/choice.ts @@ -0,0 +1,35 @@ +/** + * The overlay's choices without a drag: for files pasted outside a text field, and for a + * file opened with the desktop app ("Open with" in Explorer, Finder or a file manager). + * The host shows them as a dialog that works from the keyboard. Requests wait for the + * host to mount, and the host waits for a sign-in. + */ + +import type { FileDropZoneId } from './zones'; + +export interface FileChoiceRequest { + files: File[]; + /** Where the focus starts, if that choice is there. */ + preferred?: FileDropZoneId; +} + +type Listener = (request: FileChoiceRequest) => void; + +let listener: Listener | null = null; +const waiting: FileChoiceRequest[] = []; + +export function requestFileChoice(request: FileChoiceRequest): void { + if (request.files.length === 0) return; + if (listener) listener(request); + else waiting.push(request); +} + +/** One host at a time. What came before it mounted is handed over at once. */ +export function listenForFileChoices(next: Listener): () => void { + listener = next; + for (const request of waiting.splice(0)) next(request); + return () => { + if (listener === next) listener = null; + }; +} + diff --git a/lib/file-drop/drag-watch.ts b/lib/file-drop/drag-watch.ts new file mode 100644 index 000000000..64286d0c1 --- /dev/null +++ b/lib/file-drop/drag-watch.ts @@ -0,0 +1,116 @@ +/** + * Notices files dragged into the window from outside, and only those. A drag that started + * in this page (a message to a folder, an attachment to the composer, a tab) fires + * `dragstart` here first, and the app's own native drag of an attachment says so through + * setOwnDragActive. Neither shows the overlay. + * + * Listens in the capture phase, so a component's own drop zone doesn't hide the drag from + * it. Once the overlay is up it's on top of everything and takes the drag's events itself. + * A file dropped anywhere else does nothing: the browser's own default would leave the app + * to show the file. + */ + +import { isOwnDragActive } from '@/lib/desktop/window-files'; +import { kindForType, type DropFiles } from './zones'; + +/** Chromium sends dragover every 50 ms and Firefox about every 350 ms while the pointer is still. */ +export const DRAG_STALE_MS = 1000; + +export interface DragWatchHandlers { + /** Files came in from outside. Return false to leave the drag alone (locked, a dialog open). */ + enter: (files: DropFiles) => boolean; + /** Out of the window, Esc, a drop, or no word for a while. */ + leave: () => void; +} + +export function draggedFiles(transfer: DataTransfer | null | undefined): DropFiles | null { + if (!transfer || !Array.from(transfer.types ?? []).includes('Files')) return null; + const items = Array.from(transfer.items ?? []).filter((item) => item.kind === 'file'); + if (items.length === 0) return { count: null, kinds: null, bytes: null }; + return { count: items.length, kinds: items.map((item) => kindForType(item.type)), bytes: null }; +} + +export function startDragWatch(handlers: DragWatchHandlers): () => void { + let internal = false; + let active = false; + let staleTimer: ReturnType | null = null; + + const stop = () => { + if (staleTimer) clearTimeout(staleTimer); + staleTimer = null; + if (!active) return; + active = false; + handlers.leave(); + }; + const keepAlive = () => { + if (staleTimer) clearTimeout(staleTimer); + staleTimer = setTimeout(stop, DRAG_STALE_MS); + }; + + const fromOutside = (event: DragEvent) => !internal && !isOwnDragActive() && draggedFiles(event.dataTransfer) !== null; + + const onDragStart = () => { + internal = true; + }; + // dragend goes to the source, which may have left the page by then (a message that moved). + // No pointer events reach a page during a drag, so the first one after says it's over. + const onDragEnd = () => { + internal = false; + }; + const onPointer = () => { + internal = false; + }; + + const onDragEnter = (event: DragEvent) => { + if (!fromOutside(event)) return; + event.preventDefault(); + if (active) { + keepAlive(); + return; + } + if (handlers.enter(draggedFiles(event.dataTransfer)!)) { + active = true; + // The component under the pointer would start its own highlight for a moment. + event.stopPropagation(); + keepAlive(); + } + }; + const onDragOver = (event: DragEvent) => { + if (!fromOutside(event)) return; + // Without this a drop anywhere opens the file in place of the app. + event.preventDefault(); + if (active) keepAlive(); + }; + const onDragLeave = (event: DragEvent) => { + // Leaving the window, or the drag cancelled with Esc: no element is next. + if (active && event.relatedTarget === null) stop(); + }; + const onDrop = (event: DragEvent) => { + internal = false; + if (draggedFiles(event.dataTransfer) === null) return; + // The overlay's zones handle their own drop before this runs (bubble, after capture), + // so here it only stops the browser's default and ends the overlay. + event.preventDefault(); + queueMicrotask(stop); + }; + + window.addEventListener('dragstart', onDragStart, true); + window.addEventListener('dragend', onDragEnd, true); + window.addEventListener('pointermove', onPointer, true); + window.addEventListener('pointerdown', onPointer, true); + window.addEventListener('dragenter', onDragEnter, true); + window.addEventListener('dragover', onDragOver, true); + window.addEventListener('dragleave', onDragLeave, true); + window.addEventListener('drop', onDrop); + return () => { + stop(); + window.removeEventListener('dragstart', onDragStart, true); + window.removeEventListener('dragend', onDragEnd, true); + window.removeEventListener('pointermove', onPointer, true); + window.removeEventListener('pointerdown', onPointer, true); + window.removeEventListener('dragenter', onDragEnter, true); + window.removeEventListener('dragover', onDragOver, true); + window.removeEventListener('dragleave', onDragLeave, true); + window.removeEventListener('drop', onDrop); + }; +} diff --git a/lib/file-drop/files-target.ts b/lib/file-drop/files-target.ts new file mode 100644 index 000000000..418c0c88d --- /dev/null +++ b/lib/file-drop/files-target.ts @@ -0,0 +1,59 @@ +/** + * The Files screen's end of the file drop overlay. While it's open on a folder that takes + * uploads it says so here, and the overlay offers "Upload to ". From anywhere else + * the files wait here while the app goes to Files, which takes them once it has listed a + * folder they can go in. + */ + +export interface FilesUpload { + files: File[]; + /** A dropped folder: the files carry their paths, and the upload keeps them. */ + asFolder: boolean; +} + +interface FilesTarget { + /** The folder in view, as the user sees its name. Null at the top of Files. */ + folder: string | null; + take: (upload: FilesUpload) => unknown; + /** The Pro shell keeps Files mounted on a tab out of sight. */ + visible: () => boolean; +} + +let target: FilesTarget | null = null; +let waiting: FilesUpload | null = null; +const listeners = new Set<() => void>(); + +export function setFilesDropTarget(next: FilesTarget | null): () => void { + target = next; + return () => { + if (target === next) target = null; + }; +} + +/** The Files screen on view, if it can take files where it is. */ +export function filesDropTarget(): FilesTarget | null { + return target?.visible() ? target : null; +} + +/** Files for the Files screen to upload when it's ready. A newer drop replaces an older one nobody took. */ +export function queueFilesUpload(upload: FilesUpload): void { + waiting = upload; + for (const listener of listeners) listener(); +} + +export function takeQueuedFilesUpload(): FilesUpload | null { + const upload = waiting; + waiting = null; + return upload; +} + +export function hasQueuedFilesUpload(): boolean { + return waiting !== null; +} + +export function subscribeQueuedFilesUpload(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/lib/file-drop/read.ts b/lib/file-drop/read.ts new file mode 100644 index 000000000..5c9ebd960 --- /dev/null +++ b/lib/file-drop/read.ts @@ -0,0 +1,30 @@ +/** + * The files of a drop, read while the drop event lasts. A dropped folder only shows up + * through the entry API, and only Files can take one: its upload keeps the folder's + * layout. Everywhere else a folder is left out. + */ + +import { getDroppedFilesAndFolders } from '@/lib/webdav/drop-utils'; + +export interface DroppedFiles { + /** Files at the top of the drop, no folders. */ + plain: File[]; + /** Everything, folders walked, with their paths: for Files. */ + all: File[]; + folders: boolean; +} + +/** Call from the drop handler itself: the browser empties the transfer once it returns. */ +export function readDroppedFiles(transfer: DataTransfer): Promise { + const items = Array.from(transfer.items ?? []); + const entries = items.map((item) => (typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null)); + const plainNow = items.flatMap((item, index) => { + if (item.kind !== 'file' || entries[index]?.isDirectory) return []; + const file = item.getAsFile(); + return file ? [file] : []; + }); + const plain = items.length > 0 ? plainNow : Array.from(transfer.files ?? []); + const folders = entries.some((entry) => entry?.isDirectory); + if (!folders) return Promise.resolve({ plain, all: plain, folders: false }); + return getDroppedFilesAndFolders(transfer).then(({ files }) => ({ plain, all: files, folders: true })); +} diff --git a/lib/file-drop/zones.ts b/lib/file-drop/zones.ts new file mode 100644 index 000000000..d0ab21855 --- /dev/null +++ b/lib/file-drop/zones.ts @@ -0,0 +1,145 @@ +/** + * Which choices the file drop overlay offers, with nothing of React or the DOM in it. + * + * Two main choices everywhere: attach to a new message, upload to Files. An open composer + * comes first, and a file the app knows (.ics, .vcf, .eml) adds its own. Never more than + * four, so the zones stay big enough to hit with a drag. + * + * During a drag the browser shows only how many files there are and their MIME types, + * never names or sizes (Chromium's protected drag data). So kinds come from the type + * then, from the name once dropped, and a size limit is shown on the zone rather than + * checked against files nobody can measure yet. + */ + +import { kindForFile, type HandoffKind } from '@/lib/launch-handoff'; + +export type FileDropZoneId = 'attach-here' | 'new-message' | 'upload' | 'calendar' | 'contacts' | 'open-eml' | 'import-eml'; + +export type DroppedKind = Exclude | 'other'; + +export interface DropFiles { + /** Unknown during a drag in some browsers (Safari gives no items). */ + count: number | null; + /** One per file, when known. */ + kinds: DroppedKind[] | null; + /** Known only once the files are in hand. */ + bytes: number | null; +} + +export interface DropPlace { + /** A composer is on screen and takes files. */ + composer: boolean; + /** A pop-out window: only its own composer, nothing that needs the main window. */ + popout: boolean; + /** Files is on for this account and its server has it. */ + files: boolean; + /** On the Files screen, the folder in view, when files can go there. */ + filesFolder: string | null; + calendar: boolean; + contacts: boolean; + /** On the mail screen, a real folder in view that takes new messages. */ + importFolder: string | null; + /** No connection to the mail server. */ + offline: boolean; + /** Offline mode keeps a draft and its files until the connection is back. */ + keepsOffline: boolean; + /** 0: the server says nothing. */ + maxUploadBytes: number; + maxMessageBytes: number; +} + +export type FileDropNote = + | { key: 'note_limit_message'; size: number } + | { key: 'note_limit_file'; size: number } + | { key: 'note_kept_offline' } + | { key: 'note_needs_connection' }; + +export interface FileDropZone { + id: FileDropZoneId; + /** Names the folder for `upload` on the Files screen and for `import-eml`. */ + folder?: string; + note?: FileDropNote; + /** Shown, with the note saying why, and takes no drop. */ + disabled?: boolean; +} + +export const MAX_ZONES = 4; + +/** What a dragged item's MIME type says. Empty or anything else: no special choice. */ +function dropped(kind: HandoffKind | null): DroppedKind { + return kind && kind !== 'compose' ? kind : 'other'; +} + +export function kindForType(type: string): DroppedKind { + return dropped(kindForFile({ name: '', type })); +} + +export function kindForName(file: { name: string; type?: string }): DroppedKind { + return dropped(kindForFile(file)); +} + +/** The one kind every file shares, or null for a mix or for none known. */ +export function sharedKind(files: DropFiles): DroppedKind | null { + if (!files.kinds || files.kinds.length === 0) return null; + const [first] = files.kinds; + return files.kinds.every((kind) => kind === first) ? first : null; +} + +function mailNote(place: DropPlace): Pick { + if (place.offline && !place.keepsOffline) return { note: { key: 'note_needs_connection' }, disabled: true }; + if (place.offline) return { note: { key: 'note_kept_offline' } }; + return place.maxMessageBytes > 0 ? { note: { key: 'note_limit_message', size: place.maxMessageBytes } } : {}; +} + +function serverOnly(place: DropPlace, zone: FileDropZone): FileDropZone { + return place.offline ? { ...zone, note: { key: 'note_needs_connection' }, disabled: true } : zone; +} + +/** The zones for these files here, in the order they're shown. */ +export function fileDropZones(files: DropFiles, place: DropPlace): FileDropZone[] { + const kind = sharedKind(files); + const one = files.count === 1; + const main: FileDropZone[] = []; + if (place.composer) main.push({ id: 'attach-here', ...mailNote(place) }); + if (!place.popout) main.push({ id: 'new-message', ...mailNote(place) }); + if (!place.popout && place.files) { + const note: FileDropNote | undefined = place.maxUploadBytes > 0 ? { key: 'note_limit_file', size: place.maxUploadBytes } : undefined; + main.push(serverOnly(place, { id: 'upload', ...(place.filesFolder ? { folder: place.filesFolder } : {}), ...(note ? { note } : {}) })); + } + + // What this kind of file is for, best first. Not in a pop-out: each opens on its own page. + const special: FileDropZone[] = []; + if (!place.popout) { + if (kind === 'eml' && one) special.push({ id: 'open-eml' }); + if (kind === 'eml' && place.importFolder) special.push(serverOnly(place, { id: 'import-eml', folder: place.importFolder })); + if (kind === 'calendar' && one && place.calendar) special.push({ id: 'calendar' }); + if (kind === 'contacts' && one && place.contacts) special.push({ id: 'contacts' }); + } + + const room = Math.max(0, MAX_ZONES - main.length); + const kept = special.slice(0, room); + const attachHere = main.filter((zone) => zone.id === 'attach-here'); + const rest = main.filter((zone) => zone.id !== 'attach-here'); + return [...attachHere, ...kept, ...rest]; +} + +/** What the files may still do, once they're in hand: those too big for the zone stay out. */ +export function filesWithinLimits( + zone: FileDropZoneId, + files: readonly File[], + place: Pick, +): { fitting: File[]; tooLarge: File[]; overMessage: boolean } { + const perFile = place.maxUploadBytes; + const fitting = perFile > 0 ? files.filter((file) => file.size <= perFile) : [...files]; + const tooLarge = files.filter((file) => !fitting.includes(file)); + const mail = zone === 'attach-here' || zone === 'new-message'; + const total = fitting.reduce((sum, file) => sum + file.size, 0); + const overMessage = mail && place.maxMessageBytes > 0 && total > place.maxMessageBytes; + return { fitting: overMessage ? [] : fitting, tooLarge, overMessage }; +} + +/** The zone a keyboard user lands on: the asked-for one if it's there and usable, else the first usable. */ +export function defaultZone(zones: readonly FileDropZone[], preferred?: FileDropZoneId): FileDropZone | undefined { + const usable = zones.filter((zone) => !zone.disabled); + return usable.find((zone) => zone.id === preferred) ?? usable[0]; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index ed9cbecb0..aa5807286 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -7058,6 +7058,35 @@ "types_character": "تكتب هذه المفاتيح حرفًا على لوحة مفاتيحك، وسيتوقف عن العمل في كل التطبيقات. جرّب مفاتيح أخرى.", "reserved": "يستخدم النظام أو معظم التطبيقات هذه المفاتيح. جرّب مفاتيح أخرى." }, + "fileDrop": { + "overlay_label": "أفلت الملفات", + "overlay_title": "إلى أين تذهب الملفات؟", + "overlay_hint": "أفلت خارج الخيارات أو اضغط Esc للإلغاء.", + "files_unknown": "ملفات", + "files_summary": "{count, plural, zero {لا ملفات} one {ملف واحد} two {ملفان} few {# ملفات} many {# ملفًا} other {# ملف}}", + "files_summary_size": "{count, plural, zero {لا ملفات} one {ملف واحد} two {ملفان} few {# ملفات} many {# ملفًا} other {# ملف}}, {size}", + "zone_attach_here": "إرفاق بهذه الرسالة", + "zone_new_message": "إرفاق برسالة جديدة", + "zone_upload_files": "رفع إلى الملفات", + "zone_upload_folder": "رفع إلى {folder}", + "zone_calendar": "إضافة إلى التقويم", + "zone_contacts": "إضافة إلى جهات الاتصال", + "zone_open_eml": "فتح الرسالة", + "zone_import_eml": "استيراد إلى {folder}", + "note_limit_message": "حتى {size} لكل رسالة", + "note_limit_file": "حتى {size} لكل ملف", + "note_kept_offline": "تبقى على هذا الجهاز حتى تعود متصلًا", + "note_needs_connection": "يحتاج إلى اتصال", + "announce": "{files, plural, =0 {ملفات مسحوبة} zero {لا ملفات مسحوبة} one {ملف واحد مسحوب} two {ملفان مسحوبان} few {# ملفات مسحوبة} many {# ملفًا مسحوبًا} other {# ملف مسحوب}}. الخيارات: {choices}.", + "choose_title": "{count, plural, zero {إلى أين تذهب هذه الملفات؟} one {إلى أين يذهب هذا الملف؟} two {إلى أين يذهب هذان الملفان؟} few {إلى أين تذهب هذه الملفات؟} many {إلى أين تذهب هذه الملفات؟} other {إلى أين تذهب هذه الملفات؟}}", + "save_first_failed": "تعذّر حفظ الرسالة المفتوحة، لذا بقي التطبيق حيث كان.", + "import_done": "{count, plural, zero {لم تُستورد أي رسالة إلى {folder}.} one {استُوردت رسالة واحدة إلى {folder}.} two {استُوردت رسالتان إلى {folder}.} few {استُوردت # رسائل إلى {folder}.} many {استُوردت # رسالة إلى {folder}.} other {استُوردت # رسالة إلى {folder}.}}", + "import_failed": "تعذّر استيراد {name}.", + "too_large_file": "{name} كبير جدًا. الحد هو {max} لكل ملف.", + "too_large_message": "هذه الملفات كبيرة جدًا لرسالة واحدة. الحد هو {max}.", + "folders_left_out": "يمكن أن تذهب المجلدات إلى الملفات فقط، لذا تُركت.", + "nothing_here": "لا يمكن استخدام هذه الملفات هنا." + }, "desktopReminders": { "generic_title": "تذكير", "snooze": "تأجيل {count} د", diff --git a/locales/ca/common.json b/locales/ca/common.json index 5e3d4916d..04f546b54 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -7058,6 +7058,35 @@ "types_character": "Aquestes tecles escriuen un caràcter al teu teclat, i deixaria de funcionar a totes les aplicacions. Prova altres tecles.", "reserved": "El sistema o la majoria d'aplicacions fan servir aquestes tecles. Prova altres tecles." }, + "fileDrop": { + "overlay_label": "Deixa anar fitxers", + "overlay_title": "On han d'anar els fitxers?", + "overlay_hint": "Deixa anar fora de les opcions o prem Esc per cancel·lar.", + "files_unknown": "Fitxers", + "files_summary": "{count, plural, one {# fitxer} other {# fitxers}}", + "files_summary_size": "{count, plural, one {# fitxer} other {# fitxers}}, {size}", + "zone_attach_here": "Adjunta a aquest missatge", + "zone_new_message": "Adjunta a un missatge nou", + "zone_upload_files": "Puja a Fitxers", + "zone_upload_folder": "Puja a {folder}", + "zone_calendar": "Afegeix al calendari", + "zone_contacts": "Afegeix als contactes", + "zone_open_eml": "Obre el missatge", + "zone_import_eml": "Importa a {folder}", + "note_limit_message": "Fins a {size} per missatge", + "note_limit_file": "Fins a {size} per fitxer", + "note_kept_offline": "Es queda en aquest dispositiu fins que tornis a tenir connexió", + "note_needs_connection": "Necessita connexió", + "announce": "{files, plural, =0 {Fitxers arrossegats} one {# fitxer arrossegat} other {# fitxers arrossegats}}. Opcions: {choices}.", + "choose_title": "{count, plural, one {On ha d'anar aquest fitxer?} other {On han d'anar aquests fitxers?}}", + "save_first_failed": "No s'ha pogut desar el missatge obert, així que l'app s'ha quedat on era.", + "import_done": "{count, plural, one {# missatge importat a {folder}.} other {# missatges importats a {folder}.}}", + "import_failed": "No s'ha pogut importar {name}.", + "too_large_file": "{name} és massa gran. El límit és {max} per fitxer.", + "too_large_message": "Aquests fitxers són massa grans per a un missatge. El límit és {max}.", + "folders_left_out": "Les carpetes només poden anar a Fitxers, així que s'han deixat fora.", + "nothing_here": "Aquests fitxers no es poden fer servir aquí." + }, "desktopReminders": { "generic_title": "Recordatori", "snooze": "Posposa {count} min", diff --git a/locales/cs/common.json b/locales/cs/common.json index ace3e4105..9c5721a75 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -7058,6 +7058,35 @@ "types_character": "Tyto klávesy na vaší klávesnici píší znak, který by pak nefungoval v žádné aplikaci. Zkuste jiné klávesy.", "reserved": "Tyto klávesy používá systém nebo většina aplikací. Zkuste jiné klávesy." }, + "fileDrop": { + "overlay_label": "Pustit soubory", + "overlay_title": "Kam mají soubory jít?", + "overlay_hint": "Pro zrušení pusťte mimo volby nebo stiskněte Esc.", + "files_unknown": "Soubory", + "files_summary": "{count, plural, one {# soubor} few {# soubory} many {# souboru} other {# souborů}}", + "files_summary_size": "{count, plural, one {# soubor} few {# soubory} many {# souboru} other {# souborů}}, {size}", + "zone_attach_here": "Připojit k této zprávě", + "zone_new_message": "Připojit k nové zprávě", + "zone_upload_files": "Nahrát do Souborů", + "zone_upload_folder": "Nahrát do {folder}", + "zone_calendar": "Přidat do kalendáře", + "zone_contacts": "Přidat do kontaktů", + "zone_open_eml": "Otevřít zprávu", + "zone_import_eml": "Importovat do {folder}", + "note_limit_message": "Až {size} na zprávu", + "note_limit_file": "Až {size} na soubor", + "note_kept_offline": "Zůstane v tomto zařízení, dokud nebudete online", + "note_needs_connection": "Vyžaduje připojení", + "announce": "{files, plural, =0 {Přetaženy soubory} one {Přetažen # soubor} few {Přetaženy # soubory} many {Přetaženo # souboru} other {Přetaženo # souborů}}. Volby: {choices}.", + "choose_title": "{count, plural, one {Kam má tento soubor jít?} few {Kam mají tyto soubory jít?} many {Kam mají tyto soubory jít?} other {Kam mají tyto soubory jít?}}", + "save_first_failed": "Otevřenou zprávu se nepodařilo uložit, proto aplikace zůstala, kde byla.", + "import_done": "{count, plural, one {# zpráva importována do {folder}.} few {# zprávy importovány do {folder}.} many {# zprávy importováno do {folder}.} other {# zpráv importováno do {folder}.}}", + "import_failed": "{name} se nepodařilo importovat.", + "too_large_file": "{name} je příliš velký. Limit je {max} na soubor.", + "too_large_message": "Tyto soubory jsou na jednu zprávu příliš velké. Limit je {max}.", + "folders_left_out": "Složky mohou jít jen do Souborů, proto byly vynechány.", + "nothing_here": "Tyto soubory zde nelze použít." + }, "desktopReminders": { "generic_title": "Připomenutí", "snooze": "Odložit o {count} min", diff --git a/locales/da/common.json b/locales/da/common.json index b473c8379..ad28302ef 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -7058,6 +7058,35 @@ "types_character": "Disse taster skriver et tegn på dit tastatur, og det ville holde op med at virke i alle apps. Prøv andre taster.", "reserved": "Dit system eller de fleste apps bruger disse taster. Prøv andre taster." }, + "fileDrop": { + "overlay_label": "Slip filer", + "overlay_title": "Hvor skal filerne hen?", + "overlay_hint": "Slip uden for valgene, eller tryk på Esc for at annullere.", + "files_unknown": "Filer", + "files_summary": "{count, plural, one {# fil} other {# filer}}", + "files_summary_size": "{count, plural, one {# fil} other {# filer}}, {size}", + "zone_attach_here": "Vedhæft til denne besked", + "zone_new_message": "Vedhæft til en ny besked", + "zone_upload_files": "Upload til Filer", + "zone_upload_folder": "Upload til {folder}", + "zone_calendar": "Føj til kalender", + "zone_contacts": "Føj til kontakter", + "zone_open_eml": "Åbn besked", + "zone_import_eml": "Importér til {folder}", + "note_limit_message": "Op til {size} pr. besked", + "note_limit_file": "Op til {size} pr. fil", + "note_kept_offline": "Bliver på denne enhed, til du er online igen", + "note_needs_connection": "Kræver forbindelse", + "announce": "{files, plural, =0 {Filer} one {# fil} other {# filer}} trukket ind. Valg: {choices}.", + "choose_title": "{count, plural, one {Hvor skal denne fil hen?} other {Hvor skal disse filer hen?}}", + "save_first_failed": "Den åbne besked kunne ikke gemmes, så appen blev, hvor den var.", + "import_done": "{count, plural, one {# besked importeret til {folder}.} other {# beskeder importeret til {folder}.}}", + "import_failed": "{name} kunne ikke importeres.", + "too_large_file": "{name} er for stor. Grænsen er {max} pr. fil.", + "too_large_message": "Disse filer er for store til én besked. Grænsen er {max}.", + "folders_left_out": "Mapper kan kun komme i Filer, så de blev udeladt.", + "nothing_here": "Disse filer kan ikke bruges her." + }, "desktopReminders": { "generic_title": "Påmindelse", "snooze": "Udsæt {count} min.", diff --git a/locales/de/common.json b/locales/de/common.json index 9cb2097f7..ae9368ddc 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -7058,6 +7058,35 @@ "types_character": "Diese Tasten erzeugen auf Ihrer Tastatur ein Zeichen, das dann in keiner App mehr funktionieren würde. Versuchen Sie andere Tasten.", "reserved": "Ihr System oder die meisten Apps verwenden diese Tasten. Versuchen Sie andere Tasten." }, + "fileDrop": { + "overlay_label": "Dateien ablegen", + "overlay_title": "Wohin sollen die Dateien?", + "overlay_hint": "Zum Abbrechen außerhalb der Felder ablegen oder Esc drücken.", + "files_unknown": "Dateien", + "files_summary": "{count, plural, one {# Datei} other {# Dateien}}", + "files_summary_size": "{count, plural, one {# Datei} other {# Dateien}}, {size}", + "zone_attach_here": "An diese Nachricht anhängen", + "zone_new_message": "An neue Nachricht anhängen", + "zone_upload_files": "In Dateien hochladen", + "zone_upload_folder": "In {folder} hochladen", + "zone_calendar": "Zum Kalender hinzufügen", + "zone_contacts": "Zu Kontakten hinzufügen", + "zone_open_eml": "Nachricht öffnen", + "zone_import_eml": "In {folder} importieren", + "note_limit_message": "Bis zu {size} pro Nachricht", + "note_limit_file": "Bis zu {size} pro Datei", + "note_kept_offline": "Bleibt auf diesem Gerät, bis Sie wieder online sind", + "note_needs_connection": "Braucht eine Verbindung", + "announce": "{files, plural, =0 {Dateien} one {# Datei} other {# Dateien}} hereingezogen. Möglichkeiten: {choices}.", + "choose_title": "{count, plural, one {Wohin soll diese Datei?} other {Wohin sollen diese Dateien?}}", + "save_first_failed": "Die offene Nachricht konnte nicht gespeichert werden, daher bleibt die App, wo sie war.", + "import_done": "{count, plural, one {# Nachricht in {folder} importiert.} other {# Nachrichten in {folder} importiert.}}", + "import_failed": "{name} konnte nicht importiert werden.", + "too_large_file": "{name} ist zu groß. Die Grenze liegt bei {max} pro Datei.", + "too_large_message": "Diese Dateien sind zusammen zu groß für eine Nachricht. Die Grenze liegt bei {max}.", + "folders_left_out": "Ordner können nur in Dateien, daher wurden sie weggelassen.", + "nothing_here": "Diese Dateien lassen sich hier nicht verwenden." + }, "desktopReminders": { "generic_title": "Erinnerung", "snooze": "{count} Min. schlummern", diff --git a/locales/en/common.json b/locales/en/common.json index 84f9389a7..3cba72c6a 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -7058,6 +7058,35 @@ "types_character": "These keys type a character on your keyboard, and it would stop working in every app. Try other keys.", "reserved": "Your system or most apps use these keys. Try other keys." }, + "fileDrop": { + "overlay_label": "Drop files", + "overlay_title": "Where should the files go?", + "overlay_hint": "Drop outside the choices or press Esc to cancel.", + "files_unknown": "Files", + "files_summary": "{count, plural, one {# file} other {# files}}", + "files_summary_size": "{count, plural, one {# file} other {# files}}, {size}", + "zone_attach_here": "Attach to this message", + "zone_new_message": "Attach to a new message", + "zone_upload_files": "Upload to Files", + "zone_upload_folder": "Upload to {folder}", + "zone_calendar": "Add to calendar", + "zone_contacts": "Add to contacts", + "zone_open_eml": "Open message", + "zone_import_eml": "Import into {folder}", + "note_limit_message": "Up to {size} per message", + "note_limit_file": "Up to {size} per file", + "note_kept_offline": "Kept on this device until you're online", + "note_needs_connection": "Needs a connection", + "announce": "{files, plural, =0 {Files} one {# file} other {# files}} dragged in. Choices: {choices}.", + "choose_title": "{count, plural, one {Where should this file go?} other {Where should these files go?}}", + "save_first_failed": "The open message could not be saved, so the app stayed where it was.", + "import_done": "{count, plural, one {# message imported into {folder}.} other {# messages imported into {folder}.}}", + "import_failed": "{name} could not be imported.", + "too_large_file": "{name} is too large. The limit is {max} per file.", + "too_large_message": "These files are too large for one message. The limit is {max}.", + "folders_left_out": "Folders can only go to Files, so they were left out.", + "nothing_here": "These files can't be used here." + }, "desktopReminders": { "generic_title": "Reminder", "snooze": "Snooze {count} min", diff --git a/locales/es/common.json b/locales/es/common.json index a44fce868..398480475 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -7058,6 +7058,35 @@ "types_character": "Estas teclas escriben un carácter en tu teclado, y dejaría de funcionar en todas las aplicaciones. Prueba otras teclas.", "reserved": "Tu sistema o la mayoría de las aplicaciones usan estas teclas. Prueba otras teclas." }, + "fileDrop": { + "overlay_label": "Soltar archivos", + "overlay_title": "¿Dónde van los archivos?", + "overlay_hint": "Suelta fuera de las opciones o pulsa Esc para cancelar.", + "files_unknown": "Archivos", + "files_summary": "{count, plural, one {# archivo} other {# archivos}}", + "files_summary_size": "{count, plural, one {# archivo} other {# archivos}}, {size}", + "zone_attach_here": "Adjuntar a este mensaje", + "zone_new_message": "Adjuntar a un mensaje nuevo", + "zone_upload_files": "Subir a Archivos", + "zone_upload_folder": "Subir a {folder}", + "zone_calendar": "Añadir al calendario", + "zone_contacts": "Añadir a contactos", + "zone_open_eml": "Abrir mensaje", + "zone_import_eml": "Importar en {folder}", + "note_limit_message": "Hasta {size} por mensaje", + "note_limit_file": "Hasta {size} por archivo", + "note_kept_offline": "Se guarda en este dispositivo hasta que vuelvas a tener conexión", + "note_needs_connection": "Necesita conexión", + "announce": "{files, plural, =0 {Archivos arrastrados} one {# archivo arrastrado} other {# archivos arrastrados}}. Opciones: {choices}.", + "choose_title": "{count, plural, one {¿Dónde va este archivo?} other {¿Dónde van estos archivos?}}", + "save_first_failed": "No se pudo guardar el mensaje abierto, así que la app se quedó donde estaba.", + "import_done": "{count, plural, one {# mensaje importado en {folder}.} other {# mensajes importados en {folder}.}}", + "import_failed": "No se pudo importar {name}.", + "too_large_file": "{name} es demasiado grande. El límite es {max} por archivo.", + "too_large_message": "Estos archivos son demasiado grandes para un mensaje. El límite es {max}.", + "folders_left_out": "Las carpetas solo pueden ir a Archivos, así que se han dejado fuera.", + "nothing_here": "Estos archivos no se pueden usar aquí." + }, "desktopReminders": { "generic_title": "Recordatorio", "snooze": "Posponer {count} min", diff --git a/locales/et/common.json b/locales/et/common.json index ace51f3b5..25c3db37e 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -7058,6 +7058,35 @@ "types_character": "Need klahvid kirjutavad teie klaviatuuril märgi, mis lakkaks kõigis rakendustes töötamast. Proovige teisi klahve.", "reserved": "Neid klahve kasutab süsteem või enamik rakendusi. Proovige teisi klahve." }, + "fileDrop": { + "overlay_label": "Lohista failid siia", + "overlay_title": "Kuhu failid lähevad?", + "overlay_hint": "Tühistamiseks lase lahti väljaspool valikuid või vajuta Esc.", + "files_unknown": "Failid", + "files_summary": "{count, plural, one {# fail} other {# faili}}", + "files_summary_size": "{count, plural, one {# fail} other {# faili}}, {size}", + "zone_attach_here": "Lisa sellele kirjale", + "zone_new_message": "Lisa uuele kirjale", + "zone_upload_files": "Laadi üles kausta Failid", + "zone_upload_folder": "Laadi üles kausta {folder}", + "zone_calendar": "Lisa kalendrisse", + "zone_contacts": "Lisa kontaktidesse", + "zone_open_eml": "Ava kiri", + "zone_import_eml": "Impordi kausta {folder}", + "note_limit_message": "Kuni {size} kirja kohta", + "note_limit_file": "Kuni {size} faili kohta", + "note_kept_offline": "Jääb sellesse seadmesse, kuni oled taas võrgus", + "note_needs_connection": "Vajab ühendust", + "announce": "{files, plural, =0 {Failid} one {# fail} other {# faili}} lohistati sisse. Valikud: {choices}.", + "choose_title": "{count, plural, one {Kuhu see fail läheb?} other {Kuhu need failid lähevad?}}", + "save_first_failed": "Avatud kirja ei saanud salvestada, seega jäi rakendus sinna, kus ta oli.", + "import_done": "{count, plural, one {# kiri imporditi kausta {folder}.} other {# kirja imporditi kausta {folder}.}}", + "import_failed": "Faili {name} ei saanud importida.", + "too_large_file": "{name} on liiga suur. Piir on {max} faili kohta.", + "too_large_message": "Need failid on ühe kirja jaoks liiga suured. Piir on {max}.", + "folders_left_out": "Kaustu saab panna ainult kausta Failid, seega jäeti need välja.", + "nothing_here": "Neid faile ei saa siin kasutada." + }, "desktopReminders": { "generic_title": "Meeldetuletus", "snooze": "Lükka {count} min edasi", diff --git a/locales/fa/common.json b/locales/fa/common.json index 9961aab01..ee05eb3dc 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -7058,6 +7058,35 @@ "types_character": "این کلیدها روی صفحه‌کلید شما نویسه‌ای می‌نویسند و آن نویسه دیگر در هیچ برنامه‌ای کار نمی‌کند. کلیدهای دیگری را امتحان کنید.", "reserved": "سیستم یا بیشتر برنامه‌ها از این کلیدها استفاده می‌کنند. کلیدهای دیگری را امتحان کنید." }, + "fileDrop": { + "overlay_label": "رها کردن فایل‌ها", + "overlay_title": "فایل‌ها کجا بروند؟", + "overlay_hint": "برای لغو، بیرون از گزینه‌ها رها کنید یا Esc را بزنید.", + "files_unknown": "فایل‌ها", + "files_summary": "{count, plural, one {# فایل} other {# فایل}}", + "files_summary_size": "{count, plural, one {# فایل} other {# فایل}}, {size}", + "zone_attach_here": "پیوست به این پیام", + "zone_new_message": "پیوست به پیام جدید", + "zone_upload_files": "بارگذاری در فایل‌ها", + "zone_upload_folder": "بارگذاری در {folder}", + "zone_calendar": "افزودن به تقویم", + "zone_contacts": "افزودن به مخاطبان", + "zone_open_eml": "باز کردن پیام", + "zone_import_eml": "وارد کردن به {folder}", + "note_limit_message": "تا {size} برای هر پیام", + "note_limit_file": "تا {size} برای هر فایل", + "note_kept_offline": "تا وقتی دوباره آنلاین شوید روی این دستگاه می‌ماند", + "note_needs_connection": "به اتصال نیاز دارد", + "announce": "{files, plural, =0 {فایل‌ها} one {# فایل} other {# فایل}} کشیده شد. گزینه‌ها: {choices}.", + "choose_title": "{count, plural, one {این فایل کجا برود؟} other {این فایل‌ها کجا بروند؟}}", + "save_first_failed": "پیام باز ذخیره نشد، پس برنامه همان‌جا ماند.", + "import_done": "{count, plural, one {# پیام به {folder} وارد شد.} other {# پیام به {folder} وارد شد.}}", + "import_failed": "{name} وارد نشد.", + "too_large_file": "{name} خیلی بزرگ است. حد آن {max} برای هر فایل است.", + "too_large_message": "این فایل‌ها برای یک پیام خیلی بزرگ‌اند. حد آن {max} است.", + "folders_left_out": "پوشه‌ها فقط می‌توانند به فایل‌ها بروند، پس کنار گذاشته شدند.", + "nothing_here": "این فایل‌ها اینجا قابل استفاده نیستند." + }, "desktopReminders": { "generic_title": "یادآوری", "snooze": "تعویق {count} دقیقه", diff --git a/locales/fr/common.json b/locales/fr/common.json index 750e84417..1c45bde45 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -7058,6 +7058,35 @@ "types_character": "Ces touches produisent un caractère sur votre clavier, et il ne fonctionnerait plus dans aucune application. Essayez d'autres touches.", "reserved": "Votre système ou la plupart des applications utilisent ces touches. Essayez d'autres touches." }, + "fileDrop": { + "overlay_label": "Déposer des fichiers", + "overlay_title": "Où mettre les fichiers ?", + "overlay_hint": "Déposez hors des choix ou appuyez sur Échap pour annuler.", + "files_unknown": "Fichiers", + "files_summary": "{count, plural, one {# fichier} other {# fichiers}}", + "files_summary_size": "{count, plural, one {# fichier} other {# fichiers}}, {size}", + "zone_attach_here": "Joindre à ce message", + "zone_new_message": "Joindre à un nouveau message", + "zone_upload_files": "Envoyer dans Fichiers", + "zone_upload_folder": "Envoyer dans {folder}", + "zone_calendar": "Ajouter au calendrier", + "zone_contacts": "Ajouter aux contacts", + "zone_open_eml": "Ouvrir le message", + "zone_import_eml": "Importer dans {folder}", + "note_limit_message": "Jusqu'à {size} par message", + "note_limit_file": "Jusqu'à {size} par fichier", + "note_kept_offline": "Gardé sur cet appareil jusqu'au retour de la connexion", + "note_needs_connection": "Nécessite une connexion", + "announce": "{files, plural, =0 {Fichiers glissés} one {# fichier glissé} other {# fichiers glissés}}. Choix : {choices}.", + "choose_title": "{count, plural, one {Où mettre ce fichier ?} other {Où mettre ces fichiers ?}}", + "save_first_failed": "Le message ouvert n'a pas pu être enregistré, l'app est donc restée où elle était.", + "import_done": "{count, plural, one {# message importé dans {folder}.} other {# messages importés dans {folder}.}}", + "import_failed": "{name} n'a pas pu être importé.", + "too_large_file": "{name} est trop volumineux. La limite est de {max} par fichier.", + "too_large_message": "Ces fichiers sont trop volumineux pour un seul message. La limite est de {max}.", + "folders_left_out": "Les dossiers ne peuvent aller que dans Fichiers, ils ont donc été laissés de côté.", + "nothing_here": "Ces fichiers ne peuvent pas être utilisés ici." + }, "desktopReminders": { "generic_title": "Rappel", "snooze": "Reporter de {count} min", diff --git a/locales/he/common.json b/locales/he/common.json index 23cb11fa8..5066ec4b6 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -7059,6 +7059,35 @@ "types_character": "המקשים האלה מקלידים תו במקלדת שלכם, והוא יפסיק לעבוד בכל האפליקציות. נסו מקשים אחרים.", "reserved": "המערכת או רוב האפליקציות משתמשות במקשים האלה. נסו מקשים אחרים." }, + "fileDrop": { + "overlay_label": "שחררו קבצים", + "overlay_title": "לאן הקבצים ילכו?", + "overlay_hint": "כדי לבטל, שחררו מחוץ לאפשרויות או הקישו Esc.", + "files_unknown": "קבצים", + "files_summary": "{count, plural, one {קובץ אחד} two {שני קבצים} other {# קבצים}}", + "files_summary_size": "{count, plural, one {קובץ אחד} two {שני קבצים} other {# קבצים}}, {size}", + "zone_attach_here": "צירוף להודעה הזו", + "zone_new_message": "צירוף להודעה חדשה", + "zone_upload_files": "העלאה לקבצים", + "zone_upload_folder": "העלאה אל {folder}", + "zone_calendar": "הוספה ללוח השנה", + "zone_contacts": "הוספה לאנשי הקשר", + "zone_open_eml": "פתיחת ההודעה", + "zone_import_eml": "ייבוא אל {folder}", + "note_limit_message": "עד {size} להודעה", + "note_limit_file": "עד {size} לקובץ", + "note_kept_offline": "נשמר במכשיר הזה עד שתחזרו לרשת", + "note_needs_connection": "נדרש חיבור", + "announce": "{files, plural, =0 {קבצים נגררו} one {קובץ אחד נגרר} two {שני קבצים נגררו} other {# קבצים נגררו}}. אפשרויות: {choices}.", + "choose_title": "{count, plural, one {לאן הקובץ הזה ילך?} two {לאן הקבצים האלה ילכו?} other {לאן הקבצים האלה ילכו?}}", + "save_first_failed": "לא ניתן היה לשמור את ההודעה הפתוחה, לכן האפליקציה נשארה במקומה.", + "import_done": "{count, plural, one {הודעה אחת יובאה אל {folder}.} two {שתי הודעות יובאו אל {folder}.} other {# הודעות יובאו אל {folder}.}}", + "import_failed": "לא ניתן היה לייבא את {name}.", + "too_large_file": "{name} גדול מדי. המגבלה היא {max} לקובץ.", + "too_large_message": "הקבצים האלה גדולים מדי להודעה אחת. המגבלה היא {max}.", + "folders_left_out": "תיקיות יכולות ללכת רק לקבצים, לכן הן הושמטו.", + "nothing_here": "אי אפשר להשתמש בקבצים האלה כאן." + }, "desktopReminders": { "generic_title": "תזכורת", "snooze": "דחייה ב-{count} דק'", diff --git a/locales/hu/common.json b/locales/hu/common.json index 172fa6df6..db74f7bc4 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -7058,6 +7058,35 @@ "types_character": "Ezek a billentyűk karaktert írnak az Ön billentyűzetén, amely többé egyik alkalmazásban sem működne. Próbáljon más billentyűket.", "reserved": "Ezeket a billentyűket a rendszer vagy a legtöbb alkalmazás használja. Próbáljon más billentyűket." }, + "fileDrop": { + "overlay_label": "Fájlok ejtése", + "overlay_title": "Hová kerüljenek a fájlok?", + "overlay_hint": "A megszakításhoz engedje el a lehetőségeken kívül, vagy nyomja meg az Esc billentyűt.", + "files_unknown": "Fájlok", + "files_summary": "{count, plural, one {# fájl} other {# fájl}}", + "files_summary_size": "{count, plural, one {# fájl} other {# fájl}}, {size}", + "zone_attach_here": "Csatolás ehhez az üzenethez", + "zone_new_message": "Csatolás új üzenethez", + "zone_upload_files": "Feltöltés a Fájlokba", + "zone_upload_folder": "Feltöltés ide: {folder}", + "zone_calendar": "Hozzáadás a naptárhoz", + "zone_contacts": "Hozzáadás a névjegyekhez", + "zone_open_eml": "Üzenet megnyitása", + "zone_import_eml": "Importálás ide: {folder}", + "note_limit_message": "Legfeljebb {size} üzenetenként", + "note_limit_file": "Legfeljebb {size} fájlonként", + "note_kept_offline": "Ezen az eszközön marad, amíg újra online nem lesz", + "note_needs_connection": "Kapcsolat szükséges", + "announce": "{files, plural, =0 {Fájlok} one {# fájl} other {# fájl}} behúzva. Lehetőségek: {choices}.", + "choose_title": "{count, plural, one {Hová kerüljön ez a fájl?} other {Hová kerüljenek ezek a fájlok?}}", + "save_first_failed": "A megnyitott üzenetet nem sikerült menteni, ezért az alkalmazás ott maradt, ahol volt.", + "import_done": "{count, plural, one {# üzenet importálva ide: {folder}.} other {# üzenet importálva ide: {folder}.}}", + "import_failed": "{name} nem importálható.", + "too_large_file": "{name} túl nagy. A korlát {max} fájlonként.", + "too_large_message": "Ezek a fájlok együtt túl nagyok egy üzenethez. A korlát {max}.", + "folders_left_out": "Mappák csak a Fájlokba kerülhetnek, ezért kimaradtak.", + "nothing_here": "Ezek a fájlok itt nem használhatók." + }, "desktopReminders": { "generic_title": "Emlékeztető", "snooze": "Halasztás {count} perccel", diff --git a/locales/it/common.json b/locales/it/common.json index 71f13cca0..a6d8ff703 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -7058,6 +7058,35 @@ "types_character": "Questi tasti scrivono un carattere sulla tua tastiera, che smetterebbe di funzionare in tutte le app. Prova altri tasti.", "reserved": "Il sistema o la maggior parte delle app usano questi tasti. Prova altri tasti." }, + "fileDrop": { + "overlay_label": "Rilascia file", + "overlay_title": "Dove vanno i file?", + "overlay_hint": "Rilascia fuori dalle opzioni o premi Esc per annullare.", + "files_unknown": "File", + "files_summary": "{count, plural, one {# file} other {# file}}", + "files_summary_size": "{count, plural, one {# file} other {# file}}, {size}", + "zone_attach_here": "Allega a questo messaggio", + "zone_new_message": "Allega a un nuovo messaggio", + "zone_upload_files": "Carica in File", + "zone_upload_folder": "Carica in {folder}", + "zone_calendar": "Aggiungi al calendario", + "zone_contacts": "Aggiungi ai contatti", + "zone_open_eml": "Apri messaggio", + "zone_import_eml": "Importa in {folder}", + "note_limit_message": "Fino a {size} per messaggio", + "note_limit_file": "Fino a {size} per file", + "note_kept_offline": "Resta su questo dispositivo finché non torni online", + "note_needs_connection": "Serve una connessione", + "announce": "{files, plural, =0 {File trascinati} one {# file trascinato} other {# file trascinati}}. Opzioni: {choices}.", + "choose_title": "{count, plural, one {Dove va questo file?} other {Dove vanno questi file?}}", + "save_first_failed": "Non è stato possibile salvare il messaggio aperto, quindi l'app è rimasta dov'era.", + "import_done": "{count, plural, one {# messaggio importato in {folder}.} other {# messaggi importati in {folder}.}}", + "import_failed": "Non è stato possibile importare {name}.", + "too_large_file": "{name} è troppo grande. Il limite è {max} per file.", + "too_large_message": "Questi file sono troppo grandi per un solo messaggio. Il limite è {max}.", + "folders_left_out": "Le cartelle possono andare solo in File, quindi sono state escluse.", + "nothing_here": "Questi file non si possono usare qui." + }, "desktopReminders": { "generic_title": "Promemoria", "snooze": "Rimanda di {count} min", diff --git a/locales/ja/common.json b/locales/ja/common.json index 7d382e843..8b3443527 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -7058,6 +7058,35 @@ "types_character": "このキーはお使いのキーボードで文字を入力します。割り当てると、すべてのアプリでその文字を入力できなくなります。別のキーをお試しください。", "reserved": "このキーはシステムまたは多くのアプリが使用しています。別のキーをお試しください。" }, + "fileDrop": { + "overlay_label": "ファイルをドロップ", + "overlay_title": "ファイルをどこに入れますか?", + "overlay_hint": "キャンセルするには選択肢の外でドロップするか、Esc キーを押します。", + "files_unknown": "ファイル", + "files_summary": "{count, plural, other {# 個のファイル}}", + "files_summary_size": "{count, plural, other {# 個のファイル}}, {size}", + "zone_attach_here": "このメッセージに添付", + "zone_new_message": "新規メッセージに添付", + "zone_upload_files": "ファイルにアップロード", + "zone_upload_folder": "{folder} にアップロード", + "zone_calendar": "カレンダーに追加", + "zone_contacts": "連絡先に追加", + "zone_open_eml": "メッセージを開く", + "zone_import_eml": "{folder} にインポート", + "note_limit_message": "1 通あたり {size} まで", + "note_limit_file": "1 ファイルあたり {size} まで", + "note_kept_offline": "オンラインに戻るまでこのデバイスに保存されます", + "note_needs_connection": "接続が必要です", + "announce": "{files, plural, =0 {ファイル} other {# 個のファイル}}がドラッグされました。選択肢: {choices}。", + "choose_title": "{count, plural, other {このファイルをどこに入れますか?}}", + "save_first_failed": "開いているメッセージを保存できなかったため、アプリはそのままです。", + "import_done": "{count, plural, other {# 通のメッセージを {folder} にインポートしました。}}", + "import_failed": "{name} をインポートできませんでした。", + "too_large_file": "{name} は大きすぎます。上限は 1 ファイルあたり {max} です。", + "too_large_message": "これらのファイルは 1 通のメッセージには大きすぎます。上限は {max} です。", + "folders_left_out": "フォルダーはファイルにしか入れられないため、除外しました。", + "nothing_here": "これらのファイルはここでは使えません。" + }, "desktopReminders": { "generic_title": "リマインダー", "snooze": "{count} 分後に再通知", diff --git a/locales/ko/common.json b/locales/ko/common.json index 9f9643ad0..2afd81a3d 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -7058,6 +7058,35 @@ "types_character": "이 키는 사용 중인 키보드에서 문자를 입력하며, 지정하면 모든 앱에서 그 문자를 입력할 수 없게 됩니다. 다른 키를 사용해 보세요.", "reserved": "시스템 또는 대부분의 앱이 이 키를 사용합니다. 다른 키를 사용해 보세요." }, + "fileDrop": { + "overlay_label": "파일 놓기", + "overlay_title": "파일을 어디에 둘까요?", + "overlay_hint": "취소하려면 선택 항목 밖에 놓거나 Esc 키를 누르세요.", + "files_unknown": "파일", + "files_summary": "{count, plural, other {파일 #개}}", + "files_summary_size": "{count, plural, other {파일 #개}}, {size}", + "zone_attach_here": "이 메시지에 첨부", + "zone_new_message": "새 메시지에 첨부", + "zone_upload_files": "파일에 업로드", + "zone_upload_folder": "{folder}에 업로드", + "zone_calendar": "캘린더에 추가", + "zone_contacts": "연락처에 추가", + "zone_open_eml": "메시지 열기", + "zone_import_eml": "{folder}(으)로 가져오기", + "note_limit_message": "메시지당 최대 {size}", + "note_limit_file": "파일당 최대 {size}", + "note_kept_offline": "다시 온라인이 될 때까지 이 기기에 보관됩니다", + "note_needs_connection": "연결이 필요합니다", + "announce": "{files, plural, =0 {파일을} other {파일 #개를}} 끌어왔습니다. 선택 항목: {choices}.", + "choose_title": "{count, plural, other {이 파일을 어디에 둘까요?}}", + "save_first_failed": "열린 메시지를 저장하지 못해 앱이 그대로 있습니다.", + "import_done": "{count, plural, other {메시지 #개를 {folder}(으)로 가져왔습니다.}}", + "import_failed": "{name}을(를) 가져오지 못했습니다.", + "too_large_file": "{name}이(가) 너무 큽니다. 한도는 파일당 {max}입니다.", + "too_large_message": "이 파일들은 한 메시지에 넣기에 너무 큽니다. 한도는 {max}입니다.", + "folders_left_out": "폴더는 파일에만 넣을 수 있어 제외했습니다.", + "nothing_here": "이 파일은 여기에서 사용할 수 없습니다." + }, "desktopReminders": { "generic_title": "알림", "snooze": "{count}분 후 다시 알림", diff --git a/locales/lv/common.json b/locales/lv/common.json index a4bc8ef83..d17bb9fb0 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -7058,6 +7058,35 @@ "types_character": "Šie taustiņi jūsu tastatūrā raksta rakstzīmi, kas pārstātu darboties visās lietotnēs. Izmēģiniet citus taustiņus.", "reserved": "Šos taustiņus izmanto sistēma vai lielākā daļa lietotņu. Izmēģiniet citus taustiņus." }, + "fileDrop": { + "overlay_label": "Nometiet failus", + "overlay_title": "Kur likt failus?", + "overlay_hint": "Lai atceltu, nometiet ārpus izvēlēm vai nospiediet Esc.", + "files_unknown": "Faili", + "files_summary": "{count, plural, zero {# failu} one {# fails} other {# faili}}", + "files_summary_size": "{count, plural, zero {# failu} one {# fails} other {# faili}}, {size}", + "zone_attach_here": "Pievienot šim ziņojumam", + "zone_new_message": "Pievienot jaunam ziņojumam", + "zone_upload_files": "Augšupielādēt failos", + "zone_upload_folder": "Augšupielādēt mapē {folder}", + "zone_calendar": "Pievienot kalendāram", + "zone_contacts": "Pievienot kontaktiem", + "zone_open_eml": "Atvērt ziņojumu", + "zone_import_eml": "Importēt mapē {folder}", + "note_limit_message": "Līdz {size} vienam ziņojumam", + "note_limit_file": "Līdz {size} vienam failam", + "note_kept_offline": "Paliek šajā ierīcē, līdz atkal būsit tiešsaistē", + "note_needs_connection": "Vajadzīgs savienojums", + "announce": "{files, plural, =0 {Ievilkti faili} zero {Ievilkts # failu} one {Ievilkts # fails} other {Ievilkti # faili}}. Izvēles: {choices}.", + "choose_title": "{count, plural, zero {Kur likt šos failus?} one {Kur likt šo failu?} other {Kur likt šos failus?}}", + "save_first_failed": "Atvērto ziņojumu neizdevās saglabāt, tāpēc lietotne palika, kur bija.", + "import_done": "{count, plural, zero {# ziņojumu importēts mapē {folder}.} one {# ziņojums importēts mapē {folder}.} other {# ziņojumi importēti mapē {folder}.}}", + "import_failed": "{name} neizdevās importēt.", + "too_large_file": "{name} ir pārāk liels. Ierobežojums ir {max} vienam failam.", + "too_large_message": "Šie faili ir pārāk lieli vienam ziņojumam. Ierobežojums ir {max}.", + "folders_left_out": "Mapes var likt tikai failos, tāpēc tās tika izlaistas.", + "nothing_here": "Šos failus šeit nevar izmantot." + }, "desktopReminders": { "generic_title": "Atgādinājums", "snooze": "Atlikt par {count} min", diff --git a/locales/mn/common.json b/locales/mn/common.json index 152574c4e..2303cb1aa 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -7058,6 +7058,35 @@ "types_character": "Эдгээр товч таны гар дээр тэмдэгт бичдэг тул тэр тэмдэгт бүх аппад ажиллахаа болино. Өөр товч туршина уу.", "reserved": "Систем эсвэл ихэнх апп эдгээр товчийг ашигладаг. Өөр товч туршина уу." }, + "fileDrop": { + "overlay_label": "Файл буулгах", + "overlay_title": "Файлуудыг хаана хийх вэ?", + "overlay_hint": "Цуцлахын тулд сонголтуудын гадна буулгах эсвэл Esc дарна уу.", + "files_unknown": "Файлууд", + "files_summary": "{count, plural, one {# файл} other {# файл}}", + "files_summary_size": "{count, plural, one {# файл} other {# файл}}, {size}", + "zone_attach_here": "Энэ мессежид хавсаргах", + "zone_new_message": "Шинэ мессежид хавсаргах", + "zone_upload_files": "Файл руу байршуулах", + "zone_upload_folder": "{folder} руу байршуулах", + "zone_calendar": "Хуанлид нэмэх", + "zone_contacts": "Харилцагчид нэмэх", + "zone_open_eml": "Мессеж нээх", + "zone_import_eml": "{folder} руу импортлох", + "note_limit_message": "Нэг мессежид {size} хүртэл", + "note_limit_file": "Нэг файлд {size} хүртэл", + "note_kept_offline": "Дахин онлайн болох хүртэл энэ төхөөрөмжид хадгалагдана", + "note_needs_connection": "Холболт шаардлагатай", + "announce": "{files, plural, =0 {Файлууд} one {# файл} other {# файл}} чирэгдлээ. Сонголтууд: {choices}.", + "choose_title": "{count, plural, one {Энэ файлыг хаана хийх вэ?} other {Эдгээр файлыг хаана хийх вэ?}}", + "save_first_failed": "Нээлттэй мессежийг хадгалж чадсангүй, тиймээс апп байсан газартаа үлдлээ.", + "import_done": "{count, plural, one {# мессежийг {folder} руу импортоллоо.} other {# мессежийг {folder} руу импортоллоо.}}", + "import_failed": "{name}-г импортлож чадсангүй.", + "too_large_file": "{name} хэт том байна. Хязгаар нь нэг файлд {max}.", + "too_large_message": "Эдгээр файл нэг мессежид хэт том байна. Хязгаар нь {max}.", + "folders_left_out": "Хавтас зөвхөн Файл руу орох боломжтой тул орхигдлоо.", + "nothing_here": "Эдгээр файлыг энд ашиглах боломжгүй." + }, "desktopReminders": { "generic_title": "Сануулга", "snooze": "{count} минут хойшлуулах", diff --git a/locales/nb/common.json b/locales/nb/common.json index 9a6c14f76..f1e3cfd14 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -7058,6 +7058,35 @@ "types_character": "Disse tastene skriver et tegn på tastaturet ditt, og det ville slutte å virke i alle apper. Prøv andre taster.", "reserved": "Systemet ditt eller de fleste apper bruker disse tastene. Prøv andre taster." }, + "fileDrop": { + "overlay_label": "Slipp filer", + "overlay_title": "Hvor skal filene?", + "overlay_hint": "Slipp utenfor valgene eller trykk på Esc for å avbryte.", + "files_unknown": "Filer", + "files_summary": "{count, plural, one {# fil} other {# filer}}", + "files_summary_size": "{count, plural, one {# fil} other {# filer}}, {size}", + "zone_attach_here": "Legg ved denne meldingen", + "zone_new_message": "Legg ved en ny melding", + "zone_upload_files": "Last opp til Filer", + "zone_upload_folder": "Last opp til {folder}", + "zone_calendar": "Legg til i kalenderen", + "zone_contacts": "Legg til i kontakter", + "zone_open_eml": "Åpne melding", + "zone_import_eml": "Importer til {folder}", + "note_limit_message": "Opptil {size} per melding", + "note_limit_file": "Opptil {size} per fil", + "note_kept_offline": "Blir på denne enheten til du er på nett igjen", + "note_needs_connection": "Trenger tilkobling", + "announce": "{files, plural, =0 {Filer} one {# fil} other {# filer}} dratt inn. Valg: {choices}.", + "choose_title": "{count, plural, one {Hvor skal denne filen?} other {Hvor skal disse filene?}}", + "save_first_failed": "Den åpne meldingen kunne ikke lagres, så appen ble der den var.", + "import_done": "{count, plural, one {# melding importert til {folder}.} other {# meldinger importert til {folder}.}}", + "import_failed": "{name} kunne ikke importeres.", + "too_large_file": "{name} er for stor. Grensen er {max} per fil.", + "too_large_message": "Disse filene er for store for én melding. Grensen er {max}.", + "folders_left_out": "Mapper kan bare gå til Filer, så de ble utelatt.", + "nothing_here": "Disse filene kan ikke brukes her." + }, "desktopReminders": { "generic_title": "Påminnelse", "snooze": "Utsett {count} min", diff --git a/locales/nl/common.json b/locales/nl/common.json index 3c57a4007..b9ddfd6af 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -7058,6 +7058,35 @@ "types_character": "Deze toetsen typen een teken op je toetsenbord, en dat zou in geen enkele app meer werken. Probeer andere toetsen.", "reserved": "Je systeem of de meeste apps gebruiken deze toetsen. Probeer andere toetsen." }, + "fileDrop": { + "overlay_label": "Bestanden neerzetten", + "overlay_title": "Waar moeten de bestanden heen?", + "overlay_hint": "Zet buiten de keuzes neer of druk op Esc om te annuleren.", + "files_unknown": "Bestanden", + "files_summary": "{count, plural, one {# bestand} other {# bestanden}}", + "files_summary_size": "{count, plural, one {# bestand} other {# bestanden}}, {size}", + "zone_attach_here": "Bij dit bericht voegen", + "zone_new_message": "Bij een nieuw bericht voegen", + "zone_upload_files": "Uploaden naar Bestanden", + "zone_upload_folder": "Uploaden naar {folder}", + "zone_calendar": "Aan agenda toevoegen", + "zone_contacts": "Aan contacten toevoegen", + "zone_open_eml": "Bericht openen", + "zone_import_eml": "Importeren in {folder}", + "note_limit_message": "Tot {size} per bericht", + "note_limit_file": "Tot {size} per bestand", + "note_kept_offline": "Blijft op dit apparaat tot je weer online bent", + "note_needs_connection": "Heeft een verbinding nodig", + "announce": "{files, plural, =0 {Bestanden} one {# bestand} other {# bestanden}} binnengesleept. Keuzes: {choices}.", + "choose_title": "{count, plural, one {Waar moet dit bestand heen?} other {Waar moeten deze bestanden heen?}}", + "save_first_failed": "Het open bericht kon niet worden opgeslagen, dus de app bleef waar hij was.", + "import_done": "{count, plural, one {# bericht geïmporteerd in {folder}.} other {# berichten geïmporteerd in {folder}.}}", + "import_failed": "{name} kon niet worden geïmporteerd.", + "too_large_file": "{name} is te groot. De grens is {max} per bestand.", + "too_large_message": "Deze bestanden zijn samen te groot voor één bericht. De grens is {max}.", + "folders_left_out": "Mappen kunnen alleen naar Bestanden, dus ze zijn weggelaten.", + "nothing_here": "Deze bestanden kunnen hier niet worden gebruikt." + }, "desktopReminders": { "generic_title": "Herinnering", "snooze": "{count} min uitstellen", diff --git a/locales/pl/common.json b/locales/pl/common.json index 20bd079ae..65b613b33 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -7058,6 +7058,35 @@ "types_character": "Te klawisze wpisują znak na Twojej klawiaturze i przestałby on działać we wszystkich aplikacjach. Wypróbuj inne klawisze.", "reserved": "Te klawisze są używane przez system lub większość aplikacji. Wypróbuj inne klawisze." }, + "fileDrop": { + "overlay_label": "Upuść pliki", + "overlay_title": "Gdzie mają trafić pliki?", + "overlay_hint": "Upuść poza opcjami lub naciśnij Esc, aby anulować.", + "files_unknown": "Pliki", + "files_summary": "{count, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}}", + "files_summary_size": "{count, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}}, {size}", + "zone_attach_here": "Załącz do tej wiadomości", + "zone_new_message": "Załącz do nowej wiadomości", + "zone_upload_files": "Prześlij do Plików", + "zone_upload_folder": "Prześlij do {folder}", + "zone_calendar": "Dodaj do kalendarza", + "zone_contacts": "Dodaj do kontaktów", + "zone_open_eml": "Otwórz wiadomość", + "zone_import_eml": "Importuj do {folder}", + "note_limit_message": "Do {size} na wiadomość", + "note_limit_file": "Do {size} na plik", + "note_kept_offline": "Zostaje na tym urządzeniu, aż znów będziesz online", + "note_needs_connection": "Wymaga połączenia", + "announce": "{files, plural, =0 {Przeciągnięto pliki} one {Przeciągnięto # plik} few {Przeciągnięto # pliki} many {Przeciągnięto # plików} other {Przeciągnięto # pliku}}. Opcje: {choices}.", + "choose_title": "{count, plural, one {Gdzie ma trafić ten plik?} few {Gdzie mają trafić te pliki?} many {Gdzie mają trafić te pliki?} other {Gdzie mają trafić te pliki?}}", + "save_first_failed": "Nie udało się zapisać otwartej wiadomości, więc aplikacja została tam, gdzie była.", + "import_done": "{count, plural, one {Zaimportowano # wiadomość do {folder}.} few {Zaimportowano # wiadomości do {folder}.} many {Zaimportowano # wiadomości do {folder}.} other {Zaimportowano # wiadomości do {folder}.}}", + "import_failed": "Nie udało się zaimportować {name}.", + "too_large_file": "{name} jest za duży. Limit to {max} na plik.", + "too_large_message": "Te pliki są za duże na jedną wiadomość. Limit to {max}.", + "folders_left_out": "Foldery mogą trafić tylko do Plików, więc zostały pominięte.", + "nothing_here": "Tych plików nie można tu użyć." + }, "desktopReminders": { "generic_title": "Przypomnienie", "snooze": "Odłóż o {count} min", diff --git a/locales/pt/common.json b/locales/pt/common.json index 2452edf4a..f9a029c78 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -7058,6 +7058,35 @@ "types_character": "Estas teclas escrevem um carácter no seu teclado, e ele deixaria de funcionar em todas as aplicações. Experimente outras teclas.", "reserved": "O seu sistema ou a maioria das aplicações usam estas teclas. Experimente outras teclas." }, + "fileDrop": { + "overlay_label": "Soltar arquivos", + "overlay_title": "Para onde vão os arquivos?", + "overlay_hint": "Solte fora das opções ou prima Esc para cancelar.", + "files_unknown": "Arquivos", + "files_summary": "{count, plural, one {# arquivo} other {# arquivos}}", + "files_summary_size": "{count, plural, one {# arquivo} other {# arquivos}}, {size}", + "zone_attach_here": "Anexar a esta mensagem", + "zone_new_message": "Anexar a uma nova mensagem", + "zone_upload_files": "Enviar para Arquivos", + "zone_upload_folder": "Enviar para {folder}", + "zone_calendar": "Adicionar ao calendário", + "zone_contacts": "Adicionar aos contactos", + "zone_open_eml": "Abrir mensagem", + "zone_import_eml": "Importar para {folder}", + "note_limit_message": "Até {size} por mensagem", + "note_limit_file": "Até {size} por arquivo", + "note_kept_offline": "Fica neste dispositivo até voltar a estar online", + "note_needs_connection": "Precisa de ligação", + "announce": "{files, plural, =0 {Arquivos arrastados} one {# arquivo arrastado} other {# arquivos arrastados}}. Opções: {choices}.", + "choose_title": "{count, plural, one {Para onde vai este arquivo?} other {Para onde vão estes arquivos?}}", + "save_first_failed": "Não foi possível guardar a mensagem aberta, por isso a app ficou onde estava.", + "import_done": "{count, plural, one {# mensagem importada para {folder}.} other {# mensagens importadas para {folder}.}}", + "import_failed": "Não foi possível importar {name}.", + "too_large_file": "{name} é demasiado grande. O limite é {max} por arquivo.", + "too_large_message": "Estes arquivos são demasiado grandes para uma mensagem. O limite é {max}.", + "folders_left_out": "As pastas só podem ir para Arquivos, por isso ficaram de fora.", + "nothing_here": "Estes arquivos não podem ser usados aqui." + }, "desktopReminders": { "generic_title": "Lembrete", "snooze": "Adiar {count} min", diff --git a/locales/ro/common.json b/locales/ro/common.json index 03bfef66a..d72dd9ebd 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -7058,6 +7058,35 @@ "types_character": "Aceste taste scriu un caracter pe tastatura ta, iar el n-ar mai funcționa în nicio aplicație. Încearcă alte taste.", "reserved": "Sistemul sau majoritatea aplicațiilor folosesc aceste taste. Încearcă alte taste." }, + "fileDrop": { + "overlay_label": "Plasați fișiere", + "overlay_title": "Unde merg fișierele?", + "overlay_hint": "Plasați în afara opțiunilor sau apăsați Esc pentru a anula.", + "files_unknown": "Fișiere", + "files_summary": "{count, plural, one {# fișier} few {# fișiere} other {# de fișiere}}", + "files_summary_size": "{count, plural, one {# fișier} few {# fișiere} other {# de fișiere}}, {size}", + "zone_attach_here": "Atașați la acest mesaj", + "zone_new_message": "Atașați la un mesaj nou", + "zone_upload_files": "Încărcați în Fișiere", + "zone_upload_folder": "Încărcați în {folder}", + "zone_calendar": "Adăugați în calendar", + "zone_contacts": "Adăugați la contacte", + "zone_open_eml": "Deschideți mesajul", + "zone_import_eml": "Importați în {folder}", + "note_limit_message": "Până la {size} pe mesaj", + "note_limit_file": "Până la {size} pe fișier", + "note_kept_offline": "Rămâne pe acest dispozitiv până reveniți online", + "note_needs_connection": "Necesită conexiune", + "announce": "{files, plural, =0 {Fișiere trase} one {# fișier tras} few {# fișiere trase} other {# de fișiere trase}}. Opțiuni: {choices}.", + "choose_title": "{count, plural, one {Unde merge acest fișier?} few {Unde merg aceste fișiere?} other {Unde merg aceste fișiere?}}", + "save_first_failed": "Mesajul deschis nu a putut fi salvat, așa că aplicația a rămas unde era.", + "import_done": "{count, plural, one {# mesaj importat în {folder}.} few {# mesaje importate în {folder}.} other {# de mesaje importate în {folder}.}}", + "import_failed": "{name} nu a putut fi importat.", + "too_large_file": "{name} este prea mare. Limita este {max} pe fișier.", + "too_large_message": "Aceste fișiere sunt prea mari pentru un mesaj. Limita este {max}.", + "folders_left_out": "Dosarele pot merge doar în Fișiere, așa că au fost lăsate deoparte.", + "nothing_here": "Aceste fișiere nu pot fi folosite aici." + }, "desktopReminders": { "generic_title": "Memento", "snooze": "Amână {count} min", diff --git a/locales/ru/common.json b/locales/ru/common.json index ebb25a96f..2a34bc36b 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -7058,6 +7058,35 @@ "types_character": "На вашей клавиатуре эти клавиши вводят символ, и он перестал бы работать во всех приложениях. Попробуйте другие клавиши.", "reserved": "Эти клавиши использует система или большинство приложений. Попробуйте другие клавиши." }, + "fileDrop": { + "overlay_label": "Перетащите файлы", + "overlay_title": "Куда отправить файлы?", + "overlay_hint": "Чтобы отменить, отпустите вне вариантов или нажмите Esc.", + "files_unknown": "Файлы", + "files_summary": "{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}", + "files_summary_size": "{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}, {size}", + "zone_attach_here": "Прикрепить к этому письму", + "zone_new_message": "Прикрепить к новому письму", + "zone_upload_files": "Загрузить в Файлы", + "zone_upload_folder": "Загрузить в {folder}", + "zone_calendar": "Добавить в календарь", + "zone_contacts": "Добавить в контакты", + "zone_open_eml": "Открыть письмо", + "zone_import_eml": "Импортировать в {folder}", + "note_limit_message": "До {size} на письмо", + "note_limit_file": "До {size} на файл", + "note_kept_offline": "Хранится на этом устройстве, пока вы снова не будете в сети", + "note_needs_connection": "Нужно подключение", + "announce": "{files, plural, =0 {Перетащены файлы} one {Перетащен # файл} few {Перетащено # файла} many {Перетащено # файлов} other {Перетащено # файла}}. Варианты: {choices}.", + "choose_title": "{count, plural, one {Куда отправить этот файл?} few {Куда отправить эти файлы?} many {Куда отправить эти файлы?} other {Куда отправить эти файлы?}}", + "save_first_failed": "Открытое письмо не удалось сохранить, поэтому приложение осталось на месте.", + "import_done": "{count, plural, one {# письмо импортировано в {folder}.} few {# письма импортировано в {folder}.} many {# писем импортировано в {folder}.} other {# письма импортировано в {folder}.}}", + "import_failed": "Не удалось импортировать {name}.", + "too_large_file": "{name} слишком большой. Ограничение: {max} на файл.", + "too_large_message": "Эти файлы слишком большие для одного письма. Ограничение: {max}.", + "folders_left_out": "Папки можно отправить только в Файлы, поэтому они пропущены.", + "nothing_here": "Эти файлы здесь нельзя использовать." + }, "desktopReminders": { "generic_title": "Напоминание", "snooze": "Отложить на {count} мин", diff --git a/locales/sk/common.json b/locales/sk/common.json index 43343c99e..8acfc3240 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -7058,6 +7058,35 @@ "types_character": "Tieto klávesy na vašej klávesnici píšu znak, ktorý by potom nefungoval v žiadnej aplikácii. Skúste iné klávesy.", "reserved": "Tieto klávesy používa systém alebo väčšina aplikácií. Skúste iné klávesy." }, + "fileDrop": { + "overlay_label": "Pustiť súbory", + "overlay_title": "Kam majú súbory ísť?", + "overlay_hint": "Na zrušenie pustite mimo volieb alebo stlačte Esc.", + "files_unknown": "Súbory", + "files_summary": "{count, plural, one {# súbor} few {# súbory} many {# súboru} other {# súborov}}", + "files_summary_size": "{count, plural, one {# súbor} few {# súbory} many {# súboru} other {# súborov}}, {size}", + "zone_attach_here": "Priložiť k tejto správe", + "zone_new_message": "Priložiť k novej správe", + "zone_upload_files": "Nahrať do Súborov", + "zone_upload_folder": "Nahrať do {folder}", + "zone_calendar": "Pridať do kalendára", + "zone_contacts": "Pridať do kontaktov", + "zone_open_eml": "Otvoriť správu", + "zone_import_eml": "Importovať do {folder}", + "note_limit_message": "Až {size} na správu", + "note_limit_file": "Až {size} na súbor", + "note_kept_offline": "Zostane v tomto zariadení, kým nebudete online", + "note_needs_connection": "Vyžaduje pripojenie", + "announce": "{files, plural, =0 {Pretiahnuté súbory} one {Pretiahnutý # súbor} few {Pretiahnuté # súbory} many {Pretiahnutého # súboru} other {Pretiahnutých # súborov}}. Voľby: {choices}.", + "choose_title": "{count, plural, one {Kam má tento súbor ísť?} few {Kam majú tieto súbory ísť?} many {Kam majú tieto súbory ísť?} other {Kam majú tieto súbory ísť?}}", + "save_first_failed": "Otvorenú správu sa nepodarilo uložiť, preto aplikácia zostala, kde bola.", + "import_done": "{count, plural, one {# správa importovaná do {folder}.} few {# správy importované do {folder}.} many {# správy importovanej do {folder}.} other {# správ importovaných do {folder}.}}", + "import_failed": "{name} sa nepodarilo importovať.", + "too_large_file": "{name} je príliš veľký. Limit je {max} na súbor.", + "too_large_message": "Tieto súbory sú na jednu správu príliš veľké. Limit je {max}.", + "folders_left_out": "Priečinky môžu ísť len do Súborov, preto boli vynechané.", + "nothing_here": "Tieto súbory sa tu nedajú použiť." + }, "desktopReminders": { "generic_title": "Pripomienka", "snooze": "Odložiť o {count} min", diff --git a/locales/th/common.json b/locales/th/common.json index d4aef2af2..c25f55fdf 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -7066,6 +7066,35 @@ "types_character": "ปุ่มเหล่านี้พิมพ์อักขระบนแป้นพิมพ์ของคุณ และอักขระนั้นจะใช้ไม่ได้ในทุกแอป ลองใช้ปุ่มอื่น", "reserved": "ระบบหรือแอปส่วนใหญ่ใช้ปุ่มเหล่านี้ ลองใช้ปุ่มอื่น" }, + "fileDrop": { + "overlay_label": "วางไฟล์", + "overlay_title": "จะให้ไฟล์ไปที่ไหน", + "overlay_hint": "วางนอกตัวเลือกหรือกด Esc เพื่อยกเลิก", + "files_unknown": "ไฟล์", + "files_summary": "{count, plural, other {# ไฟล์}}", + "files_summary_size": "{count, plural, other {# ไฟล์}}, {size}", + "zone_attach_here": "แนบกับข้อความนี้", + "zone_new_message": "แนบกับข้อความใหม่", + "zone_upload_files": "อัปโหลดไปยังไฟล์", + "zone_upload_folder": "อัปโหลดไปยัง {folder}", + "zone_calendar": "เพิ่มในปฏิทิน", + "zone_contacts": "เพิ่มในรายชื่อติดต่อ", + "zone_open_eml": "เปิดข้อความ", + "zone_import_eml": "นำเข้าไปยัง {folder}", + "note_limit_message": "สูงสุด {size} ต่อข้อความ", + "note_limit_file": "สูงสุด {size} ต่อไฟล์", + "note_kept_offline": "เก็บไว้ในอุปกรณ์นี้จนกว่าคุณจะออนไลน์อีกครั้ง", + "note_needs_connection": "ต้องใช้การเชื่อมต่อ", + "announce": "{files, plural, =0 {ลากไฟล์เข้ามาแล้ว} other {ลาก # ไฟล์เข้ามาแล้ว}} ตัวเลือก: {choices}", + "choose_title": "{count, plural, other {จะให้ไฟล์นี้ไปที่ไหน}}", + "save_first_failed": "บันทึกข้อความที่เปิดอยู่ไม่ได้ แอปจึงอยู่ที่เดิม", + "import_done": "{count, plural, other {นำเข้า # ข้อความไปยัง {folder} แล้ว}}", + "import_failed": "นำเข้า {name} ไม่ได้", + "too_large_file": "{name} ใหญ่เกินไป ขีดจำกัดคือ {max} ต่อไฟล์", + "too_large_message": "ไฟล์เหล่านี้ใหญ่เกินไปสำหรับข้อความเดียว ขีดจำกัดคือ {max}", + "folders_left_out": "โฟลเดอร์ไปได้เฉพาะที่ไฟล์ จึงไม่ได้รวมไว้", + "nothing_here": "ใช้ไฟล์เหล่านี้ที่นี่ไม่ได้" + }, "desktopReminders": { "generic_title": "การแจ้งเตือน", "snooze": "เลื่อน {count} นาที", diff --git a/locales/tr/common.json b/locales/tr/common.json index 03393f7f0..956d66056 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -7058,6 +7058,35 @@ "types_character": "Bu tuşlar klavyenizde bir karakter yazar ve bu karakter hiçbir uygulamada çalışmaz olur. Başka tuşlar deneyin.", "reserved": "Bu tuşları sisteminiz veya çoğu uygulama kullanıyor. Başka tuşlar deneyin." }, + "fileDrop": { + "overlay_label": "Dosyaları bırakın", + "overlay_title": "Dosyalar nereye gitsin?", + "overlay_hint": "İptal etmek için seçeneklerin dışına bırakın veya Esc tuşuna basın.", + "files_unknown": "Dosyalar", + "files_summary": "{count, plural, one {# dosya} other {# dosya}}", + "files_summary_size": "{count, plural, one {# dosya} other {# dosya}}, {size}", + "zone_attach_here": "Bu iletiye ekle", + "zone_new_message": "Yeni bir iletiye ekle", + "zone_upload_files": "Dosyalar'a yükle", + "zone_upload_folder": "{folder} klasörüne yükle", + "zone_calendar": "Takvime ekle", + "zone_contacts": "Kişilere ekle", + "zone_open_eml": "İletiyi aç", + "zone_import_eml": "{folder} klasörüne içe aktar", + "note_limit_message": "İleti başına en fazla {size}", + "note_limit_file": "Dosya başına en fazla {size}", + "note_kept_offline": "Tekrar çevrimiçi olana kadar bu cihazda kalır", + "note_needs_connection": "Bağlantı gerekir", + "announce": "{files, plural, =0 {Dosyalar} one {# dosya} other {# dosya}} sürüklendi. Seçenekler: {choices}.", + "choose_title": "{count, plural, one {Bu dosya nereye gitsin?} other {Bu dosyalar nereye gitsin?}}", + "save_first_failed": "Açık ileti kaydedilemedi, bu yüzden uygulama olduğu yerde kaldı.", + "import_done": "{count, plural, one {# ileti {folder} klasörüne aktarıldı.} other {# ileti {folder} klasörüne aktarıldı.}}", + "import_failed": "{name} içe aktarılamadı.", + "too_large_file": "{name} çok büyük. Sınır dosya başına {max}.", + "too_large_message": "Bu dosyalar tek bir ileti için çok büyük. Sınır {max}.", + "folders_left_out": "Klasörler yalnızca Dosyalar'a gidebilir, bu yüzden dışarıda bırakıldı.", + "nothing_here": "Bu dosyalar burada kullanılamaz." + }, "desktopReminders": { "generic_title": "Anımsatıcı", "snooze": "{count} dk ertele", diff --git a/locales/uk/common.json b/locales/uk/common.json index f033cd1ee..58b3f3d2d 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -7058,6 +7058,35 @@ "types_character": "На вашій клавіатурі ці клавіші вводять символ, і він перестав би працювати в усіх застосунках. Спробуйте інші клавіші.", "reserved": "Ці клавіші використовує система або більшість застосунків. Спробуйте інші клавіші." }, + "fileDrop": { + "overlay_label": "Перетягніть файли", + "overlay_title": "Куди надіслати файли?", + "overlay_hint": "Щоб скасувати, відпустіть поза варіантами або натисніть Esc.", + "files_unknown": "Файли", + "files_summary": "{count, plural, one {# файл} few {# файли} many {# файлів} other {# файлу}}", + "files_summary_size": "{count, plural, one {# файл} few {# файли} many {# файлів} other {# файлу}}, {size}", + "zone_attach_here": "Прикріпити до цього повідомлення", + "zone_new_message": "Прикріпити до нового повідомлення", + "zone_upload_files": "Завантажити у Файли", + "zone_upload_folder": "Завантажити в {folder}", + "zone_calendar": "Додати до календаря", + "zone_contacts": "Додати до контактів", + "zone_open_eml": "Відкрити повідомлення", + "zone_import_eml": "Імпортувати в {folder}", + "note_limit_message": "До {size} на повідомлення", + "note_limit_file": "До {size} на файл", + "note_kept_offline": "Зберігається на цьому пристрої, доки ви знову не будете в мережі", + "note_needs_connection": "Потрібне з’єднання", + "announce": "{files, plural, =0 {Перетягнуто файли} one {Перетягнуто # файл} few {Перетягнуто # файли} many {Перетягнуто # файлів} other {Перетягнуто # файлу}}. Варіанти: {choices}.", + "choose_title": "{count, plural, one {Куди надіслати цей файл?} few {Куди надіслати ці файли?} many {Куди надіслати ці файли?} other {Куди надіслати ці файли?}}", + "save_first_failed": "Відкрите повідомлення не вдалося зберегти, тому застосунок лишився на місці.", + "import_done": "{count, plural, one {# повідомлення імпортовано в {folder}.} few {# повідомлення імпортовано в {folder}.} many {# повідомлень імпортовано в {folder}.} other {# повідомлення імпортовано в {folder}.}}", + "import_failed": "Не вдалося імпортувати {name}.", + "too_large_file": "{name} завеликий. Обмеження: {max} на файл.", + "too_large_message": "Ці файли завеликі для одного повідомлення. Обмеження: {max}.", + "folders_left_out": "Теки можна надіслати лише у Файли, тому їх пропущено.", + "nothing_here": "Ці файли тут не можна використати." + }, "desktopReminders": { "generic_title": "Нагадування", "snooze": "Відкласти на {count} хв", diff --git a/locales/vi/common.json b/locales/vi/common.json index 9faa09bde..59b4ac37b 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -7070,6 +7070,35 @@ "types_character": "Các phím này gõ một ký tự trên bàn phím của bạn, và ký tự đó sẽ không dùng được trong mọi ứng dụng. Hãy thử phím khác.", "reserved": "Hệ thống hoặc hầu hết ứng dụng dùng các phím này. Hãy thử phím khác." }, + "fileDrop": { + "overlay_label": "Thả tệp", + "overlay_title": "Đưa tệp đi đâu?", + "overlay_hint": "Thả bên ngoài các lựa chọn hoặc nhấn Esc để hủy.", + "files_unknown": "Tệp", + "files_summary": "{count, plural, other {# tệp}}", + "files_summary_size": "{count, plural, other {# tệp}}, {size}", + "zone_attach_here": "Đính kèm vào thư này", + "zone_new_message": "Đính kèm vào thư mới", + "zone_upload_files": "Tải lên Tệp", + "zone_upload_folder": "Tải lên {folder}", + "zone_calendar": "Thêm vào lịch", + "zone_contacts": "Thêm vào danh bạ", + "zone_open_eml": "Mở thư", + "zone_import_eml": "Nhập vào {folder}", + "note_limit_message": "Tối đa {size} mỗi thư", + "note_limit_file": "Tối đa {size} mỗi tệp", + "note_kept_offline": "Được giữ trên thiết bị này đến khi bạn trực tuyến lại", + "note_needs_connection": "Cần kết nối", + "announce": "{files, plural, =0 {Đã kéo tệp vào} other {Đã kéo vào # tệp}}. Lựa chọn: {choices}.", + "choose_title": "{count, plural, other {Đưa các tệp này đi đâu?}}", + "save_first_failed": "Không lưu được thư đang mở, nên ứng dụng vẫn ở chỗ cũ.", + "import_done": "{count, plural, other {Đã nhập # thư vào {folder}.}}", + "import_failed": "Không nhập được {name}.", + "too_large_file": "{name} quá lớn. Giới hạn là {max} mỗi tệp.", + "too_large_message": "Các tệp này quá lớn cho một thư. Giới hạn là {max}.", + "folders_left_out": "Thư mục chỉ có thể đưa vào Tệp, nên đã bị bỏ qua.", + "nothing_here": "Không dùng được các tệp này ở đây." + }, "desktopReminders": { "generic_title": "Lời nhắc", "snooze": "Báo lại sau {count} phút", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index eed67bf21..4a6e8f67a 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -7058,6 +7058,35 @@ "types_character": "這些鍵在您的鍵盤上會輸入一個字元,占用後該字元在所有應用程式中都無法輸入。請試試其他按鍵。", "reserved": "系統或大多數應用程式正在使用這些鍵。請試試其他按鍵。" }, + "fileDrop": { + "overlay_label": "放開檔案", + "overlay_title": "要把檔案放到哪裡?", + "overlay_hint": "在選項外放開或按 Esc 取消。", + "files_unknown": "檔案", + "files_summary": "{count, plural, other {# 個檔案}}", + "files_summary_size": "{count, plural, other {# 個檔案}}, {size}", + "zone_attach_here": "附加到此郵件", + "zone_new_message": "附加到新郵件", + "zone_upload_files": "上傳到檔案", + "zone_upload_folder": "上傳到 {folder}", + "zone_calendar": "加入行事曆", + "zone_contacts": "加入聯絡人", + "zone_open_eml": "開啟郵件", + "zone_import_eml": "匯入到 {folder}", + "note_limit_message": "每封郵件最多 {size}", + "note_limit_file": "每個檔案最多 {size}", + "note_kept_offline": "在您重新連線前保留在此裝置上", + "note_needs_connection": "需要連線", + "announce": "{files, plural, =0 {已拖入檔案} other {已拖入 # 個檔案}}。選項:{choices}。", + "choose_title": "{count, plural, other {要把這些檔案放到哪裡?}}", + "save_first_failed": "無法儲存開啟的郵件,因此應用程式停留在原處。", + "import_done": "{count, plural, other {已將 # 封郵件匯入到 {folder}。}}", + "import_failed": "無法匯入 {name}。", + "too_large_file": "{name} 太大。每個檔案的上限為 {max}。", + "too_large_message": "這些檔案對一封郵件來說太大。上限為 {max}。", + "folders_left_out": "資料夾只能放到檔案中,因此已略過。", + "nothing_here": "這些檔案無法在此使用。" + }, "desktopReminders": { "generic_title": "提醒", "snooze": "{count} 分鐘後再提醒", diff --git a/locales/zh/common.json b/locales/zh/common.json index 0edeb8c81..e17e37435 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -7058,6 +7058,35 @@ "types_character": "这些键在您的键盘上会输入一个字符,占用后该字符在所有应用中都无法输入。请试试其他按键。", "reserved": "系统或大多数应用在使用这些键。请试试其他按键。" }, + "fileDrop": { + "overlay_label": "放下文件", + "overlay_title": "要把文件放到哪里?", + "overlay_hint": "在选项外放下或按 Esc 取消。", + "files_unknown": "文件", + "files_summary": "{count, plural, other {# 个文件}}", + "files_summary_size": "{count, plural, other {# 个文件}}, {size}", + "zone_attach_here": "附加到此邮件", + "zone_new_message": "附加到新邮件", + "zone_upload_files": "上传到文件", + "zone_upload_folder": "上传到 {folder}", + "zone_calendar": "添加到日历", + "zone_contacts": "添加到联系人", + "zone_open_eml": "打开邮件", + "zone_import_eml": "导入到 {folder}", + "note_limit_message": "每封邮件最多 {size}", + "note_limit_file": "每个文件最多 {size}", + "note_kept_offline": "在您重新联网前保存在此设备上", + "note_needs_connection": "需要连接", + "announce": "{files, plural, =0 {已拖入文件} other {已拖入 # 个文件}}。选项:{choices}。", + "choose_title": "{count, plural, other {要把这些文件放到哪里?}}", + "save_first_failed": "无法保存打开的邮件,因此应用停留在原处。", + "import_done": "{count, plural, other {已将 # 封邮件导入到 {folder}。}}", + "import_failed": "无法导入 {name}。", + "too_large_file": "{name} 太大。每个文件的上限为 {max}。", + "too_large_message": "这些文件对一封邮件来说太大。上限为 {max}。", + "folders_left_out": "文件夹只能放到文件中,因此已略过。", + "nothing_here": "这些文件无法在此使用。" + }, "desktopReminders": { "generic_title": "提醒", "snooze": "{count} 分钟后再提醒",